1
0

Add OAuth2 resource server project: JWT validation, JWKS and key rotation

Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
This commit is contained in:
2026-08-23 11:00:56 +00:00
parent 4a8dab6739
commit 4dc45d5e00
101 changed files with 4087 additions and 64 deletions

175
README.md
View File

@@ -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 &mdash;
a real Keycloak, and a stub issuer whose signing keys can be rotated on command.
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) is
real program output, regenerated by a script &mdash; not transcribed by hand.
| | |
|---|---|
@@ -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 &mdash; JWT authentication with a hand-written filter
```bash
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
cd jwt-auth-demo
cd jwt-auth-demo/jwt-authentication
./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256
# in another shell
./scripts/curl-transcript.sh # the whole flow, end to end
```
### Project 2 &mdash; OAuth2 resource server, JWKS and rotation
```bash
cd jwt-auth-demo/oauth2-resource-server
# a stub issuer whose JWK Set can be mutated on command
./scripts/run-stub-issuer.sh
./scripts/run-rs.sh stub,roles,audience
./scripts/issuer-audience-demo.sh "stub,roles,audience"
# or a real Keycloak
docker compose -f docker/compose.yaml up -d
./scripts/run-rs.sh keycloak,roles
./scripts/keycloak-demo.sh
```
---
## Project 1 &mdash; `jwt-authentication/`
Three demo users:
| username | password | authorities |
|---|---|---|
| `alice` | `alice-password` | `ROLE_USER`, `SCOPE_profile:read` |
| `root` | `root-password` | `ROLE_USER`, `ROLE_ADMIN`, `SCOPE_profile:read`, `SCOPE_admin:read` |
| `locked` | `locked-password` | locked account always fails login |
| `locked` | `locked-password` | locked account &mdash; 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 &mdash; `oauth2-resource-server/`
Two applications in one Maven module, started by main class:
| application | port | what it is |
|---|---|---|
| `ResourceServerApplication` | 8081 | the resource server. Validates only; never mints. |
| `StubIssuerApplication` | 9000 | an authorization server whose JWK Set can be mutated on command |
The stub exists because Keycloak will not rotate its signing key at a chosen second, will not
report how many times its JWKS endpoint was fetched, and will not drop a key from the
published set on request &mdash; and every measurement about caching and rotation timing needs
all three. The Keycloak run confirms the same code path against a real issuer.
### Profiles
| profile | what it changes |
|---|---|
| `stub` | issuer is the in-repo stub on :9000 |
| `keycloak` | issuer is the Keycloak in `docker/compose.yaml` |
| `roles` | a Java `JwtAuthenticationConverter` mapping Keycloak's nested roles |
| `propsroles` | the same mapping in configuration only, with the SpEL indexer quoted |
| `propsroles-broken` | the same, unquoted &mdash; fails silently. See [docs/14](docs/14-authentication-converter.md) |
| `audience` | adds a `JwtAudienceValidator` bean |
| `props` | audience validation by property instead |
| `attyp` | a type validator that accepts RFC 9068 `at+jwt` |
| `springcache` | a Caffeine JWKS cache with a 5-minute TTL |
| `nottlcache` | a `ConcurrentMapCache` with no TTL &mdash; the trap in [docs/15](docs/15-jwks-caching-and-rotation.md) |
| `hardened` | the `JWKSource` built directly, with rate limiting and outage tolerance restored |
| `trace` | `TRACE` logging for `org.springframework.security` |
| `tracespel` | just enough logging to see a claim expression fail |
```bash
./scripts/run-rs.sh stub,roles,audience
./scripts/run-rs.sh keycloak,propsroles
./scripts/run-rs.sh stub,roles,nottlcache
```
### Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
| `GET` | `/api/public/ping` | `permitAll()` | reachable with no token |
| `GET` | `/api/me` | authenticated | prints the authorities the converter produced |
| `GET` | `/api/admin/stats` | `hasRole('ADMIN')` | realm role, from `realm_access.roles` |
| `GET` | `/api/reports` | `@PreAuthorize` | client role, from `resource_access.reports-api.roles` |
| `GET` | `/api/public/decoder` | `permitAll()` | **prints the live JWK source chain.** Delete before shipping |
| `GET` | `/.well-known/oauth-protected-resource` | &mdash; | published by Spring Security 7 itself |
Stub issuer admin endpoints, for driving a rotation:
| method | path | what it does |
|---|---|---|
| `POST` | `/admin/publish` | generate a key and add it to the JWK Set |
| `POST` | `/admin/activate?kid=` | start signing with that key |
| `POST` | `/admin/retire?kid=` | remove it from the JWK Set. It can still sign |
| `POST` | `/admin/reset-counter` | zero the JWKS fetch counter |
| `GET` | `/admin/state` | active kid, published kids, fetch count |
| `POST` | `/token?sub=&aud=&roles=&expiresInSeconds=&issuedAgoSeconds=&typ=&kid=&issuerOverride=` | mint anything, correct or not |
| `POST` | `/token/unknown-kid` | a token whose `kid` never existed |
Regenerate its captured output with `./oauth2-resource-server/scripts/run-all.sh`
(needs Docker; takes roughly twenty-five minutes, most of it waiting out cache lifetimes).
---
## Documentation
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 &mdash; see
[docs/10-production-checklist.md](docs/10-production-checklist.md) and
[docs/18-resource-server-checklist.md](docs/18-resource-server-checklist.md).
`/api/public/decoder` reads private fields by reflection and prints your JWK Set URI and
cache timings. It is a diagnostic. Delete it before you ship.
## License

