Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:
jwt-authentication/ the hand-written filter application (unchanged, moved)
oauth2-resource-server/ a resource server, a Keycloak compose, and a stub
issuer whose JWK Set can be mutated on command
The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.
Findings captured under docs/output/, all from real runs:
* The default validator stack does not check aud. A token minted for another
service in the same realm is accepted.
* Spring Security builds its JWKSource with refreshAheadCache(false) and
rateLimited(false), overriding two of Nimbus's protective defaults, and
enables Nimbus caching only when NO Spring cache was supplied - so
supplying one removes the five-minute expiry.
* A key retired from the JWK Set stops being accepted at t+300s with the
default cache, and never with a Spring cache that has no TTL.
* 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
through permitAll() endpoints included.
* A hyphenated client id in an authorities-claim-expression parses as
subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
* A clientScopes key in a Keycloak realm import replaces the built-in scopes
rather than adding to them.
New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
128 lines
5.3 KiB
Markdown
128 lines
5.3 KiB
Markdown
# 13 — The validator stack
|
|
|
|
[← issuer and audience](12-issuer-and-audience.md) · [next: the authentication converter →](14-authentication-converter.md)
|
|
|
|
Signature verification and claim validation are two separate stages, run by two different
|
|
libraries. Nimbus verifies the signature; Spring Security validates the claims. Knowing
|
|
which stage refused a token tells you where to look.
|
|
|
|
```
|
|
BearerTokenAuthenticationFilter
|
|
└─ JwtAuthenticationProvider
|
|
└─ NimbusJwtDecoder.decode(token)
|
|
├─ 1. Nimbus DefaultJWTProcessor signature, alg, kid → key
|
|
└─ 2. Spring OAuth2TokenValidator iss, exp, nbf, aud, typ, ...
|
|
└─ JwtAuthenticationConverter claims → authorities (chapter 14)
|
|
```
|
|
|
|
Stage 1 failures read like `Signed JWT rejected: Another algorithm expected, or no matching
|
|
key(s) found`. Stage 2 failures name the claim: `The iss claim is not valid`. Both arrive as
|
|
`invalid_token` in the `WWW-Authenticate` header, so the description is the only thing that
|
|
distinguishes them.
|
|
|
|
## What is actually in the default stack
|
|
|
|
From `JwtValidators` in Spring Security 7.1.1:
|
|
|
|
| factory | contents |
|
|
|---|---|
|
|
| `createDefault()` | `JwtTypeValidator.jwt()`, `JwtTimestampValidator`, `X509CertificateThumbprintValidator` |
|
|
| `createDefaultWithIssuer(iss)` | the above plus `JwtIssuerValidator(iss)` |
|
|
| `createDefaultWithValidators(..)` | your validators, plus any of the three above you did not supply |
|
|
| `createAtJwtValidator()` | a builder for RFC 9068 access tokens |
|
|
|
|
Three things follow from that table.
|
|
|
|
**There is no audience validator.** Covered in [chapter 12](12-issuer-and-audience.md); it
|
|
is the most consequential omission in the list.
|
|
|
|
**`createDefaultWithValidators` adds, it does not replace.** Pass it a
|
|
`JwtTimestampValidator` with your own clock skew and it uses yours; pass it nothing of the
|
|
kind and it inserts the default. That is why supplying a custom timestamp validator works
|
|
without also having to re-supply the type and thumbprint validators.
|
|
|
|
**`createAtJwtValidator()` is much stricter than the name suggests.** Its builder
|
|
pre-populates required-claim validators for `exp`, `sub`, `iat`, `jti` and `client_id`, plus
|
|
a type validator restricted to `at+jwt` and `application/at+jwt`. A token missing any one of
|
|
those is refused. Reach for it when you control the issuer and it genuinely emits RFC 9068
|
|
access tokens — not as a general-purpose hardening switch.
|
|
|
|
## Two places a `typ` check can live
|
|
|
|
There are *two* independent type checks, and only one of them is on by default.
|
|
|
|
```java
|
|
NimbusJwtDecoder.withIssuerLocation(issuer)
|
|
.validateType(true) // Nimbus-level. Default: FALSE.
|
|
.build();
|
|
```
|
|
|
|
`validateType(boolean)` swaps Nimbus's `JOSEObjectTypeVerifier` between a no-op and one that
|
|
demands `typ=JWT`. It defaults to the no-op. Meanwhile the Spring-level
|
|
`JwtTypeValidator.jwt()` inside the default validator stack *is* present and *does* demand
|
|
`typ=JWT` or nothing. So the type is checked once, by Spring, on the way out.
|
|
|
|
Note the spelling. The reference documentation shows `validateTypes(false)`, plural. The
|
|
method on `JwkSetUriJwtDecoderBuilder` in 7.1.1 is **`validateType`**, singular. Reading the
|
|
docs and typing what they say does not compile.
|
|
|
|
## Adding a validator without losing the ones you have
|
|
|
|
The safe route, because it does not touch the decoder at all:
|
|
|
|
```java
|
|
@Bean
|
|
OAuth2TokenValidator<Jwt> audienceValidator() {
|
|
return new JwtAudienceValidator("reports-api");
|
|
}
|
|
```
|
|
|
|
Boot's `JwtDecoderConfiguration` gathers every such bean and appends it. The dangerous route
|
|
is the one the reference documentation demonstrates:
|
|
|
|
```java
|
|
@Bean
|
|
JwtDecoder jwtDecoder() {
|
|
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
|
|
decoder.setJwtValidator(new JwtAudienceValidator("reports-api")); // ← WRONG
|
|
return decoder;
|
|
}
|
|
```
|
|
|
|
`setJwtValidator` **replaces**. That decoder now checks the audience and nothing else — not
|
|
the issuer, not the expiry. It is a one-line downgrade that no test of the happy path will
|
|
catch, because the happy path still returns 200. Wrap with `createDefaultWithValidators` and
|
|
you keep everything:
|
|
|
|
```java
|
|
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
|
|
new JwtIssuerValidator(issuer), new JwtAudienceValidator("reports-api")));
|
|
```
|
|
|
|
## Pin the defaults in a test
|
|
|
|
The validator stack is exactly the kind of thing that changes between minor versions and
|
|
fails closed when it does — or worse, fails open. Nine assertions in
|
|
[`JwtValidationContractTests`](../oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java)
|
|
assert the *defaults* rather than this application's configuration, so that a Spring Security
|
|
upgrade that moves them turns a test red instead of turning a production check off:
|
|
|
|
```
|
|
defaultStackAcceptsAWellFormedToken
|
|
defaultStackDoesNotCheckAudience
|
|
addingJwtAudienceValidatorRefusesTheSameToken
|
|
defaultStackRefusesRfc9068AccessTokens
|
|
aPermissiveTypeValidatorAcceptsThem
|
|
issuerComparisonIsExactStringEquality
|
|
defaultClockSkewIsSixtySeconds
|
|
audienceValidatorMatchesAnyEntryNotAllOfThem
|
|
aMissingAudienceClaimIsRefusedNotIgnored
|
|
```
|
|
|
|
Run: `cd oauth2-resource-server && mvn test`. Result committed in
|
|
[`rs-test-run.txt`](output/rs-test-run.txt).
|
|
|
|
---
|
|
|
|
[← issuer and audience](12-issuer-and-audience.md) · [next: the authentication converter →](14-authentication-converter.md)
|