Add OAuth2 resource server project: JWT validation, JWKS and key rotation
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
This commit is contained in:
127
docs/13-validator-stack.md
Normal file
127
docs/13-validator-stack.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user