1
0
Files
spring-security-demo/service-to-service/docs/04-what-is-not-validated.md

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:

  • JwtTypeValidator
  • JwtTimestampValidator
  • X509CertificateThumbprintValidator

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