Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:
jwt-authentication/ the hand-written filter application (unchanged, moved)
oauth2-resource-server/ a resource server, a Keycloak compose, and a stub
issuer whose JWK Set can be mutated on command
The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.
Findings captured under docs/output/, all from real runs:
* The default validator stack does not check aud. A token minted for another
service in the same realm is accepted.
* Spring Security builds its JWKSource with refreshAheadCache(false) and
rateLimited(false), overriding two of Nimbus's protective defaults, and
enables Nimbus caching only when NO Spring cache was supplied - so
supplying one removes the five-minute expiry.
* A key retired from the JWK Set stops being accepted at t+300s with the
default cache, and never with a Spring cache that has no TTL.
* 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
through permitAll() endpoints included.
* A hyphenated client id in an authorities-claim-expression parses as
subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
* A clientScopes key in a Keycloak realm import replaces the built-in scopes
rather than adding to them.
New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
This commit is contained in:
175
README.md
175
README.md
@@ -1,10 +1,18 @@
|
||||
# jwt-auth-demo
|
||||
|
||||
Runnable companion code for **[Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/)** on ankurm.com.
|
||||
Runnable companion code for two articles on [ankurm.com](https://ankurm.com):
|
||||
|
||||
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output)
|
||||
is real program output, regenerated by [`scripts/run-all.sh`](scripts/run-all.sh) — not
|
||||
transcribed by hand.
|
||||
| | article | code |
|
||||
|---|---|---|
|
||||
| 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) |
|
||||
| 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) |
|
||||
|
||||
Two Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
|
||||
tokens with a hand-written filter; the second verifies tokens minted by somebody else —
|
||||
a real Keycloak, and a stub issuer whose signing keys can be rotated on command.
|
||||
|
||||
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) is
|
||||
real program output, regenerated by a script — not transcribed by hand.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -15,33 +23,53 @@ transcribed by hand.
|
||||
| Nimbus JOSE+JWT | **10.9.1** |
|
||||
| Tomcat | **11.0.24** |
|
||||
| Jackson | **3.1.5** (`tools.jackson`) |
|
||||
| Keycloak | **26.7.2** (resource server project only) |
|
||||
| Caffeine | **3.2.4** (resource server project only) |
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Project 1 — JWT authentication with a hand-written filter
|
||||
|
||||
```bash
|
||||
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
|
||||
cd jwt-auth-demo
|
||||
cd jwt-auth-demo/jwt-authentication
|
||||
./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256
|
||||
|
||||
# in another shell
|
||||
./scripts/curl-transcript.sh # the whole flow, end to end
|
||||
```
|
||||
|
||||
### Project 2 — OAuth2 resource server, JWKS and rotation
|
||||
|
||||
```bash
|
||||
cd jwt-auth-demo/oauth2-resource-server
|
||||
|
||||
# a stub issuer whose JWK Set can be mutated on command
|
||||
./scripts/run-stub-issuer.sh
|
||||
./scripts/run-rs.sh stub,roles,audience
|
||||
./scripts/issuer-audience-demo.sh "stub,roles,audience"
|
||||
|
||||
# or a real Keycloak
|
||||
docker compose -f docker/compose.yaml up -d
|
||||
./scripts/run-rs.sh keycloak,roles
|
||||
./scripts/keycloak-demo.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project 1 — `jwt-authentication/`
|
||||
|
||||
Three demo users:
|
||||
|
||||
| username | password | authorities |
|
||||
|---|---|---|
|
||||
| `alice` | `alice-password` | `ROLE_USER`, `SCOPE_profile:read` |
|
||||
| `root` | `root-password` | `ROLE_USER`, `ROLE_ADMIN`, `SCOPE_profile:read`, `SCOPE_admin:read` |
|
||||
| `locked` | `locked-password` | locked account — always fails login |
|
||||
| `locked` | `locked-password` | locked account — always fails login |
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
The same application demonstrates four axes. Combine them freely.
|
||||
### Profiles
|
||||
|
||||
| profile | what it changes |
|
||||
|---|---|
|
||||
@@ -54,15 +82,7 @@ The same application demonstrates four axes. Combine them freely.
|
||||
| `shortlived` | 2-second access tokens, for observing expiry and clock skew. |
|
||||
| `trace` | `TRACE` logging for `org.springframework.security`. |
|
||||
|
||||
```bash
|
||||
./scripts/run.sh rs256
|
||||
./scripts/run.sh hs256,resourceserver,strict
|
||||
./scripts/run.sh hs256,csrfon,trace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
### Endpoints
|
||||
|
||||
| method | path | rule | why it exists |
|
||||
|---|---|---|---|
|
||||
@@ -77,11 +97,80 @@ The same application demonstrates four axes. Combine them freely.
|
||||
| `GET` | `/api/async-demo` | authenticated | `SecurityContext` across a thread boundary |
|
||||
| `GET` | `/.well-known/jwks.json` | `permitAll()` | `rs256` profile only |
|
||||
|
||||
Runs on **:8080**. Regenerate its captured output with `./jwt-authentication/scripts/run-all.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Project 2 — `oauth2-resource-server/`
|
||||
|
||||
Two applications in one Maven module, started by main class:
|
||||
|
||||
| application | port | what it is |
|
||||
|---|---|---|
|
||||
| `ResourceServerApplication` | 8081 | the resource server. Validates only; never mints. |
|
||||
| `StubIssuerApplication` | 9000 | an authorization server whose JWK Set can be mutated on command |
|
||||
|
||||
The stub exists because Keycloak will not rotate its signing key at a chosen second, will not
|
||||
report how many times its JWKS endpoint was fetched, and will not drop a key from the
|
||||
published set on request — and every measurement about caching and rotation timing needs
|
||||
all three. The Keycloak run confirms the same code path against a real issuer.
|
||||
|
||||
### Profiles
|
||||
|
||||
| profile | what it changes |
|
||||
|---|---|
|
||||
| `stub` | issuer is the in-repo stub on :9000 |
|
||||
| `keycloak` | issuer is the Keycloak in `docker/compose.yaml` |
|
||||
| `roles` | a Java `JwtAuthenticationConverter` mapping Keycloak's nested roles |
|
||||
| `propsroles` | the same mapping in configuration only, with the SpEL indexer quoted |
|
||||
| `propsroles-broken` | the same, unquoted — fails silently. See [docs/14](docs/14-authentication-converter.md) |
|
||||
| `audience` | adds a `JwtAudienceValidator` bean |
|
||||
| `props` | audience validation by property instead |
|
||||
| `attyp` | a type validator that accepts RFC 9068 `at+jwt` |
|
||||
| `springcache` | a Caffeine JWKS cache with a 5-minute TTL |
|
||||
| `nottlcache` | a `ConcurrentMapCache` with no TTL — the trap in [docs/15](docs/15-jwks-caching-and-rotation.md) |
|
||||
| `hardened` | the `JWKSource` built directly, with rate limiting and outage tolerance restored |
|
||||
| `trace` | `TRACE` logging for `org.springframework.security` |
|
||||
| `tracespel` | just enough logging to see a claim expression fail |
|
||||
|
||||
```bash
|
||||
./scripts/run-rs.sh stub,roles,audience
|
||||
./scripts/run-rs.sh keycloak,propsroles
|
||||
./scripts/run-rs.sh stub,roles,nottlcache
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
| method | path | rule | why it exists |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/public/ping` | `permitAll()` | reachable with no token |
|
||||
| `GET` | `/api/me` | authenticated | prints the authorities the converter produced |
|
||||
| `GET` | `/api/admin/stats` | `hasRole('ADMIN')` | realm role, from `realm_access.roles` |
|
||||
| `GET` | `/api/reports` | `@PreAuthorize` | client role, from `resource_access.reports-api.roles` |
|
||||
| `GET` | `/api/public/decoder` | `permitAll()` | **prints the live JWK source chain.** Delete before shipping |
|
||||
| `GET` | `/.well-known/oauth-protected-resource` | — | published by Spring Security 7 itself |
|
||||
|
||||
Stub issuer admin endpoints, for driving a rotation:
|
||||
|
||||
| method | path | what it does |
|
||||
|---|---|---|
|
||||
| `POST` | `/admin/publish` | generate a key and add it to the JWK Set |
|
||||
| `POST` | `/admin/activate?kid=` | start signing with that key |
|
||||
| `POST` | `/admin/retire?kid=` | remove it from the JWK Set. It can still sign |
|
||||
| `POST` | `/admin/reset-counter` | zero the JWKS fetch counter |
|
||||
| `GET` | `/admin/state` | active kid, published kids, fetch count |
|
||||
| `POST` | `/token?sub=&aud=&roles=&expiresInSeconds=&issuedAgoSeconds=&typ=&kid=&issuerOverride=` | mint anything, correct or not |
|
||||
| `POST` | `/token/unknown-kid` | a token whose `kid` never existed |
|
||||
|
||||
Regenerate its captured output with `./oauth2-resource-server/scripts/run-all.sh`
|
||||
(needs Docker; takes roughly twenty-five minutes, most of it waiting out cache lifetimes).
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the trail.
|
||||
One numbered trail across both projects. Start at
|
||||
[`docs/01-architecture.md`](docs/01-architecture.md).
|
||||
|
||||
| doc | covers |
|
||||
|---|---|
|
||||
@@ -96,11 +185,20 @@ Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the t
|
||||
| [09 — Manual filter vs resource server](docs/09-manual-filter-vs-resource-server.md) | a side-by-side, and which to pick |
|
||||
| [10 — Production checklist](docs/10-production-checklist.md) | the list to run before you ship |
|
||||
| [11 — What changed in Spring Security 7](docs/11-spring-security-7-changes.md) | the 7.x-specific surprises this repo hit |
|
||||
| [12 — Issuer and audience](docs/12-issuer-and-audience.md) | the two claims that make a token yours, and why `aud` is unchecked by default |
|
||||
| [13 — The validator stack](docs/13-validator-stack.md) | what is in it, how to add to it without losing it |
|
||||
| [14 — The authentication converter](docs/14-authentication-converter.md) | claims to authorities, and Keycloak's invisible roles |
|
||||
| [15 — JWKS caching and key rotation](docs/15-jwks-caching-and-rotation.md) | what the cache really does, and how long a retired key lives |
|
||||
| [16 — What an unknown `kid` costs](docs/16-jwks-amplification.md) | rate limiting is off, measured 1:1 |
|
||||
| [17 — Keycloak setup](docs/17-keycloak-setup.md) | compose, realm import, and three ways it bites |
|
||||
| [18 — Resource server checklist](docs/18-resource-server-checklist.md) | the list for the resource-server side |
|
||||
|
||||
---
|
||||
|
||||
## Captured output
|
||||
|
||||
### Project 1
|
||||
|
||||
| file | what it shows |
|
||||
|---|---|
|
||||
| [`curl-transcript-hs256.txt`](docs/output/curl-transcript-hs256.txt) | 20 steps: login → token → 401 → 403 → tamper → refresh → revoke |
|
||||
@@ -112,20 +210,39 @@ Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the t
|
||||
| [`resource-server-strict.txt`](docs/output/resource-server-strict.txt) | the same request, refused |
|
||||
| [`test-run.txt`](docs/output/test-run.txt) | 13 passing tests |
|
||||
|
||||
Regenerate all of it:
|
||||
### Project 2
|
||||
|
||||
```bash
|
||||
./scripts/run-all.sh
|
||||
```
|
||||
| file | what it shows |
|
||||
|---|---|
|
||||
| [`rs-issuer-audience.txt`](docs/output/rs-issuer-audience.txt) | wrong `iss`, wrong `aud`, expiry either side of the clock skew, `at+jwt` refused |
|
||||
| [`rs-issuer-audience-attyp.txt`](docs/output/rs-issuer-audience-attyp.txt) | the same run with a type validator that accepts `at+jwt` |
|
||||
| [`rs-converter-default.txt`](docs/output/rs-converter-default.txt) | Keycloak-shaped roles, and the 403 they produce untouched |
|
||||
| [`rs-converter-java.txt`](docs/output/rs-converter-java.txt) | the same token through a custom converter |
|
||||
| [`rs-converter-properties.txt`](docs/output/rs-converter-properties.txt) | the same mapping in configuration only |
|
||||
| [`rs-converter-properties-broken.txt`](docs/output/rs-converter-properties-broken.txt) | one unquoted SpEL indexer, and the silence it produces |
|
||||
| [`rs-decoder-chain.txt`](docs/output/rs-decoder-chain.txt) | the live JWK source chain under three cache configurations |
|
||||
| [`rs-rotation.txt`](docs/output/rs-rotation.txt) | publish, activate and retire, watched from the other side |
|
||||
| [`rs-jwks-amplification.txt`](docs/output/rs-jwks-amplification.txt) | 25 bad tokens, 25 JWKS fetches |
|
||||
| [`rs-retired-key-default.txt`](docs/output/rs-retired-key-default.txt) | how long a retired key lives with the default cache |
|
||||
| [`rs-retired-key-nottlcache.txt`](docs/output/rs-retired-key-nottlcache.txt) | the same, with a Spring cache that has no TTL |
|
||||
| [`rs-keycloak.txt`](docs/output/rs-keycloak.txt) | the same code against a real Keycloak 26.7.2 |
|
||||
| [`rs-keycloak-default-converter.txt`](docs/output/rs-keycloak-default-converter.txt) | real Keycloak, roles unmapped |
|
||||
| [`rs-test-run.txt`](docs/output/rs-test-run.txt) | 10 tests pinning the default validator stack |
|
||||
|
||||
---
|
||||
|
||||
## Security note
|
||||
|
||||
The keys in `src/main/resources/` and the HMAC secret in `application.yaml` are
|
||||
**demo values committed on purpose** so the repository runs with no setup. They are
|
||||
public. Never point them at anything you care about — see
|
||||
[docs/10-production-checklist.md](docs/10-production-checklist.md).
|
||||
The keys in `jwt-authentication/src/main/resources/`, the HMAC secret in its
|
||||
`application.yaml`, and the Keycloak credentials in
|
||||
[`docker/realm-demo.json`](oauth2-resource-server/docker/realm-demo.json) are **demo values
|
||||
committed on purpose** so the repository runs with no setup. They are public. Never point
|
||||
them at anything you care about — see
|
||||
[docs/10-production-checklist.md](docs/10-production-checklist.md) and
|
||||
[docs/18-resource-server-checklist.md](docs/18-resource-server-checklist.md).
|
||||
|
||||
`/api/public/decoder` reads private fields by reflection and prints your JWK Set URI and
|
||||
cache timings. It is a diagnostic. Delete it before you ship.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
210
docs/12-issuer-and-audience.md
Normal file
210
docs/12-issuer-and-audience.md
Normal 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 *“it works with curl but not from the
|
||||
application”* against Keycloak, because Keycloak derives `iss` from the request host
|
||||
unless you pin `KC_HOSTNAME`. A token fetched through `localhost:8080` and a token fetched
|
||||
through `keycloak:8080` inside a Docker network carry different issuers, and exactly one of
|
||||
them matches your configuration. See [17 — Keycloak setup](17-keycloak-setup.md).
|
||||
|
||||
## `aud` is not checked at all by default
|
||||
|
||||
This is the part worth reading twice.
|
||||
|
||||
`JwtValidators.createDefaultWithIssuer(issuer)` — what Boot's auto-configuration uses when
|
||||
you set only `issuer-uri` — builds a stack of `JwtTypeValidator`, `JwtTimestampValidator`,
|
||||
`X509CertificateThumbprintValidator` and `JwtIssuerValidator`. There is no audience
|
||||
validator in it.
|
||||
|
||||
So this happens:
|
||||
|
||||
```
|
||||
iss = "http://localhost:9000" <- correct
|
||||
aud = "billing-api" <- a different service entirely
|
||||
HTTP 200
|
||||
```
|
||||
|
||||
with the default configuration, and 401 once an audience check is added. Both transcripts
|
||||
are in the repository: [`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) runs with
|
||||
the `audience` profile and refuses it; the assertion that the *default* stack accepts it is
|
||||
in `defaultStackDoesNotCheckAudience`.
|
||||
|
||||
Any of these three fixes it. They are ordered by how little you have to write.
|
||||
|
||||
```yaml
|
||||
spring.security.oauth2.resourceserver.jwt.audiences: reports-api
|
||||
```
|
||||
|
||||
```java
|
||||
@Bean
|
||||
OAuth2TokenValidator<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 *“short-lived tokens”* —
|
||||
your real worst case is the lifetime plus a minute.
|
||||
|
||||
To change it you have to build the validator yourself:
|
||||
|
||||
```java
|
||||
new JwtTimestampValidator(Duration.ofSeconds(5))
|
||||
```
|
||||
|
||||
## `typ=at+jwt` is refused by the default stack
|
||||
|
||||
RFC 9068 defines a media type for JWT access tokens and says an access token SHOULD carry
|
||||
`typ: at+jwt` in its JOSE header. The default validator stack contains
|
||||
`JwtTypeValidator.jwt()`, which accepts an **absent** `typ` or `typ=JWT`, and nothing else.
|
||||
|
||||
A conforming RFC 9068 access token therefore gets:
|
||||
|
||||
```
|
||||
HTTP 401 error_description="... the given typ value needs to be one of [JWT]"
|
||||
```
|
||||
|
||||
Keycloak is not affected, because its JOSE header says `typ: JWT` and it puts `Bearer` in a
|
||||
*claim* of the same name, which nothing validates. An issuer that follows RFC 9068 more
|
||||
closely will trip this. Two ways out:
|
||||
|
||||
```java
|
||||
// accept the type explicitly
|
||||
JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt");
|
||||
types.setAllowEmpty(true);
|
||||
```
|
||||
|
||||
```java
|
||||
// or validate the whole token as an RFC 9068 access token
|
||||
decoder.setJwtValidator(JwtValidators.createAtJwtValidator()
|
||||
.issuer(issuer).audience("reports-api").clientId("demo-client").build());
|
||||
```
|
||||
|
||||
The second is stricter than it looks: `createAtJwtValidator()` also **requires** `exp`,
|
||||
`sub`, `iat`, `jti` and `client_id` to be present. Keycloak does not emit `client_id` in an
|
||||
access token, so this builder refuses Keycloak tokens until you tell it otherwise.
|
||||
|
||||
The two transcripts differing in exactly this one validator are
|
||||
[`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) and
|
||||
[`rs-issuer-audience-attyp.txt`](output/rs-issuer-audience-attyp.txt).
|
||||
|
||||
## Where failures explain themselves
|
||||
|
||||
Nowhere in the response body. A resource server returns an empty body on 401 and puts the
|
||||
reason in the `WWW-Authenticate` header, per RFC 6750:
|
||||
|
||||
```
|
||||
WWW-Authenticate: Bearer error="invalid_token",
|
||||
error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid",
|
||||
error_uri="https://tools.ietf.org/html/rfc6750#section-3.1",
|
||||
resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
|
||||
```
|
||||
|
||||
If you are debugging with a tool that hides response headers, every claim-validation
|
||||
failure looks identical. That is worth knowing before you spend an afternoon on it.
|
||||
|
||||
## The endpoint you did not configure
|
||||
|
||||
That last `resource_metadata` parameter points at something new. Spring Security 7
|
||||
publishes RFC 9728 protected resource metadata automatically and advertises it in the
|
||||
challenge. It answers **without a token**, on a resource server whose chain says
|
||||
`anyRequest().authenticated()`:
|
||||
|
||||
```
|
||||
$ GET /.well-known/oauth-protected-resource
|
||||
HTTP 200
|
||||
{"resource":"http://localhost:8081","bearer_methods_supported":["header"],
|
||||
"tls_client_certificate_bound_access_tokens":true}
|
||||
```
|
||||
|
||||
It is standards-compliant and mostly harmless, but it is a new unauthenticated endpoint
|
||||
that appears on upgrade, it confirms to an unauthenticated caller which authorization
|
||||
server you trust, and it will show up in your next penetration test. Know that it is
|
||||
there and that it is yours.
|
||||
|
||||
---
|
||||
|
||||
[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md)
|
||||
127
docs/13-validator-stack.md
Normal file
127
docs/13-validator-stack.md
Normal 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)
|
||||
144
docs/14-authentication-converter.md
Normal file
144
docs/14-authentication-converter.md
Normal 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)
|
||||
180
docs/15-jwks-caching-and-rotation.md
Normal file
180
docs/15-jwks-caching-and-rotation.md
Normal 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: *“Spring Security caches the JWK Set for five
|
||||
minutes and rotates keys automatically.”* It is half true, and the half that is not
|
||||
decides whether a compromised key stops working in five minutes or never.
|
||||
|
||||
This chapter is what the code actually does, read from the Spring Security 7.1.1 and Nimbus
|
||||
10.9.1 sources and then measured.
|
||||
|
||||
## The one method that decides everything
|
||||
|
||||
`NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource()`:
|
||||
|
||||
```java
|
||||
JWKSource<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
|
||||
> “how fast does revocation propagate” for this stack, and the answer is
|
||||
> different depending on one line of configuration you may have added for an unrelated
|
||||
> reason.
|
||||
|
||||
The default configuration — no Spring cache — has Nimbus's `CachingJWKSetSource`
|
||||
with `DEFAULT_CACHE_TIME_TO_LIVE = 5 * 60 * 1000L` in front of it. The set expires on its
|
||||
own and the retired key goes with it.
|
||||
|
||||
A `ConcurrentMapCache` has no TTL. Neither does a `ConcurrentMapCacheManager`, which is what
|
||||
Boot hands you when `spring-boot-starter-cache` is on the classpath and no cache provider
|
||||
is configured. With one of those supplied, Nimbus's cache layer is off and nothing in the
|
||||
system expires anything.
|
||||
|
||||
If you supply a cache, give it a TTL, and make that TTL a deliberate decision rather than an
|
||||
inherited default:
|
||||
|
||||
```java
|
||||
Cache cache = new CaffeineCache("jwks",
|
||||
Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build());
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).cache(cache).build();
|
||||
```
|
||||
|
||||
## One more thing the Spring cache does
|
||||
|
||||
`SpringJWKSource.getJWKSet` calls `this.cache.invalidate()` — not `evict(key)` — when a
|
||||
refresh is required. That clears **the entire cache**, not just the JWK Set entry. Give the
|
||||
JWK Set its own dedicated cache; do not point it at a cache you share with anything else.
|
||||
|
||||
## What is not there
|
||||
|
||||
- **No outage tolerance.** `outageTolerant` is false. If the issuer is unreachable when the
|
||||
cache expires, every request fails until it comes back. Nimbus offers
|
||||
`OutageTolerantJWKSetSource`, which serves a stale set through an outage; Spring Security
|
||||
does not wire it in and the builder gives you no way to ask for it short of building the
|
||||
`JWKSource` yourself and passing it to `NimbusJwtDecoder.withJwkSource(..)`.
|
||||
- **No retry.** A single failed HTTP call to the JWKS endpoint fails the request.
|
||||
- **No rate limiting.** See [chapter 16](16-jwks-amplification.md), which is the sharpest
|
||||
consequence of anything in this chapter.
|
||||
|
||||
---
|
||||
|
||||
[← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md)
|
||||
135
docs/16-jwks-amplification.md
Normal file
135
docs/16-jwks-amplification.md
Normal 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
153
docs/17-keycloak-setup.md
Normal 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 *“the token works in curl but not from the
|
||||
application”* reports. Both must agree with the value your resource servers are
|
||||
configured with, from wherever they run.
|
||||
|
||||
## Keycloak does not add an `aud` for you
|
||||
|
||||
An access token from a bare Keycloak client has no `aud` naming your resource server. Since
|
||||
[chapter 12](12-issuer-and-audience.md) argues you should be validating `aud`, you need a
|
||||
mapper:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "reports-api-audience",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"config": {
|
||||
"included.client.audience": "reports-api",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note `included.client.audience` for a client that exists in the realm, versus
|
||||
`included.custom.audience` for an arbitrary string. Using the former means the audience
|
||||
value is checked against a real client at configuration time.
|
||||
|
||||
## A `clientScopes` key in the import replaces the built-ins
|
||||
|
||||
This one cost a rebuild. A realm export/import that declares:
|
||||
|
||||
```json
|
||||
"clientScopes": [ { "name": "reports:read", ... } ]
|
||||
```
|
||||
|
||||
does not *add* that scope. It **replaces the entire set**, and Keycloak's built-in
|
||||
`profile`, `email`, `roles`, `acr`, `basic` and `web-origins` scopes are never created.
|
||||
Tokens from that realm then have:
|
||||
|
||||
- no `realm_access` claim, because the `roles` scope is what adds it
|
||||
- no `preferred_username`, because the `profile` scope is what adds it
|
||||
|
||||
which looks exactly like a broken authorities converter, and sends you to
|
||||
[chapter 14](14-authentication-converter.md) to debug something that is not wrong. Verified
|
||||
on 26.7.2 by listing the realm's client scopes through the admin API after import:
|
||||
|
||||
```
|
||||
=== realm client scopes available ===
|
||||
offline_access
|
||||
reports:read
|
||||
```
|
||||
|
||||
[`realm-demo.json`](../oauth2-resource-server/docker/realm-demo.json) therefore declares no
|
||||
`clientScopes` at all, and gets permissions across using realm roles and client roles
|
||||
instead.
|
||||
|
||||
## Users need a name
|
||||
|
||||
A user in a realm import with no `firstName` and `lastName` fails the password grant with a
|
||||
message that names nothing useful:
|
||||
|
||||
```json
|
||||
{"error":"invalid_grant","error_description":"Account is not fully set up"}
|
||||
```
|
||||
|
||||
The realm's default required actions want a complete profile. Supply the names, and
|
||||
`"requiredActions": []`.
|
||||
|
||||
## Read the JWK Set before assuming it holds one key
|
||||
|
||||
```
|
||||
keys published: 2
|
||||
kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA
|
||||
kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA
|
||||
```
|
||||
|
||||
A JWK Set contains keys you must not verify signatures with. Nimbus's
|
||||
`JWSVerificationKeySelector` filters on `use` and `alg` before matching `kid`, so this is
|
||||
handled — but if you are writing anything that reads a JWK Set yourself, filter on
|
||||
`use: "sig"` rather than taking `keys[0]`.
|
||||
|
||||
## `typ` is a claim as well as a header
|
||||
|
||||
A Keycloak access token has `typ: "JWT"` in the **JOSE header** and `typ: "Bearer"` in the
|
||||
**claim set**. `JwtTypeValidator` reads the header, so Keycloak passes the default type
|
||||
check. Nothing validates the claim. Do not write a validator that reads
|
||||
`jwt.getClaimAsString("typ")` expecting the header value.
|
||||
|
||||
## `start-dev` resets everything
|
||||
|
||||
Including the signing keys. Every restart is a new realm from the import, and a new `kid`.
|
||||
Convenient for the rotation work in [chapter 15](15-jwks-caching-and-rotation.md); a
|
||||
surprise if you were expecting yesterday's tokens to still verify.
|
||||
|
||||
## What a real access token looks like here
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "http://localhost:8080/realms/demo",
|
||||
"aud": "reports-api",
|
||||
"typ": "Bearer",
|
||||
"scope": "email profile",
|
||||
"preferred_username": "alice",
|
||||
"realm_access": { "roles": ["USER"] },
|
||||
"resource_access": { "reports-api": { "roles": ["reports-reader"] } }
|
||||
}
|
||||
```
|
||||
|
||||
Which produces, with the converter from [chapter 14](14-authentication-converter.md):
|
||||
|
||||
```
|
||||
"authorities": ["FACTOR_BEARER", "ROLE_USER", "ROLE_reports-reader", "SCOPE_email", "SCOPE_profile"]
|
||||
```
|
||||
|
||||
There is no `client_id` claim, which is why
|
||||
`JwtValidators.createAtJwtValidator()` — which requires one — refuses Keycloak tokens
|
||||
unless reconfigured. Keycloak puts the client in `azp`.
|
||||
|
||||
---
|
||||
|
||||
[← what an unknown kid costs](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md)
|
||||
82
docs/18-resource-server-checklist.md
Normal file
82
docs/18-resource-server-checklist.md
Normal 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)
|
||||
66
docs/output/rs-converter-default.txt
Normal file
66
docs/output/rs-converter-default.txt
Normal 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"
|
||||
77
docs/output/rs-converter-java.txt
Normal file
77
docs/output/rs-converter-java.txt
Normal 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"
|
||||
}
|
||||
|
||||
77
docs/output/rs-converter-properties-broken.txt
Normal file
77
docs/output/rs-converter-properties-broken.txt
Normal 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]
|
||||
68
docs/output/rs-converter-properties.txt
Normal file
68
docs/output/rs-converter-properties.txt
Normal 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"
|
||||
}
|
||||
|
||||
107
docs/output/rs-decoder-chain.txt
Normal file
107
docs/output/rs-decoder-chain.txt
Normal 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."
|
||||
}
|
||||
160
docs/output/rs-issuer-audience-attyp.txt
Normal file
160
docs/output/rs-issuer-audience-attyp.txt
Normal 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
|
||||
}
|
||||
|
||||
143
docs/output/rs-issuer-audience.txt
Normal file
143
docs/output/rs-issuer-audience.txt
Normal 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
|
||||
}
|
||||
|
||||
34
docs/output/rs-jwks-amplification.txt
Normal file
34
docs/output/rs-jwks-amplification.txt
Normal 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"
|
||||
71
docs/output/rs-keycloak-default-converter.txt
Normal file
71
docs/output/rs-keycloak-default-converter.txt
Normal 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"
|
||||
79
docs/output/rs-keycloak.txt
Normal file
79
docs/output/rs-keycloak.txt
Normal 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"
|
||||
47
docs/output/rs-retired-key-default.txt
Normal file
47
docs/output/rs-retired-key-default.txt
Normal 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.
|
||||
47
docs/output/rs-retired-key-nottlcache.txt
Normal file
47
docs/output/rs-retired-key-nottlcache.txt
Normal 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.
|
||||
69
docs/output/rs-rotation.txt
Normal file
69
docs/output/rs-rotation.txt
Normal 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.
|
||||
13
docs/output/rs-test-run.txt
Normal file
13
docs/output/rs-test-run.txt
Normal 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
|
||||
@@ -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}"
|
||||
@@ -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."
|
||||
34
oauth2-resource-server/docker/compose.yaml
Normal file
34
oauth2-resource-server/docker/compose.yaml
Normal 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
|
||||
129
oauth2-resource-server/docker/realm-demo.json
Normal file
129
oauth2-resource-server/docker/realm-demo.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
74
oauth2-resource-server/pom.xml
Normal file
74
oauth2-resource-server/pom.xml
Normal 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>
|
||||
58
oauth2-resource-server/scripts/amplification-demo.sh
Executable file
58
oauth2-resource-server/scripts/amplification-demo.sh
Executable 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"
|
||||
20
oauth2-resource-server/scripts/converter-demo.sh
Executable file
20
oauth2-resource-server/scripts/converter-demo.sh
Executable 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"
|
||||
9
oauth2-resource-server/scripts/decoder-chain.sh
Executable file
9
oauth2-resource-server/scripts/decoder-chain.sh
Executable 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
|
||||
51
oauth2-resource-server/scripts/issuer-audience-demo.sh
Executable file
51
oauth2-resource-server/scripts/issuer-audience-demo.sh
Executable 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"
|
||||
35
oauth2-resource-server/scripts/keycloak-demo.sh
Executable file
35
oauth2-resource-server/scripts/keycloak-demo.sh
Executable 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"
|
||||
50
oauth2-resource-server/scripts/lib.sh
Executable file
50
oauth2-resource-server/scripts/lib.sh
Executable 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
|
||||
}
|
||||
56
oauth2-resource-server/scripts/retired-key-demo.sh
Executable file
56
oauth2-resource-server/scripts/retired-key-demo.sh
Executable 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."
|
||||
58
oauth2-resource-server/scripts/rotation-demo.sh
Executable file
58
oauth2-resource-server/scripts/rotation-demo.sh
Executable 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."
|
||||
100
oauth2-resource-server/scripts/run-all.sh
Executable file
100
oauth2-resource-server/scripts/run-all.sh
Executable 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."
|
||||
16
oauth2-resource-server/scripts/run-rs.sh
Executable file
16
oauth2-resource-server/scripts/run-rs.sh
Executable 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
|
||||
14
oauth2-resource-server/scripts/run-stub-issuer.sh
Executable file
14
oauth2-resource-server/scripts/run-stub-issuer.sh
Executable 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
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.rsdemo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* The resource server. It never mints a token — it only validates the ones it is given.
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
}
|
||||
@@ -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<>(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’s own
|
||||
* five-minute cache <em>off</em>, leaving your cache’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’s own
|
||||
* auto-configured decoder is used, which is the configuration most applications run.
|
||||
*/
|
||||
@Configuration
|
||||
public class JwtDecoderConfig {
|
||||
|
||||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
|
||||
private String issuerUri;
|
||||
|
||||
@Value("${demo.audience:reports-api}")
|
||||
private String audience;
|
||||
|
||||
/** Only used by the hardened profile, which cannot discover it. */
|
||||
@Value("${demo.jwk-set-uri:}")
|
||||
private String jwkSetUri;
|
||||
|
||||
/**
|
||||
* Boot’s {@code JwtDecoderConfiguration} collects every {@code OAuth2TokenValidator<Jwt>}
|
||||
* bean in the context and appends it to the validator stack, so audience validation does
|
||||
* not require replacing the decoder. This bean and the
|
||||
* {@code spring.security.oauth2.resourceserver.jwt.audiences} property do the same job;
|
||||
* the property builds a {@code JwtClaimValidator} on {@code aud}, this builds the
|
||||
* purpose-made {@code JwtAudienceValidator}.
|
||||
*/
|
||||
@Bean
|
||||
@Profile("audience")
|
||||
OAuth2TokenValidator<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’s own
|
||||
* {@code DefaultResourceRetriever}, not Spring’s {@code RestOperations}, so
|
||||
* any client customisation, proxy configuration or observability you had wired into
|
||||
* the Spring HTTP client does not apply</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’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;
|
||||
}
|
||||
}
|
||||
@@ -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’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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 — 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’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;
|
||||
}
|
||||
}
|
||||
@@ -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> — the new key appears in the JWK Set, nothing signs with it yet</li>
|
||||
* <li><b>activate</b> — the issuer starts signing with the new key</li>
|
||||
* <li><b>retire</b> — 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.stubissuer;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* The stub issuer authenticates nobody. Defining any {@code SecurityFilterChain} bean makes
|
||||
* Boot’s default chain back off, which is the whole purpose of this class.
|
||||
*/
|
||||
@Configuration
|
||||
public class StubSecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain open(HttpSecurity http) throws Exception {
|
||||
return http.csrf((csrf) -> csrf.disable())
|
||||
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]"
|
||||
@@ -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]"
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# The stub authorization server itself. Nothing here is a resource server.
|
||||
server:
|
||||
port: 9000
|
||||
stub:
|
||||
issuer: http://localhost:9000
|
||||
@@ -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
|
||||
@@ -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
|
||||
16
oauth2-resource-server/src/main/resources/application.yaml
Normal file
16
oauth2-resource-server/src/main/resources/application.yaml
Normal 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
Reference in New Issue
Block a user