126 lines
4.9 KiB
Markdown
126 lines
4.9 KiB
Markdown
# 4. What a resource server does not validate
|
|
|
|
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · Next: [5. The gateway](05-the-gateway.md)*
|
|
|
|
This is the most important chapter in the module.
|
|
|
|
## The demonstration
|
|
|
|
`reporting-service` is a client registered on the same authorization server, with nothing to do
|
|
with the downstream service. Its access tokens carry `aud: reporting-api`.
|
|
|
|
Send one to the downstream service, whose audience is `downstream-api`:
|
|
|
|
```
|
|
HTTP/1.1 200
|
|
{ "sub": "reporting-service", "aud": ["reporting-api"], "scope": "[orders.read]", ... }
|
|
```
|
|
|
|
Two hundred. Full transcript:
|
|
[`docs/output/03-audience-ignored.txt`](output/03-audience-ignored.txt).
|
|
|
|
## Why
|
|
|
|
`JwtValidators.createDefault()` is a `DelegatingOAuth2TokenValidator` over three validators,
|
|
read back by reflection in `ValidatorContractTests.defaultDelegates`:
|
|
|
|
- `JwtTypeValidator`
|
|
- `JwtTimestampValidator`
|
|
- `X509CertificateThumbprintValidator`
|
|
|
|
Structure, expiry, and certificate binding. **No issuer. No audience. No scope.** Setting
|
|
`spring.security.oauth2.resourceserver.jwt.issuer-uri` gets you a `JwtIssuerValidator` on top of
|
|
that, so the issuer is covered. Nothing anywhere adds an audience check unless you write it.
|
|
|
|
The practical consequence: in an estate where several services trust one authorization server —
|
|
which is the normal shape — **any token from that issuer is accepted by every service in it**.
|
|
A token a partner integration obtained for the reporting API is a valid credential for the
|
|
payments API. Scope may or may not save you; `scope: orders.read` did not, above.
|
|
|
|
## The fix, and its price
|
|
|
|
```java
|
|
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
|
|
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
|
JwtValidators.createDefaultWithIssuer(issuer),
|
|
new JwtAudienceValidator("downstream-api")));
|
|
```
|
|
|
|
`JwtAudienceValidator` is a public class in 7.1. Two lines, and the demonstration above turns
|
|
into `The aud claim is not valid`.
|
|
|
|
The heavier option is the RFC 9068 profile:
|
|
|
|
```java
|
|
JwtValidators.createAtJwtValidator()
|
|
.issuer("http://127.0.0.1:9000")
|
|
.audience("downstream-api")
|
|
.build();
|
|
```
|
|
|
|
That requires `typ`, `exp`, `sub`, `iat`, `jti`, `iss`, `aud` **and `client_id`** to all be
|
|
present. It is stricter and it is also where two surprises live.
|
|
|
|
## Surprise 1: Spring Authorization Server does not emit an RFC 9068 token
|
|
|
|
Out of the box its access tokens carry `typ: JWT` in the header and no `client_id` claim. Point
|
|
`createAtJwtValidator()` at one and every token is rejected. Both are one line in an
|
|
`OAuth2TokenCustomizer`:
|
|
|
|
```java
|
|
context.getJwsHeader().type("at+jwt");
|
|
context.getClaims().claim("client_id", clientId);
|
|
```
|
|
|
|
## Surprise 2: making the token compliant breaks every resource server that was not updated
|
|
|
|
`NimbusJwtDecoder`'s default JOSE type verifier accepts `JWT` and an absent `typ`, and nothing
|
|
else. The moment the authorization server starts typing tokens `at+jwt`, every service using
|
|
Boot's auto-configured decoder answers:
|
|
|
|
```
|
|
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]"
|
|
```
|
|
|
|
[`docs/output/05-strict-validation.txt`](output/05-strict-validation.txt) has the downstream
|
|
service accepting the new tokens and the edge service, one hop away and not updated, rejecting
|
|
them. The message mentions neither RFC 9068 nor the authorization server.
|
|
|
|
The fix on the receiving side:
|
|
|
|
```java
|
|
NimbusJwtDecoder.withJwkSetUri(jwks)
|
|
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
|
|
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
|
|
JOSEObjectType.JWT, null)))
|
|
.build();
|
|
```
|
|
|
|
Roll that out **before** you change the authorization server, not after.
|
|
|
|
## A message worth recognising twice
|
|
|
|
Two components produce almost the same sentence and mean opposite things:
|
|
|
|
| Source | Message |
|
|
|---|---|
|
|
| Nimbus `DefaultJOSEObjectTypeVerifier` | `the given typ value needs to be one of [JWT]` |
|
|
| Spring `JwtTypeValidator` (at+jwt profile) | `the given typ value needs to be one of [at+jwt, application/at+jwt]` |
|
|
|
|
The first means "your authorization server is too modern for this decoder". The second means
|
|
"your authorization server is not modern enough for this validator".
|
|
|
|
## And one thing that *is* on by default
|
|
|
|
`X509CertificateThumbprintValidator` is in the default set. If a token carries a `cnf` claim
|
|
with `x5t#S256` — a certificate-bound access token, RFC 8705 — Spring Security **already**
|
|
checks it against the client certificate on the TLS connection, with no configuration. That is
|
|
the strongest available defence against a stolen bearer token, it costs nothing on the resource
|
|
server, and it needs mTLS to be terminated in the application rather than in a sidecar. Chapter
|
|
6.
|
|
|
|
---
|
|
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · Next: [5. The gateway](05-the-gateway.md)*
|