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