1
0
Files
spring-auth-demo/README.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

250 lines
13 KiB
Markdown

# jwt-auth-demo
Runnable companion code for two articles on [ankurm.com](https://ankurm.com):
| | article | code |
|---|---|---|
| 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) |
| 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) |
Two Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
tokens with a hand-written filter; the second verifies tokens minted by somebody else &mdash;
a real Keycloak, and a stub issuer whose signing keys can be rotated on command.
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) is
real program output, regenerated by a script &mdash; not transcribed by hand.
| | |
|---|---|
| JDK | Temurin **25.0.4.1+1** (current LTS) |
| Spring Boot | **4.1.1** |
| Spring Framework | **7.0.9** |
| Spring Security | **7.1.1** |
| Nimbus JOSE+JWT | **10.9.1** |
| Tomcat | **11.0.24** |
| Jackson | **3.1.5** (`tools.jackson`) |
| Keycloak | **26.7.2** (resource server project only) |
| Caffeine | **3.2.4** (resource server project only) |
---
## Quickstart
### Project 1 &mdash; JWT authentication with a hand-written filter
```bash
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
cd jwt-auth-demo/jwt-authentication
./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256
# in another shell
./scripts/curl-transcript.sh # the whole flow, end to end
```
### Project 2 &mdash; OAuth2 resource server, JWKS and rotation
```bash
cd jwt-auth-demo/oauth2-resource-server
# a stub issuer whose JWK Set can be mutated on command
./scripts/run-stub-issuer.sh
./scripts/run-rs.sh stub,roles,audience
./scripts/issuer-audience-demo.sh "stub,roles,audience"
# or a real Keycloak
docker compose -f docker/compose.yaml up -d
./scripts/run-rs.sh keycloak,roles
./scripts/keycloak-demo.sh
```
---
## Project 1 &mdash; `jwt-authentication/`
Three demo users:
| username | password | authorities |
|---|---|---|
| `alice` | `alice-password` | `ROLE_USER`, `SCOPE_profile:read` |
| `root` | `root-password` | `ROLE_USER`, `ROLE_ADMIN`, `SCOPE_profile:read`, `SCOPE_admin:read` |
| `locked` | `locked-password` | locked account &mdash; always fails login |
### Profiles
| profile | what it changes |
|---|---|
| `hs256` *(default)* | Symmetric HMAC signing. One secret signs and verifies. |
| `rs256` | RSA signing, plus a real `/.well-known/jwks.json` endpoint. |
| *(none)* | Validation by a hand-written `OncePerRequestFilter`. |
| `resourceserver` | Validation by Spring Security's built-in `oauth2ResourceServer().jwt()`. |
| `strict` | Adds the `token_type` validator to the resource-server chain. |
| `csrfon` | Turns CSRF on, reproducing the "permitAll() returns 403" failure. |
| `shortlived` | 2-second access tokens, for observing expiry and clock skew. |
| `trace` | `TRACE` logging for `org.springframework.security`. |
### Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
| `POST` | `/api/auth/login` | `permitAll()` | issues an access + refresh token pair |
| `POST` | `/api/auth/refresh` | `permitAll()` | rotates the refresh token |
| `POST` | `/api/auth/logout` | authenticated | revokes the presented token by `jti` |
| `GET` | `/api/public/ping` | `permitAll()` | reachable with no token at all |
| `GET` | `/api/me` | authenticated | **401** without a token |
| `GET` | `/api/admin/stats` | `hasRole('ADMIN')` | **403** with a valid non-admin token |
| `GET` | `/api/reports` | `@PreAuthorize` scope | the method-security twin of the above |
| `GET` | `/api/public/filters` | `permitAll()` | prints the live filter chain |
| `GET` | `/api/async-demo` | authenticated | `SecurityContext` across a thread boundary |
| `GET` | `/.well-known/jwks.json` | `permitAll()` | `rs256` profile only |
Runs on **:8080**. Regenerate its captured output with `./jwt-authentication/scripts/run-all.sh`.
---
## Project 2 &mdash; `oauth2-resource-server/`
Two applications in one Maven module, started by main class:
| application | port | what it is |
|---|---|---|
| `ResourceServerApplication` | 8081 | the resource server. Validates only; never mints. |
| `StubIssuerApplication` | 9000 | an authorization server whose JWK Set can be mutated on command |
The stub exists because Keycloak will not rotate its signing key at a chosen second, will not
report how many times its JWKS endpoint was fetched, and will not drop a key from the
published set on request &mdash; and every measurement about caching and rotation timing needs
all three. The Keycloak run confirms the same code path against a real issuer.
### Profiles
| profile | what it changes |
|---|---|
| `stub` | issuer is the in-repo stub on :9000 |
| `keycloak` | issuer is the Keycloak in `docker/compose.yaml` |
| `roles` | a Java `JwtAuthenticationConverter` mapping Keycloak's nested roles |
| `propsroles` | the same mapping in configuration only, with the SpEL indexer quoted |
| `propsroles-broken` | the same, unquoted &mdash; fails silently. See [docs/14](docs/14-authentication-converter.md) |
| `audience` | adds a `JwtAudienceValidator` bean |
| `props` | audience validation by property instead |
| `attyp` | a type validator that accepts RFC 9068 `at+jwt` |
| `springcache` | a Caffeine JWKS cache with a 5-minute TTL |
| `nottlcache` | a `ConcurrentMapCache` with no TTL &mdash; the trap in [docs/15](docs/15-jwks-caching-and-rotation.md) |
| `hardened` | the `JWKSource` built directly, with rate limiting and outage tolerance restored |
| `trace` | `TRACE` logging for `org.springframework.security` |
| `tracespel` | just enough logging to see a claim expression fail |
```bash
./scripts/run-rs.sh stub,roles,audience
./scripts/run-rs.sh keycloak,propsroles
./scripts/run-rs.sh stub,roles,nottlcache
```
### Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
| `GET` | `/api/public/ping` | `permitAll()` | reachable with no token |
| `GET` | `/api/me` | authenticated | prints the authorities the converter produced |
| `GET` | `/api/admin/stats` | `hasRole('ADMIN')` | realm role, from `realm_access.roles` |
| `GET` | `/api/reports` | `@PreAuthorize` | client role, from `resource_access.reports-api.roles` |
| `GET` | `/api/public/decoder` | `permitAll()` | **prints the live JWK source chain.** Delete before shipping |
| `GET` | `/.well-known/oauth-protected-resource` | &mdash; | published by Spring Security 7 itself |
Stub issuer admin endpoints, for driving a rotation:
| method | path | what it does |
|---|---|---|
| `POST` | `/admin/publish` | generate a key and add it to the JWK Set |
| `POST` | `/admin/activate?kid=` | start signing with that key |
| `POST` | `/admin/retire?kid=` | remove it from the JWK Set. It can still sign |
| `POST` | `/admin/reset-counter` | zero the JWKS fetch counter |
| `GET` | `/admin/state` | active kid, published kids, fetch count |
| `POST` | `/token?sub=&aud=&roles=&expiresInSeconds=&issuedAgoSeconds=&typ=&kid=&issuerOverride=` | mint anything, correct or not |
| `POST` | `/token/unknown-kid` | a token whose `kid` never existed |
Regenerate its captured output with `./oauth2-resource-server/scripts/run-all.sh`
(needs Docker; takes roughly twenty-five minutes, most of it waiting out cache lifetimes).
---
## Documentation
One numbered trail across both projects. Start at
[`docs/01-architecture.md`](docs/01-architecture.md).
| doc | covers |
|---|---|
| [01 — Architecture](docs/01-architecture.md) | the whole request path, drawn |
| [02 — Filter chain and ordering](docs/02-filter-chain-and-ordering.md) | where a custom filter goes, and the four ways to place it wrong |
| [03 — 401 vs 403](docs/03-401-vs-403.md) | `ExceptionTranslationFilter`'s actual decision, and RFC 6750 headers |
| [04 — CSRF vs permitAll](docs/04-csrf-permitall-403.md) | why `permitAll()` still returns 403, and when to disable CSRF |
| [05 — HS256 vs RS256](docs/05-hs256-vs-rs256.md) | key handling, JWKS, rotation, algorithm confusion |
| [06 — SecurityContext and statelessness](docs/06-securitycontext-and-statelessness.md) | explicit save, repositories, thread boundaries |
| [07 — Edge cases](docs/07-edge-cases.md) | 18 things that bite, each with the fix |
| [08 — Testing](docs/08-testing.md) | what to pin, and the Boot 4 test-slice split |
| [09 — Manual filter vs resource server](docs/09-manual-filter-vs-resource-server.md) | a side-by-side, and which to pick |
| [10 — Production checklist](docs/10-production-checklist.md) | the list to run before you ship |
| [11 — What changed in Spring Security 7](docs/11-spring-security-7-changes.md) | the 7.x-specific surprises this repo hit |
| [12 — Issuer and audience](docs/12-issuer-and-audience.md) | the two claims that make a token yours, and why `aud` is unchecked by default |
| [13 — The validator stack](docs/13-validator-stack.md) | what is in it, how to add to it without losing it |
| [14 — The authentication converter](docs/14-authentication-converter.md) | claims to authorities, and Keycloak's invisible roles |
| [15 — JWKS caching and key rotation](docs/15-jwks-caching-and-rotation.md) | what the cache really does, and how long a retired key lives |
| [16 — What an unknown `kid` costs](docs/16-jwks-amplification.md) | rate limiting is off, measured 1:1 |
| [17 — Keycloak setup](docs/17-keycloak-setup.md) | compose, realm import, and three ways it bites |
| [18 — Resource server checklist](docs/18-resource-server-checklist.md) | the list for the resource-server side |
---
## Captured output
### Project 1
| file | what it shows |
|---|---|
| [`curl-transcript-hs256.txt`](docs/output/curl-transcript-hs256.txt) | 20 steps: login → token → 401 → 403 → tamper → refresh → revoke |
| [`rs256-demo.txt`](docs/output/rs256-demo.txt) | JWKS, `alg=RS256`, signature sizes, tamper rejection |
| [`csrf-vs-permitall.txt`](docs/output/csrf-vs-permitall.txt) | the 403 on a `permitAll()` endpoint |
| [`csrf-trace.txt`](docs/output/csrf-trace.txt) | the TRACE log proving the chain stops at filter 5 of 12 |
| [`expiry-and-clock-skew.txt`](docs/output/expiry-and-clock-skew.txt) | a token still accepted 5s after `exp` |
| [`resource-server-loose.txt`](docs/output/resource-server-loose.txt) | a refresh token accepted as an access token |
| [`resource-server-strict.txt`](docs/output/resource-server-strict.txt) | the same request, refused |
| [`test-run.txt`](docs/output/test-run.txt) | 13 passing tests |
### Project 2
| file | what it shows |
|---|---|
| [`rs-issuer-audience.txt`](docs/output/rs-issuer-audience.txt) | wrong `iss`, wrong `aud`, expiry either side of the clock skew, `at+jwt` refused |
| [`rs-issuer-audience-attyp.txt`](docs/output/rs-issuer-audience-attyp.txt) | the same run with a type validator that accepts `at+jwt` |
| [`rs-converter-default.txt`](docs/output/rs-converter-default.txt) | Keycloak-shaped roles, and the 403 they produce untouched |
| [`rs-converter-java.txt`](docs/output/rs-converter-java.txt) | the same token through a custom converter |
| [`rs-converter-properties.txt`](docs/output/rs-converter-properties.txt) | the same mapping in configuration only |
| [`rs-converter-properties-broken.txt`](docs/output/rs-converter-properties-broken.txt) | one unquoted SpEL indexer, and the silence it produces |
| [`rs-decoder-chain.txt`](docs/output/rs-decoder-chain.txt) | the live JWK source chain under three cache configurations |
| [`rs-rotation.txt`](docs/output/rs-rotation.txt) | publish, activate and retire, watched from the other side |
| [`rs-jwks-amplification.txt`](docs/output/rs-jwks-amplification.txt) | 25 bad tokens, 25 JWKS fetches |
| [`rs-retired-key-default.txt`](docs/output/rs-retired-key-default.txt) | how long a retired key lives with the default cache |
| [`rs-retired-key-nottlcache.txt`](docs/output/rs-retired-key-nottlcache.txt) | the same, with a Spring cache that has no TTL |
| [`rs-keycloak.txt`](docs/output/rs-keycloak.txt) | the same code against a real Keycloak 26.7.2 |
| [`rs-keycloak-default-converter.txt`](docs/output/rs-keycloak-default-converter.txt) | real Keycloak, roles unmapped |
| [`rs-test-run.txt`](docs/output/rs-test-run.txt) | 10 tests pinning the default validator stack |
---
## Security note
The keys in `jwt-authentication/src/main/resources/`, the HMAC secret in its
`application.yaml`, and the Keycloak credentials in
[`docker/realm-demo.json`](oauth2-resource-server/docker/realm-demo.json) are **demo values
committed on purpose** so the repository runs with no setup. They are public. Never point
them at anything you care about &mdash; see
[docs/10-production-checklist.md](docs/10-production-checklist.md) and
[docs/18-resource-server-checklist.md](docs/18-resource-server-checklist.md).
`/api/public/decoder` reads private fields by reflection and prints your JWK Set URI and
cache timings. It is a diagnostic. Delete it before you ship.
## License
MIT.