1
0
Files
jwt-auth-demo/docs/14-authentication-converter.md
Ankur Mhatre 4dc45d5e00 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
2026-08-23 11:00:56 +00:00

145 lines
6.2 KiB
Markdown

# 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)