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:
175
README.md
175
README.md
@@ -1,10 +1,18 @@
|
||||
# jwt-auth-demo
|
||||
|
||||
Runnable companion code for **[Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/)** on ankurm.com.
|
||||
Runnable companion code for two articles on [ankurm.com](https://ankurm.com):
|
||||
|
||||
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output)
|
||||
is real program output, regenerated by [`scripts/run-all.sh`](scripts/run-all.sh) — not
|
||||
transcribed by hand.
|
||||
| | 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 —
|
||||
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 — not transcribed by hand.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -15,33 +23,53 @@ transcribed by hand.
|
||||
| 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 — JWT authentication with a hand-written filter
|
||||
|
||||
```bash
|
||||
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
|
||||
cd jwt-auth-demo
|
||||
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 — 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 — `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 — always fails login |
|
||||
| `locked` | `locked-password` | locked account — always fails login |
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
The same application demonstrates four axes. Combine them freely.
|
||||
### Profiles
|
||||
|
||||
| profile | what it changes |
|
||||
|---|---|
|
||||
@@ -54,15 +82,7 @@ The same application demonstrates four axes. Combine them freely.
|
||||
| `shortlived` | 2-second access tokens, for observing expiry and clock skew. |
|
||||
| `trace` | `TRACE` logging for `org.springframework.security`. |
|
||||
|
||||
```bash
|
||||
./scripts/run.sh rs256
|
||||
./scripts/run.sh hs256,resourceserver,strict
|
||||
./scripts/run.sh hs256,csrfon,trace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
### Endpoints
|
||||
|
||||
| method | path | rule | why it exists |
|
||||
|---|---|---|---|
|
||||
@@ -77,11 +97,80 @@ The same application demonstrates four axes. Combine them freely.
|
||||
| `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 — `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 — 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 — 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 — 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` | — | 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
|
||||
|
||||
Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the trail.
|
||||
One numbered trail across both projects. Start at
|
||||
[`docs/01-architecture.md`](docs/01-architecture.md).
|
||||
|
||||
| doc | covers |
|
||||
|---|---|
|
||||
@@ -96,11 +185,20 @@ Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the t
|
||||
| [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 |
|
||||
@@ -112,20 +210,39 @@ Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the t
|
||||
| [`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 |
|
||||
|
||||
Regenerate all of it:
|
||||
### Project 2
|
||||
|
||||
```bash
|
||||
./scripts/run-all.sh
|
||||
```
|
||||
| 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 `src/main/resources/` and the HMAC secret in `application.yaml` are
|
||||
**demo values committed on purpose** so the repository runs with no setup. They are
|
||||
public. Never point them at anything you care about — see
|
||||
[docs/10-production-checklist.md](docs/10-production-checklist.md).
|
||||
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 — 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user