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
211 lines
8.6 KiB
Markdown
211 lines
8.6 KiB
Markdown
# 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)
|