4.9 KiB
4. What a resource server does not validate
Prev: 3. RestClient interceptors · Next: 5. The gateway
This is the most important chapter in the module.
The demonstration
reporting-service is a client registered on the same authorization server, with nothing to do
with the downstream service. Its access tokens carry aud: reporting-api.
Send one to the downstream service, whose audience is downstream-api:
HTTP/1.1 200
{ "sub": "reporting-service", "aud": ["reporting-api"], "scope": "[orders.read]", ... }
Two hundred. Full transcript:
docs/output/03-audience-ignored.txt.
Why
JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three validators,
read back by reflection in ValidatorContractTests.defaultDelegates:
JwtTypeValidatorJwtTimestampValidatorX509CertificateThumbprintValidator
Structure, expiry, and certificate binding. No issuer. No audience. No scope. Setting
spring.security.oauth2.resourceserver.jwt.issuer-uri gets you a JwtIssuerValidator on top of
that, so the issuer is covered. Nothing anywhere adds an audience check unless you write it.
The practical consequence: in an estate where several services trust one authorization server —
which is the normal shape — any token from that issuer is accepted by every service in it.
A token a partner integration obtained for the reporting API is a valid credential for the
payments API. Scope may or may not save you; scope: orders.read did not, above.
The fix, and its price
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtAudienceValidator("downstream-api")));
JwtAudienceValidator is a public class in 7.1. Two lines, and the demonstration above turns
into The aud claim is not valid.
The heavier option is the RFC 9068 profile:
JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
That requires typ, exp, sub, iat, jti, iss, aud and client_id to all be
present. It is stricter and it is also where two surprises live.
Surprise 1: Spring Authorization Server does not emit an RFC 9068 token
Out of the box its access tokens carry typ: JWT in the header and no client_id claim. Point
createAtJwtValidator() at one and every token is rejected. Both are one line in an
OAuth2TokenCustomizer:
context.getJwsHeader().type("at+jwt");
context.getClaims().claim("client_id", clientId);
Surprise 2: making the token compliant breaks every resource server that was not updated
NimbusJwtDecoder's default JOSE type verifier accepts JWT and an absent typ, and nothing
else. The moment the authorization server starts typing tokens at+jwt, every service using
Boot's auto-configured decoder answers:
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt:
the given typ value needs to be one of [JWT]"
docs/output/05-strict-validation.txt has the downstream
service accepting the new tokens and the edge service, one hop away and not updated, rejecting
them. The message mentions neither RFC 9068 nor the authorization server.
The fix on the receiving side:
NimbusJwtDecoder.withJwkSetUri(jwks)
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
JOSEObjectType.JWT, null)))
.build();
Roll that out before you change the authorization server, not after.
A message worth recognising twice
Two components produce almost the same sentence and mean opposite things:
| Source | Message |
|---|---|
Nimbus DefaultJOSEObjectTypeVerifier |
the given typ value needs to be one of [JWT] |
Spring JwtTypeValidator (at+jwt profile) |
the given typ value needs to be one of [at+jwt, application/at+jwt] |
The first means "your authorization server is too modern for this decoder". The second means "your authorization server is not modern enough for this validator".
And one thing that is on by default
X509CertificateThumbprintValidator is in the default set. If a token carries a cnf claim
with x5t#S256 — a certificate-bound access token, RFC 8705 — Spring Security already
checks it against the client certificate on the TLS connection, with no configuration. That is
the strongest available defence against a stolen bearer token, it costs nothing on the resource
server, and it needs mTLS to be terminated in the application rather than in a sidecar. Chapter
6.
Prev: 3. RestClient interceptors · Next: 5. The gateway