1
0

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:
2026-08-23 11:00:56 +00:00
parent 4a8dab6739
commit 4dc45d5e00
101 changed files with 4087 additions and 64 deletions

View 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"

View 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"
}

View 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]

View 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"
}

View 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."
}

View 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
}

View 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
}

View 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"

View 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"

View 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"

View 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.

View 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.

View 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.

View 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