From 4dc45d5e004facd4ac4c7fd895b0393712f35d75 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Sun, 23 Aug 2026 11:00:56 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t --- README.md | 175 ++++++++++++--- docs/01-architecture.md | 16 +- docs/02-filter-chain-and-ordering.md | 4 +- docs/03-401-vs-403.md | 2 +- docs/04-csrf-permitall-403.md | 2 +- docs/05-hs256-vs-rs256.md | 4 +- docs/06-securitycontext-and-statelessness.md | 2 +- docs/07-edge-cases.md | 8 +- docs/08-testing.md | 4 +- docs/09-manual-filter-vs-resource-server.md | 8 +- docs/11-spring-security-7-changes.md | 2 +- docs/12-issuer-and-audience.md | 210 ++++++++++++++++++ docs/13-validator-stack.md | 127 +++++++++++ docs/14-authentication-converter.md | 144 ++++++++++++ docs/15-jwks-caching-and-rotation.md | 180 +++++++++++++++ docs/16-jwks-amplification.md | 135 +++++++++++ docs/17-keycloak-setup.md | 153 +++++++++++++ docs/18-resource-server-checklist.md | 82 +++++++ docs/output/rs-converter-default.txt | 66 ++++++ docs/output/rs-converter-java.txt | 77 +++++++ .../output/rs-converter-properties-broken.txt | 77 +++++++ docs/output/rs-converter-properties.txt | 68 ++++++ docs/output/rs-decoder-chain.txt | 107 +++++++++ docs/output/rs-issuer-audience-attyp.txt | 160 +++++++++++++ docs/output/rs-issuer-audience.txt | 143 ++++++++++++ docs/output/rs-jwks-amplification.txt | 34 +++ docs/output/rs-keycloak-default-converter.txt | 71 ++++++ docs/output/rs-keycloak.txt | 79 +++++++ docs/output/rs-retired-key-default.txt | 47 ++++ docs/output/rs-retired-key-nottlcache.txt | 47 ++++ docs/output/rs-rotation.txt | 69 ++++++ docs/output/rs-test-run.txt | 13 ++ pom.xml => jwt-authentication/pom.xml | 0 .../scripts}/csrf-demo.sh | 0 .../scripts}/curl-transcript.sh | 2 +- .../scripts}/expiry-demo.sh | 0 .../scripts}/resource-server-demo.sh | 0 .../scripts}/rs256-demo.sh | 0 .../scripts}/run-all.sh | 16 +- .../scripts}/run.sh | 0 .../jwtauth/JwtAuthDemoApplication.java | 0 .../ankurm/jwtauth/auth/AuthController.java | 0 .../com/ankurm/jwtauth/auth/AuthDtos.java | 0 .../jwtauth/auth/JwtAuthenticationFilter.java | 0 .../jwtauth/auth/RevokedTokenStore.java | 0 .../com/ankurm/jwtauth/auth/TokenService.java | 0 .../jwtauth/config/ApiExceptionHandler.java | 0 .../com/ankurm/jwtauth/config/AppUsers.java | 0 .../ankurm/jwtauth/config/Hs256KeyConfig.java | 0 .../jwtauth/config/JwtValidatorFactory.java | 0 .../config/ResourceServerSecurityConfig.java | 0 .../ankurm/jwtauth/config/Rs256KeyConfig.java | 0 .../ankurm/jwtauth/config/SecurityConfig.java | 0 .../jwtauth/diag/FilterChainReport.java | 0 .../edge/AccessTokenTypeValidator.java | 0 .../jwtauth/edge/AsyncPropagationDemo.java | 0 .../jwtauth/edge/AudienceValidator.java | 0 .../ankurm/jwtauth/web/ApiControllers.java | 0 .../main/resources/application-csrfon.yaml | 0 .../resources/application-shortlived.yaml | 0 .../main/resources/application-strict.yaml | 0 .../main/resources/application-trace.yaml | 0 .../src}/main/resources/application.yaml | 0 .../src}/main/resources/demo-private-key.pem | 0 .../src}/main/resources/demo-public-key.pem | 0 .../jwtauth/AuthenticationFlowTests.java | 0 .../jwtauth/CsrfBreaksPermitAllTests.java | 0 oauth2-resource-server/docker/compose.yaml | 34 +++ oauth2-resource-server/docker/realm-demo.json | 129 +++++++++++ oauth2-resource-server/pom.xml | 74 ++++++ .../scripts/amplification-demo.sh | 58 +++++ .../scripts/converter-demo.sh | 20 ++ .../scripts/decoder-chain.sh | 9 + .../scripts/issuer-audience-demo.sh | 51 +++++ .../scripts/keycloak-demo.sh | 35 +++ oauth2-resource-server/scripts/lib.sh | 50 +++++ .../scripts/retired-key-demo.sh | 56 +++++ .../scripts/rotation-demo.sh | 58 +++++ oauth2-resource-server/scripts/run-all.sh | 100 +++++++++ oauth2-resource-server/scripts/run-rs.sh | 16 ++ .../scripts/run-stub-issuer.sh | 14 ++ .../rsdemo/ResourceServerApplication.java | 23 ++ .../rsdemo/config/JwtDecoderConfig.java | 171 ++++++++++++++ .../config/KeycloakAuthoritiesConfig.java | 109 +++++++++ .../config/ResourceServerSecurityConfig.java | 42 ++++ .../com/ankurm/rsdemo/web/ApiControllers.java | 61 +++++ .../web/DecoderDiagnosticsController.java | 128 +++++++++++ .../stubissuer/StubIssuerApplication.java | 25 +++ .../stubissuer/StubIssuerController.java | 181 +++++++++++++++ .../com/ankurm/stubissuer/StubKeyStore.java | 115 ++++++++++ .../ankurm/stubissuer/StubSecurityConfig.java | 21 ++ .../main/resources/application-keycloak.yaml | 11 + .../src/main/resources/application-props.yaml | 8 + .../application-propsroles-broken.yaml | 15 ++ .../resources/application-propsroles.yaml | 28 +++ .../src/main/resources/application-stub.yaml | 14 ++ .../resources/application-stubissuer.yaml | 5 + .../src/main/resources/application-trace.yaml | 6 + .../main/resources/application-tracespel.yaml | 4 + .../src/main/resources/application.yaml | 16 ++ .../rsdemo/JwtValidationContractTests.java | 130 +++++++++++ 101 files changed, 4087 insertions(+), 64 deletions(-) create mode 100644 docs/12-issuer-and-audience.md create mode 100644 docs/13-validator-stack.md create mode 100644 docs/14-authentication-converter.md create mode 100644 docs/15-jwks-caching-and-rotation.md create mode 100644 docs/16-jwks-amplification.md create mode 100644 docs/17-keycloak-setup.md create mode 100644 docs/18-resource-server-checklist.md create mode 100644 docs/output/rs-converter-default.txt create mode 100644 docs/output/rs-converter-java.txt create mode 100644 docs/output/rs-converter-properties-broken.txt create mode 100644 docs/output/rs-converter-properties.txt create mode 100644 docs/output/rs-decoder-chain.txt create mode 100644 docs/output/rs-issuer-audience-attyp.txt create mode 100644 docs/output/rs-issuer-audience.txt create mode 100644 docs/output/rs-jwks-amplification.txt create mode 100644 docs/output/rs-keycloak-default-converter.txt create mode 100644 docs/output/rs-keycloak.txt create mode 100644 docs/output/rs-retired-key-default.txt create mode 100644 docs/output/rs-retired-key-nottlcache.txt create mode 100644 docs/output/rs-rotation.txt create mode 100644 docs/output/rs-test-run.txt rename pom.xml => jwt-authentication/pom.xml (100%) rename {scripts => jwt-authentication/scripts}/csrf-demo.sh (100%) rename {scripts => jwt-authentication/scripts}/curl-transcript.sh (98%) rename {scripts => jwt-authentication/scripts}/expiry-demo.sh (100%) rename {scripts => jwt-authentication/scripts}/resource-server-demo.sh (100%) rename {scripts => jwt-authentication/scripts}/rs256-demo.sh (100%) rename {scripts => jwt-authentication/scripts}/run-all.sh (77%) rename {scripts => jwt-authentication/scripts}/run.sh (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/auth/AuthController.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/auth/AuthDtos.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/auth/TokenService.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/config/AppUsers.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/config/SecurityConfig.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java (100%) rename {src => jwt-authentication/src}/main/java/com/ankurm/jwtauth/web/ApiControllers.java (100%) rename {src => jwt-authentication/src}/main/resources/application-csrfon.yaml (100%) rename {src => jwt-authentication/src}/main/resources/application-shortlived.yaml (100%) rename {src => jwt-authentication/src}/main/resources/application-strict.yaml (100%) rename {src => jwt-authentication/src}/main/resources/application-trace.yaml (100%) rename {src => jwt-authentication/src}/main/resources/application.yaml (100%) rename {src => jwt-authentication/src}/main/resources/demo-private-key.pem (100%) rename {src => jwt-authentication/src}/main/resources/demo-public-key.pem (100%) rename {src => jwt-authentication/src}/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java (100%) rename {src => jwt-authentication/src}/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java (100%) create mode 100644 oauth2-resource-server/docker/compose.yaml create mode 100644 oauth2-resource-server/docker/realm-demo.json create mode 100644 oauth2-resource-server/pom.xml create mode 100755 oauth2-resource-server/scripts/amplification-demo.sh create mode 100755 oauth2-resource-server/scripts/converter-demo.sh create mode 100755 oauth2-resource-server/scripts/decoder-chain.sh create mode 100755 oauth2-resource-server/scripts/issuer-audience-demo.sh create mode 100755 oauth2-resource-server/scripts/keycloak-demo.sh create mode 100755 oauth2-resource-server/scripts/lib.sh create mode 100755 oauth2-resource-server/scripts/retired-key-demo.sh create mode 100755 oauth2-resource-server/scripts/rotation-demo.sh create mode 100755 oauth2-resource-server/scripts/run-all.sh create mode 100755 oauth2-resource-server/scripts/run-rs.sh create mode 100755 oauth2-resource-server/scripts/run-stub-issuer.sh create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/rsdemo/ResourceServerApplication.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/JwtDecoderConfig.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/KeycloakAuthoritiesConfig.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/ResourceServerSecurityConfig.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/ApiControllers.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/DecoderDiagnosticsController.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerApplication.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerController.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubKeyStore.java create mode 100644 oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubSecurityConfig.java create mode 100644 oauth2-resource-server/src/main/resources/application-keycloak.yaml create mode 100644 oauth2-resource-server/src/main/resources/application-props.yaml create mode 100644 oauth2-resource-server/src/main/resources/application-propsroles-broken.yaml create mode 100644 oauth2-resource-server/src/main/resources/application-propsroles.yaml create mode 100644 oauth2-resource-server/src/main/resources/application-stub.yaml create mode 100644 oauth2-resource-server/src/main/resources/application-stubissuer.yaml create mode 100644 oauth2-resource-server/src/main/resources/application-trace.yaml create mode 100644 oauth2-resource-server/src/main/resources/application-tracespel.yaml create mode 100644 oauth2-resource-server/src/main/resources/application.yaml create mode 100644 oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java diff --git a/README.md b/README.md index 75fec8a..4ade748 100644 --- a/README.md +++ b/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 diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 21aa916..05a660a 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -74,20 +74,20 @@ Authorization: Bearer eyJ... That list is not from memory. It is printed by `GET /api/public/filters`, which reads `FilterChainProxy.getFilterChains()` at runtime — see -[`FilterChainReport`](../src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java) +[`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`](../src/main/java/com/ankurm/jwtauth/config/AppUsers.java) + `DaoAuthenticationProvider` | — | -| token minting | [`TokenService`](../src/main/java/com/ankurm/jwtauth/auth/TokenService.java) | [05](05-hs256-vs-rs256.md) | -| token verifying | [`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) | [02](02-filter-chain-and-ordering.md) | -| claim validation | [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java) | [07](07-edge-cases.md) | -| key material | [`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) / [`Rs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) | [05](05-hs256-vs-rs256.md) | -| authorization rules | [`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) | [03](03-401-vs-403.md) | -| revocation | [`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java) | [07](07-edge-cases.md) | +| 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 diff --git a/docs/02-filter-chain-and-ordering.md b/docs/02-filter-chain-and-ordering.md index 10761fd..bbf46c2 100644 --- a/docs/02-filter-chain-and-ordering.md +++ b/docs/02-filter-chain-and-ordering.md @@ -84,7 +84,7 @@ FilterRegistrationBean disableAutoRegistration( ``` This repository sidesteps it: `JwtAuthenticationFilter` is constructed with `new` inside -[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) and is +[`SecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) and is never a bean. ### 4. Extending `GenericFilterBean` instead of `OncePerRequestFilter` @@ -97,7 +97,7 @@ authentication side effects fire twice. ## The five details inside the filter From -[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java): +[`JwtAuthenticationFilter`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java): **1. No token is not an error.** Continue the chain. This is what keeps `permitAll()` endpoints reachable. diff --git a/docs/03-401-vs-403.md b/docs/03-401-vs-403.md index 9289c8d..def3a4c 100644 --- a/docs/03-401-vs-403.md +++ b/docs/03-401-vs-403.md @@ -116,7 +116,7 @@ Two different 401 shapes for the same logical failure is a needless client bug. `BadCredentialsException` is an ordinary MVC exception by the time anything security-shaped could see it. `AuthenticationEntryPoint` is never invoked. It needs its own `@RestControllerAdvice` — see -[`ApiExceptionHandler`](../src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java): +[`ApiExceptionHandler`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java): ```java @ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class}) diff --git a/docs/04-csrf-permitall-403.md b/docs/04-csrf-permitall-403.md index 890aa80..e2cc47d 100644 --- a/docs/04-csrf-permitall-403.md +++ b/docs/04-csrf-permitall-403.md @@ -21,7 +21,7 @@ HTTP 403 WWW-Authenticate: Bearer ``` -Reproduce it: `./scripts/run.sh hs256,csrfon` then `./scripts/csrf-demo.sh`. Captured in +Reproduce it: `./jwt-authentication/scripts/run.sh hs256,csrfon` then `./jwt-authentication/scripts/csrf-demo.sh`. Captured in [`csrf-vs-permitall.txt`](output/csrf-vs-permitall.txt). Note the response body is empty and the header mentions `Bearer` — which sends people diff --git a/docs/05-hs256-vs-rs256.md b/docs/05-hs256-vs-rs256.md index d422482..d0c3420 100644 --- a/docs/05-hs256-vs-rs256.md +++ b/docs/05-hs256-vs-rs256.md @@ -50,7 +50,7 @@ The decoder side, confusingly, *does* use `macAlgorithm(..)` / `signatureAlgorit **The secret must be ≥ 256 bits.** Nimbus enforces the JWA rule that an HMAC key is at least as long as its digest; a shorter one throws `KeyLengthException` at encoder construction, not at first request. -[`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) fails +[`Hs256KeyConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) fails fast with a clearer message. A short secret is also brute-forceable offline — the attacker has the ciphertext, the plaintext, and unlimited attempts. @@ -84,7 +84,7 @@ key rotation is impossible: the verifier cannot tell which of two published keys ### Publishing the public half -[`Rs256KeyConfig.JwkSetEndpoint`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) +[`Rs256KeyConfig.JwkSetEndpoint`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) serves a real JWK Set. From [`rs256-demo.txt`](output/rs256-demo.txt): ```json diff --git a/docs/06-securitycontext-and-statelessness.md b/docs/06-securitycontext-and-statelessness.md index 57113c2..fd36363 100644 --- a/docs/06-securitycontext-and-statelessness.md +++ b/docs/06-securitycontext-and-statelessness.md @@ -49,7 +49,7 @@ this.contextRepository.saveContext(context, request, response); // <-- easy to For a genuinely stateless API `saveContext` on a `NullSecurityContextRepository` is a no-op, so omitting it appears to work — until an `ERROR` dispatch, a `FORWARD`, or an async re-dispatch clears the `ThreadLocal` and the principal vanishes on `/error`. -[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) +[`JwtAuthenticationFilter`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) uses `RequestAttributeSecurityContextRepository`, which survives a dispatch without ever touching a session — the right middle ground. diff --git a/docs/07-edge-cases.md b/docs/07-edge-cases.md index c5eec17..a8b45a2 100644 --- a/docs/07-edge-cases.md +++ b/docs/07-edge-cases.md @@ -21,8 +21,8 @@ decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( JwtValidators.createDefaultWithIssuer(issuer), audience)); ``` -See [`AudienceValidator`](../src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java) -and [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java). +See [`AudienceValidator`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java) +and [`JwtValidatorFactory`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java). --- @@ -46,7 +46,7 @@ no scopes. The caller is authenticated as alice with no privileges, so `/api/me` while `/api/admin/stats` does not. A partial compromise is still a compromise. Fix: a `token_type` claim and a validator that checks it — -[`AccessTokenTypeValidator`](../src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java). +[`AccessTokenTypeValidator`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java). --- @@ -79,7 +79,7 @@ new DelegatingOAuth2TokenValidator<>( "Logout" that deletes the token client-side is not revocation — the token stays valid until `exp` and works from anywhere it was copied. The minimum viable fix is a `jti` claim plus a denylist checked on every request: -[`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java). +[`RevokedTokenStore`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java). ```java if (this.revokedTokens.isRevoked(jwt.getId())) { diff --git a/docs/08-testing.md b/docs/08-testing.md index 52321a5..adb1178 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -91,7 +91,7 @@ this.mvc.perform(post("/api/auth/login").with(csrf()) ... ) ``` Convenient, and it will make a test pass against a configuration that 403s in production. -[`CsrfBreaksPermitAllTests`](../src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java) +[`CsrfBreaksPermitAllTests`](../jwt-authentication/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java) deliberately has both tests: one asserting the 403 **without** `csrf()`, one asserting the 200 with it. If you only ever write the second, you have tested your test. @@ -125,7 +125,7 @@ already in the past. ## Integration testing against the real server -`scripts/curl-transcript.sh` is the integration test that MockMvc cannot be — it exercises +`jwt-authentication/scripts/curl-transcript.sh` is the integration test that MockMvc cannot be — it exercises a real Tomcat, a real HTTP client, real header parsing, and real base64url. Several findings in these docs (the `resource_metadata` parameter, the `FACTOR_BEARER` authority, the bare `WWW-Authenticate` on a wrapped `JwtException`) came from that script, not from diff --git a/docs/09-manual-filter-vs-resource-server.md b/docs/09-manual-filter-vs-resource-server.md index a7d40c6..cca8a6a 100644 --- a/docs/09-manual-filter-vs-resource-server.md +++ b/docs/09-manual-filter-vs-resource-server.md @@ -5,14 +5,14 @@ Both are in this repository, behind profiles, secured identically. Run them side by side: ```bash -./scripts/run.sh hs256 # hand-written OncePerRequestFilter -./scripts/run.sh hs256,resourceserver # oauth2ResourceServer().jwt() +./jwt-authentication/scripts/run.sh hs256 # hand-written OncePerRequestFilter +./jwt-authentication/scripts/run.sh hs256,resourceserver # oauth2ResourceServer().jwt() ``` ## The configuration, side by side **Manual** — -[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java): +[`SecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java): ```java .addFilterBefore(new JwtAuthenticationFilter(jwtDecoder, revokedTokens, entryPoint), @@ -22,7 +22,7 @@ Both are in this repository, behind profiles, secured identically. Run them side plus ~120 lines of filter, plus the entry point and access-denied handler wired by hand. **Built-in** — -[`ResourceServerSecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java): +[`ResourceServerSecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java): ```java .oauth2ResourceServer(oauth2 -> oauth2 diff --git a/docs/11-spring-security-7-changes.md b/docs/11-spring-security-7-changes.md index 827b3df..784f4b8 100644 --- a/docs/11-spring-security-7-changes.md +++ b/docs/11-spring-security-7-changes.md @@ -1,6 +1,6 @@ # 11 — What changed in Spring Security 7 -[← production checklist](10-production-checklist.md) · [README](../README.md) +[← production checklist](10-production-checklist.md) · [next: issuer and audience →](12-issuer-and-audience.md) Everything below was hit while building this repository against Spring Security **7.1.1** on Spring Boot **4.1.1**, JDK **25**. Verified by compiling or by reading real responses, diff --git a/docs/12-issuer-and-audience.md b/docs/12-issuer-and-audience.md new file mode 100644 index 0000000..11e3651 --- /dev/null +++ b/docs/12-issuer-and-audience.md @@ -0,0 +1,210 @@ +# 12 — Issuer and audience: the two claims that make a token yours + +[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md) + +A valid signature proves the token was minted by something holding the signing key. It +proves nothing about *who it was minted for*. Those are two different questions, and a +resource server that only answers the first one is a resource server that will accept a +token issued to a different service in the same realm. + +Everything here was captured from [`rs-issuer-audience.txt`](output/rs-issuer-audience.txt), +produced by [`issuer-audience-demo.sh`](../oauth2-resource-server/scripts/issuer-audience-demo.sh). + +## One property, three network calls + +```yaml +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8080/realms/demo +``` + +That line makes Spring Boot build a `SupplierJwtDecoder`. The word that matters is +*supplier*: nothing happens at startup. On the **first token decoded**, three things occur +in order: + +1. `GET {issuer-uri}/.well-known/openid-configuration` — the discovery document +2. The `issuer` value inside it is compared against your configured `issuer-uri`, and a + mismatch fails the whole decoder, not just one token +3. `GET {jwks_uri}` — the JWK Set, both to learn which algorithms the issuer signs with + and to get the keys themselves + +The laziness is a feature. Your resource server starts even when the authorization server +is down; it fails on the first request instead of failing to boot. The cost is that the +first request after startup pays for two extra HTTP round trips, and any misconfiguration +in this chain shows up as a 500-flavoured `JwtDecoderInitializationException` on a request +rather than as a startup failure you would notice in a deployment pipeline. + +Set `jwk-set-uri` **as well as** `issuer-uri` to skip step 1 and 2. You keep issuer +validation and lose discovery. + +## `iss` is compared with `String.equals` + +Not normalised. Not parsed as a URI. Compared. + +``` +iss = "http://localhost:9000/other" +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", + error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid" +``` + +The token in that capture was signed by the correct key, by the same issuer, seconds +earlier. Only the string differed. A trailing slash is enough: +[`JwtValidationContractTests.issuerComparisonIsExactStringEquality`](../oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java) +pins that behaviour. + +This is the single most common cause of *“it works with curl but not from the +application”* against Keycloak, because Keycloak derives `iss` from the request host +unless you pin `KC_HOSTNAME`. A token fetched through `localhost:8080` and a token fetched +through `keycloak:8080` inside a Docker network carry different issuers, and exactly one of +them matches your configuration. See [17 — Keycloak setup](17-keycloak-setup.md). + +## `aud` is not checked at all by default + +This is the part worth reading twice. + +`JwtValidators.createDefaultWithIssuer(issuer)` — what Boot's auto-configuration uses when +you set only `issuer-uri` — builds a stack of `JwtTypeValidator`, `JwtTimestampValidator`, +`X509CertificateThumbprintValidator` and `JwtIssuerValidator`. There is no audience +validator in it. + +So this happens: + +``` +iss = "http://localhost:9000" <- correct +aud = "billing-api" <- a different service entirely +HTTP 200 +``` + +with the default configuration, and 401 once an audience check is added. Both transcripts +are in the repository: [`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) runs with +the `audience` profile and refuses it; the assertion that the *default* stack accepts it is +in `defaultStackDoesNotCheckAudience`. + +Any of these three fixes it. They are ordered by how little you have to write. + +```yaml +spring.security.oauth2.resourceserver.jwt.audiences: reports-api +``` + +```java +@Bean +OAuth2TokenValidator audienceValidator() { + return new JwtAudienceValidator("reports-api"); +} +``` + +```java +NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); +decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtIssuerValidator(issuer), new JwtAudienceValidator("reports-api"))); +``` + +The second one deserves attention: Boot's `JwtDecoderConfiguration` collects **every** +`OAuth2TokenValidator` bean in the context and appends it to the stack. Adding a +validator does not mean replacing the decoder, and replacing the decoder is how people +accidentally lose the issuer validator they thought they still had. + +Two details of `JwtAudienceValidator` that are easy to guess wrong, both pinned by tests: + +- a token with `aud: ["billing-api", "reports-api"]` **passes** — it matches any entry, + not all of them +- a token with no `aud` claim at all is **refused**, not ignored + +## The clock skew is 60 seconds and you will meet it + +`JwtTimestampValidator` allows 60 seconds of skew by default in both directions. A token +that expired 30 seconds ago is accepted; one that expired 90 seconds ago is not: + +``` +4. Expired 90 seconds ago -> HTTP 401 "Jwt expired at ..." +5. Expired 30 seconds ago -> HTTP 200 +``` + +That is usually what you want across machines whose clocks disagree. It is not what you +want if you are writing a test that asserts a token stops working the instant it expires, +and it is not what you want if your revocation story is *“short-lived tokens”* — +your real worst case is the lifetime plus a minute. + +To change it you have to build the validator yourself: + +```java +new JwtTimestampValidator(Duration.ofSeconds(5)) +``` + +## `typ=at+jwt` is refused by the default stack + +RFC 9068 defines a media type for JWT access tokens and says an access token SHOULD carry +`typ: at+jwt` in its JOSE header. The default validator stack contains +`JwtTypeValidator.jwt()`, which accepts an **absent** `typ` or `typ=JWT`, and nothing else. + +A conforming RFC 9068 access token therefore gets: + +``` +HTTP 401 error_description="... the given typ value needs to be one of [JWT]" +``` + +Keycloak is not affected, because its JOSE header says `typ: JWT` and it puts `Bearer` in a +*claim* of the same name, which nothing validates. An issuer that follows RFC 9068 more +closely will trip this. Two ways out: + +```java +// accept the type explicitly +JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt"); +types.setAllowEmpty(true); +``` + +```java +// or validate the whole token as an RFC 9068 access token +decoder.setJwtValidator(JwtValidators.createAtJwtValidator() + .issuer(issuer).audience("reports-api").clientId("demo-client").build()); +``` + +The second is stricter than it looks: `createAtJwtValidator()` also **requires** `exp`, +`sub`, `iat`, `jti` and `client_id` to be present. Keycloak does not emit `client_id` in an +access token, so this builder refuses Keycloak tokens until you tell it otherwise. + +The two transcripts differing in exactly this one validator are +[`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) and +[`rs-issuer-audience-attyp.txt`](output/rs-issuer-audience-attyp.txt). + +## Where failures explain themselves + +Nowhere in the response body. A resource server returns an empty body on 401 and puts the +reason in the `WWW-Authenticate` header, per RFC 6750: + +``` +WWW-Authenticate: Bearer error="invalid_token", + error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", + error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", + resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +``` + +If you are debugging with a tool that hides response headers, every claim-validation +failure looks identical. That is worth knowing before you spend an afternoon on it. + +## The endpoint you did not configure + +That last `resource_metadata` parameter points at something new. Spring Security 7 +publishes RFC 9728 protected resource metadata automatically and advertises it in the +challenge. It answers **without a token**, on a resource server whose chain says +`anyRequest().authenticated()`: + +``` +$ GET /.well-known/oauth-protected-resource +HTTP 200 +{"resource":"http://localhost:8081","bearer_methods_supported":["header"], + "tls_client_certificate_bound_access_tokens":true} +``` + +It is standards-compliant and mostly harmless, but it is a new unauthenticated endpoint +that appears on upgrade, it confirms to an unauthenticated caller which authorization +server you trust, and it will show up in your next penetration test. Know that it is +there and that it is yours. + +--- + +[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md) diff --git a/docs/13-validator-stack.md b/docs/13-validator-stack.md new file mode 100644 index 0000000..fdb522d --- /dev/null +++ b/docs/13-validator-stack.md @@ -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 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) diff --git a/docs/14-authentication-converter.md b/docs/14-authentication-converter.md new file mode 100644 index 0000000..8ee9b63 --- /dev/null +++ b/docs/14-authentication-converter.md @@ -0,0 +1,144 @@ +# 14 — The authentication converter: from claims to authorities + +[← the validator stack](13-validator-stack.md) · [next: JWKS caching and key rotation →](15-jwks-caching-and-rotation.md) + +Validation decides whether a token is genuine. Conversion decides what it is allowed to do. +They fail differently: a validation failure is a **401** with a reason in +`WWW-Authenticate`; a conversion failure is a **403** with `insufficient_scope` and no +explanation of what was missing. + +Three transcripts of the same token against the same endpoints, differing only in the +converter: + +| profile | authorities produced | `/api/reports` | `/api/admin/stats` | +|---|---|---|---| +| [`stub`](output/rs-converter-default.txt) | `SCOPE_profile:read`, `SCOPE_reports:read` | 403 | 403 | +| [`stub,roles`](output/rs-converter-java.txt) | the above plus `ROLE_USER`, `ROLE_reports-reader` | 200 | 403 | +| [`stub,propsroles`](output/rs-converter-properties.txt) | `ROLE_USER`, `ROLE_reports-reader` | 200 | 403 | + +`FACTOR_BEARER` appears in all three; it is a Spring Security 7 addition covered in +[chapter 11](11-spring-security-7-changes.md). + +## What the default converter looks at + +`JwtGrantedAuthoritiesConverter` reads the `scope` claim, or `scp` if `scope` is absent, +splits it on whitespace, and prefixes each value with `SCOPE_`. That is the whole algorithm. + +Keycloak emits `scope`, so scopes work with no configuration. Roles do not, because Keycloak +puts them here: + +```json +"realm_access": { "roles": ["USER"] }, +"resource_access": { "reports-api": { "roles": ["reports-reader"] } } +``` + +Neither is the `scope` claim, so the default converter finds nothing, and every +`hasRole(..)` rule returns 403 against a token that authenticated perfectly. The first +transcript in the table above is that failure, and +`nestedKeycloakRolesAreInvisibleToTheDefaultAuthoritiesConverter` asserts it. + +## Route one: configuration only + +Spring Boot 4 added `authorities-claim-expressions`, a list of SpEL expressions evaluated +against the claim map. Nested claims need no Java: + +```yaml +spring: + security: + oauth2: + resourceserver: + jwt: + principal-claim-name: preferred_username + authority-prefix: "ROLE_" + authorities-claim-expressions: + - "[realm_access][roles]" + - "[resource_access]['reports-api'][roles]" +``` + +**Quote the hyphenated client id.** Inside a SpEL indexer the contents are an expression, +not a literal key, so `[resource_access][reports-api][roles]` parses as `reports` minus +`api`: + +``` +[realm_access][roles] -> [USER] +[resource_access][reports-api][roles] -> SpelEvaluationException: EL1008E: + Property or field 'reports' cannot be found +[resource_access]['reports-api'][roles] -> [reports-reader] +``` + +And the failure does not reach you. `ExpressionJwtGrantedAuthoritiesConverter.getAuthorities` +catches `ExpressionException`, substitutes an empty list, and logs the reason at **TRACE**: + +```java +catch (ExpressionException ee) { + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Failed to evaluate expression. error=%s", ee.getMessage())); + } + authorities = Collections.emptyList(); +} +``` + +So a mistyped expression produces a 403, no exception, no WARN, and nothing in the log at +default levels. If a claim expression is not producing the authority you expect, the first +move is: + +```yaml +logging.level.org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter: TRACE +``` + +Two more limits of this route, both visible in the table above: + +- **`authority-prefix` is a single value applied to every expression.** A mixed mapping — + `SCOPE_` for scopes and `ROLE_` for roles — cannot be expressed here. +- **Naming expressions replaces the default converter.** The `SCOPE_*` authorities are gone + from the `propsroles` row for exactly that reason. Adding `[scope]` as an expression brings + the values back, prefixed `ROLE_`, which is not what you meant. + +`authorities-claim-expressions`, `authorities-claim-name` and `authorities-claim-delimiter` +are mutually exclusive; combining them throws +`MutuallyExclusiveConfigurationPropertiesException` at startup, which is the one failure in +this chapter that is loud. + +## Route two: a `JwtAuthenticationConverter` bean + +When the mapping is mixed, write it: + +```java +@Bean +JwtAuthenticationConverter keycloakJwtAuthenticationConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setPrincipalClaimName("preferred_username"); + converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api")); + return converter; +} +``` + +Full source: +[`KeycloakAuthoritiesConfig`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/KeycloakAuthoritiesConfig.java). +It delegates scopes to the stock `JwtGrantedAuthoritiesConverter` and adds realm and client +roles with a `ROLE_` prefix, which is the mapping the middle row of the table produces. + +**Defining this bean silently disables every one of the properties above.** Boot's +`JwtConverterConfiguration` is annotated +`@ConditionalOnMissingBean(JwtAuthenticationConverter.class)`, so the moment your bean +exists, `principal-claim-name`, `authority-prefix`, `authorities-claim-name`, +`authorities-claim-delimiter` and `authorities-claim-expressions` stop doing anything. No +warning is logged. If you have both, the YAML is decoration. + +## Flattening namespaces has a cost + +The converter here maps realm roles and client roles into one `ROLE_` namespace. That reads +well and matches what `hasRole(..)` expects, but if two clients in your realm each define a +role named `admin`, both collapse onto `ROLE_admin` and a token for one client passes a check +meant for the other. Prefix by client id if that is a real risk in your realm. + +## Choosing + +- Only scopes matter, and the issuer emits `scope` → change nothing. +- Roles from one nested claim, one prefix → properties. +- Two prefixes, filtering, a custom principal type, or anything conditional → a bean, and + delete the properties so nobody reads them and believes them. + +--- + +[← the validator stack](13-validator-stack.md) · [next: JWKS caching and key rotation →](15-jwks-caching-and-rotation.md) diff --git a/docs/15-jwks-caching-and-rotation.md b/docs/15-jwks-caching-and-rotation.md new file mode 100644 index 0000000..fdeee2c --- /dev/null +++ b/docs/15-jwks-caching-and-rotation.md @@ -0,0 +1,180 @@ +# 15 — JWKS caching and key rotation + +[← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md) + +Every guide says the same sentence: *“Spring Security caches the JWK Set for five +minutes and rotates keys automatically.”* It is half true, and the half that is not +decides whether a compromised key stops working in five minutes or never. + +This chapter is what the code actually does, read from the Spring Security 7.1.1 and Nimbus +10.9.1 sources and then measured. + +## The one method that decides everything + +`NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource()`: + +```java +JWKSource jwkSource() { + String jwkSetUri = this.jwkSetUri.apply(this.restOperations); + return JWKSourceBuilder.create(new SpringJWKSource<>(this.restOperations, this.cache, jwkSetUri)) + .refreshAheadCache(false) + .rateLimited(false) + .cache(this.cache instanceof NoOpCache) + .build(); +} +``` + +Nimbus's own defaults, from `JWKSourceBuilder`: + +| feature | Nimbus default | Spring Security 7.1.1 | +|---|---|---| +| `caching` | `true`, TTL 5 min, refresh timeout 15 s | only when no Spring cache was supplied | +| `refreshAhead` | `true`, 30 s ahead of expiry | **`false`** | +| `rateLimited` | `true`, min 30 s between forced refreshes | **`false`** | +| `outageTolerant` | `false` | `false` | +| `retrying` | `false` | `false` | + +Two of Nimbus's protective defaults are switched off outright. The third line is the +surprising one: **supplying a Spring `Cache` switches Nimbus's cache off**, because +`this.cache instanceof NoOpCache` is false the moment you supply one. The reference +documentation recommends supplying a cache in order to share the JWK Set between instances, +and does not mention that doing so removes the five-minute expiry. + +You do not have to take the source's word for it. `/api/public/decoder` +([`DecoderDiagnosticsController`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/DecoderDiagnosticsController.java)) +walks the live object graph. From [`rs-decoder-chain.txt`](output/rs-decoder-chain.txt), +with a Caffeine cache supplied: + +```json +"jwkSourceChain": [ + { "class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource" }, + { "class": "...NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource", + "jwkSetUri": "http://localhost:9000/jwks.json", + "springCache": "org.springframework.cache.caffeine.CaffeineCache", + "meaning": "a Spring cache was supplied, so Nimbus's cache layer was disabled and this cache's TTL is the only expiry" } +] +``` + +There is no `CachingJWKSetSource` in that chain. There is no `RateLimitedJWKSetSource`. Two +layers, and one of them is the HTTP call. + +## What actually triggers a re-fetch + +`JWKSetBasedJWKSource.get` is the whole rotation mechanism: + +```java +JWKSet jwkSet = source.getJWKSet(JWKSetCacheRefreshEvaluator.noRefresh(), currentTime, context); +List select = jwkSelector.select(jwkSet); +if (select.isEmpty()) { + JWKSet recentJwkSet = source.getJWKSet(JWKSetCacheRefreshEvaluator.referenceComparison(jwkSet), currentTime, context); + select = jwkSelector.select(recentJwkSet); +} +``` + +Select against the cached set; if nothing matches, force a refresh and select again. So a +JWK Set is re-fetched when, and only when: + +1. a token arrives whose `kid` is not in the cached set, **or** +2. the cache expires — Nimbus's five-minute TTL if no Spring cache was supplied, your + cache's TTL if one was + +With `refreshAhead(false)`, case 2 is always a synchronous refresh on a request thread. One +unlucky request every five minutes pays for the round trip to your identity provider. + +## Rotation is three events, not one + +Conflating them is where rotation incidents come from. The issuer in this repository fires +them separately on command, so a resource server can be watched in between. From +[`rs-rotation.txt`](output/rs-rotation.txt): + +**Publish.** The new key appears in the JWK Set; nothing signs with it yet. + +``` +published: stub-key-2 publishedKids: [stub-key-1, stub-key-2] +Old token still works: 200 +jwks fetches so far: 1 <- unchanged since the cold-start discovery fetch +``` + +The resource server has not noticed and has no reason to. This is the window an issuer is +supposed to leave, and its length is what makes rotation safe. + +**Activate.** The issuer starts signing with the new key. + +``` +New token: 200 +jwks fetches so far: 2 <- the unknown kid forced one +``` + +The first token with the new `kid` misses the cache, forces a refresh, and succeeds. Note +what that means: **the recovery is driven by the failure**. There is no scheduled refresh, +no background poll, no notification. If refresh-ahead were on it would matter less; it is +off. + +**Retire.** The old key is removed from the JWK Set. + +``` +retired: stub-key-1 publishedKids: [stub-key-2] +Old token: 200 <- still accepted +jwks fetches so far: 2 <- nothing forced a refresh +``` + +Nothing changes for the resource server, because nothing forced it to look. How long a +retired key keeps working is decided entirely by the cache. + +## How long a retired key keeps working + +This matters when the reason for retiring is that the key leaked. The issuer removes it +immediately; the question is when your resource servers stop honouring tokens signed with +it. + +The measurement is deliberately narrow: after the retirement the **only** traffic is the +leaked token itself. Its `kid` is in the stale cached set, so it never triggers the +unknown-kid refresh. Nothing else can dislodge the cache except its own expiry. + +Two runs, identical but for the cache, in +[`rs-retired-key-default.txt`](output/rs-retired-key-default.txt) and +[`rs-retired-key-nottlcache.txt`](output/rs-retired-key-nottlcache.txt). + +> **Read the two transcripts side by side.** They are the answer to +> “how fast does revocation propagate” for this stack, and the answer is +> different depending on one line of configuration you may have added for an unrelated +> reason. + +The default configuration — no Spring cache — has Nimbus's `CachingJWKSetSource` +with `DEFAULT_CACHE_TIME_TO_LIVE = 5 * 60 * 1000L` in front of it. The set expires on its +own and the retired key goes with it. + +A `ConcurrentMapCache` has no TTL. Neither does a `ConcurrentMapCacheManager`, which is what +Boot hands you when `spring-boot-starter-cache` is on the classpath and no cache provider +is configured. With one of those supplied, Nimbus's cache layer is off and nothing in the +system expires anything. + +If you supply a cache, give it a TTL, and make that TTL a deliberate decision rather than an +inherited default: + +```java +Cache cache = new CaffeineCache("jwks", + Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build()); +NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).cache(cache).build(); +``` + +## One more thing the Spring cache does + +`SpringJWKSource.getJWKSet` calls `this.cache.invalidate()` — not `evict(key)` — when a +refresh is required. That clears **the entire cache**, not just the JWK Set entry. Give the +JWK Set its own dedicated cache; do not point it at a cache you share with anything else. + +## What is not there + +- **No outage tolerance.** `outageTolerant` is false. If the issuer is unreachable when the + cache expires, every request fails until it comes back. Nimbus offers + `OutageTolerantJWKSetSource`, which serves a stale set through an outage; Spring Security + does not wire it in and the builder gives you no way to ask for it short of building the + `JWKSource` yourself and passing it to `NimbusJwtDecoder.withJwkSource(..)`. +- **No retry.** A single failed HTTP call to the JWKS endpoint fails the request. +- **No rate limiting.** See [chapter 16](16-jwks-amplification.md), which is the sharpest + consequence of anything in this chapter. + +--- + +[← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md) diff --git a/docs/16-jwks-amplification.md b/docs/16-jwks-amplification.md new file mode 100644 index 0000000..75c6124 --- /dev/null +++ b/docs/16-jwks-amplification.md @@ -0,0 +1,135 @@ +# 16 — What an unknown `kid` costs your identity provider + +[← JWKS caching and rotation](15-jwks-caching-and-rotation.md) · [next: Keycloak setup →](17-keycloak-setup.md) + +Nimbus rate-limits forced JWK Set refreshes by default: `rateLimited = true`, with +`DEFAULT_RATE_LIMIT_MIN_INTERVAL = 30_000L`. Spring Security turns it off. + +```java +JWKSourceBuilder.create(new SpringJWKSource<>(...)) + .refreshAheadCache(false) + .rateLimited(false) // ← this line + .cache(this.cache instanceof NoOpCache) + .build(); +``` + +With nothing between an unrecognised `kid` and the network, the refresh that +[chapter 15](15-jwks-caching-and-rotation.md) describes as the recovery mechanism becomes +something an attacker can drive. + +## The measurement + +[`amplification-demo.sh`](../oauth2-resource-server/scripts/amplification-demo.sh) sends 25 +requests to the resource server and counts how many times the issuer's `/jwks.json` is +fetched. The issuer counts its own fetches, so this is not inferred from logs. + +From [`rs-jwks-amplification.txt`](output/rs-jwks-amplification.txt): + +``` +Baseline: 25 requests with a VALID token, whose kid is in the cached JWK Set. + requests to the resource server : 25 + fetches of /jwks.json : 0 + +Now 25 requests carrying a token whose kid has never existed. + requests to the resource server : 25 + fetches of /jwks.json : 25 +``` + +One to one. Every rejected request became an outbound HTTP request to the authorization +server, from a resource server that has not authenticated anybody. + +The token itself is trivially cheap to make. It does not have to verify — it does not even +have to be signed by anything real. It only has to carry a `kid` the resource server has not +seen, which is a random string: + +``` +$ GET /api/me with an unknown kid +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", + error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: + Another algorithm expected, or no matching key(s) found" +``` + +Nothing in that response suggests anything unusual happened. The caller sees a 401. Your +identity provider sees the traffic. + +It does not need an endpoint that requires authentication. The same 25 requests against +`/api/public/ping`, whose rule is `permitAll()`: + +``` + requests to /api/public/ping : 25 + fetches of /jwks.json : 25 +``` + +`BearerTokenAuthenticationFilter` runs before any authorization rule, so a public endpoint +still evaluates a bearer token when one is present. A broken token sent to a `permitAll()` +endpoint returns 401 from that public endpoint, and fetches the JWK Set on the way. Any +endpoint reachable without credentials is an entry point. + +## Why this is worse than it first looks + +- The amplification is **per resource server instance**, and every instance has its own + cache, so a fleet multiplies it. +- It is reachable through any endpoint at all, `permitAll()` ones included, because the + JWKS fetch happens during token decoding, before any authorization rule runs. +- Keycloak's JWKS endpoint is not usually the thing you capacity-plan for, and it sits in + front of the token endpoint that every one of your services depends on. +- The requests come from your own resource servers, which are on your identity provider's + allow-lists. + +I have not found this documented anywhere as a consideration, and I would be glad to be +shown it is. What is certain is the behaviour: Nimbus defends against it, Spring Security +opts out, and the opt-out is one line in a package-private method with no property to +change it. + +## What you can do + +**Restore rate limiting.** Build the `JWKSource` yourself and hand it over. The builder does +not expose the toggle, but `withJwkSource` accepts a fully-built source: + +```java +JWKSource source = JWKSourceBuilder + .create(new URI(jwkSetUri).toURL()) + .cache(Duration.ofMinutes(5).toMillis(), Duration.ofSeconds(15).toMillis()) + .refreshAheadCache(true) + .rateLimited(Duration.ofSeconds(30).toMillis()) + .outageTolerant(Duration.ofMinutes(30).toMillis()) + .retrying(true) + .build(); +NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSource(source).build(); +decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtIssuerValidator(issuer), new JwtAudienceValidator(audience))); +``` + +That is the `hardened` profile in +[`JwtDecoderConfig`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/JwtDecoderConfig.java), +so it compiles and runs rather than being a sketch. `new URL(String)` is deprecated for +removal on modern JDKs; `new URI(..).toURL()` is the replacement. + +You lose three things: issuer discovery, so the JWK Set URI has to be configured explicitly; +the validator stack that `withIssuerLocation` supplied, which now has to be set in full; and +Spring's `RestOperations`, because `JWKSourceBuilder.create(URL)` fetches with Nimbus's own +`DefaultResourceRetriever` — any client customisation, proxy configuration or observability +wired into the Spring HTTP client no longer applies to JWKS fetches. In exchange you get +every protective layer Nimbus offers, which is more than the default gives you. + +The trade is real: rate limiting means that during a genuine rotation, tokens signed with +the new key are refused for up to the rate-limit interval after the first miss. Thirty +seconds of 401s during a planned rotation, against an unbounded outbound request rate that +anyone can trigger. For most services that is the right way round, but it is a decision, not +a default. + +**Rate-limit at the edge.** A limit on 401 responses per client is worth having anyway and +costs nothing here. + +**Alert on the JWKS endpoint.** A fetch rate that tracks your request rate rather than your +instance count means this is happening. It is the cheapest detection available and most +people are not looking at that metric at all. + +**Do not share one cache.** [Chapter 15](15-jwks-caching-and-rotation.md) covers +`cache.invalidate()` clearing everything; combined with this, a shared cache is repeatedly +emptied by unauthenticated traffic. + +--- + +[← JWKS caching and rotation](15-jwks-caching-and-rotation.md) · [next: Keycloak setup →](17-keycloak-setup.md) diff --git a/docs/17-keycloak-setup.md b/docs/17-keycloak-setup.md new file mode 100644 index 0000000..5628926 --- /dev/null +++ b/docs/17-keycloak-setup.md @@ -0,0 +1,153 @@ +# 17 — Keycloak setup, and the three ways the realm import bites + +[← what an unknown kid costs](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md) + +```bash +docker compose -f oauth2-resource-server/docker/compose.yaml up -d +cd oauth2-resource-server && ./scripts/run-rs.sh keycloak,roles +./scripts/keycloak-demo.sh +``` + +Transcript: [`rs-keycloak.txt`](output/rs-keycloak.txt). Keycloak **26.7.2**, released +19 August 2026. + +The point of running against a real issuer is that nothing in the resource server changes. +The application code is identical to the stub runs; one property differs: + +```yaml +spring.security.oauth2.resourceserver.jwt.issuer-uri: http://localhost:8080/realms/demo +``` + +## Pin `KC_HOSTNAME` + +Keycloak derives the `iss` claim, and the `issuer` in its discovery document, from the +request host unless you pin it. A token fetched through `localhost:8080` and the same token +fetched through `keycloak:8080` from inside a Docker network carry **different issuers**, +and [chapter 12](12-issuer-and-audience.md) explains why `JwtIssuerValidator` will refuse +one of them. + +```yaml +environment: + KC_HOSTNAME: http://localhost:8080 + KC_HOSTNAME_STRICT: "false" +``` + +This is the fix for the majority of *“the token works in curl but not from the +application”* reports. Both must agree with the value your resource servers are +configured with, from wherever they run. + +## Keycloak does not add an `aud` for you + +An access token from a bare Keycloak client has no `aud` naming your resource server. Since +[chapter 12](12-issuer-and-audience.md) argues you should be validating `aud`, you need a +mapper: + +```json +{ + "name": "reports-api-audience", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "reports-api", + "access.token.claim": "true" + } +} +``` + +Note `included.client.audience` for a client that exists in the realm, versus +`included.custom.audience` for an arbitrary string. Using the former means the audience +value is checked against a real client at configuration time. + +## A `clientScopes` key in the import replaces the built-ins + +This one cost a rebuild. A realm export/import that declares: + +```json +"clientScopes": [ { "name": "reports:read", ... } ] +``` + +does not *add* that scope. It **replaces the entire set**, and Keycloak's built-in +`profile`, `email`, `roles`, `acr`, `basic` and `web-origins` scopes are never created. +Tokens from that realm then have: + +- no `realm_access` claim, because the `roles` scope is what adds it +- no `preferred_username`, because the `profile` scope is what adds it + +which looks exactly like a broken authorities converter, and sends you to +[chapter 14](14-authentication-converter.md) to debug something that is not wrong. Verified +on 26.7.2 by listing the realm's client scopes through the admin API after import: + +``` +=== realm client scopes available === + offline_access + reports:read +``` + +[`realm-demo.json`](../oauth2-resource-server/docker/realm-demo.json) therefore declares no +`clientScopes` at all, and gets permissions across using realm roles and client roles +instead. + +## Users need a name + +A user in a realm import with no `firstName` and `lastName` fails the password grant with a +message that names nothing useful: + +```json +{"error":"invalid_grant","error_description":"Account is not fully set up"} +``` + +The realm's default required actions want a complete profile. Supply the names, and +`"requiredActions": []`. + +## Read the JWK Set before assuming it holds one key + +``` +keys published: 2 + kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA + kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA +``` + +A JWK Set contains keys you must not verify signatures with. Nimbus's +`JWSVerificationKeySelector` filters on `use` and `alg` before matching `kid`, so this is +handled — but if you are writing anything that reads a JWK Set yourself, filter on +`use: "sig"` rather than taking `keys[0]`. + +## `typ` is a claim as well as a header + +A Keycloak access token has `typ: "JWT"` in the **JOSE header** and `typ: "Bearer"` in the +**claim set**. `JwtTypeValidator` reads the header, so Keycloak passes the default type +check. Nothing validates the claim. Do not write a validator that reads +`jwt.getClaimAsString("typ")` expecting the header value. + +## `start-dev` resets everything + +Including the signing keys. Every restart is a new realm from the import, and a new `kid`. +Convenient for the rotation work in [chapter 15](15-jwks-caching-and-rotation.md); a +surprise if you were expecting yesterday's tokens to still verify. + +## What a real access token looks like here + +```json +{ + "iss": "http://localhost:8080/realms/demo", + "aud": "reports-api", + "typ": "Bearer", + "scope": "email profile", + "preferred_username": "alice", + "realm_access": { "roles": ["USER"] }, + "resource_access": { "reports-api": { "roles": ["reports-reader"] } } +} +``` + +Which produces, with the converter from [chapter 14](14-authentication-converter.md): + +``` +"authorities": ["FACTOR_BEARER", "ROLE_USER", "ROLE_reports-reader", "SCOPE_email", "SCOPE_profile"] +``` + +There is no `client_id` claim, which is why +`JwtValidators.createAtJwtValidator()` — which requires one — refuses Keycloak tokens +unless reconfigured. Keycloak puts the client in `azp`. + +--- + +[← what an unknown kid costs](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md) diff --git a/docs/18-resource-server-checklist.md b/docs/18-resource-server-checklist.md new file mode 100644 index 0000000..86a9886 --- /dev/null +++ b/docs/18-resource-server-checklist.md @@ -0,0 +1,82 @@ +# 18 — Resource server checklist + +[← Keycloak setup](17-keycloak-setup.md) · [README](../README.md) + +The list for a Spring Security resource server specifically. For the token-minting side, see +[10 — Production checklist](10-production-checklist.md). + +## Claims + +- [ ] **`aud` is validated.** It is not by default. One property, one bean, or one wrapped + validator — [12](12-issuer-and-audience.md). Without it, any token from your realm + works against any of your services. +- [ ] **`issuer-uri` matches the issuer string byte for byte**, from wherever the resource + server runs. Trailing slashes count. Pin `KC_HOSTNAME` — [17](17-keycloak-setup.md). +- [ ] **The clock skew is a decision.** 60 seconds by default, in both directions. If your + revocation story is short-lived tokens, the real worst case is the lifetime plus a + minute — [12](12-issuer-and-audience.md). +- [ ] **You know whether your issuer emits `typ: at+jwt`.** The default stack refuses it — + [12](12-issuer-and-audience.md). +- [ ] **`setJwtValidator` is never called with a bare validator.** It replaces the whole + stack. Wrap with `JwtValidators.createDefaultWithValidators` — [13](13-validator-stack.md). +- [ ] **The default stack is pinned by a test**, so an upgrade that moves it goes red rather + than quiet — [13](13-validator-stack.md). + +## Authorities + +- [ ] **Roles actually arrive.** Keycloak's live under `realm_access.roles` and + `resource_access..roles`; the default converter reads neither — + [14](14-authentication-converter.md). +- [ ] **Hyphenated client ids in SpEL expressions are quoted.** `['reports-api']`, not + `[reports-api]`. The failure is a silent empty authority list — [14](14-authentication-converter.md). +- [ ] **You have either a `JwtAuthenticationConverter` bean or the properties, not both.** + The bean silently disables the properties — [14](14-authentication-converter.md). +- [ ] **Realm and client roles that share a name do not collide** into one `ROLE_` + namespace in a way that grants something — [14](14-authentication-converter.md). + +## JWKS and rotation + +- [ ] **If you supplied a Spring `Cache`, it has a TTL.** Supplying one disables Nimbus's + five-minute cache; a `ConcurrentMapCache` never expires and a retired key stays + trusted — [15](15-jwks-caching-and-rotation.md). +- [ ] **The JWKS cache is not shared with anything else.** A refresh calls + `cache.invalidate()`, which clears the whole cache — [15](15-jwks-caching-and-rotation.md). +- [ ] **You know your revocation window.** It is the cache TTL, not zero, and not the token + lifetime — [15](15-jwks-caching-and-rotation.md). +- [ ] **Your issuer's rotation leaves a publish window** long enough for every resource + server to see the new key before it starts signing with it — + [15](15-jwks-caching-and-rotation.md). +- [ ] **Someone is watching the JWKS endpoint's request rate.** A rate that tracks request + volume rather than instance count means unknown-`kid` traffic is amplifying through + you — [16](16-jwks-amplification.md). +- [ ] **You have decided about rate limiting.** Spring Security disables Nimbus's. Restoring + it means building the `JWKSource` yourself and giving up discovery — + [16](16-jwks-amplification.md). +- [ ] **You have decided about outage tolerance.** It is off. When the cache expires and the + issuer is unreachable, every request fails — [15](15-jwks-caching-and-rotation.md). + +## Endpoints + +- [ ] **`/api/public/decoder` is deleted.** The diagnostic in this repository reveals your + JWK Set URI and cache timings to anyone who can reach it. +- [ ] **You know `/.well-known/oauth-protected-resource` exists.** Spring Security 7 + publishes it, unauthenticated, without being asked — [12](12-issuer-and-audience.md). +- [ ] **CSRF is disabled deliberately**, because a stateless bearer-token API has no + ambient credential to protect — and for no other reason + — [04](04-csrf-permitall-403.md). + +## Should you build this at all + +If your services are minting their own tokens for their own users, you do not need a +resource server and you do not need an identity provider; the hand-written filter in +[`jwt-authentication/`](../jwt-authentication) is less code and fewer moving parts. See +[09 — manual filter vs resource server](09-manual-filter-vs-resource-server.md). + +The resource server earns its complexity when tokens come from somewhere you do not control: +several services trusting one issuer, an identity provider you did not write, key rotation +that has to happen without redeploying anything. If that is not your situation, most of this +document is a list of ways to get something wrong that you could simply not have. + +--- + +[← Keycloak setup](17-keycloak-setup.md) · [README](../README.md) diff --git a/docs/output/rs-converter-default.txt b/docs/output/rs-converter-default.txt new file mode 100644 index 0000000..056c182 --- /dev/null +++ b/docs/output/rs-converter-default.txt @@ -0,0 +1,66 @@ +-------------------------------------------------------------------------- + resource server profiles: stub +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- + 1. alice - realm role USER, client role reports-reader +-------------------------------------------------------------------------- + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me (which authorities did the converter produce?) +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:33:03Z" +} + + +$ GET /api/reports (needs ROLE_reports-reader, from resource_access) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" + +$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" +-------------------------------------------------------------------------- + 2. root - realm roles USER and ADMIN +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 200 +{ + "name": "root", + "authorities": [ + "FACTOR_BEARER", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:33:04Z" +} + + +$ GET /api/admin/stats +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" diff --git a/docs/output/rs-converter-java.txt b/docs/output/rs-converter-java.txt new file mode 100644 index 0000000..2372009 --- /dev/null +++ b/docs/output/rs-converter-java.txt @@ -0,0 +1,77 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,roles +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- + 1. alice - realm role USER, client role reports-reader +-------------------------------------------------------------------------- + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me (which authorities did the converter produce?) +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:33:11Z" +} + + +$ GET /api/reports (needs ROLE_reports-reader, from resource_access) +HTTP 200 +{ + "reports": 3 +} + + +$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" +-------------------------------------------------------------------------- + 2. root - realm roles USER and ADMIN +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 200 +{ + "name": "root", + "authorities": [ + "FACTOR_BEARER", + "ROLE_ADMIN", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:33:11Z" +} + + +$ GET /api/admin/stats +HTTP 200 +{ + "secret": "only ROLE_ADMIN sees this" +} + diff --git a/docs/output/rs-converter-properties-broken.txt b/docs/output/rs-converter-properties-broken.txt new file mode 100644 index 0000000..5380610 --- /dev/null +++ b/docs/output/rs-converter-properties-broken.txt @@ -0,0 +1,77 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,propsroles-broken,tracespel +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- + 1. alice - realm role USER, client role reports-reader +-------------------------------------------------------------------------- + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me (which authorities did the converter produce?) +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T11:00:53Z" +} + + +$ GET /api/reports (needs ROLE_reports-reader, from resource_access) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" + +$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" +-------------------------------------------------------------------------- + 2. root - realm roles USER and ADMIN +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 200 +{ + "name": "root", + "authorities": [ + "FACTOR_BEARER", + "ROLE_ADMIN", + "ROLE_USER" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T11:00:53Z" +} + + +$ GET /api/admin/stats +HTTP 200 +{ + "secret": "only ROLE_ADMIN sees this" +} + + +-------------------------------------------------------------------------- + what the resource server logged, at TRACE, while producing that 403 +-------------------------------------------------------------------------- + Failed to evaluate expression. error=EL1008E: Property or field 'reports' cannot be found on object of type 'java.util.Collections$UnmodifiableMap' - maybe not public or not valid? + Found authorities with expression. authorities=[USER, ADMIN] + Found authorities with expression. authorities=[USER] + Looking for authorities with expression. expression=[realm_access][roles] + Looking for authorities with expression. expression=[resource_access][reports-api][roles] diff --git a/docs/output/rs-converter-properties.txt b/docs/output/rs-converter-properties.txt new file mode 100644 index 0000000..1a5a986 --- /dev/null +++ b/docs/output/rs-converter-properties.txt @@ -0,0 +1,68 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,propsroles +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- + 1. alice - realm role USER, client role reports-reader +-------------------------------------------------------------------------- + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me (which authorities did the converter produce?) +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:33:19Z" +} + + +$ GET /api/reports (needs ROLE_reports-reader, from resource_access) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" + +$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" +-------------------------------------------------------------------------- + 2. root - realm roles USER and ADMIN +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 200 +{ + "name": "root", + "authorities": [ + "FACTOR_BEARER", + "ROLE_ADMIN", + "ROLE_USER" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:33:19Z" +} + + +$ GET /api/admin/stats +HTTP 200 +{ + "secret": "only ROLE_ADMIN sees this" +} + diff --git a/docs/output/rs-decoder-chain.txt b/docs/output/rs-decoder-chain.txt new file mode 100644 index 0000000..9deace0 --- /dev/null +++ b/docs/output/rs-decoder-chain.txt @@ -0,0 +1,107 @@ +-------------------------------------------------------------------------- + resource server profiles: stub +-------------------------------------------------------------------------- +One token is decoded first, because the decoder for an issuer-uri is built lazily. +{ + "decoderClass": "org.springframework.security.oauth2.jwt.SupplierJwtDecoder", + "note": "SupplierJwtDecoder: built lazily on first decode, then cached", + "resolvedDecoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder", + "processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor", + "keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector", + "jwkSourceChain": [ + { + "class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource" + }, + { + "class": "com.nimbusds.jose.jwk.source.CachingJWKSetSource", + "timeToLiveMs": 300000, + "cacheRefreshTimeoutMs": 15000, + "meaning": "the JWK Set is re-fetched no more often than timeToLive" + }, + { + "class": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource", + "jwkSetUri": "http://localhost:9000/jwks.json", + "springCache": "org.springframework.cache.support.NoOpCache", + "meaning": "no Spring cache supplied, so Nimbus's own cache layer is enabled above" + } + ], + "readMe": "Each entry wraps the next. A layer that is absent was switched off." +} +-------------------------------------------------------------------------- + resource server profiles: stub,springcache +-------------------------------------------------------------------------- +One token is decoded first, because the decoder for an issuer-uri is built lazily. +{ + "decoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder", + "processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor", + "keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector", + "jwkSourceChain": [ + { + "class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource" + }, + { + "class": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource", + "jwkSetUri": "http://localhost:9000/jwks.json", + "springCache": "org.springframework.cache.caffeine.CaffeineCache", + "meaning": "a Spring cache was supplied, so Nimbus's cache layer was disabled and this cache's TTL is the only expiry" + } + ], + "readMe": "Each entry wraps the next. A layer that is absent was switched off." +} +-------------------------------------------------------------------------- + resource server profiles: stub,nottlcache +-------------------------------------------------------------------------- +One token is decoded first, because the decoder for an issuer-uri is built lazily. +{ + "decoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder", + "processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor", + "keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector", + "jwkSourceChain": [ + { + "class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource" + }, + { + "class": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource", + "jwkSetUri": "http://localhost:9000/jwks.json", + "springCache": "org.springframework.cache.concurrent.ConcurrentMapCache", + "meaning": "a Spring cache was supplied, so Nimbus's cache layer was disabled and this cache's TTL is the only expiry" + } + ], + "readMe": "Each entry wraps the next. A layer that is absent was switched off." +} +-------------------------------------------------------------------------- + resource server profiles: stub,hardened +-------------------------------------------------------------------------- +One token is decoded first, because the decoder for an issuer-uri is built lazily. +{ + "decoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder", + "processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor", + "keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector", + "jwkSourceChain": [ + { + "class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource" + }, + { + "class": "com.nimbusds.jose.jwk.source.RefreshAheadCachingJWKSetSource", + "timeToLiveMs": 300000, + "cacheRefreshTimeoutMs": 15000, + "meaning": "the JWK Set is re-fetched no more often than timeToLive" + }, + { + "class": "com.nimbusds.jose.jwk.source.RateLimitedJWKSetSource", + "minTimeIntervalMs": 30000, + "meaning": "forced refreshes are throttled to this interval" + }, + { + "class": "com.nimbusds.jose.jwk.source.OutageTolerantJWKSetSource", + "meaning": "a stale JWK Set is served if the issuer is unreachable" + }, + { + "class": "com.nimbusds.jose.jwk.source.RetryingJWKSetSource" + }, + { + "class": "com.nimbusds.jose.jwk.source.URLBasedJWKSetSource" + } + ], + "readMe": "Each entry wraps the next. A layer that is absent was switched off." +} diff --git a/docs/output/rs-issuer-audience-attyp.txt b/docs/output/rs-issuer-audience-attyp.txt new file mode 100644 index 0000000..2f1a031 --- /dev/null +++ b/docs/output/rs-issuer-audience-attyp.txt @@ -0,0 +1,160 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,roles,audience,attyp +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- + 1. A correct token +-------------------------------------------------------------------------- + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:32:56Z" +} + +-------------------------------------------------------------------------- + 2. Signed by the right key, but iss says something else +-------------------------------------------------------------------------- +The signature verifies. The key is the same key. Only the string differs. + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000/other" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +-------------------------------------------------------------------------- + 3. A token minted for a different service in the same realm +-------------------------------------------------------------------------- +This is the one that silently works when nothing checks aud. + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "billing-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +-------------------------------------------------------------------------- + 4. Expired 90 seconds ago +-------------------------------------------------------------------------- +The default clock skew is 60s, so a token has to be more than a minute stale +before JwtTimestampValidator refuses it. + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-08-23T10:26:26Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +-------------------------------------------------------------------------- + 5. Expired 30 seconds ago - inside the default clock skew +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:27:26Z" +} + +-------------------------------------------------------------------------- + 6. typ=at+jwt, which RFC 9068 says an access token SHOULD carry +-------------------------------------------------------------------------- +The default validator stack contains JwtTypeValidator.jwt(), which accepts only an +absent typ or typ=JWT. Whether this passes depends on the attyp profile. + header: {"kid": "stub-key-1", "typ": "at+jwt", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "at+jwt", + "kid": "stub-key-1", + "exp": "2026-08-23T10:32:56Z" +} + +-------------------------------------------------------------------------- + 7. No token at all +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" + +$ GET /api/public/ping +HTTP 200 +{ + "status": "up" +} + +-------------------------------------------------------------------------- + 8. The endpoint nobody configured +-------------------------------------------------------------------------- +Spring Security 7 publishes RFC 9728 protected resource metadata and points the +WWW-Authenticate challenge at it. It answers without a token. + +$ GET /.well-known/oauth-protected-resource +HTTP 200 +{ + "resource": "http://localhost:8081", + "bearer_methods_supported": [ + "header" + ], + "tls_client_certificate_bound_access_tokens": true +} + diff --git a/docs/output/rs-issuer-audience.txt b/docs/output/rs-issuer-audience.txt new file mode 100644 index 0000000..5284346 --- /dev/null +++ b/docs/output/rs-issuer-audience.txt @@ -0,0 +1,143 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,roles,audience +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- + 1. A correct token +-------------------------------------------------------------------------- + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:32:48Z" +} + +-------------------------------------------------------------------------- + 2. Signed by the right key, but iss says something else +-------------------------------------------------------------------------- +The signature verifies. The key is the same key. Only the string differs. + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000/other" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +-------------------------------------------------------------------------- + 3. A token minted for a different service in the same realm +-------------------------------------------------------------------------- +This is the one that silently works when nothing checks aud. + header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"} + iss "http://localhost:9000" + aud "billing-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +-------------------------------------------------------------------------- + 4. Expired 90 seconds ago +-------------------------------------------------------------------------- +The default clock skew is 60s, so a token has to be more than a minute stale +before JwtTimestampValidator refuses it. + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-08-23T10:26:18Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +-------------------------------------------------------------------------- + 5. Expired 30 seconds ago - inside the default clock skew +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_profile:read", + "SCOPE_reports:read" + ], + "iss": "http://localhost:9000", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "stub-key-1", + "exp": "2026-08-23T10:27:18Z" +} + +-------------------------------------------------------------------------- + 6. typ=at+jwt, which RFC 9068 says an access token SHOULD carry +-------------------------------------------------------------------------- +The default validator stack contains JwtTypeValidator.jwt(), which accepts only an +absent typ or typ=JWT. Whether this passes depends on the attyp profile. + header: {"kid": "stub-key-1", "typ": "at+jwt", "alg": "RS256"} + iss "http://localhost:9000" + aud "reports-api" + scope "profile:read reports:read" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" +-------------------------------------------------------------------------- + 7. No token at all +-------------------------------------------------------------------------- + +$ GET /api/me +HTTP 401 +WWW-Authenticate: Bearer resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" + +$ GET /api/public/ping +HTTP 200 +{ + "status": "up" +} + +-------------------------------------------------------------------------- + 8. The endpoint nobody configured +-------------------------------------------------------------------------- +Spring Security 7 publishes RFC 9728 protected resource metadata and points the +WWW-Authenticate challenge at it. It answers without a token. + +$ GET /.well-known/oauth-protected-resource +HTTP 200 +{ + "resource": "http://localhost:8081", + "bearer_methods_supported": [ + "header" + ], + "tls_client_certificate_bound_access_tokens": true +} + diff --git a/docs/output/rs-jwks-amplification.txt b/docs/output/rs-jwks-amplification.txt new file mode 100644 index 0000000..1da7543 --- /dev/null +++ b/docs/output/rs-jwks-amplification.txt @@ -0,0 +1,34 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,roles +-------------------------------------------------------------------------- +Baseline: 25 requests with a VALID token, whose kid is in the cached JWK Set. + requests to the resource server : 25 + fetches of /jwks.json : 0 + +Now 25 requests carrying a token whose kid has never existed. +Each one is refused - but look at what it costs the issuer first. + requests to the resource server : 25 + fetches of /jwks.json : 25 + +Every rejected request became a request to the authorization server. An attacker who +can reach an unauthenticated endpoint of your resource server can point that ratio at +your identity provider, from one connection, using tokens that are never valid. + +And it does not need an authenticated endpoint. /api/public/ping is permitAll(). + requests to /api/public/ping : 25 + fetches of /jwks.json : 25 + +A permitAll() endpoint still evaluates a bearer token if one is present, because +BearerTokenAuthenticationFilter runs before any authorization rule. Presenting a +broken token to a public endpoint gets a 401 from the public endpoint - and a +JWKS fetch on the way. + +$ GET /api/public/ping with an unknown kid +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Another algorithm expected, or no matching key(s) found", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" + +The status returned to the caller is unremarkable: + +$ GET /api/me with an unknown kid +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Another algorithm expected, or no matching key(s) found", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource" diff --git a/docs/output/rs-keycloak-default-converter.txt b/docs/output/rs-keycloak-default-converter.txt new file mode 100644 index 0000000..6ce2554 --- /dev/null +++ b/docs/output/rs-keycloak-default-converter.txt @@ -0,0 +1,71 @@ +-------------------------------------------------------------------------- + Keycloak discovery document +-------------------------------------------------------------------------- + issuer http://localhost:8080/realms/demo + jwks_uri http://localhost:8080/realms/demo/protocol/openid-connect/certs + token_endpoint http://localhost:8080/realms/demo/protocol/openid-connect/token +-------------------------------------------------------------------------- + Keycloak JWK Set +-------------------------------------------------------------------------- + keys published: 2 + kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA + kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA +-------------------------------------------------------------------------- + 1. alice, password grant +-------------------------------------------------------------------------- + header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"} + iss "http://localhost:8080/realms/demo" + aud "reports-api" + typ "Bearer" + scope "email profile" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 200 +{ + "name": "79448051-2590-4dfe-8374-314fcf2d7273", + "authorities": [ + "FACTOR_BEARER", + "SCOPE_email", + "SCOPE_profile" + ], + "iss": "http://localhost:8080/realms/demo", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE", + "exp": "2026-08-23T10:39:01Z" +} + + +$ GET /api/reports (client role, from resource_access.reports-api.roles) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" + +$ GET /api/admin/stats (realm role ADMIN, which alice does not have) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" +-------------------------------------------------------------------------- + 2. root +-------------------------------------------------------------------------- + +$ GET /api/admin/stats +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" +-------------------------------------------------------------------------- + 3. nobody - a user with no client role +-------------------------------------------------------------------------- + header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"} + iss "http://localhost:8080/realms/demo" + aud "reports-api" + typ "Bearer" + scope "email profile" + preferred_username "nobody" + realm_access {"roles": ["USER"]} + +$ GET /api/reports +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" diff --git a/docs/output/rs-keycloak.txt b/docs/output/rs-keycloak.txt new file mode 100644 index 0000000..6468a74 --- /dev/null +++ b/docs/output/rs-keycloak.txt @@ -0,0 +1,79 @@ +-------------------------------------------------------------------------- + Keycloak discovery document +-------------------------------------------------------------------------- + issuer http://localhost:8080/realms/demo + jwks_uri http://localhost:8080/realms/demo/protocol/openid-connect/certs + token_endpoint http://localhost:8080/realms/demo/protocol/openid-connect/token +-------------------------------------------------------------------------- + Keycloak JWK Set +-------------------------------------------------------------------------- + keys published: 2 + kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA + kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA +-------------------------------------------------------------------------- + 1. alice, password grant +-------------------------------------------------------------------------- + header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"} + iss "http://localhost:8080/realms/demo" + aud "reports-api" + typ "Bearer" + scope "email profile" + preferred_username "alice" + realm_access {"roles": ["USER"]} + resource_access {"reports-api": {"roles": ["reports-reader"]}} + +$ GET /api/me +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "ROLE_reports-reader", + "SCOPE_email", + "SCOPE_profile" + ], + "iss": "http://localhost:8080/realms/demo", + "aud": [ + "reports-api" + ], + "typ": "JWT", + "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE", + "exp": "2026-08-23T10:38:53Z" +} + + +$ GET /api/reports (client role, from resource_access.reports-api.roles) +HTTP 200 +{ + "reports": 3 +} + + +$ GET /api/admin/stats (realm role ADMIN, which alice does not have) +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" +-------------------------------------------------------------------------- + 2. root +-------------------------------------------------------------------------- + +$ GET /api/admin/stats +HTTP 200 +{ + "secret": "only ROLE_ADMIN sees this" +} + +-------------------------------------------------------------------------- + 3. nobody - a user with no client role +-------------------------------------------------------------------------- + header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"} + iss "http://localhost:8080/realms/demo" + aud "reports-api" + typ "Bearer" + scope "email profile" + preferred_username "nobody" + realm_access {"roles": ["USER"]} + +$ GET /api/reports +HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" diff --git a/docs/output/rs-retired-key-default.txt b/docs/output/rs-retired-key-default.txt new file mode 100644 index 0000000..2e64072 --- /dev/null +++ b/docs/output/rs-retired-key-default.txt @@ -0,0 +1,47 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,roles +-------------------------------------------------------------------------- +Scenario: a signing key is compromised. The issuer publishes a replacement and +removes the compromised key from the JWK Set immediately. Tokens it signed are +already out there with an hour left to run. + +1. A token signed with stub-key-1, one hour to live: GET /api/me -> 200 + jwks fetches: 1 + +2. Issuer rotates to stub-key-2 and retires stub-key-1. +{ + "message": "ok", + "activeKid": "stub-key-2", + "publishedKids": [ + "stub-key-2" + ], + "jwksFetches": 1 +} + + Anyone fetching /jwks.json from this moment sees only stub-key-2. + +3. From here the ONLY traffic is the leaked token. Nothing carries an unknown kid, + so nothing forces a refresh. Whether the token keeps working is decided purely + by whether the cached JWK Set expires. + + elapsed leaked jwksFetches + t+0s 200 1 + t+30s 200 1 + t+60s 200 1 + t+90s 200 1 + t+120s 200 1 + t+150s 200 1 + t+180s 200 1 + t+210s 200 1 + t+240s 200 1 + t+270s 200 1 + t+300s 401 3 + t+330s 401 4 + t+360s 401 5 + t+390s 401 6 + t+420s 401 7 + t+450s 401 8 + +A row that flips to 401 is the cache expiring and the retired key going away. +A column of 200s is a resource server that has not noticed, and will not, until +something happens to bring it a token it cannot verify. diff --git a/docs/output/rs-retired-key-nottlcache.txt b/docs/output/rs-retired-key-nottlcache.txt new file mode 100644 index 0000000..550006e --- /dev/null +++ b/docs/output/rs-retired-key-nottlcache.txt @@ -0,0 +1,47 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,roles,nottlcache +-------------------------------------------------------------------------- +Scenario: a signing key is compromised. The issuer publishes a replacement and +removes the compromised key from the JWK Set immediately. Tokens it signed are +already out there with an hour left to run. + +1. A token signed with stub-key-1, one hour to live: GET /api/me -> 200 + jwks fetches: 0 + +2. Issuer rotates to stub-key-2 and retires stub-key-1. +{ + "message": "ok", + "activeKid": "stub-key-2", + "publishedKids": [ + "stub-key-2" + ], + "jwksFetches": 0 +} + + Anyone fetching /jwks.json from this moment sees only stub-key-2. + +3. From here the ONLY traffic is the leaked token. Nothing carries an unknown kid, + so nothing forces a refresh. Whether the token keeps working is decided purely + by whether the cached JWK Set expires. + + elapsed leaked jwksFetches + t+0s 200 0 + t+30s 200 0 + t+60s 200 0 + t+90s 200 0 + t+120s 200 0 + t+150s 200 0 + t+180s 200 0 + t+210s 200 0 + t+240s 200 0 + t+270s 200 0 + t+300s 200 0 + t+330s 200 0 + t+360s 200 0 + t+390s 200 0 + t+420s 200 0 + t+450s 200 0 + +A row that flips to 401 is the cache expiring and the retired key going away. +A column of 200s is a resource server that has not noticed, and will not, until +something happens to bring it a token it cannot verify. diff --git a/docs/output/rs-rotation.txt b/docs/output/rs-rotation.txt new file mode 100644 index 0000000..ead0cfe --- /dev/null +++ b/docs/output/rs-rotation.txt @@ -0,0 +1,69 @@ +-------------------------------------------------------------------------- + resource server profiles: stub,roles,audience +-------------------------------------------------------------------------- +Issuer state at the start: +{ + "message": "ok", + "activeKid": "stub-key-1", + "publishedKids": [ + "stub-key-1" + ], + "jwksFetches": 0 +} +-------------------------------------------------------------------------- + 0. Warm the cache +-------------------------------------------------------------------------- +token signed with stub-key-1 +GET /api/me -> 200 +jwks fetches so far: 1 +-------------------------------------------------------------------------- + 1. PUBLISH a second key. Nothing signs with it yet. +-------------------------------------------------------------------------- +published: stub-key-2 +{ + "message": "ok", + "activeKid": "stub-key-1", + "publishedKids": [ + "stub-key-1", + "stub-key-2" + ], + "jwksFetches": 1 +} + +The resource server has not been told. Its cached JWK Set still holds one key. +Old token still works: 200 +jwks fetches so far: 1 <- unchanged: nothing forced a refresh +-------------------------------------------------------------------------- + 2. ACTIVATE the new key. The issuer starts signing with it. +-------------------------------------------------------------------------- +A token arrives whose kid is not in the cached JWK Set. +New token: 200 +jwks fetches so far: 2 <- the unknown kid forced one + +This is the recovery path, and it works. It is also the only thing in the default +configuration that notices a rotation, because refresh-ahead is switched off. +-------------------------------------------------------------------------- + 3. Tokens signed with the old key are still in flight +-------------------------------------------------------------------------- +They were minted before the switch and have not expired yet. +Old token: 200 <- still accepted, because the old key is still published +-------------------------------------------------------------------------- + 4. RETIRE the old key from the JWK Set +-------------------------------------------------------------------------- +retired: stub-key-1 +{ + "message": "ok", + "activeKid": "stub-key-2", + "publishedKids": [ + "stub-key-2" + ], + "jwksFetches": 2 +} + +The resource server's cache still contains it, so nothing changes yet. +Old token: 200 +New token: 200 +jwks fetches so far: 2 + +How long the old key keeps working from here is decided entirely by the cache. +See retired-key-demo.sh, which runs this same step under two cache configurations. diff --git a/docs/output/rs-test-run.txt b/docs/output/rs-test-run.txt new file mode 100644 index 0000000..80977c3 --- /dev/null +++ b/docs/output/rs-test-run.txt @@ -0,0 +1,13 @@ +========================================================================== + oauth2-resource-server-demo - test run +========================================================================== + +Running com.ankurm.rsdemo.JwtValidationContractTests +Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.288 s -- in com.ankurm.rsdemo.JwtValidationContractTests +Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS + +JDK : openjdk version "25.0.4.1" 2026-08-18 LTS +Boot : 4.1.1 +Security : 7.1.1 +Nimbus : 10.9.1 diff --git a/pom.xml b/jwt-authentication/pom.xml similarity index 100% rename from pom.xml rename to jwt-authentication/pom.xml diff --git a/scripts/csrf-demo.sh b/jwt-authentication/scripts/csrf-demo.sh similarity index 100% rename from scripts/csrf-demo.sh rename to jwt-authentication/scripts/csrf-demo.sh diff --git a/scripts/curl-transcript.sh b/jwt-authentication/scripts/curl-transcript.sh similarity index 98% rename from scripts/curl-transcript.sh rename to jwt-authentication/scripts/curl-transcript.sh index 9acd1f9..594130f 100755 --- a/scripts/curl-transcript.sh +++ b/jwt-authentication/scripts/curl-transcript.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Regenerates docs/output/curl-transcript.txt against a running instance. +# Regenerates ../docs/output/curl-transcript-hs256.txt against a running instance. # Usage: ./scripts/curl-transcript.sh [base-url] set -u BASE="${1:-http://localhost:8080}" diff --git a/scripts/expiry-demo.sh b/jwt-authentication/scripts/expiry-demo.sh similarity index 100% rename from scripts/expiry-demo.sh rename to jwt-authentication/scripts/expiry-demo.sh diff --git a/scripts/resource-server-demo.sh b/jwt-authentication/scripts/resource-server-demo.sh similarity index 100% rename from scripts/resource-server-demo.sh rename to jwt-authentication/scripts/resource-server-demo.sh diff --git a/scripts/rs256-demo.sh b/jwt-authentication/scripts/rs256-demo.sh similarity index 100% rename from scripts/rs256-demo.sh rename to jwt-authentication/scripts/rs256-demo.sh diff --git a/scripts/run-all.sh b/jwt-authentication/scripts/run-all.sh similarity index 77% rename from scripts/run-all.sh rename to jwt-authentication/scripts/run-all.sh index 05ede0f..96448e3 100755 --- a/scripts/run-all.sh +++ b/jwt-authentication/scripts/run-all.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Regenerates every file under docs/output/ from a real run. +# Regenerates every file under ../docs/output/ from a real run. # Usage: ./scripts/run-all.sh set -eu cd "$(dirname "$0")/.." @@ -16,27 +16,27 @@ echo "==> mvn test" echo "JDK : $(java -version 2>&1 | grep -v JAVA_TOOL | head -1)" echo "Boot : 4.1.1" echo "Security : 7.1.1" -} > docs/output/test-run.txt +} > ../docs/output/test-run.txt echo "==> hs256 (manual filter) transcript" -./scripts/run.sh hs256 >/dev/null && ./scripts/curl-transcript.sh > docs/output/curl-transcript-hs256.txt 2>&1 +./scripts/run.sh hs256 >/dev/null && ./scripts/curl-transcript.sh > ../docs/output/curl-transcript-hs256.txt 2>&1 echo "==> rs256 transcript" -./scripts/run.sh rs256 >/dev/null && ./scripts/rs256-demo.sh > docs/output/rs256-demo.txt 2>&1 +./scripts/run.sh rs256 >/dev/null && ./scripts/rs256-demo.sh > ../docs/output/rs256-demo.txt 2>&1 echo "==> csrf vs permitAll" -./scripts/run.sh hs256,csrfon >/dev/null && ./scripts/csrf-demo.sh > docs/output/csrf-vs-permitall.txt 2>&1 +./scripts/run.sh hs256,csrfon >/dev/null && ./scripts/csrf-demo.sh > ../docs/output/csrf-vs-permitall.txt 2>&1 echo "==> expiry and clock skew (takes ~70s)" -./scripts/run.sh hs256,shortlived >/dev/null && ./scripts/expiry-demo.sh > docs/output/expiry-and-clock-skew.txt 2>&1 +./scripts/run.sh hs256,shortlived >/dev/null && ./scripts/expiry-demo.sh > ../docs/output/expiry-and-clock-skew.txt 2>&1 echo "==> built-in resource server, without and with the token_type validator" ./scripts/run.sh hs256,resourceserver >/dev/null \ && ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver" \ - > docs/output/resource-server-loose.txt 2>&1 + > ../docs/output/resource-server-loose.txt 2>&1 ./scripts/run.sh hs256,resourceserver,strict >/dev/null \ && ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver,strict" \ - > docs/output/resource-server-strict.txt 2>&1 + > ../docs/output/resource-server-strict.txt 2>&1 for p in $(ps -eo pid,cmd | grep '[J]wtAuthDemoApplication' | awk '{print $1}'); do kill -9 "$p" || true; done echo "==> done. docs/output/ regenerated." diff --git a/scripts/run.sh b/jwt-authentication/scripts/run.sh similarity index 100% rename from scripts/run.sh rename to jwt-authentication/scripts/run.sh diff --git a/src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java diff --git a/src/main/java/com/ankurm/jwtauth/auth/AuthController.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/AuthController.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/auth/AuthController.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/AuthController.java diff --git a/src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java diff --git a/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java diff --git a/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java diff --git a/src/main/java/com/ankurm/jwtauth/auth/TokenService.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/TokenService.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/auth/TokenService.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/TokenService.java diff --git a/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java diff --git a/src/main/java/com/ankurm/jwtauth/config/AppUsers.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/config/AppUsers.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/config/AppUsers.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/config/AppUsers.java diff --git a/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java diff --git a/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java diff --git a/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java diff --git a/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java diff --git a/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java diff --git a/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java diff --git a/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java diff --git a/src/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java diff --git a/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java diff --git a/src/main/java/com/ankurm/jwtauth/web/ApiControllers.java b/jwt-authentication/src/main/java/com/ankurm/jwtauth/web/ApiControllers.java similarity index 100% rename from src/main/java/com/ankurm/jwtauth/web/ApiControllers.java rename to jwt-authentication/src/main/java/com/ankurm/jwtauth/web/ApiControllers.java diff --git a/src/main/resources/application-csrfon.yaml b/jwt-authentication/src/main/resources/application-csrfon.yaml similarity index 100% rename from src/main/resources/application-csrfon.yaml rename to jwt-authentication/src/main/resources/application-csrfon.yaml diff --git a/src/main/resources/application-shortlived.yaml b/jwt-authentication/src/main/resources/application-shortlived.yaml similarity index 100% rename from src/main/resources/application-shortlived.yaml rename to jwt-authentication/src/main/resources/application-shortlived.yaml diff --git a/src/main/resources/application-strict.yaml b/jwt-authentication/src/main/resources/application-strict.yaml similarity index 100% rename from src/main/resources/application-strict.yaml rename to jwt-authentication/src/main/resources/application-strict.yaml diff --git a/src/main/resources/application-trace.yaml b/jwt-authentication/src/main/resources/application-trace.yaml similarity index 100% rename from src/main/resources/application-trace.yaml rename to jwt-authentication/src/main/resources/application-trace.yaml diff --git a/src/main/resources/application.yaml b/jwt-authentication/src/main/resources/application.yaml similarity index 100% rename from src/main/resources/application.yaml rename to jwt-authentication/src/main/resources/application.yaml diff --git a/src/main/resources/demo-private-key.pem b/jwt-authentication/src/main/resources/demo-private-key.pem similarity index 100% rename from src/main/resources/demo-private-key.pem rename to jwt-authentication/src/main/resources/demo-private-key.pem diff --git a/src/main/resources/demo-public-key.pem b/jwt-authentication/src/main/resources/demo-public-key.pem similarity index 100% rename from src/main/resources/demo-public-key.pem rename to jwt-authentication/src/main/resources/demo-public-key.pem diff --git a/src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java b/jwt-authentication/src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java similarity index 100% rename from src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java rename to jwt-authentication/src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java diff --git a/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java b/jwt-authentication/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java similarity index 100% rename from src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java rename to jwt-authentication/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java diff --git a/oauth2-resource-server/docker/compose.yaml b/oauth2-resource-server/docker/compose.yaml new file mode 100644 index 0000000..42aa194 --- /dev/null +++ b/oauth2-resource-server/docker/compose.yaml @@ -0,0 +1,34 @@ +# A real issuer, in one command. +# +# docker compose -f docker/compose.yaml up -d +# ./scripts/run-rs.sh keycloak,roles +# ./scripts/keycloak-demo.sh +# +# Notes that matter for a resource server: +# +# * KC_HOSTNAME fixes the issuer string. Keycloak derives `iss` from the request host +# unless you pin it, so a token fetched via localhost and a token fetched via a +# container name carry DIFFERENT issuers and one of them will fail JwtIssuerValidator. +# Pinning it is the single most common fix for "it works from curl but not from the app". +# +# * start-dev keeps everything in an in-memory H2 database. Every restart is a fresh realm +# and, importantly for this repository, a fresh signing key. +# +# * The realm is imported at boot from realm-demo.json, so the demo users, roles and the +# audience mapper exist without any admin-console clicking. +services: + keycloak: + image: quay.io/keycloak/keycloak:26.7.2 + container_name: jwt-demo-keycloak + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HOSTNAME: http://localhost:8080 + KC_HOSTNAME_STRICT: "false" + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + ports: + - "8080:8080" + volumes: + - ./realm-demo.json:/opt/keycloak/data/import/realm-demo.json:ro diff --git a/oauth2-resource-server/docker/realm-demo.json b/oauth2-resource-server/docker/realm-demo.json new file mode 100644 index 0000000..3530bf4 --- /dev/null +++ b/oauth2-resource-server/docker/realm-demo.json @@ -0,0 +1,129 @@ +{ + "realm": "demo", + "enabled": true, + "sslRequired": "none", + "accessTokenLifespan": 300, + "roles": { + "realm": [ + { + "name": "USER", + "description": "Ordinary user" + }, + { + "name": "ADMIN", + "description": "Administrator" + } + ], + "client": { + "reports-api": [ + { + "name": "reports-reader", + "description": "May read reports" + } + ] + } + }, + "clients": [ + { + "clientId": "reports-api", + "enabled": true, + "bearerOnly": true, + "protocol": "openid-connect", + "description": "The resource server. It never logs anyone in; it only owns roles and is an audience." + }, + { + "clientId": "demo-client", + "enabled": true, + "publicClient": false, + "secret": "demo-secret", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "protocol": "openid-connect", + "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "reports-api-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "reports-api", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "alice", + "enabled": true, + "emailVerified": true, + "firstName": "Alice", + "lastName": "Anderson", + "email": "alice@example.com", + "requiredActions": [], + "credentials": [ + { + "type": "password", + "value": "alice-password", + "temporary": false + } + ], + "realmRoles": [ + "USER" + ], + "clientRoles": { + "reports-api": [ + "reports-reader" + ] + } + }, + { + "username": "root", + "enabled": true, + "emailVerified": true, + "firstName": "Root", + "lastName": "Admin", + "email": "root@example.com", + "requiredActions": [], + "credentials": [ + { + "type": "password", + "value": "root-password", + "temporary": false + } + ], + "realmRoles": [ + "USER", + "ADMIN" + ], + "clientRoles": { + "reports-api": [ + "reports-reader" + ] + } + }, + { + "username": "nobody", + "enabled": true, + "emailVerified": true, + "firstName": "No", + "lastName": "Body", + "email": "nobody@example.com", + "requiredActions": [], + "credentials": [ + { + "type": "password", + "value": "nobody-password", + "temporary": false + } + ], + "realmRoles": [ + "USER" + ] + } + ] +} \ No newline at end of file diff --git a/oauth2-resource-server/pom.xml b/oauth2-resource-server/pom.xml new file mode 100644 index 0000000..4292a88 --- /dev/null +++ b/oauth2-resource-server/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + oauth2-resource-server-demo + 1.0.0 + oauth2-resource-server-demo + Spring Security OAuth2 resource server: JWT validation, JWKS and key rotation - runnable companion for ankurm.com + + + 25 + + com.ankurm.rsdemo.ResourceServerApplication + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/oauth2-resource-server/scripts/amplification-demo.sh b/oauth2-resource-server/scripts/amplification-demo.sh new file mode 100755 index 0000000..c577f55 --- /dev/null +++ b/oauth2-resource-server/scripts/amplification-demo.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# What an unknown kid costs the issuer. +# +# Spring Security builds its JWKSource with rateLimited(false), overriding Nimbus's own +# default of a 30-second minimum interval between forced refreshes. Nothing then stands +# between a token bearing an unrecognised kid and an HTTP request to the issuer. +set -eu +. "$(dirname "$0")/lib.sh" +PROFILES="${1:-unknown}" +N="${2:-25}" + +head1 "resource server profiles: $PROFILES" + +echo "Baseline: $N requests with a VALID token, whose kid is in the cached JWK Set." +curl -s -X POST "$STUB/admin/reset-counter" >/dev/null +GOOD=$(stub_token "sub=alice&aud=reports-api") +curl -s -o /dev/null -H "Authorization: Bearer $GOOD" "$RS/api/me" # warm +curl -s -X POST "$STUB/admin/reset-counter" >/dev/null +for i in $(seq 1 "$N"); do + curl -s -o /dev/null -H "Authorization: Bearer $GOOD" "$RS/api/me" +done +echo " requests to the resource server : $N" +echo " fetches of /jwks.json : $(stub_fetches)" + +echo +echo "Now $N requests carrying a token whose kid has never existed." +echo "Each one is refused - but look at what it costs the issuer first." +curl -s -X POST "$STUB/admin/reset-counter" >/dev/null +for i in $(seq 1 "$N"); do + BAD=$(curl -s -X POST "$STUB/token/unknown-kid") + curl -s -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/me" +done +FETCHES=$(stub_fetches) +echo " requests to the resource server : $N" +echo " fetches of /jwks.json : $FETCHES" +echo +echo "Every rejected request became a request to the authorization server. An attacker who" +echo "can reach an unauthenticated endpoint of your resource server can point that ratio at" +echo "your identity provider, from one connection, using tokens that are never valid." +echo +echo "And it does not need an authenticated endpoint. /api/public/ping is permitAll()." +curl -s -X POST "$STUB/admin/reset-counter" >/dev/null +for i in $(seq 1 "$N"); do + BAD=$(curl -s -X POST "$STUB/token/unknown-kid") + curl -s -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/public/ping" +done +echo " requests to /api/public/ping : $N" +echo " fetches of /jwks.json : $(stub_fetches)" +echo +echo "A permitAll() endpoint still evaluates a bearer token if one is present, because" +echo "BearerTokenAuthenticationFilter runs before any authorization rule. Presenting a" +echo "broken token to a public endpoint gets a 401 from the public endpoint - and a" +echo "JWKS fetch on the way." +call "GET /api/public/ping with an unknown kid" "$RS/api/public/ping" "$(curl -s -X POST "$STUB/token/unknown-kid")" +echo +echo "The status returned to the caller is unremarkable:" +BAD=$(curl -s -X POST "$STUB/token/unknown-kid") +call "GET /api/me with an unknown kid" "$RS/api/me" "$BAD" diff --git a/oauth2-resource-server/scripts/converter-demo.sh b/oauth2-resource-server/scripts/converter-demo.sh new file mode 100755 index 0000000..0ca91f9 --- /dev/null +++ b/oauth2-resource-server/scripts/converter-demo.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# What a token is allowed to do, and why Keycloak's roles are invisible by default. +# Pass the profile set the resource server is running with, for the transcript header. +set -eu +. "$(dirname "$0")/lib.sh" +PROFILES="${1:-unknown}" + +head1 "resource server profiles: $PROFILES" + +head1 "1. alice - realm role USER, client role reports-reader" +T=$(stub_token "sub=alice&roles=USER") +claims "$T" +call "GET /api/me (which authorities did the converter produce?)" "$RS/api/me" "$T" +call "GET /api/reports (needs ROLE_reports-reader, from resource_access)" "$RS/api/reports" "$T" +call "GET /api/admin/stats (needs ROLE_ADMIN, from realm_access)" "$RS/api/admin/stats" "$T" + +head1 "2. root - realm roles USER and ADMIN" +T=$(stub_token "sub=root&roles=USER%20ADMIN") +call "GET /api/me" "$RS/api/me" "$T" +call "GET /api/admin/stats" "$RS/api/admin/stats" "$T" diff --git a/oauth2-resource-server/scripts/decoder-chain.sh b/oauth2-resource-server/scripts/decoder-chain.sh new file mode 100755 index 0000000..c92a8c8 --- /dev/null +++ b/oauth2-resource-server/scripts/decoder-chain.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Prints the JWK source chain the running decoder actually has. +set -eu +. "$(dirname "$0")/lib.sh" +head1 "resource server profiles: ${1:-unknown}" +echo "One token is decoded first, because the decoder for an issuer-uri is built lazily." +T=$(stub_token "sub=alice&aud=reports-api" 2>/dev/null || true) +[ -n "${T:-}" ] && curl -s -o /dev/null -H "Authorization: Bearer $T" "$RS/api/me" || true +curl -s "$RS/api/public/decoder" | python3 -m json.tool diff --git a/oauth2-resource-server/scripts/issuer-audience-demo.sh b/oauth2-resource-server/scripts/issuer-audience-demo.sh new file mode 100755 index 0000000..cf456ab --- /dev/null +++ b/oauth2-resource-server/scripts/issuer-audience-demo.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# The claim checks that decide whether a correctly signed token is yours. +# Usage: ./scripts/issuer-audience-demo.sh "" +set -eu +. "$(dirname "$0")/lib.sh" +PROFILES="${1:-unknown}" + +head1 "resource server profiles: $PROFILES" + +head1 "1. A correct token" +T=$(stub_token "sub=alice&aud=reports-api") +claims "$T" +call "GET /api/me" "$RS/api/me" "$T" + +head1 "2. Signed by the right key, but iss says something else" +echo "The signature verifies. The key is the same key. Only the string differs." +T=$(stub_token "sub=alice&aud=reports-api&issuerOverride=http://localhost:9000/other") +claims "$T" +call "GET /api/me" "$RS/api/me" "$T" + +head1 "3. A token minted for a different service in the same realm" +echo "This is the one that silently works when nothing checks aud." +T=$(stub_token "sub=alice&aud=billing-api") +claims "$T" +call "GET /api/me" "$RS/api/me" "$T" + +head1 "4. Expired 90 seconds ago" +echo "The default clock skew is 60s, so a token has to be more than a minute stale" +echo "before JwtTimestampValidator refuses it." +T=$(stub_token "sub=alice&aud=reports-api&issuedAgoSeconds=120&expiresInSeconds=30") +call "GET /api/me" "$RS/api/me" "$T" + +head1 "5. Expired 30 seconds ago - inside the default clock skew" +T=$(stub_token "sub=alice&aud=reports-api&issuedAgoSeconds=60&expiresInSeconds=30") +call "GET /api/me" "$RS/api/me" "$T" + +head1 "6. typ=at+jwt, which RFC 9068 says an access token SHOULD carry" +echo "The default validator stack contains JwtTypeValidator.jwt(), which accepts only an" +echo "absent typ or typ=JWT. Whether this passes depends on the attyp profile." +T=$(stub_token "sub=alice&aud=reports-api&typ=at%2Bjwt") +claims "$T" +call "GET /api/me" "$RS/api/me" "$T" + +head1 "7. No token at all" +call "GET /api/me" "$RS/api/me" +call "GET /api/public/ping" "$RS/api/public/ping" + +head1 "8. The endpoint nobody configured" +echo "Spring Security 7 publishes RFC 9728 protected resource metadata and points the" +echo "WWW-Authenticate challenge at it. It answers without a token." +call "GET /.well-known/oauth-protected-resource" "$RS/.well-known/oauth-protected-resource" diff --git a/oauth2-resource-server/scripts/keycloak-demo.sh b/oauth2-resource-server/scripts/keycloak-demo.sh new file mode 100755 index 0000000..06446cc --- /dev/null +++ b/oauth2-resource-server/scripts/keycloak-demo.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# The same resource server, the same code, a real Keycloak. +# Requires: docker compose -f docker/compose.yaml up -d +# ./scripts/run-rs.sh keycloak,roles +set -eu +. "$(dirname "$0")/lib.sh" + +head1 "Keycloak discovery document" +curl -s "$KC/realms/demo/.well-known/openid-configuration" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); [print(" %-22s %s" % (k, d[k])) for k in ("issuer","jwks_uri","token_endpoint")]' + +head1 "Keycloak JWK Set" +curl -s "$KC/realms/demo/protocol/openid-connect/certs" \ + | python3 -c ' +import json,sys +d=json.load(sys.stdin) +print(" keys published:", len(d["keys"])) +for k in d["keys"]: + print(" kid=%s alg=%s use=%s kty=%s" % (k.get("kid"), k.get("alg"), k.get("use"), k.get("kty")))' + +head1 "1. alice, password grant" +T=$(kc_token alice alice-password) +claims "$T" +call "GET /api/me" "$RS/api/me" "$T" +call "GET /api/reports (client role, from resource_access.reports-api.roles)" "$RS/api/reports" "$T" +call "GET /api/admin/stats (realm role ADMIN, which alice does not have)" "$RS/api/admin/stats" "$T" + +head1 "2. root" +T=$(kc_token root root-password) +call "GET /api/admin/stats" "$RS/api/admin/stats" "$T" + +head1 "3. nobody - a user with no client role" +T=$(kc_token nobody nobody-password) +claims "$T" +call "GET /api/reports" "$RS/api/reports" "$T" diff --git a/oauth2-resource-server/scripts/lib.sh b/oauth2-resource-server/scripts/lib.sh new file mode 100755 index 0000000..0e01222 --- /dev/null +++ b/oauth2-resource-server/scripts/lib.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Shared helpers. Sourced, not run. +RS="${RS:-http://localhost:8081}" +STUB="${STUB:-http://localhost:9000}" +KC="${KC:-http://localhost:8080}" + +hr() { printf '%s\n' "--------------------------------------------------------------------------"; } +head1(){ hr; printf ' %s\n' "$1"; hr; } + +# Prints status, the RFC 6750 challenge header, and the body. The WWW-Authenticate header is +# the only place a claim-validation failure explains itself, so it is never omitted here. +call() { + local label="$1" url="$2" token="${3:-}" + printf '\n$ %s\n' "$label" + local args=(-s -o /tmp/.body -D /tmp/.hdr -w '%{http_code}') + [ -n "$token" ] && args+=(-H "Authorization: Bearer $token") + local code + code=$(curl "${args[@]}" "$url") + printf 'HTTP %s\n' "$code" + grep -i '^www-authenticate:' /tmp/.hdr | sed 's/\r$//' || true + if [ -s /tmp/.body ]; then + python3 -m json.tool < /tmp/.body 2>/dev/null || cat /tmp/.body + echo + fi +} + +stub_token() { curl -s -X POST "$STUB/token?$1"; } +stub_state() { curl -s "$STUB/admin/state" | python3 -m json.tool; } +stub_fetches() { curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["jwksFetches"])'; } + +kc_token() { + curl -s -X POST "$KC/realms/demo/protocol/openid-connect/token" \ + -d grant_type=password -d client_id=demo-client -d client_secret=demo-secret \ + -d "username=$1" -d "password=$2" \ + | python3 -c 'import json,sys;print(json.load(sys.stdin).get("access_token",""))' +} + +claims() { + python3 - "$1" <<'PY' +import sys, base64, json +tok = sys.argv[1] +h, p, _ = tok.split('.') +pad = lambda s: s + '=' * (-len(s) % 4) +print(" header:", json.dumps(json.loads(base64.urlsafe_b64decode(pad(h))))) +c = json.loads(base64.urlsafe_b64decode(pad(p))) +for k in ("iss", "aud", "typ", "scope", "preferred_username", "realm_access", "resource_access"): + if k in c: + print(" %-18s %s" % (k, json.dumps(c[k]))) +PY +} diff --git a/oauth2-resource-server/scripts/retired-key-demo.sh b/oauth2-resource-server/scripts/retired-key-demo.sh new file mode 100755 index 0000000..fee0150 --- /dev/null +++ b/oauth2-resource-server/scripts/retired-key-demo.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# How long a key stays trusted after the issuer removes it from the JWK Set. +# +# The only traffic after the retirement is the leaked token itself. That is the point: +# a token whose kid IS in the cached set never triggers the unknown-kid refresh, so the +# only thing that can dislodge the stale JWK Set is the cache expiring on its own. +# +# Run under both cache configurations and diff the transcripts: +# ./scripts/run-rs.sh stub,roles && ./scripts/retired-key-demo.sh "stub,roles" +# ./scripts/run-rs.sh stub,roles,nottlcache && ./scripts/retired-key-demo.sh "stub,roles,nottlcache" +set -eu +. "$(dirname "$0")/lib.sh" +PROFILES="${1:-unknown}" +PROBES="${2:-16}" +INTERVAL="${3:-30}" + +probe() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $1" "$RS/api/me"; } + +head1 "resource server profiles: $PROFILES" +curl -s -X POST "$STUB/admin/reset-counter" >/dev/null + +echo "Scenario: a signing key is compromised. The issuer publishes a replacement and" +echo "removes the compromised key from the JWK Set immediately. Tokens it signed are" +echo "already out there with an hour left to run." +echo + +VICTIM_KID=$(curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["activeKid"])') +LEAKED=$(stub_token "sub=attacker&aud=reports-api&expiresInSeconds=3600") +echo "1. A token signed with $VICTIM_KID, one hour to live: GET /api/me -> $(probe "$LEAKED")" +echo " jwks fetches: $(stub_fetches)" +echo + +NEW=$(curl -s -X POST "$STUB/admin/publish" | python3 -c 'import json,sys;print(json.load(sys.stdin)["publishedKids"][-1])') +curl -s -X POST "$STUB/admin/activate?kid=$NEW" >/dev/null +curl -s -X POST "$STUB/admin/retire?kid=$VICTIM_KID" >/dev/null +echo "2. Issuer rotates to $NEW and retires $VICTIM_KID." +stub_state +echo +echo " Anyone fetching /jwks.json from this moment sees only $NEW." +echo + +echo "3. From here the ONLY traffic is the leaked token. Nothing carries an unknown kid," +echo " so nothing forces a refresh. Whether the token keeps working is decided purely" +echo " by whether the cached JWK Set expires." +echo +printf ' %-10s %-8s %s\n' "elapsed" "leaked" "jwksFetches" +START=$(date +%s) +for i in $(seq 1 "$PROBES"); do + ELAPSED=$(( $(date +%s) - START )) + printf ' t+%-8s %-8s %s\n' "${ELAPSED}s" "$(probe "$LEAKED")" "$(stub_fetches)" + sleep "$INTERVAL" +done +echo +echo "A row that flips to 401 is the cache expiring and the retired key going away." +echo "A column of 200s is a resource server that has not noticed, and will not, until" +echo "something happens to bring it a token it cannot verify." diff --git a/oauth2-resource-server/scripts/rotation-demo.sh b/oauth2-resource-server/scripts/rotation-demo.sh new file mode 100755 index 0000000..5fe3e70 --- /dev/null +++ b/oauth2-resource-server/scripts/rotation-demo.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Key rotation as three separate events, with the resource server watched in between. +# Requires the stub issuer and a resource server pointed at it. +set -eu +. "$(dirname "$0")/lib.sh" +PROFILES="${1:-unknown}" + +probe() { # prints just the status code for a token + local token="$1" + curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" "$RS/api/me" +} + +head1 "resource server profiles: $PROFILES" +curl -s -X POST "$STUB/admin/reset-counter" >/dev/null +echo "Issuer state at the start:"; stub_state + +head1 "0. Warm the cache" +OLD=$(stub_token "sub=alice&aud=reports-api") +echo "token signed with $(curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["activeKid"])')" +echo "GET /api/me -> $(probe "$OLD")" +echo "jwks fetches so far: $(stub_fetches)" + +head1 "1. PUBLISH a second key. Nothing signs with it yet." +NEW=$(curl -s -X POST "$STUB/admin/publish" | python3 -c 'import json,sys;print(json.load(sys.stdin)["publishedKids"][-1])') +echo "published: $NEW" +stub_state +echo +echo "The resource server has not been told. Its cached JWK Set still holds one key." +echo "Old token still works: $(probe "$OLD")" +echo "jwks fetches so far: $(stub_fetches) <- unchanged: nothing forced a refresh" + +head1 "2. ACTIVATE the new key. The issuer starts signing with it." +curl -s -X POST "$STUB/admin/activate?kid=$NEW" >/dev/null +NEWTOK=$(stub_token "sub=alice&aud=reports-api") +echo "A token arrives whose kid is not in the cached JWK Set." +echo "New token: $(probe "$NEWTOK")" +echo "jwks fetches so far: $(stub_fetches) <- the unknown kid forced one" +echo +echo "This is the recovery path, and it works. It is also the only thing in the default" +echo "configuration that notices a rotation, because refresh-ahead is switched off." + +head1 "3. Tokens signed with the old key are still in flight" +echo "They were minted before the switch and have not expired yet." +echo "Old token: $(probe "$OLD") <- still accepted, because the old key is still published" + +head1 "4. RETIRE the old key from the JWK Set" +OLDKID=$(curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["publishedKids"][0])') +curl -s -X POST "$STUB/admin/retire?kid=$OLDKID" >/dev/null +echo "retired: $OLDKID" +stub_state +echo +echo "The resource server's cache still contains it, so nothing changes yet." +echo "Old token: $(probe "$OLD")" +echo "New token: $(probe "$NEWTOK")" +echo "jwks fetches so far: $(stub_fetches)" +echo +echo "How long the old key keeps working from here is decided entirely by the cache." +echo "See retired-key-demo.sh, which runs this same step under two cache configurations." diff --git a/oauth2-resource-server/scripts/run-all.sh b/oauth2-resource-server/scripts/run-all.sh new file mode 100755 index 0000000..4d467fb --- /dev/null +++ b/oauth2-resource-server/scripts/run-all.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Regenerates every ../docs/output/rs-*.txt file from a real run. +# +# Needs Docker for the Keycloak leg. Takes roughly twenty-five minutes, most of it spent +# restarting the resource server between profile sets and waiting out cache lifetimes. +set -eu +cd "$(dirname "$0")/.." +OUT=../docs/output +mkdir -p "$OUT" + +echo "==> tests" +{ + echo "==========================================================================" + echo " oauth2-resource-server-demo - test run" + echo "==========================================================================" + echo + mvn -B test 2>&1 | grep -E '^\[INFO\] (Running|Tests run)|^\[ERROR\]|BUILD (SUCCESS|FAILURE)' \ + | sed 's/^\[INFO\] //' + echo + echo "JDK : $(java -version 2>&1 | grep -v 'JAVA_TOOL\|Picked up' | head -1)" + echo "Boot : 4.1.1" + echo "Security : 7.1.1" + echo "Nimbus : 10.9.1" +} > "$OUT/rs-test-run.txt" + +echo "==> stub issuer" +./scripts/run-stub-issuer.sh >/dev/null + +echo "==> issuer and audience validation" +./scripts/run-rs.sh stub,roles,audience >/dev/null +./scripts/issuer-audience-demo.sh "stub,roles,audience" > "$OUT/rs-issuer-audience.txt" 2>&1 + +echo "==> the same run with a type validator that accepts at+jwt" +./scripts/run-rs.sh stub,roles,audience,attyp >/dev/null +./scripts/issuer-audience-demo.sh "stub,roles,audience,attyp" > "$OUT/rs-issuer-audience-attyp.txt" 2>&1 + +echo "==> authorities: the default converter" +./scripts/run-rs.sh stub >/dev/null +./scripts/converter-demo.sh "stub" > "$OUT/rs-converter-default.txt" 2>&1 + +echo "==> authorities: a custom JwtAuthenticationConverter" +./scripts/run-rs.sh stub,roles >/dev/null +./scripts/converter-demo.sh "stub,roles" > "$OUT/rs-converter-java.txt" 2>&1 + +echo "==> authorities: configuration only" +./scripts/run-rs.sh stub,propsroles >/dev/null +./scripts/converter-demo.sh "stub,propsroles" > "$OUT/rs-converter-properties.txt" 2>&1 + +echo "==> authorities: the same configuration with an unquoted SpEL indexer" +./scripts/run-rs.sh stub,propsroles-broken,tracespel >/dev/null +{ + ./scripts/converter-demo.sh "stub,propsroles-broken,tracespel" + echo + echo "--------------------------------------------------------------------------" + echo " what the resource server logged, at TRACE, while producing that 403" + echo "--------------------------------------------------------------------------" + grep -F 'ExpressionJwtGrantedAuthoritiesConverter' /tmp/rs-stub-propsroles-broken-tracespel.log \ + | sed 's/^.*ExpressionJwtGrantedAuthoritiesConverter *: / /' | sort -u +} > "$OUT/rs-converter-properties-broken.txt" 2>&1 + +echo "==> the live JWK source chain, three cache configurations" +rm -f "$OUT/rs-decoder-chain.txt" +for P in stub stub,springcache stub,nottlcache stub,hardened; do + ./scripts/run-rs.sh "$P" >/dev/null + ./scripts/decoder-chain.sh "$P" >> "$OUT/rs-decoder-chain.txt" 2>&1 +done + +echo "==> rotation, from a cold resource server" +./scripts/run-stub-issuer.sh >/dev/null +./scripts/run-rs.sh stub,roles,audience >/dev/null +./scripts/rotation-demo.sh "stub,roles,audience" > "$OUT/rs-rotation.txt" 2>&1 + +echo "==> what an unknown kid costs the issuer" +./scripts/run-stub-issuer.sh >/dev/null +./scripts/run-rs.sh stub,roles >/dev/null +./scripts/amplification-demo.sh "stub,roles" 25 > "$OUT/rs-jwks-amplification.txt" 2>&1 + +echo "==> a retired key, default caching (takes ~8 minutes)" +./scripts/run-stub-issuer.sh >/dev/null +./scripts/run-rs.sh stub,roles >/dev/null +./scripts/retired-key-demo.sh "stub,roles" 16 30 > "$OUT/rs-retired-key-default.txt" 2>&1 + +echo "==> a retired key, Spring cache with no TTL (takes ~8 minutes)" +./scripts/run-stub-issuer.sh >/dev/null +./scripts/run-rs.sh stub,roles,nottlcache >/dev/null +./scripts/retired-key-demo.sh "stub,roles,nottlcache" 16 30 > "$OUT/rs-retired-key-nottlcache.txt" 2>&1 + +echo "==> Keycloak" +docker compose -f docker/compose.yaml up -d >/dev/null 2>&1 +for i in $(seq 1 60); do + curl -sf http://localhost:8080/realms/demo/.well-known/openid-configuration -o /dev/null 2>/dev/null && break + sleep 3 +done +./scripts/run-rs.sh keycloak,roles >/dev/null +./scripts/keycloak-demo.sh > "$OUT/rs-keycloak.txt" 2>&1 +./scripts/run-rs.sh keycloak >/dev/null +./scripts/keycloak-demo.sh > "$OUT/rs-keycloak-default-converter.txt" 2>&1 + +for p in $(ps -eo pid,cmd | grep -E '[R]esourceServerApplication|[S]tubIssuerApplication' | awk '{print $1}'); do kill -9 "$p" || true; done +echo "==> done. docs/output/rs-*.txt regenerated." diff --git a/oauth2-resource-server/scripts/run-rs.sh b/oauth2-resource-server/scripts/run-rs.sh new file mode 100755 index 0000000..274e25f --- /dev/null +++ b/oauth2-resource-server/scripts/run-rs.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Starts the resource server on :8081 with the given profiles, replacing any previous instance. +# Usage: ./scripts/run-rs.sh stub,roles,audience +set -eu +PROFILES="${1:-stub}" +cd "$(dirname "$0")/.." +for p in $(ps -eo pid,cmd | grep '[R]esourceServerApplication' | awk '{print $1}'); do kill -9 "$p" || true; done +sleep 1 +setsid nohup mvn -B -o org.springframework.boot:spring-boot-maven-plugin:run \ + -Dspring-boot.run.main-class=com.ankurm.rsdemo.ResourceServerApplication \ + -Dspring-boot.run.profiles="$PROFILES" > "/tmp/rs-${PROFILES//,/-}.log" 2>&1 < /dev/null & +for i in $(seq 1 60); do + curl -sf http://localhost:8081/api/public/ping -o /dev/null 2>/dev/null && { echo "resource server up on :8081 with profiles: $PROFILES"; exit 0; } + sleep 2 +done +echo "resource server failed to start; see /tmp/rs-${PROFILES//,/-}.log" >&2; exit 1 diff --git a/oauth2-resource-server/scripts/run-stub-issuer.sh b/oauth2-resource-server/scripts/run-stub-issuer.sh new file mode 100755 index 0000000..9ce85b5 --- /dev/null +++ b/oauth2-resource-server/scripts/run-stub-issuer.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Starts the stub authorization server on :9000, replacing any previous instance. +set -eu +cd "$(dirname "$0")/.." +for p in $(ps -eo pid,cmd | grep '[S]tubIssuerApplication' | awk '{print $1}'); do kill -9 "$p" || true; done +sleep 1 +setsid nohup mvn -B -o org.springframework.boot:spring-boot-maven-plugin:run \ + -Dspring-boot.run.main-class=com.ankurm.stubissuer.StubIssuerApplication \ + -Dspring-boot.run.profiles=stubissuer > /tmp/stub-issuer.log 2>&1 < /dev/null & +for i in $(seq 1 60); do + curl -sf http://localhost:9000/admin/state -o /dev/null 2>/dev/null && { echo "stub issuer up on :9000"; exit 0; } + sleep 2 +done +echo "stub issuer failed to start; see /tmp/stub-issuer.log" >&2; exit 1 diff --git a/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/ResourceServerApplication.java b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/ResourceServerApplication.java new file mode 100644 index 0000000..a7bc588 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/ResourceServerApplication.java @@ -0,0 +1,23 @@ +package com.ankurm.rsdemo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * The resource server. It never mints a token — it only validates the ones it is given. + * + *

Run it against either issuer: + *

+ * ./scripts/run-rs.sh stub        # the in-repo stub issuer on :9000
+ * ./scripts/run-rs.sh keycloak    # real Keycloak on :8080
+ * 
+ * + *

Explained in docs/12. + */ +@SpringBootApplication +public class ResourceServerApplication { + + public static void main(String[] args) { + SpringApplication.run(ResourceServerApplication.class, args); + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/JwtDecoderConfig.java b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/JwtDecoderConfig.java new file mode 100644 index 0000000..2783f69 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/JwtDecoderConfig.java @@ -0,0 +1,171 @@ +package com.ankurm.rsdemo.config; + +import java.time.Duration; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtAudienceValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTypeValidator; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +/** + * Decoder variants that differ only in how the JWK Set is cached. + * + *

Read {@code NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource()} in the Spring + * Security 7.1.1 sources before assuming any of this is obvious: + * + *

+ * JWKSourceBuilder.create(new SpringJWKSource<>(restOperations, cache, jwkSetUri))
+ *     .refreshAheadCache(false)
+ *     .rateLimited(false)
+ *     .cache(this.cache instanceof NoOpCache)
+ *     .build();
+ * 
+ * + * Nimbus enables all three by default. Spring Security switches two off outright, and the + * third line means that supplying a Spring cache switches Nimbus’s own + * five-minute cache off, leaving your cache’s TTL as the only expiry in the + * system. Measured in + * docs/15. + * + *

With no profile active this class contributes nothing and Spring Boot’s own + * auto-configured decoder is used, which is the configuration most applications run. + */ +@Configuration +public class JwtDecoderConfig { + + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUri; + + @Value("${demo.audience:reports-api}") + private String audience; + + /** Only used by the hardened profile, which cannot discover it. */ + @Value("${demo.jwk-set-uri:}") + private String jwkSetUri; + + /** + * Boot’s {@code JwtDecoderConfiguration} collects every {@code OAuth2TokenValidator<Jwt>} + * bean in the context and appends it to the validator stack, so audience validation does + * not require replacing the decoder. This bean and the + * {@code spring.security.oauth2.resourceserver.jwt.audiences} property do the same job; + * the property builds a {@code JwtClaimValidator} on {@code aud}, this builds the + * purpose-made {@code JwtAudienceValidator}. + */ + @Bean + @Profile("audience") + OAuth2TokenValidator audienceValidator() { + return new JwtAudienceValidator(this.audience); + } + + /** + * Accepts RFC 9068 access tokens. The default stack contains {@code JwtTypeValidator.jwt()}, + * which accepts only an absent {@code typ} or {@code typ=JWT}; a token carrying + * {@code typ=at+jwt} - which RFC 9068 says an access token SHOULD carry - is refused by it. + */ + @Bean + @Profile("attyp") + OAuth2TokenValidator accessTokenTypeValidator() { + JwtTypeValidator validator = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt", "Bearer"); + validator.setAllowEmpty(true); + return validator; + } + + // ------------------------------------------------------------------ cache variants + + private OAuth2TokenValidator validators() { + return JwtValidators.createDefaultWithValidators(new JwtIssuerValidator(this.issuerUri), + permissiveTypeValidator()); + } + + private JwtTypeValidator permissiveTypeValidator() { + JwtTypeValidator validator = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt", "Bearer"); + validator.setAllowEmpty(true); + return validator; + } + + /** + * The shared-cache configuration the reference documentation recommends, done correctly: + * an explicit TTL. Nimbus caching is off, so this TTL is the only expiry that exists. + */ + @Bean + @Profile("springcache") + JwtDecoder caffeineCachedDecoder() { + Cache cache = new CaffeineCache("jwks", + Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build()); + NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(this.issuerUri).cache(cache).build(); + decoder.setJwtValidator(validators()); + return decoder; + } + + /** + * Every protective layer Nimbus offers, restored. + * + *

{@code JwkSetUriJwtDecoderBuilder} exposes no way to re-enable rate limiting, + * refresh-ahead or outage tolerance, so the {@code JWKSource} is built directly and handed + * to {@code NimbusJwtDecoder.withJwkSource(..)}. What that costs: + * + *

    + *
  • issuer discovery is gone - the JWK Set URI has to be configured explicitly
  • + *
  • the validator stack is no longer supplied for you, so it is set here in full
  • + *
  • {@code JWKSourceBuilder.create(URL)} fetches with Nimbus’s own + * {@code DefaultResourceRetriever}, not Spring’s {@code RestOperations}, so + * any client customisation, proxy configuration or observability you had wired into + * the Spring HTTP client does not apply
  • + *
+ * + *

The rate limit also means that during a genuine rotation, tokens signed with the new + * key are refused for up to the interval after the first miss. That is the trade, and it + * is discussed in docs/16. + */ + @Bean + @Profile("hardened") + JwtDecoder hardenedDecoder() throws java.net.MalformedURLException, java.net.URISyntaxException { + JWKSource source = JWKSourceBuilder + .create(new java.net.URI(this.jwkSetUri).toURL()) + .cache(Duration.ofMinutes(5).toMillis(), Duration.ofSeconds(15).toMillis()) + .refreshAheadCache(true) + .rateLimited(Duration.ofSeconds(30).toMillis()) + .outageTolerant(Duration.ofMinutes(30).toMillis()) + .retrying(true) + .build(); + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSource(source).build(); + decoder.setJwtValidator(validators()); + return decoder; + } + + /** + * The same configuration with the cache most people reach for first. A + * {@code ConcurrentMapCache} - which is also what {@code ConcurrentMapCacheManager}, + * Boot’s fallback cache manager, hands out - has no TTL at all, so the JWK Set is + * cached until something forces a refresh. + * + *

The only thing that forces a refresh is a token whose {@code kid} is missing from the + * cached set. A key that has been removed from the JWK Set is still present in the + * stale cache and still matches, so tokens signed with a retired - or compromised - key keep + * being accepted. Demonstrated by {@code scripts/retired-key-demo.sh}. + */ + @Bean + @Profile("nottlcache") + JwtDecoder noTtlCachedDecoder() { + Cache cache = new ConcurrentMapCache("jwks"); + NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(this.issuerUri).cache(cache).build(); + decoder.setJwtValidator(validators()); + return decoder; + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/KeycloakAuthoritiesConfig.java b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/KeycloakAuthoritiesConfig.java new file mode 100644 index 0000000..13c4218 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/KeycloakAuthoritiesConfig.java @@ -0,0 +1,109 @@ +package com.ankurm.rsdemo.config; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; + +/** + * Mapping a Keycloak token onto Spring Security authorities. + * + *

The default {@link JwtGrantedAuthoritiesConverter} reads the {@code scope} or {@code scp} + * claim, splits it on spaces, and prefixes each value with {@code SCOPE_}. Keycloak does emit + * {@code scope}, so scopes work out of the box. Roles do not: Keycloak nests realm roles under + * {@code realm_access.roles} and client roles under {@code resource_access..roles}, + * and the default converter looks at neither. The symptom is a token that authenticates + * perfectly and then gets 403 from every {@code hasRole(..)} rule. + * + *

Two ways out. This class is the Java one, active under the {@code roles} profile; + * {@code application-propsroles.yaml} is the configuration-only one. They produce the same + * authorities, and the configuration route has one limitation the Java route does not - see + * docs/14. + * + *

Defining this bean silently disables the properties. Boot’s + * {@code JwtConverterConfiguration} is annotated + * {@code @ConditionalOnMissingBean(JwtAuthenticationConverter.class)}, so the moment a + * {@code JwtAuthenticationConverter} bean exists, every + * {@code spring.security.oauth2.resourceserver.jwt.authorities-*} and {@code principal-claim-name} + * property stops having any effect. No warning is logged. + */ +@Configuration +public class KeycloakAuthoritiesConfig { + + @Bean + @Profile("roles") + JwtAuthenticationConverter keycloakJwtAuthenticationConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setPrincipalClaimName("preferred_username"); + converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api")); + return converter; + } + + /** + * Scopes keep the {@code SCOPE_} prefix, realm and client roles get {@code ROLE_}. + * A mixed mapping like this is the one thing the property-only route cannot express, + * because {@code authority-prefix} is a single value applied to every expression. + */ + static final class KeycloakGrantedAuthoritiesConverter implements Converter> { + + private final JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); + + private final String clientId; + + KeycloakGrantedAuthoritiesConverter(String clientId) { + this.clientId = clientId; + } + + @Override + public Collection convert(Jwt jwt) { + Collection authorities = new ArrayList<>(this.scopes.convert(jwt)); + addPrefixed(authorities, realmRoles(jwt)); + addPrefixed(authorities, clientRoles(jwt)); + return authorities; + } + + @SuppressWarnings("unchecked") + private List realmRoles(Jwt jwt) { + Map realmAccess = jwt.getClaimAsMap("realm_access"); + if (realmAccess == null) { + return List.of(); + } + Object roles = realmAccess.get("roles"); + return (roles instanceof List list) ? (List) list : List.of(); + } + + @SuppressWarnings("unchecked") + private List clientRoles(Jwt jwt) { + Map resourceAccess = jwt.getClaimAsMap("resource_access"); + if (resourceAccess == null) { + return List.of(); + } + Object client = resourceAccess.get(this.clientId); + if (!(client instanceof Map clientMap)) { + return List.of(); + } + Object roles = clientMap.get("roles"); + return (roles instanceof List list) ? (List) list : List.of(); + } + + private void addPrefixed(Collection target, List roles) { + for (String role : roles) { + // Realm roles and client roles are flattened into one ROLE_ namespace here. + // If two clients in your realm both define a role named "admin", this + // collapses them onto the same authority. Prefix by client if that is a + // risk for you. + target.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + } + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/ResourceServerSecurityConfig.java b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/ResourceServerSecurityConfig.java new file mode 100644 index 0000000..77b39fe --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/ResourceServerSecurityConfig.java @@ -0,0 +1,42 @@ +package com.ankurm.rsdemo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +/** + * The whole resource server, in one chain. + * + *

Note what is not here: no login endpoint, no user store, no password encoder, + * no token minting. A resource server only ever verifies. Compare with the hand-written + * filter in {@code ../../../jwt-authentication/} and + * docs/09. + * + *

Explained in docs/12. + */ +@Configuration +@EnableMethodSecurity +public class ResourceServerSecurityConfig { + + @Bean + SecurityFilterChain api(HttpSecurity http) throws Exception { + return http + // A resource server authenticates every request from the token alone, so there is + // no session to protect and nothing for CSRF to defend. This is the one place the + // blanket "never disable CSRF" advice genuinely does not apply - see docs/04. + .csrf((csrf) -> csrf.disable()) + .sessionManagement((s) -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests((auth) -> auth + .requestMatchers("/api/public/**").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .anyRequest().authenticated()) + // Everything interesting about this application is inside these two lines. + // The JwtDecoder bean decides which tokens are genuine; the JwtAuthenticationConverter + // bean decides what a genuine token is allowed to do. + .oauth2ResourceServer((oauth2) -> oauth2.jwt((jwt) -> { })) + .build(); + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/ApiControllers.java b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/ApiControllers.java new file mode 100644 index 0000000..ca4dce2 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/ApiControllers.java @@ -0,0 +1,61 @@ +package com.ankurm.rsdemo.web; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Four endpoints, each testing one layer of the chain. + * + *

Explained in docs/12. + */ +@RestController +public class ApiControllers { + + /** Reachable with no token at all. If this 401s, the problem is not your token. */ + @GetMapping("/api/public/ping") + public Map ping() { + return Map.of("status", "up"); + } + + /** + * 401 without a valid token. The response body is where every claim-validation failure + * shows up - in the {@code WWW-Authenticate} header, not the body. + */ + @GetMapping("/api/me") + public Map me(Authentication authentication) { + Jwt jwt = (Jwt) authentication.getPrincipal(); + Map out = new LinkedHashMap<>(); + out.put("name", authentication.getName()); + out.put("authorities", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).sorted().toList()); + out.put("iss", jwt.getClaimAsString("iss")); + out.put("aud", jwt.getAudience()); + out.put("typ", jwt.getHeaders().get("typ")); + out.put("kid", jwt.getHeaders().get("kid")); + out.put("exp", jwt.getExpiresAt()); + return out; + } + + /** 403 with a valid token that lacks ROLE_ADMIN. This is where the converter shows up. */ + @GetMapping("/api/admin/stats") + public Map adminStats() { + return Map.of("secret", "only ROLE_ADMIN sees this"); + } + + /** + * The method-security twin. This one needs a Keycloak client role, which lives + * two levels down in {@code resource_access.reports-api.roles} - the claim the default + * converter is least likely to find. + */ + @GetMapping("/api/reports") + @PreAuthorize("hasAuthority('ROLE_reports-reader')") + public Map reports() { + return Map.of("reports", 3); + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/DecoderDiagnosticsController.java b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/DecoderDiagnosticsController.java new file mode 100644 index 0000000..b48ad8c --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/DecoderDiagnosticsController.java @@ -0,0 +1,128 @@ +package com.ankurm.rsdemo.web; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Prints the JWK source chain that the running {@code JwtDecoder} actually has. + * + *

Every claim in the post about caching, rate limiting and refresh-ahead can be read out + * of the Spring Security sources, but reading sources tells you what a decoder looks + * like, not what yours looks like after auto-configuration, your profiles, your + * {@code JwkSetUriJwtDecoderBuilderCustomizer} beans and your cache have all had a turn. + * This endpoint walks the live object graph and reports the layers it finds, with the + * timings each layer was constructed with. + * + *

It reads private fields by reflection, which is the price of asking a question the API + * does not answer. It is a diagnostic, not a feature: delete it before you ship. + * It reveals your JWK Set URI and cache timings to anyone who can reach it. + * + *

Explained in docs/15. + */ +@RestController +public class DecoderDiagnosticsController { + + private final JwtDecoder decoder; + + public DecoderDiagnosticsController(JwtDecoder decoder) { + this.decoder = decoder; + } + + @GetMapping(path = "/api/public/decoder", produces = MediaType.APPLICATION_JSON_VALUE) + public Map decoder() { + Map report = new LinkedHashMap<>(); + report.put("decoderClass", this.decoder.getClass().getName()); + + Object cursor = this.decoder; + // An issuer-uri produces a SupplierJwtDecoder, whose `delegate` field is a + // Supplier rather than the decoder - the real NimbusJwtDecoder does not + // exist until the first token is decoded. That laziness is deliberate: it decouples + // startup from the authorization server being reachable. + Object delegate = field(cursor, "delegate"); + if (delegate instanceof java.util.function.Supplier supplier) { + report.put("note", "SupplierJwtDecoder: built lazily on first decode, then cached"); + cursor = supplier.get(); + report.put("resolvedDecoderClass", className(cursor)); + } + + Object processor = field(cursor, "jwtProcessor"); + Object keySelector = (processor != null) ? field(processor, "jwsKeySelector") : null; + Object jwkSource = (keySelector != null) ? field(keySelector, "jwkSource") : null; + + report.put("processor", className(processor)); + report.put("keySelector", className(keySelector)); + + List> chain = new ArrayList<>(); + Object node = jwkSource; + int guard = 0; + while (node != null && guard++ < 12) { + Map layer = new LinkedHashMap<>(); + layer.put("class", node.getClass().getName()); + describe(node, layer); + chain.add(layer); + node = field(node, "source"); + } + report.put("jwkSourceChain", chain); + report.put("readMe", "Each entry wraps the next. A layer that is absent was switched off."); + return report; + } + + /** Pulls out the timings that decide when a rotated key becomes visible. */ + private void describe(Object node, Map layer) { + String name = node.getClass().getSimpleName(); + switch (name) { + case "CachingJWKSetSource", "RefreshAheadCachingJWKSetSource" -> { + layer.put("timeToLiveMs", field(node, "timeToLive")); + layer.put("cacheRefreshTimeoutMs", field(node, "cacheRefreshTimeout")); + layer.put("meaning", "the JWK Set is re-fetched no more often than timeToLive"); + } + case "RateLimitedJWKSetSource" -> { + layer.put("minTimeIntervalMs", field(node, "minTimeInterval")); + layer.put("meaning", "forced refreshes are throttled to this interval"); + } + case "OutageTolerantJWKSetSource" -> + layer.put("meaning", "a stale JWK Set is served if the issuer is unreachable"); + case "SpringJWKSource" -> { + layer.put("jwkSetUri", field(node, "jwkSetUri")); + Object cache = field(node, "cache"); + layer.put("springCache", className(cache)); + layer.put("meaning", (cache != null && cache.getClass().getSimpleName().equals("NoOpCache")) + ? "no Spring cache supplied, so Nimbus's own cache layer is enabled above" + : "a Spring cache was supplied, so Nimbus's cache layer was disabled and this " + + "cache's TTL is the only expiry"); + } + default -> { + } + } + } + + private static String className(Object o) { + return (o != null) ? o.getClass().getName() : null; + } + + private static Object field(Object target, String name) { + Class type = target.getClass(); + while (type != null && type != Object.class) { + try { + Field f = type.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } + catch (NoSuchFieldException ex) { + type = type.getSuperclass(); + } + catch (Exception ex) { + return null; + } + } + return null; + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerApplication.java b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerApplication.java new file mode 100644 index 0000000..5f41886 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerApplication.java @@ -0,0 +1,25 @@ +package com.ankurm.stubissuer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * A deliberately minimal OAuth2 authorization server whose JWKS I can mutate on command. + * + *

Keycloak is the realistic issuer and this repository runs against it too + * (see {@code docker/compose.yaml}). But Keycloak will not rotate its signing key at a + * chosen second, will not tell you how many times its JWKS endpoint was fetched, and + * will not drop a key from the published set on request. Every claim in the post about + * caching and rotation timing needs exactly those three things, so they + * are measured here and the Keycloak run confirms the same code path end to end. + * + *

Runs on :9000. Explained in + * docs/15. + */ +@SpringBootApplication +public class StubIssuerApplication { + + public static void main(String[] args) { + SpringApplication.run(StubIssuerApplication.class, args); + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerController.java b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerController.java new file mode 100644 index 0000000..3f12eb6 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubIssuerController.java @@ -0,0 +1,181 @@ +package com.ankurm.stubissuer; + +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * The endpoints a resource server discovers, plus admin endpoints a real issuer would + * never expose. + * + *

Discovery and JWKS are deliberately shaped like Keycloak's so the resource server + * configuration is byte-identical between the two issuers. + */ +@RestController +public class StubIssuerController { + + private final StubKeyStore keys; + + private final String issuer; + + public StubIssuerController(StubKeyStore keys, @Value("${stub.issuer}") String issuer) { + this.keys = keys; + this.issuer = issuer; + } + + // ---------------------------------------------------------------- discovery + + @GetMapping(path = "/.well-known/openid-configuration", produces = MediaType.APPLICATION_JSON_VALUE) + public Map discovery() { + Map doc = new LinkedHashMap<>(); + doc.put("issuer", this.issuer); + doc.put("jwks_uri", this.issuer + "/jwks.json"); + doc.put("token_endpoint", this.issuer + "/token"); + doc.put("id_token_signing_alg_values_supported", List.of("RS256")); + doc.put("response_types_supported", List.of("code")); + doc.put("subject_types_supported", List.of("public")); + return doc; + } + + /** + * Every fetch of this endpoint is counted. A resource server that is behaving itself + * hits this roughly once per cache lifetime; one that is not can hit it once per request. + */ + @GetMapping(path = "/jwks.json", produces = "application/jwk-set+json") + public String jwks() { + this.keys.recordJwksFetch(); + return this.keys.publishedJwkSet().toString(); + } + + // ---------------------------------------------------------------- token minting + + /** + * Mints an access token. Everything is a query parameter because the point is to be able + * to produce a deliberately wrong token as easily as a correct one. + * + * @param kid sign with a specific key rather than the active one. A retired key still + * signs perfectly well — that is the whole problem with retiring keys. + */ + @PostMapping(path = "/token", produces = MediaType.TEXT_PLAIN_VALUE) + public String token(@RequestParam(defaultValue = "alice") String sub, + @RequestParam(defaultValue = "reports-api") String aud, + @RequestParam(defaultValue = "profile:read reports:read") String scope, + @RequestParam(defaultValue = "USER") String roles, + @RequestParam(defaultValue = "300") long expiresInSeconds, + @RequestParam(defaultValue = "0") long issuedAgoSeconds, + @RequestParam(required = false) String kid, + @RequestParam(required = false) String issuerOverride, + @RequestParam(defaultValue = "JWT") String typ) throws Exception { + + RSAKey key = (kid != null) ? this.keys.key(kid) : this.keys.signingKey(); + Instant issuedAt = Instant.now().minusSeconds(issuedAgoSeconds); + + Map realmAccess = Map.of("roles", Arrays.asList(roles.split(" "))); + Map resourceAccess = Map.of("reports-api", Map.of("roles", List.of("reports-reader"))); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer((issuerOverride != null) ? issuerOverride : this.issuer) + .subject(sub) + .audience(Arrays.asList(aud.split(" "))) + .claim("scope", scope) + // Keycloak puts realm roles here, nested one level down. Spring Security's + // default converter reads a flat "scope"/"scp" claim and will not find these. + .claim("realm_access", realmAccess) + .claim("resource_access", resourceAccess) + .claim("preferred_username", sub) + .issueTime(java.util.Date.from(issuedAt)) + .expirationTime(java.util.Date.from(issuedAt.plusSeconds(expiresInSeconds))) + .jwtID(java.util.UUID.randomUUID().toString()) + .build(); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(key.getKeyID()) + .type(new JOSEObjectType(typ)) + .build(); + + SignedJWT jwt = new SignedJWT(header, claims); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } + + /** + * Mints a token whose {@code kid} header names a key that has never existed. This is what + * an attacker’s traffic looks like, and it is the input to the amplification demo. + */ + @PostMapping(path = "/token/unknown-kid", produces = MediaType.TEXT_PLAIN_VALUE) + public String tokenWithUnknownKid(@RequestParam(defaultValue = "alice") String sub) throws Exception { + RSAKey key = this.keys.signingKey(); + Instant now = Instant.now(); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(this.issuer) + .subject(sub) + .audience("reports-api") + .issueTime(java.util.Date.from(now)) + .expirationTime(java.util.Date.from(now.plusSeconds(300))) + .build(); + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID("kid-" + java.util.UUID.randomUUID()) + .type(new JOSEObjectType("at+jwt")) + .build(); + SignedJWT jwt = new SignedJWT(header, claims); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } + + // ---------------------------------------------------------------- admin + + @PostMapping(path = "/admin/publish", produces = MediaType.APPLICATION_JSON_VALUE) + public Map publish() { + String kid = this.keys.generate(); + return state("published " + kid); + } + + @PostMapping(path = "/admin/activate", produces = MediaType.APPLICATION_JSON_VALUE) + public Map activate(@RequestParam String kid) { + this.keys.activate(kid); + return state("signing with " + kid); + } + + @PostMapping(path = "/admin/retire", produces = MediaType.APPLICATION_JSON_VALUE) + public Map retire(@RequestParam String kid) { + this.keys.retire(kid); + return state("retired " + kid + " from the published set"); + } + + @PostMapping(path = "/admin/reset-counter", produces = MediaType.APPLICATION_JSON_VALUE) + public Map resetCounter() { + this.keys.resetJwksFetches(); + return state("jwks fetch counter reset"); + } + + @GetMapping(path = "/admin/state", produces = MediaType.APPLICATION_JSON_VALUE) + public Map state() { + return state("ok"); + } + + private Map state(String message) { + Map out = new LinkedHashMap<>(); + out.put("message", message); + out.put("activeKid", this.keys.activeKid()); + out.put("publishedKids", this.keys.publishedKids()); + out.put("jwksFetches", this.keys.jwksFetches()); + return out; + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubKeyStore.java b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubKeyStore.java new file mode 100644 index 0000000..ad95d17 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubKeyStore.java @@ -0,0 +1,115 @@ +package com.ankurm.stubissuer; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; + +import org.springframework.stereotype.Component; + +/** + * Holds every key this issuer has ever minted, plus which of them are currently + * published in the JWK Set and which one is currently active for signing. + * + *

Rotation in the real world is three separate events, and conflating them is where + * most rotation incidents come from: + *

    + *
  1. publish — the new key appears in the JWK Set, nothing signs with it yet
  2. + *
  3. activate — the issuer starts signing with the new key
  4. + *
  5. retire — the old key is removed from the JWK Set
  6. + *
+ * This class lets a script fire them independently and at a chosen moment, which is the + * only way to show what a resource server does in between. + */ +@Component +public class StubKeyStore { + + private final Map allKeys = new LinkedHashMap<>(); + + private final List published = new ArrayList<>(); + + private final AtomicInteger keyCounter = new AtomicInteger(); + + /** Counts every GET of /jwks.json. This number is the point of the whole class. */ + private final AtomicLong jwksFetches = new AtomicLong(); + + private volatile String activeKid; + + public StubKeyStore() { + String kid = generate(); + this.activeKid = kid; + } + + /** Creates a key, publishes it, and returns its kid. Does not make it active. */ + public synchronized String generate() { + String kid = "stub-key-" + this.keyCounter.incrementAndGet(); + try { + RSAKey key = new RSAKeyGenerator(2048).keyID(kid).keyUse(KeyUse.SIGNATURE).generate(); + this.allKeys.put(kid, key); + this.published.add(kid); + return kid; + } + catch (Exception ex) { + throw new IllegalStateException("could not generate RSA key", ex); + } + } + + public synchronized void activate(String kid) { + if (!this.allKeys.containsKey(kid)) { + throw new IllegalArgumentException("no such kid: " + kid); + } + this.activeKid = kid; + } + + /** Removes a key from the published JWK Set. The key still exists and can still sign. */ + public synchronized void retire(String kid) { + this.published.remove(kid); + } + + public synchronized RSAKey signingKey() { + return this.allKeys.get(this.activeKid); + } + + public synchronized RSAKey key(String kid) { + RSAKey key = this.allKeys.get(kid); + if (key == null) { + throw new IllegalArgumentException("no such kid: " + kid); + } + return key; + } + + public synchronized JWKSet publishedJwkSet() { + List keys = new ArrayList<>(); + for (String kid : this.published) { + keys.add(this.allKeys.get(kid).toPublicJWK()); + } + return new JWKSet(keys); + } + + public long recordJwksFetch() { + return this.jwksFetches.incrementAndGet(); + } + + public long jwksFetches() { + return this.jwksFetches.get(); + } + + public void resetJwksFetches() { + this.jwksFetches.set(0); + } + + public String activeKid() { + return this.activeKid; + } + + public synchronized List publishedKids() { + return List.copyOf(this.published); + } +} diff --git a/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubSecurityConfig.java b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubSecurityConfig.java new file mode 100644 index 0000000..93aa658 --- /dev/null +++ b/oauth2-resource-server/src/main/java/com/ankurm/stubissuer/StubSecurityConfig.java @@ -0,0 +1,21 @@ +package com.ankurm.stubissuer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * The stub issuer authenticates nobody. Defining any {@code SecurityFilterChain} bean makes + * Boot’s default chain back off, which is the whole purpose of this class. + */ +@Configuration +public class StubSecurityConfig { + + @Bean + SecurityFilterChain open(HttpSecurity http) throws Exception { + return http.csrf((csrf) -> csrf.disable()) + .authorizeHttpRequests((auth) -> auth.anyRequest().permitAll()) + .build(); + } +} diff --git a/oauth2-resource-server/src/main/resources/application-keycloak.yaml b/oauth2-resource-server/src/main/resources/application-keycloak.yaml new file mode 100644 index 0000000..a7ff048 --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-keycloak.yaml @@ -0,0 +1,11 @@ +# Points the resource server at the Keycloak in docker/compose.yaml. +# Identical shape to application-stub.yaml - one property. +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8080/realms/demo + +demo: + jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs diff --git a/oauth2-resource-server/src/main/resources/application-props.yaml b/oauth2-resource-server/src/main/resources/application-props.yaml new file mode 100644 index 0000000..87c165c --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-props.yaml @@ -0,0 +1,8 @@ +# Audience validation with no Java at all. Boot turns this into a JwtClaimValidator on +# `aud` and appends it to the default validator stack. +spring: + security: + oauth2: + resourceserver: + jwt: + audiences: reports-api diff --git a/oauth2-resource-server/src/main/resources/application-propsroles-broken.yaml b/oauth2-resource-server/src/main/resources/application-propsroles-broken.yaml new file mode 100644 index 0000000..07c80ad --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-propsroles-broken.yaml @@ -0,0 +1,15 @@ +# The same configuration with the hyphenated client id unquoted. This is what most people +# write first, and it fails silently: no error, no WARN, just an authority that never +# appears and a 403 nobody can explain. +# +# The `trace` profile makes the swallowed message visible. +spring: + security: + oauth2: + resourceserver: + jwt: + principal-claim-name: preferred_username + authority-prefix: "ROLE_" + authorities-claim-expressions: + - "[realm_access][roles]" + - "[resource_access][reports-api][roles]" diff --git a/oauth2-resource-server/src/main/resources/application-propsroles.yaml b/oauth2-resource-server/src/main/resources/application-propsroles.yaml new file mode 100644 index 0000000..c7cf486 --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-propsroles.yaml @@ -0,0 +1,28 @@ +# Keycloak's nested roles, mapped with configuration only. +# +# `authorities-claim-expressions` is a Spring Boot 4 property. Each entry is a SpEL +# expression evaluated against the claim map, so a nested claim needs no Java. +# +# NOTE THE QUOTES around 'reports-api'. Inside a SpEL indexer the contents are an +# expression, not a literal key, so [resource_access][reports-api][roles] parses as +# `reports` MINUS `api` and blows up with EL1008E. ExpressionJwtGrantedAuthoritiesConverter +# catches ExpressionException, substitutes an empty authority list, and logs the reason at +# TRACE only - so the failure surfaces as a 403 with nothing in the log to explain it. +# See application-propsroles-broken.yaml for the other spelling, and docs/14. +# +# Two more consequences of taking this route: +# * `authority-prefix` is ONE value applied to every expression. A mixed mapping - +# SCOPE_ for scopes, ROLE_ for roles - cannot be expressed here. +# * naming expressions REPLACES the default JwtGrantedAuthoritiesConverter, so the +# SCOPE_* authorities it produced from the `scope` claim disappear unless you add +# `[scope]` as an expression too - and then it gets the ROLE_ prefix as well. +spring: + security: + oauth2: + resourceserver: + jwt: + principal-claim-name: preferred_username + authority-prefix: "ROLE_" + authorities-claim-expressions: + - "[realm_access][roles]" + - "[resource_access]['reports-api'][roles]" diff --git a/oauth2-resource-server/src/main/resources/application-stub.yaml b/oauth2-resource-server/src/main/resources/application-stub.yaml new file mode 100644 index 0000000..7b935e9 --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-stub.yaml @@ -0,0 +1,14 @@ +# Points the resource server at the in-repo stub issuer on :9000. +# Discovery is used, exactly as with Keycloak: Spring reads +# /.well-known/openid-configuration, takes jwks_uri from it, and validates `iss` +# against this value. +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:9000 + +# The hardened profile builds the JWKSource itself and therefore cannot discover this. +demo: + jwk-set-uri: http://localhost:9000/jwks.json diff --git a/oauth2-resource-server/src/main/resources/application-stubissuer.yaml b/oauth2-resource-server/src/main/resources/application-stubissuer.yaml new file mode 100644 index 0000000..e0739df --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-stubissuer.yaml @@ -0,0 +1,5 @@ +# The stub authorization server itself. Nothing here is a resource server. +server: + port: 9000 +stub: + issuer: http://localhost:9000 diff --git a/oauth2-resource-server/src/main/resources/application-trace.yaml b/oauth2-resource-server/src/main/resources/application-trace.yaml new file mode 100644 index 0000000..c4af938 --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-trace.yaml @@ -0,0 +1,6 @@ +# Everything the resource server does to a token, logged. The line worth waiting for is +# the one from BearerTokenAuthenticationFilter naming the validator that refused. +logging: + level: + org.springframework.security: TRACE + org.springframework.web.client: DEBUG diff --git a/oauth2-resource-server/src/main/resources/application-tracespel.yaml b/oauth2-resource-server/src/main/resources/application-tracespel.yaml new file mode 100644 index 0000000..d015ed1 --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application-tracespel.yaml @@ -0,0 +1,4 @@ +# Just enough logging to see a claim expression fail, and nothing else. +logging: + level: + org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter: TRACE diff --git a/oauth2-resource-server/src/main/resources/application.yaml b/oauth2-resource-server/src/main/resources/application.yaml new file mode 100644 index 0000000..5fa0f4a --- /dev/null +++ b/oauth2-resource-server/src/main/resources/application.yaml @@ -0,0 +1,16 @@ +# Shared defaults. The issuer is deliberately NOT set here - it arrives with the +# `stub` or `keycloak` profile, so that the same application code demonstrably runs +# against a toy issuer and against a real one with no source difference at all. +spring: + application: + name: oauth2-resource-server-demo + +server: + port: 8081 + +demo: + audience: reports-api + +logging: + level: + org.springframework.security.oauth2: INFO diff --git a/oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java b/oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java new file mode 100644 index 0000000..5d55c9e --- /dev/null +++ b/oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java @@ -0,0 +1,130 @@ +package com.ankurm.rsdemo; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtAudienceValidator; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTypeValidator; +import org.springframework.security.oauth2.jwt.JwtValidators; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the behaviour of the default validator stack, because it is the part of a resource + * server that changes underneath you between versions and fails closed when it does. + * + *

These tests deliberately assert the defaults rather than this application's + * configuration. If a Spring Security upgrade changes what + * {@code JwtValidators.createDefaultWithIssuer} puts in the stack, this file goes red and + * the post that describes it is wrong. + * + *

Explained in docs/13. + */ +class JwtValidationContractTests { + + private static final String ISSUER = "https://issuer.example.com"; + + private Jwt.Builder token() { + Instant now = Instant.now(); + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .header("typ", "JWT") + .issuer(ISSUER) + .subject("alice") + .audience(List.of("reports-api")) + .issuedAt(now) + .expiresAt(now.plusSeconds(300)) + .claim("jti", "id"); + } + + @Test + void defaultStackAcceptsAWellFormedToken() { + OAuth2TokenValidator validator = JwtValidators.createDefaultWithIssuer(ISSUER); + assertThat(validator.validate(token().build()).hasErrors()).isFalse(); + } + + @Test + void defaultStackDoesNotCheckAudience() { + OAuth2TokenValidator validator = JwtValidators.createDefaultWithIssuer(ISSUER); + Jwt wrongAudience = token().audience(List.of("billing-api")).build(); + // This is the whole reason the audience check has to be added deliberately. + assertThat(validator.validate(wrongAudience).hasErrors()).isFalse(); + } + + @Test + void addingJwtAudienceValidatorRefusesTheSameToken() { + OAuth2TokenValidator validator = JwtValidators + .createDefaultWithValidators(new JwtIssuerValidator(ISSUER), new JwtAudienceValidator("reports-api")); + Jwt wrongAudience = token().audience(List.of("billing-api")).build(); + OAuth2TokenValidatorResult result = validator.validate(wrongAudience); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).anySatisfy((error) -> assertThat(error.getDescription()).contains("aud")); + } + + @Test + void defaultStackRefusesRfc9068AccessTokens() { + OAuth2TokenValidator validator = JwtValidators.createDefaultWithIssuer(ISSUER); + Jwt atJwt = token().headers((h) -> h.put("typ", "at+jwt")).build(); + // JwtTypeValidator.jwt() accepts an absent typ or typ=JWT and nothing else, so the + // media type RFC 9068 defines for access tokens is refused by the default stack. + assertThat(validator.validate(atJwt).hasErrors()).isTrue(); + } + + @Test + void aPermissiveTypeValidatorAcceptsThem() { + JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt"); + types.setAllowEmpty(true); + OAuth2TokenValidator validator = JwtValidators + .createDefaultWithValidators(new JwtIssuerValidator(ISSUER), types); + Jwt atJwt = token().headers((h) -> h.put("typ", "at+jwt")).build(); + assertThat(validator.validate(atJwt).hasErrors()).isFalse(); + } + + @Test + void issuerComparisonIsExactStringEquality() { + OAuth2TokenValidator validator = JwtValidators.createDefaultWithIssuer(ISSUER); + // A trailing slash is a different issuer. This is the single most common cause of + // "the token is signed correctly but the iss claim is not valid". + Jwt trailingSlash = token().issuer(ISSUER + "/").build(); + assertThat(validator.validate(trailingSlash).hasErrors()).isTrue(); + } + + @Test + void defaultClockSkewIsSixtySeconds() { + OAuth2TokenValidator validator = JwtValidators.createDefaultWithIssuer(ISSUER); + Instant now = Instant.now(); + Jwt expired30sAgo = token().issuedAt(now.minusSeconds(60)).expiresAt(now.minusSeconds(30)).build(); + Jwt expired90sAgo = token().issuedAt(now.minusSeconds(120)).expiresAt(now.minusSeconds(90)).build(); + assertThat(validator.validate(expired30sAgo).hasErrors()).isFalse(); + assertThat(validator.validate(expired90sAgo).hasErrors()).isTrue(); + } + + @Test + void audienceValidatorMatchesAnyEntryNotAllOfThem() { + OAuth2TokenValidator validator = new JwtAudienceValidator("reports-api"); + Jwt multipleAudiences = token().audience(List.of("billing-api", "reports-api")).build(); + assertThat(validator.validate(multipleAudiences).hasErrors()).isFalse(); + } + + @Test + void aMissingAudienceClaimIsRefusedNotIgnored() { + OAuth2TokenValidator validator = new JwtAudienceValidator("reports-api"); + Jwt noAudience = token().claims((c) -> c.remove("aud")).build(); + assertThat(validator.validate(noAudience).hasErrors()).isTrue(); + } + + @Test + void nestedKeycloakRolesAreInvisibleToTheDefaultAuthoritiesConverter() { + var converter = new org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter(); + Jwt keycloakish = token().claim("realm_access", Map.of("roles", List.of("ADMIN"))).build(); + // No scope claim, roles one level down: the default converter finds nothing at all. + assertThat(converter.convert(keycloakish)).isEmpty(); + } +}