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
98 lines
4.8 KiB
Markdown
98 lines
4.8 KiB
Markdown
# 01 — Architecture
|
|
|
|
[← README](../README.md) · [next: filter chain and ordering →](02-filter-chain-and-ordering.md)
|
|
|
|
There are two paths through this application, and confusing them is the source of most
|
|
JWT bugs. The **login path** runs once and is stateful in the only sense that matters:
|
|
it sees a password. The **request path** runs on every subsequent call and sees nothing
|
|
but a string.
|
|
|
|
## The login path
|
|
|
|
```
|
|
POST /api/auth/login {"username":"alice","password":"..."}
|
|
|
|
|
v
|
|
AuthController <-- the ONLY place a password is read
|
|
|
|
|
| authenticationManager.authenticate(
|
|
| UsernamePasswordAuthenticationToken.unauthenticated(user, pass))
|
|
v
|
|
ProviderManager
|
|
|
|
|
v
|
|
DaoAuthenticationProvider
|
|
| loadUserByUsername -> UserDetails
|
|
| passwordEncoder.matches(raw, encoded)
|
|
v
|
|
Authentication (authenticated=true, authorities=[ROLE_USER, SCOPE_profile:read])
|
|
|
|
|
v
|
|
TokenService.issueAccessToken(authentication)
|
|
| JwtClaimsSet: iss aud sub jti iat nbf exp scope roles token_type
|
|
| NimbusJwtEncoder.encode(...)
|
|
v
|
|
200 {"accessToken":"eyJ...","refreshToken":"eyJ...","tokenType":"Bearer",...}
|
|
```
|
|
|
|
Note what does **not** happen: no session is created, no `SecurityContext` is saved, no
|
|
cookie is set. The `Authentication` object built here is used to fill in claims and is
|
|
then discarded.
|
|
|
|
## The request path
|
|
|
|
```
|
|
GET /api/me
|
|
Authorization: Bearer eyJ...
|
|
|
|
|
v
|
|
FilterChainProxy ---------------------------------------------+
|
|
| |
|
|
| 1 DisableEncodeUrlFilter |
|
|
| 2 WebAsyncManagerIntegrationFilter |
|
|
| 3 SecurityContextHolderFilter loads context |
|
|
| 4 HeaderWriterFilter |
|
|
| 5 JwtAuthenticationFilter <-- ours |
|
|
| resolve Bearer token |
|
|
| jwtDecoder.decode(token) |
|
|
| verify signature |
|
|
| exp / nbf (+/- 60s skew), iss, aud |
|
|
| token_type == "access", jti not revoked |
|
|
| JwtAuthenticationToken -> SecurityContext |
|
|
| 6 RequestCacheAwareFilter |
|
|
| 7 SecurityContextHolderAwareRequestFilter |
|
|
| 8 AnonymousAuthenticationFilter |
|
|
| 9 SessionManagementFilter |
|
|
| 10 ExceptionTranslationFilter catches what follows |
|
|
| 11 AuthorizationFilter permitAll / hasRole |
|
|
| |
|
|
+--------------------------------------------------------+
|
|
|
|
|
v
|
|
DispatcherServlet -> @PreAuthorize -> controller
|
|
```
|
|
|
|
That list is not from memory. It is printed by `GET /api/public/filters`, which reads
|
|
`FilterChainProxy.getFilterChains()` at runtime — see
|
|
[`FilterChainReport`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java)
|
|
and step 19 of [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt).
|
|
|
|
## Where each concern lives
|
|
|
|
| concern | class | doc |
|
|
|---|---|---|
|
|
| password check | [`AppUsers`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/AppUsers.java) + `DaoAuthenticationProvider` | — |
|
|
| token minting | [`TokenService`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/TokenService.java) | [05](05-hs256-vs-rs256.md) |
|
|
| token verifying | [`JwtAuthenticationFilter`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) | [02](02-filter-chain-and-ordering.md) |
|
|
| claim validation | [`JwtValidatorFactory`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java) | [07](07-edge-cases.md) |
|
|
| key material | [`Hs256KeyConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) / [`Rs256KeyConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) | [05](05-hs256-vs-rs256.md) |
|
|
| authorization rules | [`SecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) | [03](03-401-vs-403.md) |
|
|
| revocation | [`RevokedTokenStore`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java) | [07](07-edge-cases.md) |
|
|
|
|
## The one-sentence version
|
|
|
|
A JWT deployment is an **issuer** that trades a password for a signed claims set, and a
|
|
**verifier** that trades a signed claims set for an `Authentication` — and every failure
|
|
mode in this repository comes from one of the two doing slightly less checking than the
|
|
other assumed.
|