1
0
Files
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

59 lines
2.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# Key rotation as three separate events, with the resource server watched in between.
# Requires the stub issuer and a resource server pointed at it.
set -eu
. "$(dirname "$0")/lib.sh"
PROFILES="${1:-unknown}"
probe() { # prints just the status code for a token
local token="$1"
curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" "$RS/api/me"
}
head1 "resource server profiles: $PROFILES"
curl -s -X POST "$STUB/admin/reset-counter" >/dev/null
echo "Issuer state at the start:"; stub_state
head1 "0. Warm the cache"
OLD=$(stub_token "sub=alice&aud=reports-api")
echo "token signed with $(curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["activeKid"])')"
echo "GET /api/me -> $(probe "$OLD")"
echo "jwks fetches so far: $(stub_fetches)"
head1 "1. PUBLISH a second key. Nothing signs with it yet."
NEW=$(curl -s -X POST "$STUB/admin/publish" | python3 -c 'import json,sys;print(json.load(sys.stdin)["publishedKids"][-1])')
echo "published: $NEW"
stub_state
echo
echo "The resource server has not been told. Its cached JWK Set still holds one key."
echo "Old token still works: $(probe "$OLD")"
echo "jwks fetches so far: $(stub_fetches) <- unchanged: nothing forced a refresh"
head1 "2. ACTIVATE the new key. The issuer starts signing with it."
curl -s -X POST "$STUB/admin/activate?kid=$NEW" >/dev/null
NEWTOK=$(stub_token "sub=alice&aud=reports-api")
echo "A token arrives whose kid is not in the cached JWK Set."
echo "New token: $(probe "$NEWTOK")"
echo "jwks fetches so far: $(stub_fetches) <- the unknown kid forced one"
echo
echo "This is the recovery path, and it works. It is also the only thing in the default"
echo "configuration that notices a rotation, because refresh-ahead is switched off."
head1 "3. Tokens signed with the old key are still in flight"
echo "They were minted before the switch and have not expired yet."
echo "Old token: $(probe "$OLD") <- still accepted, because the old key is still published"
head1 "4. RETIRE the old key from the JWK Set"
OLDKID=$(curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["publishedKids"][0])')
curl -s -X POST "$STUB/admin/retire?kid=$OLDKID" >/dev/null
echo "retired: $OLDKID"
stub_state
echo
echo "The resource server's cache still contains it, so nothing changes yet."
echo "Old token: $(probe "$OLD")"
echo "New token: $(probe "$NEWTOK")"
echo "jwks fetches so far: $(stub_fetches)"
echo
echo "How long the old key keeps working from here is decided entirely by the cache."
echo "See retired-key-demo.sh, which runs this same step under two cache configurations."