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.
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.