1
0
Files
jwt-auth-demo/docs/01-architecture.md
Ankur Mhatre 4dc45d5e00 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
2026-08-23 11:00:56 +00:00

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.