1
0
Files
spring-auth-demo/docs/authorization-server/05-token-customisation.md
Ankur Mhatre e9381dc5be Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server
Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.

Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:

  - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
    in 7.0, and both configuration classes moved into spring-security-config
  - ClientSettings.requireProofKey flipped from false to true, on the authorization server
    (1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
  - requireProofKey(false) does not make PKCE optional for a public client; the code
    verifier is that client's only authentication at the token endpoint
  - MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
    API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called

Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
2026-08-24 08:20:38 +05:30

4.5 KiB

← 04 Consent page · index · next: 06 — The resource server side

Token customisation

Source: TokenClaimsCustomizer.java.

The bean is found by generic type, and nothing logs if it is not

One bean of type OAuth2TokenCustomizer<JwtEncodingContext> is picked up automatically by the JWT generator. No annotation, no registration step.

Declare it as OAuth2TokenCustomizer<OAuth2TokenClaimsContext> — the type used for opaque tokens — and it is silently ignored. The generator resolves the bean by generic type and simply does not find it. Your claims are just absent, and nothing in the logs says why.

What the default access token actually contains

Diff two runs of the same flow, with and without the customiser (as-authcode-pkce.txt vs as-authcode-noclaims.txt):

  default (noclaims)                 with the customiser
  {                                  {
    "aud": "demo-spa",       <-->      "aud": "orders-api",
                                       "roles": ["ADMIN", "USER"],
                                       "tenant": "acme",
    "exp": …,                          "exp": …,
    "iat": …,                          "iat": …,
    "iss": "http://localhost:9000",    "iss": "http://localhost:9000",
    "jti": …,                          "jti": …,
    "nbf": …,                          "nbf": …,
    "scope": ["openid","orders.read"], "scope": ["openid","orders.read"],
    "sub": "alice"                     "sub": "alice"
  }                                  }

Two things worth noticing.

aud defaults to the client id. Not the API. There is no per-client audience setting on RegisteredClient, so if your resource servers validate audience — and they should — the token customiser is where you set it. A resource server that naively checks aud == "orders-api" will reject every default-issued token.

Roles are not there by default. scope is, as SCOPE_* authorities. Anything else about the user — roles, tenant, entitlements — you put there or you make a network call per request.

Guard on the grant type

client_credentials has no user. context.getPrincipal() is the client's own authentication, and copying its authorities into a roles claim gives a machine token whatever the client authentication happened to carry. The customiser here excludes that grant explicitly.

The id_token is a different token

if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {  }

The id_token's audience is the client; the access token's is the API. From the transcript:

access token   "aud": "orders-api"
id_token       "aud": "demo-spa",  "azp": "demo-spa",  "sid": "1ZK2c__DhcDY…"

Sending the id_token to a resource server is the classic mix-up. It verifies — same issuer, same signing key — and then fails the audience check:

HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token",
  error_description="An error occurred while attempting to decode the Jwt:
   the required audience orders-api is missing", …

If nobody checks audience, it passes, and a token the client was allowed to read becomes a token the API accepts. That is the argument for 06.

Put authorisation data in the access token. Put profile data in the id_token. The id_token is for the client to render a username; it is not a credential for your APIs.

Self-contained versus reference tokens

TokenSettings.accessTokenFormat takes SELF_CONTAINED (a signed JWT, verified offline) or REFERENCE (an opaque string). The opaque profile flips demo-service to the latter (as-client-credentials-opaque.txt):

The access token is an opaque reference: unf4kl7MSFlYyNpNqcVFcIT4Hbny…
Length 128. It carries no claims; the resource server must introspect it.

POST /oauth2/introspect
{ "active": true, "sub": "demo-service", "scope": "orders.read", … }

The trade is instant revocation for a network round trip on every API call. Note that the introspection response reports "aud": ["demo-service"] — the customiser did not run, because opaque tokens go through OAuth2TokenClaimsContext, not JwtEncodingContext.

Next: 06 — The resource server side