1
0
Files
spring-auth-demo/docs/authorization-server/05-token-customisation.md
Ankur Mhatre 38c0a5f358 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:12:36 +05:30

107 lines
4.5 KiB
Markdown

[← 04 Consent page](04-consent-page.md) · [index](README.md) · next: [06 — The resource server side](06-resource-server.md)
# Token customisation
Source:
[`TokenClaimsCustomizer.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/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>` &mdash; the type used for
*opaque* tokens &mdash; 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`](../output/as-authcode-pkce.txt) vs
[`as-authcode-noclaims.txt`](../output/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 &mdash; and they should
&mdash; 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 &mdash; roles, tenant, entitlements &mdash; 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
```java
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 &mdash; same
issuer, same signing key &mdash; 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](06-resource-server.md).
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`](../output/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"]` &mdash; the customiser did not run,
because opaque tokens go through `OAuth2TokenClaimsContext`, not `JwtEncodingContext`.
Next: [06 &mdash; The resource server side](06-resource-server.md)