View File

@@ -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

View File

@@ -84,7 +84,7 @@ FilterRegistrationBean<JwtAuthenticationFilter> 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.

View File

@@ -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})

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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())) {

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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 *&ldquo;it works with curl but not from the
application&rdquo;* 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<Jwt> 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<Jwt>` 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 *&ldquo;short-lived tokens&rdquo;*
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)

127
docs/13-validator-stack.md Normal file
View File

@@ -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<Jwt> 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)

View File

@@ -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)

View File

@@ -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: *&ldquo;Spring Security caches the JWK Set for five
minutes and rotates keys automatically.&rdquo;* 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<SecurityContext> 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<JWK> 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
> &ldquo;how fast does revocation propagate&rdquo; 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 &mdash; no Spring cache &mdash; 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)

View File

@@ -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<SecurityContext> source = JWKSourceBuilder
.<SecurityContext>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)

153
docs/17-keycloak-setup.md Normal file
View File

@@ -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 *&ldquo;the token works in curl but not from the
application&rdquo;* 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)

View File

@@ -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.<client>.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)

View File

@@ -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"

View File

@@ -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"
}

View File

@@ -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]

View File

@@ -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"
}

View File

@@ -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."
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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}"

View File

@@ -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."

View File

@@ -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

View File

@@ -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"
]
}
]
}

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>oauth2-resource-server-demo</artifactId>
<version>1.0.0</version>
<name>oauth2-resource-server-demo</name>
<description>Spring Security OAuth2 resource server: JWT validation, JWKS and key rotation - runnable companion for ankurm.com</description>
<properties>
<java.version>25</java.version>
<!-- Two main classes live here: the resource server and the stub issuer. This picks
the one plain `mvn spring-boot:run` starts; the scripts override it with
-Dspring-boot.run.main-class (note the kebab-case - Boot 3 spelled the same
property -Dspring-boot.run.mainClass, and the camelCase spelling is now ignored
without any warning). -->
<start-class>com.ankurm.rsdemo.ResourceServerApplication</start-class>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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 "<profiles the resource server is running with>"
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"

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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."

View File

@@ -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."

View File

@@ -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."

View File

@@ -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

View File

@@ -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

View File

@@ -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 &mdash; it only validates the ones it is given.
*
* <p>Run it against either issuer:
* <pre>
* ./scripts/run-rs.sh stub # the in-repo stub issuer on :9000
* ./scripts/run-rs.sh keycloak # real Keycloak on :8080
* </pre>
*
* <p>Explained in <a href="../../../../../../../docs/12-resource-server-vs-manual-filter.md">docs/12</a>.
*/
@SpringBootApplication
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}

View File

@@ -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.
*
* <p>Read {@code NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource()} in the Spring
* Security 7.1.1 sources before assuming any of this is obvious:
*
* <pre>
* JWKSourceBuilder.create(new SpringJWKSource&lt;&gt;(restOperations, cache, jwkSetUri))
* .refreshAheadCache(false)
* .rateLimited(false)
* .cache(this.cache instanceof NoOpCache)
* .build();
* </pre>
*
* Nimbus enables all three by default. Spring Security switches two off outright, and the
* third line means that <em>supplying</em> a Spring cache switches Nimbus&rsquo;s own
* five-minute cache <em>off</em>, leaving your cache&rsquo;s TTL as the only expiry in the
* system. Measured in
* <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*
* <p>With no profile active this class contributes nothing and Spring Boot&rsquo;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&rsquo;s {@code JwtDecoderConfiguration} collects every {@code OAuth2TokenValidator&lt;Jwt&gt;}
* 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<Jwt> 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<Jwt> accessTokenTypeValidator() {
JwtTypeValidator validator = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt", "Bearer");
validator.setAllowEmpty(true);
return validator;
}
// ------------------------------------------------------------------ cache variants
private OAuth2TokenValidator<Jwt> 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.
*
* <p>{@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:
*
* <ul>
* <li>issuer discovery is gone - the JWK Set URI has to be configured explicitly</li>
* <li>the validator stack is no longer supplied for you, so it is set here in full</li>
* <li>{@code JWKSourceBuilder.create(URL)} fetches with Nimbus&rsquo;s own
* {@code DefaultResourceRetriever}, not Spring&rsquo;s {@code RestOperations}, so
* any client customisation, proxy configuration or observability you had wired into
* the Spring HTTP client does not apply</li>
* </ul>
*
* <p>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 <a href="../../../../../../../docs/16-jwks-amplification.md">docs/16</a>.
*/
@Bean
@Profile("hardened")
JwtDecoder hardenedDecoder() throws java.net.MalformedURLException, java.net.URISyntaxException {
JWKSource<SecurityContext> source = JWKSourceBuilder
.<SecurityContext>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&rsquo;s fallback cache manager, hands out - has no TTL at all, so the JWK Set is
* cached until something forces a refresh.
*
* <p>The only thing that forces a refresh is a token whose {@code kid} is missing from the
* cached set. A key that has been <em>removed</em> 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;
}
}

View File

@@ -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.
*
* <p>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.<client>.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.
*
* <p>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
* <a href="../../../../../../../docs/14-authentication-converter.md">docs/14</a>.
*
* <p><b>Defining this bean silently disables the properties.</b> Boot&rsquo;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<Jwt, Collection<GrantedAuthority>> {
private final JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
private final String clientId;
KeycloakGrantedAuthoritiesConverter(String clientId) {
this.clientId = clientId;
}
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = new ArrayList<>(this.scopes.convert(jwt));
addPrefixed(authorities, realmRoles(jwt));
addPrefixed(authorities, clientRoles(jwt));
return authorities;
}
@SuppressWarnings("unchecked")
private List<String> realmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}
Object roles = realmAccess.get("roles");
return (roles instanceof List<?> list) ? (List<String>) list : List.of();
}
@SuppressWarnings("unchecked")
private List<String> clientRoles(Jwt jwt) {
Map<String, Object> 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<String>) list : List.of();
}
private void addPrefixed(Collection<GrantedAuthority> target, List<String> 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));
}
}
}
}

View File

@@ -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.
*
* <p>Note what is <em>not</em> 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
* <a href="../../../../../../../docs/09-manual-filter-vs-resource-server.md">docs/09</a>.
*
* <p>Explained in <a href="../../../../../../../docs/12-issuer-and-audience.md">docs/12</a>.
*/
@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();
}
}

View File

@@ -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.
*
* <p>Explained in <a href="../../../../../../../docs/12-issuer-and-audience.md">docs/12</a>.
*/
@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<String, Object> 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<String, Object> me(Authentication authentication) {
Jwt jwt = (Jwt) authentication.getPrincipal();
Map<String, Object> 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<String, Object> adminStats() {
return Map.of("secret", "only ROLE_ADMIN sees this");
}
/**
* The method-security twin. This one needs a Keycloak <em>client</em> 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<String, Object> reports() {
return Map.of("reports", 3);
}
}

View File

@@ -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.
*
* <p>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 <em>a</em> decoder looks
* like, not what <em>yours</em> 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.
*
* <p>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: <b>delete it before you ship.</b>
* It reveals your JWK Set URI and cache timings to anyone who can reach it.
*
* <p>Explained in <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*/
@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<String, Object> decoder() {
Map<String, Object> 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<JwtDecoder> 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<Map<String, Object>> chain = new ArrayList<>();
Object node = jwkSource;
int guard = 0;
while (node != null && guard++ < 12) {
Map<String, Object> 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<String, Object> 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;
}
}

View File

@@ -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.
*
* <p>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
* <em>caching</em> and <em>rotation timing</em> needs exactly those three things, so they
* are measured here and the Keycloak run confirms the same code path end to end.
*
* <p>Runs on :9000. Explained in
* <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*/
@SpringBootApplication
public class StubIssuerApplication {
public static void main(String[] args) {
SpringApplication.run(StubIssuerApplication.class, args);
}
}

View File

@@ -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.
*
* <p>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<String, Object> discovery() {
Map<String, Object> 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 &mdash; 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<String, Object> realmAccess = Map.of("roles", Arrays.asList(roles.split(" ")));
Map<String, Object> 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&rsquo;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<String, Object> publish() {
String kid = this.keys.generate();
return state("published " + kid);
}
@PostMapping(path = "/admin/activate", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> activate(@RequestParam String kid) {
this.keys.activate(kid);
return state("signing with " + kid);
}
@PostMapping(path = "/admin/retire", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> 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<String, Object> resetCounter() {
this.keys.resetJwksFetches();
return state("jwks fetch counter reset");
}
@GetMapping(path = "/admin/state", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> state() {
return state("ok");
}
private Map<String, Object> state(String message) {
Map<String, Object> 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;
}
}

View File

@@ -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
* <em>published</em> in the JWK Set and which one is currently <em>active</em> for signing.
*
* <p>Rotation in the real world is three separate events, and conflating them is where
* most rotation incidents come from:
* <ol>
* <li><b>publish</b> &mdash; the new key appears in the JWK Set, nothing signs with it yet</li>
* <li><b>activate</b> &mdash; the issuer starts signing with the new key</li>
* <li><b>retire</b> &mdash; the old key is removed from the JWK Set</li>
* </ol>
* 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<String, RSAKey> allKeys = new LinkedHashMap<>();
private final List<String> 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<com.nimbusds.jose.jwk.JWK> 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<String> publishedKids() {
return List.copyOf(this.published);
}
}

View File

@@ -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&rsquo;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();
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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]"

View File

@@ -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]"

View File

@@ -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

View File

@@ -0,0 +1,5 @@
# The stub authorization server itself. Nothing here is a resource server.
server:
port: 9000
stub:
issuer: http://localhost:9000

View File

@@ -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

View File

@@ -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

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More