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:
66
docs/output/rs-converter-default.txt
Normal file
66
docs/output/rs-converter-default.txt
Normal 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"
|
||||
77
docs/output/rs-converter-java.txt
Normal file
77
docs/output/rs-converter-java.txt
Normal 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"
|
||||
}
|
||||
|
||||
77
docs/output/rs-converter-properties-broken.txt
Normal file
77
docs/output/rs-converter-properties-broken.txt
Normal 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]
|
||||
68
docs/output/rs-converter-properties.txt
Normal file
68
docs/output/rs-converter-properties.txt
Normal 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"
|
||||
}
|
||||
|
||||
107
docs/output/rs-decoder-chain.txt
Normal file
107
docs/output/rs-decoder-chain.txt
Normal 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."
|
||||
}
|
||||
160
docs/output/rs-issuer-audience-attyp.txt
Normal file
160
docs/output/rs-issuer-audience-attyp.txt
Normal 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
|
||||
}
|
||||
|
||||
143
docs/output/rs-issuer-audience.txt
Normal file
143
docs/output/rs-issuer-audience.txt
Normal 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
|
||||
}
|
||||
|
||||
34
docs/output/rs-jwks-amplification.txt
Normal file
34
docs/output/rs-jwks-amplification.txt
Normal 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"
|
||||
71
docs/output/rs-keycloak-default-converter.txt
Normal file
71
docs/output/rs-keycloak-default-converter.txt
Normal 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"
|
||||
79
docs/output/rs-keycloak.txt
Normal file
79
docs/output/rs-keycloak.txt
Normal 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"
|
||||
47
docs/output/rs-retired-key-default.txt
Normal file
47
docs/output/rs-retired-key-default.txt
Normal 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.
|
||||
47
docs/output/rs-retired-key-nottlcache.txt
Normal file
47
docs/output/rs-retired-key-nottlcache.txt
Normal 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.
|
||||
69
docs/output/rs-rotation.txt
Normal file
69
docs/output/rs-rotation.txt
Normal 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.
|
||||
13
docs/output/rs-test-run.txt
Normal file
13
docs/output/rs-test-run.txt
Normal 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
|
||||
Reference in New Issue
Block a user