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:
@@ -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
|
||||
Reference in New Issue
Block a user