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,34 @@
# A real issuer, in one command.
#
# docker compose -f docker/compose.yaml up -d
# ./scripts/run-rs.sh keycloak,roles
# ./scripts/keycloak-demo.sh
#
# Notes that matter for a resource server:
#
# * KC_HOSTNAME fixes the issuer string. Keycloak derives `iss` from the request host
# unless you pin it, so a token fetched via localhost and a token fetched via a
# container name carry DIFFERENT issuers and one of them will fail JwtIssuerValidator.
# Pinning it is the single most common fix for "it works from curl but not from the app".
#
# * start-dev keeps everything in an in-memory H2 database. Every restart is a fresh realm
# and, importantly for this repository, a fresh signing key.
#
# * The realm is imported at boot from realm-demo.json, so the demo users, roles and the
# audience mapper exist without any admin-console clicking.
services:
keycloak:
image: quay.io/keycloak/keycloak:26.7.2
container_name: jwt-demo-keycloak
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_HOSTNAME: http://localhost:8080
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true"
ports:
- "8080:8080"
volumes:
- ./realm-demo.json:/opt/keycloak/data/import/realm-demo.json:ro

View File

@@ -0,0 +1,129 @@
{
"realm": "demo",
"enabled": true,
"sslRequired": "none",
"accessTokenLifespan": 300,
"roles": {
"realm": [
{
"name": "USER",
"description": "Ordinary user"
},
{
"name": "ADMIN",
"description": "Administrator"
}
],
"client": {
"reports-api": [
{
"name": "reports-reader",
"description": "May read reports"
}
]
}
},
"clients": [
{
"clientId": "reports-api",
"enabled": true,
"bearerOnly": true,
"protocol": "openid-connect",
"description": "The resource server. It never logs anyone in; it only owns roles and is an audience."
},
{
"clientId": "demo-client",
"enabled": true,
"publicClient": false,
"secret": "demo-secret",
"standardFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"fullScopeAllowed": true,
"protocolMappers": [
{
"name": "reports-api-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "reports-api",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "alice",
"enabled": true,
"emailVerified": true,
"firstName": "Alice",
"lastName": "Anderson",
"email": "alice@example.com",
"requiredActions": [],
"credentials": [
{
"type": "password",
"value": "alice-password",
"temporary": false
}
],
"realmRoles": [
"USER"
],
"clientRoles": {
"reports-api": [
"reports-reader"
]
}
},
{
"username": "root",
"enabled": true,
"emailVerified": true,
"firstName": "Root",
"lastName": "Admin",
"email": "root@example.com",
"requiredActions": [],
"credentials": [
{
"type": "password",
"value": "root-password",
"temporary": false
}
],
"realmRoles": [
"USER",
"ADMIN"
],
"clientRoles": {
"reports-api": [
"reports-reader"
]
}
},
{
"username": "nobody",
"enabled": true,
"emailVerified": true,
"firstName": "No",
"lastName": "Body",
"email": "nobody@example.com",
"requiredActions": [],
"credentials": [
{
"type": "password",
"value": "nobody-password",
"temporary": false
}
],
"realmRoles": [
"USER"
]
}
]
}

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>oauth2-resource-server-demo</artifactId>
<version>1.0.0</version>
<name>oauth2-resource-server-demo</name>
<description>Spring Security OAuth2 resource server: JWT validation, JWKS and key rotation - runnable companion for ankurm.com</description>
<properties>
<java.version>25</java.version>
<!-- Two main classes live here: the resource server and the stub issuer. This picks
the one plain `mvn spring-boot:run` starts; the scripts override it with
-Dspring-boot.run.main-class (note the kebab-case - Boot 3 spelled the same
property -Dspring-boot.run.mainClass, and the camelCase spelling is now ignored
without any warning). -->
<start-class>com.ankurm.rsdemo.ResourceServerApplication</start-class>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# What an unknown kid costs the issuer.
#
# Spring Security builds its JWKSource with rateLimited(false), overriding Nimbus's own
# default of a 30-second minimum interval between forced refreshes. Nothing then stands
# between a token bearing an unrecognised kid and an HTTP request to the issuer.
set -eu
. "$(dirname "$0")/lib.sh"
PROFILES="${1:-unknown}"
N="${2:-25}"
head1 "resource server profiles: $PROFILES"
echo "Baseline: $N requests with a VALID token, whose kid is in the cached JWK Set."
curl -s -X POST "$STUB/admin/reset-counter" >/dev/null
GOOD=$(stub_token "sub=alice&aud=reports-api")
curl -s -o /dev/null -H "Authorization: Bearer $GOOD" "$RS/api/me" # warm
curl -s -X POST "$STUB/admin/reset-counter" >/dev/null
for i in $(seq 1 "$N"); do
curl -s -o /dev/null -H "Authorization: Bearer $GOOD" "$RS/api/me"
done
echo " requests to the resource server : $N"
echo " fetches of /jwks.json : $(stub_fetches)"
echo
echo "Now $N requests carrying a token whose kid has never existed."
echo "Each one is refused - but look at what it costs the issuer first."
curl -s -X POST "$STUB/admin/reset-counter" >/dev/null
for i in $(seq 1 "$N"); do
BAD=$(curl -s -X POST "$STUB/token/unknown-kid")
curl -s -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/me"
done
FETCHES=$(stub_fetches)
echo " requests to the resource server : $N"
echo " fetches of /jwks.json : $FETCHES"
echo
echo "Every rejected request became a request to the authorization server. An attacker who"
echo "can reach an unauthenticated endpoint of your resource server can point that ratio at"
echo "your identity provider, from one connection, using tokens that are never valid."
echo
echo "And it does not need an authenticated endpoint. /api/public/ping is permitAll()."
curl -s -X POST "$STUB/admin/reset-counter" >/dev/null
for i in $(seq 1 "$N"); do
BAD=$(curl -s -X POST "$STUB/token/unknown-kid")
curl -s -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/public/ping"
done
echo " requests to /api/public/ping : $N"
echo " fetches of /jwks.json : $(stub_fetches)"
echo
echo "A permitAll() endpoint still evaluates a bearer token if one is present, because"
echo "BearerTokenAuthenticationFilter runs before any authorization rule. Presenting a"
echo "broken token to a public endpoint gets a 401 from the public endpoint - and a"
echo "JWKS fetch on the way."
call "GET /api/public/ping with an unknown kid" "$RS/api/public/ping" "$(curl -s -X POST "$STUB/token/unknown-kid")"
echo
echo "The status returned to the caller is unremarkable:"
BAD=$(curl -s -X POST "$STUB/token/unknown-kid")
call "GET /api/me with an unknown kid" "$RS/api/me" "$BAD"

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# What a token is allowed to do, and why Keycloak's roles are invisible by default.
# Pass the profile set the resource server is running with, for the transcript header.
set -eu
. "$(dirname "$0")/lib.sh"
PROFILES="${1:-unknown}"
head1 "resource server profiles: $PROFILES"
head1 "1. alice - realm role USER, client role reports-reader"
T=$(stub_token "sub=alice&roles=USER")
claims "$T"
call "GET /api/me (which authorities did the converter produce?)" "$RS/api/me" "$T"
call "GET /api/reports (needs ROLE_reports-reader, from resource_access)" "$RS/api/reports" "$T"
call "GET /api/admin/stats (needs ROLE_ADMIN, from realm_access)" "$RS/api/admin/stats" "$T"
head1 "2. root - realm roles USER and ADMIN"
T=$(stub_token "sub=root&roles=USER%20ADMIN")
call "GET /api/me" "$RS/api/me" "$T"
call "GET /api/admin/stats" "$RS/api/admin/stats" "$T"

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Prints the JWK source chain the running decoder actually has.
set -eu
. "$(dirname "$0")/lib.sh"
head1 "resource server profiles: ${1:-unknown}"
echo "One token is decoded first, because the decoder for an issuer-uri is built lazily."
T=$(stub_token "sub=alice&aud=reports-api" 2>/dev/null || true)
[ -n "${T:-}" ] && curl -s -o /dev/null -H "Authorization: Bearer $T" "$RS/api/me" || true
curl -s "$RS/api/public/decoder" | python3 -m json.tool

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# The claim checks that decide whether a correctly signed token is yours.
# Usage: ./scripts/issuer-audience-demo.sh "<profiles the resource server is running with>"
set -eu
. "$(dirname "$0")/lib.sh"
PROFILES="${1:-unknown}"
head1 "resource server profiles: $PROFILES"
head1 "1. A correct token"
T=$(stub_token "sub=alice&aud=reports-api")
claims "$T"
call "GET /api/me" "$RS/api/me" "$T"
head1 "2. Signed by the right key, but iss says something else"
echo "The signature verifies. The key is the same key. Only the string differs."
T=$(stub_token "sub=alice&aud=reports-api&issuerOverride=http://localhost:9000/other")
claims "$T"
call "GET /api/me" "$RS/api/me" "$T"
head1 "3. A token minted for a different service in the same realm"
echo "This is the one that silently works when nothing checks aud."
T=$(stub_token "sub=alice&aud=billing-api")
claims "$T"
call "GET /api/me" "$RS/api/me" "$T"
head1 "4. Expired 90 seconds ago"
echo "The default clock skew is 60s, so a token has to be more than a minute stale"
echo "before JwtTimestampValidator refuses it."
T=$(stub_token "sub=alice&aud=reports-api&issuedAgoSeconds=120&expiresInSeconds=30")
call "GET /api/me" "$RS/api/me" "$T"
head1 "5. Expired 30 seconds ago - inside the default clock skew"
T=$(stub_token "sub=alice&aud=reports-api&issuedAgoSeconds=60&expiresInSeconds=30")
call "GET /api/me" "$RS/api/me" "$T"
head1 "6. typ=at+jwt, which RFC 9068 says an access token SHOULD carry"
echo "The default validator stack contains JwtTypeValidator.jwt(), which accepts only an"
echo "absent typ or typ=JWT. Whether this passes depends on the attyp profile."
T=$(stub_token "sub=alice&aud=reports-api&typ=at%2Bjwt")
claims "$T"
call "GET /api/me" "$RS/api/me" "$T"
head1 "7. No token at all"
call "GET /api/me" "$RS/api/me"
call "GET /api/public/ping" "$RS/api/public/ping"
head1 "8. The endpoint nobody configured"
echo "Spring Security 7 publishes RFC 9728 protected resource metadata and points the"
echo "WWW-Authenticate challenge at it. It answers without a token."
call "GET /.well-known/oauth-protected-resource" "$RS/.well-known/oauth-protected-resource"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# The same resource server, the same code, a real Keycloak.
# Requires: docker compose -f docker/compose.yaml up -d
# ./scripts/run-rs.sh keycloak,roles
set -eu
. "$(dirname "$0")/lib.sh"
head1 "Keycloak discovery document"
curl -s "$KC/realms/demo/.well-known/openid-configuration" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); [print(" %-22s %s" % (k, d[k])) for k in ("issuer","jwks_uri","token_endpoint")]'
head1 "Keycloak JWK Set"
curl -s "$KC/realms/demo/protocol/openid-connect/certs" \
| python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" keys published:", len(d["keys"]))
for k in d["keys"]:
print(" kid=%s alg=%s use=%s kty=%s" % (k.get("kid"), k.get("alg"), k.get("use"), k.get("kty")))'
head1 "1. alice, password grant"
T=$(kc_token alice alice-password)
claims "$T"
call "GET /api/me" "$RS/api/me" "$T"
call "GET /api/reports (client role, from resource_access.reports-api.roles)" "$RS/api/reports" "$T"
call "GET /api/admin/stats (realm role ADMIN, which alice does not have)" "$RS/api/admin/stats" "$T"
head1 "2. root"
T=$(kc_token root root-password)
call "GET /api/admin/stats" "$RS/api/admin/stats" "$T"
head1 "3. nobody - a user with no client role"
T=$(kc_token nobody nobody-password)
claims "$T"
call "GET /api/reports" "$RS/api/reports" "$T"

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Shared helpers. Sourced, not run.
RS="${RS:-http://localhost:8081}"
STUB="${STUB:-http://localhost:9000}"
KC="${KC:-http://localhost:8080}"
hr() { printf '%s\n' "--------------------------------------------------------------------------"; }
head1(){ hr; printf ' %s\n' "$1"; hr; }
# Prints status, the RFC 6750 challenge header, and the body. The WWW-Authenticate header is
# the only place a claim-validation failure explains itself, so it is never omitted here.
call() {
local label="$1" url="$2" token="${3:-}"
printf '\n$ %s\n' "$label"
local args=(-s -o /tmp/.body -D /tmp/.hdr -w '%{http_code}')
[ -n "$token" ] && args+=(-H "Authorization: Bearer $token")
local code
code=$(curl "${args[@]}" "$url")
printf 'HTTP %s\n' "$code"
grep -i '^www-authenticate:' /tmp/.hdr | sed 's/\r$//' || true
if [ -s /tmp/.body ]; then
python3 -m json.tool < /tmp/.body 2>/dev/null || cat /tmp/.body
echo
fi
}
stub_token() { curl -s -X POST "$STUB/token?$1"; }
stub_state() { curl -s "$STUB/admin/state" | python3 -m json.tool; }
stub_fetches() { curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["jwksFetches"])'; }
kc_token() {
curl -s -X POST "$KC/realms/demo/protocol/openid-connect/token" \
-d grant_type=password -d client_id=demo-client -d client_secret=demo-secret \
-d "username=$1" -d "password=$2" \
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("access_token",""))'
}
claims() {
python3 - "$1" <<'PY'
import sys, base64, json
tok = sys.argv[1]
h, p, _ = tok.split('.')
pad = lambda s: s + '=' * (-len(s) % 4)
print(" header:", json.dumps(json.loads(base64.urlsafe_b64decode(pad(h)))))
c = json.loads(base64.urlsafe_b64decode(pad(p)))
for k in ("iss", "aud", "typ", "scope", "preferred_username", "realm_access", "resource_access"):
if k in c:
print(" %-18s %s" % (k, json.dumps(c[k])))
PY
}

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# How long a key stays trusted after the issuer removes it from the JWK Set.
#
# The only traffic after the retirement is the leaked token itself. That is the point:
# a token whose kid IS in the cached set never triggers the unknown-kid refresh, so the
# only thing that can dislodge the stale JWK Set is the cache expiring on its own.
#
# Run under both cache configurations and diff the transcripts:
# ./scripts/run-rs.sh stub,roles && ./scripts/retired-key-demo.sh "stub,roles"
# ./scripts/run-rs.sh stub,roles,nottlcache && ./scripts/retired-key-demo.sh "stub,roles,nottlcache"
set -eu
. "$(dirname "$0")/lib.sh"
PROFILES="${1:-unknown}"
PROBES="${2:-16}"
INTERVAL="${3:-30}"
probe() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $1" "$RS/api/me"; }
head1 "resource server profiles: $PROFILES"
curl -s -X POST "$STUB/admin/reset-counter" >/dev/null
echo "Scenario: a signing key is compromised. The issuer publishes a replacement and"
echo "removes the compromised key from the JWK Set immediately. Tokens it signed are"
echo "already out there with an hour left to run."
echo
VICTIM_KID=$(curl -s "$STUB/admin/state" | python3 -c 'import json,sys;print(json.load(sys.stdin)["activeKid"])')
LEAKED=$(stub_token "sub=attacker&aud=reports-api&expiresInSeconds=3600")
echo "1. A token signed with $VICTIM_KID, one hour to live: GET /api/me -> $(probe "$LEAKED")"
echo " jwks fetches: $(stub_fetches)"
echo
NEW=$(curl -s -X POST "$STUB/admin/publish" | python3 -c 'import json,sys;print(json.load(sys.stdin)["publishedKids"][-1])')
curl -s -X POST "$STUB/admin/activate?kid=$NEW" >/dev/null
curl -s -X POST "$STUB/admin/retire?kid=$VICTIM_KID" >/dev/null
echo "2. Issuer rotates to $NEW and retires $VICTIM_KID."
stub_state
echo
echo " Anyone fetching /jwks.json from this moment sees only $NEW."
echo
echo "3. From here the ONLY traffic is the leaked token. Nothing carries an unknown kid,"
echo " so nothing forces a refresh. Whether the token keeps working is decided purely"
echo " by whether the cached JWK Set expires."
echo
printf ' %-10s %-8s %s\n' "elapsed" "leaked" "jwksFetches"
START=$(date +%s)
for i in $(seq 1 "$PROBES"); do
ELAPSED=$(( $(date +%s) - START ))
printf ' t+%-8s %-8s %s\n' "${ELAPSED}s" "$(probe "$LEAKED")" "$(stub_fetches)"
sleep "$INTERVAL"
done
echo
echo "A row that flips to 401 is the cache expiring and the retired key going away."
echo "A column of 200s is a resource server that has not noticed, and will not, until"
echo "something happens to bring it a token it cannot verify."

View File

@@ -0,0 +1,58 @@
#!/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."

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Regenerates every ../docs/output/rs-*.txt file from a real run.
#
# Needs Docker for the Keycloak leg. Takes roughly twenty-five minutes, most of it spent
# restarting the resource server between profile sets and waiting out cache lifetimes.
set -eu
cd "$(dirname "$0")/.."
OUT=../docs/output
mkdir -p "$OUT"
echo "==> tests"
{
echo "=========================================================================="
echo " oauth2-resource-server-demo - test run"
echo "=========================================================================="
echo
mvn -B test 2>&1 | grep -E '^\[INFO\] (Running|Tests run)|^\[ERROR\]|BUILD (SUCCESS|FAILURE)' \
| sed 's/^\[INFO\] //'
echo
echo "JDK : $(java -version 2>&1 | grep -v 'JAVA_TOOL\|Picked up' | head -1)"
echo "Boot : 4.1.1"
echo "Security : 7.1.1"
echo "Nimbus : 10.9.1"
} > "$OUT/rs-test-run.txt"
echo "==> stub issuer"
./scripts/run-stub-issuer.sh >/dev/null
echo "==> issuer and audience validation"
./scripts/run-rs.sh stub,roles,audience >/dev/null
./scripts/issuer-audience-demo.sh "stub,roles,audience" > "$OUT/rs-issuer-audience.txt" 2>&1
echo "==> the same run with a type validator that accepts at+jwt"
./scripts/run-rs.sh stub,roles,audience,attyp >/dev/null
./scripts/issuer-audience-demo.sh "stub,roles,audience,attyp" > "$OUT/rs-issuer-audience-attyp.txt" 2>&1
echo "==> authorities: the default converter"
./scripts/run-rs.sh stub >/dev/null
./scripts/converter-demo.sh "stub" > "$OUT/rs-converter-default.txt" 2>&1
echo "==> authorities: a custom JwtAuthenticationConverter"
./scripts/run-rs.sh stub,roles >/dev/null
./scripts/converter-demo.sh "stub,roles" > "$OUT/rs-converter-java.txt" 2>&1
echo "==> authorities: configuration only"
./scripts/run-rs.sh stub,propsroles >/dev/null
./scripts/converter-demo.sh "stub,propsroles" > "$OUT/rs-converter-properties.txt" 2>&1
echo "==> authorities: the same configuration with an unquoted SpEL indexer"
./scripts/run-rs.sh stub,propsroles-broken,tracespel >/dev/null
{
./scripts/converter-demo.sh "stub,propsroles-broken,tracespel"
echo
echo "--------------------------------------------------------------------------"
echo " what the resource server logged, at TRACE, while producing that 403"
echo "--------------------------------------------------------------------------"
grep -F 'ExpressionJwtGrantedAuthoritiesConverter' /tmp/rs-stub-propsroles-broken-tracespel.log \
| sed 's/^.*ExpressionJwtGrantedAuthoritiesConverter *: / /' | sort -u
} > "$OUT/rs-converter-properties-broken.txt" 2>&1
echo "==> the live JWK source chain, three cache configurations"
rm -f "$OUT/rs-decoder-chain.txt"
for P in stub stub,springcache stub,nottlcache stub,hardened; do
./scripts/run-rs.sh "$P" >/dev/null
./scripts/decoder-chain.sh "$P" >> "$OUT/rs-decoder-chain.txt" 2>&1
done
echo "==> rotation, from a cold resource server"
./scripts/run-stub-issuer.sh >/dev/null
./scripts/run-rs.sh stub,roles,audience >/dev/null
./scripts/rotation-demo.sh "stub,roles,audience" > "$OUT/rs-rotation.txt" 2>&1
echo "==> what an unknown kid costs the issuer"
./scripts/run-stub-issuer.sh >/dev/null
./scripts/run-rs.sh stub,roles >/dev/null
./scripts/amplification-demo.sh "stub,roles" 25 > "$OUT/rs-jwks-amplification.txt" 2>&1
echo "==> a retired key, default caching (takes ~8 minutes)"
./scripts/run-stub-issuer.sh >/dev/null
./scripts/run-rs.sh stub,roles >/dev/null
./scripts/retired-key-demo.sh "stub,roles" 16 30 > "$OUT/rs-retired-key-default.txt" 2>&1
echo "==> a retired key, Spring cache with no TTL (takes ~8 minutes)"
./scripts/run-stub-issuer.sh >/dev/null
./scripts/run-rs.sh stub,roles,nottlcache >/dev/null
./scripts/retired-key-demo.sh "stub,roles,nottlcache" 16 30 > "$OUT/rs-retired-key-nottlcache.txt" 2>&1
echo "==> Keycloak"
docker compose -f docker/compose.yaml up -d >/dev/null 2>&1
for i in $(seq 1 60); do
curl -sf http://localhost:8080/realms/demo/.well-known/openid-configuration -o /dev/null 2>/dev/null && break
sleep 3
done
./scripts/run-rs.sh keycloak,roles >/dev/null
./scripts/keycloak-demo.sh > "$OUT/rs-keycloak.txt" 2>&1
./scripts/run-rs.sh keycloak >/dev/null
./scripts/keycloak-demo.sh > "$OUT/rs-keycloak-default-converter.txt" 2>&1
for p in $(ps -eo pid,cmd | grep -E '[R]esourceServerApplication|[S]tubIssuerApplication' | awk '{print $1}'); do kill -9 "$p" || true; done
echo "==> done. docs/output/rs-*.txt regenerated."

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Starts the resource server on :8081 with the given profiles, replacing any previous instance.
# Usage: ./scripts/run-rs.sh stub,roles,audience
set -eu
PROFILES="${1:-stub}"
cd "$(dirname "$0")/.."
for p in $(ps -eo pid,cmd | grep '[R]esourceServerApplication' | awk '{print $1}'); do kill -9 "$p" || true; done
sleep 1
setsid nohup mvn -B -o org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.main-class=com.ankurm.rsdemo.ResourceServerApplication \
-Dspring-boot.run.profiles="$PROFILES" > "/tmp/rs-${PROFILES//,/-}.log" 2>&1 < /dev/null &
for i in $(seq 1 60); do
curl -sf http://localhost:8081/api/public/ping -o /dev/null 2>/dev/null && { echo "resource server up on :8081 with profiles: $PROFILES"; exit 0; }
sleep 2
done
echo "resource server failed to start; see /tmp/rs-${PROFILES//,/-}.log" >&2; exit 1

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Starts the stub authorization server on :9000, replacing any previous instance.
set -eu
cd "$(dirname "$0")/.."
for p in $(ps -eo pid,cmd | grep '[S]tubIssuerApplication' | awk '{print $1}'); do kill -9 "$p" || true; done
sleep 1
setsid nohup mvn -B -o org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.main-class=com.ankurm.stubissuer.StubIssuerApplication \
-Dspring-boot.run.profiles=stubissuer > /tmp/stub-issuer.log 2>&1 < /dev/null &
for i in $(seq 1 60); do
curl -sf http://localhost:9000/admin/state -o /dev/null 2>/dev/null && { echo "stub issuer up on :9000"; exit 0; }
sleep 2
done
echo "stub issuer failed to start; see /tmp/stub-issuer.log" >&2; exit 1

View File

@@ -0,0 +1,23 @@
package com.ankurm.rsdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The resource server. It never mints a token &mdash; it only validates the ones it is given.
*
* <p>Run it against either issuer:
* <pre>
* ./scripts/run-rs.sh stub # the in-repo stub issuer on :9000
* ./scripts/run-rs.sh keycloak # real Keycloak on :8080
* </pre>
*
* <p>Explained in <a href="../../../../../../../docs/12-resource-server-vs-manual-filter.md">docs/12</a>.
*/
@SpringBootApplication
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}

View File

@@ -0,0 +1,171 @@
package com.ankurm.rsdemo.config;
import java.time.Duration;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.Cache;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTypeValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
/**
* Decoder variants that differ only in how the JWK Set is cached.
*
* <p>Read {@code NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource()} in the Spring
* Security 7.1.1 sources before assuming any of this is obvious:
*
* <pre>
* JWKSourceBuilder.create(new SpringJWKSource&lt;&gt;(restOperations, cache, jwkSetUri))
* .refreshAheadCache(false)
* .rateLimited(false)
* .cache(this.cache instanceof NoOpCache)
* .build();
* </pre>
*
* Nimbus enables all three by default. Spring Security switches two off outright, and the
* third line means that <em>supplying</em> a Spring cache switches Nimbus&rsquo;s own
* five-minute cache <em>off</em>, leaving your cache&rsquo;s TTL as the only expiry in the
* system. Measured in
* <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*
* <p>With no profile active this class contributes nothing and Spring Boot&rsquo;s own
* auto-configured decoder is used, which is the configuration most applications run.
*/
@Configuration
public class JwtDecoderConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Value("${demo.audience:reports-api}")
private String audience;
/** Only used by the hardened profile, which cannot discover it. */
@Value("${demo.jwk-set-uri:}")
private String jwkSetUri;
/**
* Boot&rsquo;s {@code JwtDecoderConfiguration} collects every {@code OAuth2TokenValidator&lt;Jwt&gt;}
* bean in the context and appends it to the validator stack, so audience validation does
* not require replacing the decoder. This bean and the
* {@code spring.security.oauth2.resourceserver.jwt.audiences} property do the same job;
* the property builds a {@code JwtClaimValidator} on {@code aud}, this builds the
* purpose-made {@code JwtAudienceValidator}.
*/
@Bean
@Profile("audience")
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtAudienceValidator(this.audience);
}
/**
* Accepts RFC 9068 access tokens. The default stack contains {@code JwtTypeValidator.jwt()},
* which accepts only an absent {@code typ} or {@code typ=JWT}; a token carrying
* {@code typ=at+jwt} - which RFC 9068 says an access token SHOULD carry - is refused by it.
*/
@Bean
@Profile("attyp")
OAuth2TokenValidator<Jwt> accessTokenTypeValidator() {
JwtTypeValidator validator = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt", "Bearer");
validator.setAllowEmpty(true);
return validator;
}
// ------------------------------------------------------------------ cache variants
private OAuth2TokenValidator<Jwt> validators() {
return JwtValidators.createDefaultWithValidators(new JwtIssuerValidator(this.issuerUri),
permissiveTypeValidator());
}
private JwtTypeValidator permissiveTypeValidator() {
JwtTypeValidator validator = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt", "Bearer");
validator.setAllowEmpty(true);
return validator;
}
/**
* The shared-cache configuration the reference documentation recommends, done correctly:
* an explicit TTL. Nimbus caching is off, so this TTL is the only expiry that exists.
*/
@Bean
@Profile("springcache")
JwtDecoder caffeineCachedDecoder() {
Cache cache = new CaffeineCache("jwks",
Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build());
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(this.issuerUri).cache(cache).build();
decoder.setJwtValidator(validators());
return decoder;
}
/**
* Every protective layer Nimbus offers, restored.
*
* <p>{@code JwkSetUriJwtDecoderBuilder} exposes no way to re-enable rate limiting,
* refresh-ahead or outage tolerance, so the {@code JWKSource} is built directly and handed
* to {@code NimbusJwtDecoder.withJwkSource(..)}. What that costs:
*
* <ul>
* <li>issuer discovery is gone - the JWK Set URI has to be configured explicitly</li>
* <li>the validator stack is no longer supplied for you, so it is set here in full</li>
* <li>{@code JWKSourceBuilder.create(URL)} fetches with Nimbus&rsquo;s own
* {@code DefaultResourceRetriever}, not Spring&rsquo;s {@code RestOperations}, so
* any client customisation, proxy configuration or observability you had wired into
* the Spring HTTP client does not apply</li>
* </ul>
*
* <p>The rate limit also means that during a genuine rotation, tokens signed with the new
* key are refused for up to the interval after the first miss. That is the trade, and it
* is discussed in <a href="../../../../../../../docs/16-jwks-amplification.md">docs/16</a>.
*/
@Bean
@Profile("hardened")
JwtDecoder hardenedDecoder() throws java.net.MalformedURLException, java.net.URISyntaxException {
JWKSource<SecurityContext> source = JWKSourceBuilder
.<SecurityContext>create(new java.net.URI(this.jwkSetUri).toURL())
.cache(Duration.ofMinutes(5).toMillis(), Duration.ofSeconds(15).toMillis())
.refreshAheadCache(true)
.rateLimited(Duration.ofSeconds(30).toMillis())
.outageTolerant(Duration.ofMinutes(30).toMillis())
.retrying(true)
.build();
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSource(source).build();
decoder.setJwtValidator(validators());
return decoder;
}
/**
* The same configuration with the cache most people reach for first. A
* {@code ConcurrentMapCache} - which is also what {@code ConcurrentMapCacheManager},
* Boot&rsquo;s fallback cache manager, hands out - has no TTL at all, so the JWK Set is
* cached until something forces a refresh.
*
* <p>The only thing that forces a refresh is a token whose {@code kid} is missing from the
* cached set. A key that has been <em>removed</em> from the JWK Set is still present in the
* stale cache and still matches, so tokens signed with a retired - or compromised - key keep
* being accepted. Demonstrated by {@code scripts/retired-key-demo.sh}.
*/
@Bean
@Profile("nottlcache")
JwtDecoder noTtlCachedDecoder() {
Cache cache = new ConcurrentMapCache("jwks");
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(this.issuerUri).cache(cache).build();
decoder.setJwtValidator(validators());
return decoder;
}
}

View File

@@ -0,0 +1,109 @@
package com.ankurm.rsdemo.config;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
/**
* Mapping a Keycloak token onto Spring Security authorities.
*
* <p>The default {@link JwtGrantedAuthoritiesConverter} reads the {@code scope} or {@code scp}
* claim, splits it on spaces, and prefixes each value with {@code SCOPE_}. Keycloak does emit
* {@code scope}, so scopes work out of the box. Roles do not: Keycloak nests realm roles under
* {@code realm_access.roles} and client roles under {@code resource_access.<client>.roles},
* and the default converter looks at neither. The symptom is a token that authenticates
* perfectly and then gets 403 from every {@code hasRole(..)} rule.
*
* <p>Two ways out. This class is the Java one, active under the {@code roles} profile;
* {@code application-propsroles.yaml} is the configuration-only one. They produce the same
* authorities, and the configuration route has one limitation the Java route does not - see
* <a href="../../../../../../../docs/14-authentication-converter.md">docs/14</a>.
*
* <p><b>Defining this bean silently disables the properties.</b> Boot&rsquo;s
* {@code JwtConverterConfiguration} is annotated
* {@code @ConditionalOnMissingBean(JwtAuthenticationConverter.class)}, so the moment a
* {@code JwtAuthenticationConverter} bean exists, every
* {@code spring.security.oauth2.resourceserver.jwt.authorities-*} and {@code principal-claim-name}
* property stops having any effect. No warning is logged.
*/
@Configuration
public class KeycloakAuthoritiesConfig {
@Bean
@Profile("roles")
JwtAuthenticationConverter keycloakJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("preferred_username");
converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api"));
return converter;
}
/**
* Scopes keep the {@code SCOPE_} prefix, realm and client roles get {@code ROLE_}.
* A mixed mapping like this is the one thing the property-only route cannot express,
* because {@code authority-prefix} is a single value applied to every expression.
*/
static final class KeycloakGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
private final JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
private final String clientId;
KeycloakGrantedAuthoritiesConverter(String clientId) {
this.clientId = clientId;
}
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = new ArrayList<>(this.scopes.convert(jwt));
addPrefixed(authorities, realmRoles(jwt));
addPrefixed(authorities, clientRoles(jwt));
return authorities;
}
@SuppressWarnings("unchecked")
private List<String> realmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}
Object roles = realmAccess.get("roles");
return (roles instanceof List<?> list) ? (List<String>) list : List.of();
}
@SuppressWarnings("unchecked")
private List<String> clientRoles(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess == null) {
return List.of();
}
Object client = resourceAccess.get(this.clientId);
if (!(client instanceof Map<?, ?> clientMap)) {
return List.of();
}
Object roles = clientMap.get("roles");
return (roles instanceof List<?> list) ? (List<String>) list : List.of();
}
private void addPrefixed(Collection<GrantedAuthority> target, List<String> roles) {
for (String role : roles) {
// Realm roles and client roles are flattened into one ROLE_ namespace here.
// If two clients in your realm both define a role named "admin", this
// collapses them onto the same authority. Prefix by client if that is a
// risk for you.
target.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
}
}

View File

@@ -0,0 +1,42 @@
package com.ankurm.rsdemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* The whole resource server, in one chain.
*
* <p>Note what is <em>not</em> here: no login endpoint, no user store, no password encoder,
* no token minting. A resource server only ever verifies. Compare with the hand-written
* filter in {@code ../../../jwt-authentication/} and
* <a href="../../../../../../../docs/09-manual-filter-vs-resource-server.md">docs/09</a>.
*
* <p>Explained in <a href="../../../../../../../docs/12-issuer-and-audience.md">docs/12</a>.
*/
@Configuration
@EnableMethodSecurity
public class ResourceServerSecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
// A resource server authenticates every request from the token alone, so there is
// no session to protect and nothing for CSRF to defend. This is the one place the
// blanket "never disable CSRF" advice genuinely does not apply - see docs/04.
.csrf((csrf) -> csrf.disable())
.sessionManagement((s) -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// Everything interesting about this application is inside these two lines.
// The JwtDecoder bean decides which tokens are genuine; the JwtAuthenticationConverter
// bean decides what a genuine token is allowed to do.
.oauth2ResourceServer((oauth2) -> oauth2.jwt((jwt) -> { }))
.build();
}
}

View File

@@ -0,0 +1,61 @@
package com.ankurm.rsdemo.web;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Four endpoints, each testing one layer of the chain.
*
* <p>Explained in <a href="../../../../../../../docs/12-issuer-and-audience.md">docs/12</a>.
*/
@RestController
public class ApiControllers {
/** Reachable with no token at all. If this 401s, the problem is not your token. */
@GetMapping("/api/public/ping")
public Map<String, Object> ping() {
return Map.of("status", "up");
}
/**
* 401 without a valid token. The response body is where every claim-validation failure
* shows up - in the {@code WWW-Authenticate} header, not the body.
*/
@GetMapping("/api/me")
public Map<String, Object> me(Authentication authentication) {
Jwt jwt = (Jwt) authentication.getPrincipal();
Map<String, Object> out = new LinkedHashMap<>();
out.put("name", authentication.getName());
out.put("authorities", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).sorted().toList());
out.put("iss", jwt.getClaimAsString("iss"));
out.put("aud", jwt.getAudience());
out.put("typ", jwt.getHeaders().get("typ"));
out.put("kid", jwt.getHeaders().get("kid"));
out.put("exp", jwt.getExpiresAt());
return out;
}
/** 403 with a valid token that lacks ROLE_ADMIN. This is where the converter shows up. */
@GetMapping("/api/admin/stats")
public Map<String, Object> adminStats() {
return Map.of("secret", "only ROLE_ADMIN sees this");
}
/**
* The method-security twin. This one needs a Keycloak <em>client</em> role, which lives
* two levels down in {@code resource_access.reports-api.roles} - the claim the default
* converter is least likely to find.
*/
@GetMapping("/api/reports")
@PreAuthorize("hasAuthority('ROLE_reports-reader')")
public Map<String, Object> reports() {
return Map.of("reports", 3);
}
}

View File

@@ -0,0 +1,128 @@
package com.ankurm.rsdemo.web;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the JWK source chain that the running {@code JwtDecoder} actually has.
*
* <p>Every claim in the post about caching, rate limiting and refresh-ahead can be read out
* of the Spring Security sources, but reading sources tells you what <em>a</em> decoder looks
* like, not what <em>yours</em> looks like after auto-configuration, your profiles, your
* {@code JwkSetUriJwtDecoderBuilderCustomizer} beans and your cache have all had a turn.
* This endpoint walks the live object graph and reports the layers it finds, with the
* timings each layer was constructed with.
*
* <p>It reads private fields by reflection, which is the price of asking a question the API
* does not answer. It is a diagnostic, not a feature: <b>delete it before you ship.</b>
* It reveals your JWK Set URI and cache timings to anyone who can reach it.
*
* <p>Explained in <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*/
@RestController
public class DecoderDiagnosticsController {
private final JwtDecoder decoder;
public DecoderDiagnosticsController(JwtDecoder decoder) {
this.decoder = decoder;
}
@GetMapping(path = "/api/public/decoder", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> decoder() {
Map<String, Object> report = new LinkedHashMap<>();
report.put("decoderClass", this.decoder.getClass().getName());
Object cursor = this.decoder;
// An issuer-uri produces a SupplierJwtDecoder, whose `delegate` field is a
// Supplier<JwtDecoder> rather than the decoder - the real NimbusJwtDecoder does not
// exist until the first token is decoded. That laziness is deliberate: it decouples
// startup from the authorization server being reachable.
Object delegate = field(cursor, "delegate");
if (delegate instanceof java.util.function.Supplier<?> supplier) {
report.put("note", "SupplierJwtDecoder: built lazily on first decode, then cached");
cursor = supplier.get();
report.put("resolvedDecoderClass", className(cursor));
}
Object processor = field(cursor, "jwtProcessor");
Object keySelector = (processor != null) ? field(processor, "jwsKeySelector") : null;
Object jwkSource = (keySelector != null) ? field(keySelector, "jwkSource") : null;
report.put("processor", className(processor));
report.put("keySelector", className(keySelector));
List<Map<String, Object>> chain = new ArrayList<>();
Object node = jwkSource;
int guard = 0;
while (node != null && guard++ < 12) {
Map<String, Object> layer = new LinkedHashMap<>();
layer.put("class", node.getClass().getName());
describe(node, layer);
chain.add(layer);
node = field(node, "source");
}
report.put("jwkSourceChain", chain);
report.put("readMe", "Each entry wraps the next. A layer that is absent was switched off.");
return report;
}
/** Pulls out the timings that decide when a rotated key becomes visible. */
private void describe(Object node, Map<String, Object> layer) {
String name = node.getClass().getSimpleName();
switch (name) {
case "CachingJWKSetSource", "RefreshAheadCachingJWKSetSource" -> {
layer.put("timeToLiveMs", field(node, "timeToLive"));
layer.put("cacheRefreshTimeoutMs", field(node, "cacheRefreshTimeout"));
layer.put("meaning", "the JWK Set is re-fetched no more often than timeToLive");
}
case "RateLimitedJWKSetSource" -> {
layer.put("minTimeIntervalMs", field(node, "minTimeInterval"));
layer.put("meaning", "forced refreshes are throttled to this interval");
}
case "OutageTolerantJWKSetSource" ->
layer.put("meaning", "a stale JWK Set is served if the issuer is unreachable");
case "SpringJWKSource" -> {
layer.put("jwkSetUri", field(node, "jwkSetUri"));
Object cache = field(node, "cache");
layer.put("springCache", className(cache));
layer.put("meaning", (cache != null && cache.getClass().getSimpleName().equals("NoOpCache"))
? "no Spring cache supplied, so Nimbus's own cache layer is enabled above"
: "a Spring cache was supplied, so Nimbus's cache layer was disabled and this "
+ "cache's TTL is the only expiry");
}
default -> {
}
}
}
private static String className(Object o) {
return (o != null) ? o.getClass().getName() : null;
}
private static Object field(Object target, String name) {
Class<?> type = target.getClass();
while (type != null && type != Object.class) {
try {
Field f = type.getDeclaredField(name);
f.setAccessible(true);
return f.get(target);
}
catch (NoSuchFieldException ex) {
type = type.getSuperclass();
}
catch (Exception ex) {
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,25 @@
package com.ankurm.stubissuer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* A deliberately minimal OAuth2 authorization server whose JWKS I can mutate on command.
*
* <p>Keycloak is the realistic issuer and this repository runs against it too
* (see {@code docker/compose.yaml}). But Keycloak will not rotate its signing key at a
* chosen second, will not tell you how many times its JWKS endpoint was fetched, and
* will not drop a key from the published set on request. Every claim in the post about
* <em>caching</em> and <em>rotation timing</em> needs exactly those three things, so they
* are measured here and the Keycloak run confirms the same code path end to end.
*
* <p>Runs on :9000. Explained in
* <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*/
@SpringBootApplication
public class StubIssuerApplication {
public static void main(String[] args) {
SpringApplication.run(StubIssuerApplication.class, args);
}
}

View File

@@ -0,0 +1,181 @@
package com.ankurm.stubissuer;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* The endpoints a resource server discovers, plus admin endpoints a real issuer would
* never expose.
*
* <p>Discovery and JWKS are deliberately shaped like Keycloak's so the resource server
* configuration is byte-identical between the two issuers.
*/
@RestController
public class StubIssuerController {
private final StubKeyStore keys;
private final String issuer;
public StubIssuerController(StubKeyStore keys, @Value("${stub.issuer}") String issuer) {
this.keys = keys;
this.issuer = issuer;
}
// ---------------------------------------------------------------- discovery
@GetMapping(path = "/.well-known/openid-configuration", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> discovery() {
Map<String, Object> doc = new LinkedHashMap<>();
doc.put("issuer", this.issuer);
doc.put("jwks_uri", this.issuer + "/jwks.json");
doc.put("token_endpoint", this.issuer + "/token");
doc.put("id_token_signing_alg_values_supported", List.of("RS256"));
doc.put("response_types_supported", List.of("code"));
doc.put("subject_types_supported", List.of("public"));
return doc;
}
/**
* Every fetch of this endpoint is counted. A resource server that is behaving itself
* hits this roughly once per cache lifetime; one that is not can hit it once per request.
*/
@GetMapping(path = "/jwks.json", produces = "application/jwk-set+json")
public String jwks() {
this.keys.recordJwksFetch();
return this.keys.publishedJwkSet().toString();
}
// ---------------------------------------------------------------- token minting
/**
* Mints an access token. Everything is a query parameter because the point is to be able
* to produce a deliberately wrong token as easily as a correct one.
*
* @param kid sign with a specific key rather than the active one. A retired key still
* signs perfectly well &mdash; that is the whole problem with retiring keys.
*/
@PostMapping(path = "/token", produces = MediaType.TEXT_PLAIN_VALUE)
public String token(@RequestParam(defaultValue = "alice") String sub,
@RequestParam(defaultValue = "reports-api") String aud,
@RequestParam(defaultValue = "profile:read reports:read") String scope,
@RequestParam(defaultValue = "USER") String roles,
@RequestParam(defaultValue = "300") long expiresInSeconds,
@RequestParam(defaultValue = "0") long issuedAgoSeconds,
@RequestParam(required = false) String kid,
@RequestParam(required = false) String issuerOverride,
@RequestParam(defaultValue = "JWT") String typ) throws Exception {
RSAKey key = (kid != null) ? this.keys.key(kid) : this.keys.signingKey();
Instant issuedAt = Instant.now().minusSeconds(issuedAgoSeconds);
Map<String, Object> realmAccess = Map.of("roles", Arrays.asList(roles.split(" ")));
Map<String, Object> resourceAccess = Map.of("reports-api", Map.of("roles", List.of("reports-reader")));
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer((issuerOverride != null) ? issuerOverride : this.issuer)
.subject(sub)
.audience(Arrays.asList(aud.split(" ")))
.claim("scope", scope)
// Keycloak puts realm roles here, nested one level down. Spring Security's
// default converter reads a flat "scope"/"scp" claim and will not find these.
.claim("realm_access", realmAccess)
.claim("resource_access", resourceAccess)
.claim("preferred_username", sub)
.issueTime(java.util.Date.from(issuedAt))
.expirationTime(java.util.Date.from(issuedAt.plusSeconds(expiresInSeconds)))
.jwtID(java.util.UUID.randomUUID().toString())
.build();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID(key.getKeyID())
.type(new JOSEObjectType(typ))
.build();
SignedJWT jwt = new SignedJWT(header, claims);
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
}
/**
* Mints a token whose {@code kid} header names a key that has never existed. This is what
* an attacker&rsquo;s traffic looks like, and it is the input to the amplification demo.
*/
@PostMapping(path = "/token/unknown-kid", produces = MediaType.TEXT_PLAIN_VALUE)
public String tokenWithUnknownKid(@RequestParam(defaultValue = "alice") String sub) throws Exception {
RSAKey key = this.keys.signingKey();
Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(this.issuer)
.subject(sub)
.audience("reports-api")
.issueTime(java.util.Date.from(now))
.expirationTime(java.util.Date.from(now.plusSeconds(300)))
.build();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID("kid-" + java.util.UUID.randomUUID())
.type(new JOSEObjectType("at+jwt"))
.build();
SignedJWT jwt = new SignedJWT(header, claims);
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
}
// ---------------------------------------------------------------- admin
@PostMapping(path = "/admin/publish", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> publish() {
String kid = this.keys.generate();
return state("published " + kid);
}
@PostMapping(path = "/admin/activate", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> activate(@RequestParam String kid) {
this.keys.activate(kid);
return state("signing with " + kid);
}
@PostMapping(path = "/admin/retire", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> retire(@RequestParam String kid) {
this.keys.retire(kid);
return state("retired " + kid + " from the published set");
}
@PostMapping(path = "/admin/reset-counter", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> resetCounter() {
this.keys.resetJwksFetches();
return state("jwks fetch counter reset");
}
@GetMapping(path = "/admin/state", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> state() {
return state("ok");
}
private Map<String, Object> state(String message) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("message", message);
out.put("activeKid", this.keys.activeKid());
out.put("publishedKids", this.keys.publishedKids());
out.put("jwksFetches", this.keys.jwksFetches());
return out;
}
}

View File

@@ -0,0 +1,115 @@
package com.ankurm.stubissuer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import org.springframework.stereotype.Component;
/**
* Holds every key this issuer has ever minted, plus which of them are currently
* <em>published</em> in the JWK Set and which one is currently <em>active</em> for signing.
*
* <p>Rotation in the real world is three separate events, and conflating them is where
* most rotation incidents come from:
* <ol>
* <li><b>publish</b> &mdash; the new key appears in the JWK Set, nothing signs with it yet</li>
* <li><b>activate</b> &mdash; the issuer starts signing with the new key</li>
* <li><b>retire</b> &mdash; the old key is removed from the JWK Set</li>
* </ol>
* This class lets a script fire them independently and at a chosen moment, which is the
* only way to show what a resource server does in between.
*/
@Component
public class StubKeyStore {
private final Map<String, RSAKey> allKeys = new LinkedHashMap<>();
private final List<String> published = new ArrayList<>();
private final AtomicInteger keyCounter = new AtomicInteger();
/** Counts every GET of /jwks.json. This number is the point of the whole class. */
private final AtomicLong jwksFetches = new AtomicLong();
private volatile String activeKid;
public StubKeyStore() {
String kid = generate();
this.activeKid = kid;
}
/** Creates a key, publishes it, and returns its kid. Does not make it active. */
public synchronized String generate() {
String kid = "stub-key-" + this.keyCounter.incrementAndGet();
try {
RSAKey key = new RSAKeyGenerator(2048).keyID(kid).keyUse(KeyUse.SIGNATURE).generate();
this.allKeys.put(kid, key);
this.published.add(kid);
return kid;
}
catch (Exception ex) {
throw new IllegalStateException("could not generate RSA key", ex);
}
}
public synchronized void activate(String kid) {
if (!this.allKeys.containsKey(kid)) {
throw new IllegalArgumentException("no such kid: " + kid);
}
this.activeKid = kid;
}
/** Removes a key from the published JWK Set. The key still exists and can still sign. */
public synchronized void retire(String kid) {
this.published.remove(kid);
}
public synchronized RSAKey signingKey() {
return this.allKeys.get(this.activeKid);
}
public synchronized RSAKey key(String kid) {
RSAKey key = this.allKeys.get(kid);
if (key == null) {
throw new IllegalArgumentException("no such kid: " + kid);
}
return key;
}
public synchronized JWKSet publishedJwkSet() {
List<com.nimbusds.jose.jwk.JWK> keys = new ArrayList<>();
for (String kid : this.published) {
keys.add(this.allKeys.get(kid).toPublicJWK());
}
return new JWKSet(keys);
}
public long recordJwksFetch() {
return this.jwksFetches.incrementAndGet();
}
public long jwksFetches() {
return this.jwksFetches.get();
}
public void resetJwksFetches() {
this.jwksFetches.set(0);
}
public String activeKid() {
return this.activeKid;
}
public synchronized List<String> publishedKids() {
return List.copyOf(this.published);
}
}

View File

@@ -0,0 +1,21 @@
package com.ankurm.stubissuer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The stub issuer authenticates nobody. Defining any {@code SecurityFilterChain} bean makes
* Boot&rsquo;s default chain back off, which is the whole purpose of this class.
*/
@Configuration
public class StubSecurityConfig {
@Bean
SecurityFilterChain open(HttpSecurity http) throws Exception {
return http.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.build();
}
}

View File

@@ -0,0 +1,11 @@
# Points the resource server at the Keycloak in docker/compose.yaml.
# Identical shape to application-stub.yaml - one property.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/demo
demo:
jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs

View File

@@ -0,0 +1,8 @@
# Audience validation with no Java at all. Boot turns this into a JwtClaimValidator on
# `aud` and appends it to the default validator stack.
spring:
security:
oauth2:
resourceserver:
jwt:
audiences: reports-api

View File

@@ -0,0 +1,15 @@
# The same configuration with the hyphenated client id unquoted. This is what most people
# write first, and it fails silently: no error, no WARN, just an authority that never
# appears and a 403 nobody can explain.
#
# The `trace` profile makes the swallowed message visible.
spring:
security:
oauth2:
resourceserver:
jwt:
principal-claim-name: preferred_username
authority-prefix: "ROLE_"
authorities-claim-expressions:
- "[realm_access][roles]"
- "[resource_access][reports-api][roles]"

View File

@@ -0,0 +1,28 @@
# Keycloak's nested roles, mapped with configuration only.
#
# `authorities-claim-expressions` is a Spring Boot 4 property. Each entry is a SpEL
# expression evaluated against the claim map, so a nested claim needs no Java.
#
# NOTE THE QUOTES around 'reports-api'. Inside a SpEL indexer the contents are an
# expression, not a literal key, so [resource_access][reports-api][roles] parses as
# `reports` MINUS `api` and blows up with EL1008E. ExpressionJwtGrantedAuthoritiesConverter
# catches ExpressionException, substitutes an empty authority list, and logs the reason at
# TRACE only - so the failure surfaces as a 403 with nothing in the log to explain it.
# See application-propsroles-broken.yaml for the other spelling, and docs/14.
#
# Two more consequences of taking this route:
# * `authority-prefix` is ONE value applied to every expression. A mixed mapping -
# SCOPE_ for scopes, ROLE_ for roles - cannot be expressed here.
# * naming expressions REPLACES the default JwtGrantedAuthoritiesConverter, so the
# SCOPE_* authorities it produced from the `scope` claim disappear unless you add
# `[scope]` as an expression too - and then it gets the ROLE_ prefix as well.
spring:
security:
oauth2:
resourceserver:
jwt:
principal-claim-name: preferred_username
authority-prefix: "ROLE_"
authorities-claim-expressions:
- "[realm_access][roles]"
- "[resource_access]['reports-api'][roles]"

View File

@@ -0,0 +1,14 @@
# Points the resource server at the in-repo stub issuer on :9000.
# Discovery is used, exactly as with Keycloak: Spring reads
# /.well-known/openid-configuration, takes jwks_uri from it, and validates `iss`
# against this value.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9000
# The hardened profile builds the JWKSource itself and therefore cannot discover this.
demo:
jwk-set-uri: http://localhost:9000/jwks.json

View File

@@ -0,0 +1,5 @@
# The stub authorization server itself. Nothing here is a resource server.
server:
port: 9000
stub:
issuer: http://localhost:9000

View File

@@ -0,0 +1,6 @@
# Everything the resource server does to a token, logged. The line worth waiting for is
# the one from BearerTokenAuthenticationFilter naming the validator that refused.
logging:
level:
org.springframework.security: TRACE
org.springframework.web.client: DEBUG

View File

@@ -0,0 +1,4 @@
# Just enough logging to see a claim expression fail, and nothing else.
logging:
level:
org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter: TRACE

View File

@@ -0,0 +1,16 @@
# Shared defaults. The issuer is deliberately NOT set here - it arrives with the
# `stub` or `keycloak` profile, so that the same application code demonstrably runs
# against a toy issuer and against a real one with no source difference at all.
spring:
application:
name: oauth2-resource-server-demo
server:
port: 8081
demo:
audience: reports-api
logging:
level:
org.springframework.security.oauth2: INFO

View File

@@ -0,0 +1,130 @@
package com.ankurm.rsdemo;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTypeValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Pins the behaviour of the default validator stack, because it is the part of a resource
* server that changes underneath you between versions and fails closed when it does.
*
* <p>These tests deliberately assert the <em>defaults</em> rather than this application's
* configuration. If a Spring Security upgrade changes what
* {@code JwtValidators.createDefaultWithIssuer} puts in the stack, this file goes red and
* the post that describes it is wrong.
*
* <p>Explained in <a href="../../../../../../docs/13-validator-stack.md">docs/13</a>.
*/
class JwtValidationContractTests {
private static final String ISSUER = "https://issuer.example.com";
private Jwt.Builder token() {
Instant now = Instant.now();
return Jwt.withTokenValue("token")
.header("alg", "RS256")
.header("typ", "JWT")
.issuer(ISSUER)
.subject("alice")
.audience(List.of("reports-api"))
.issuedAt(now)
.expiresAt(now.plusSeconds(300))
.claim("jti", "id");
}
@Test
void defaultStackAcceptsAWellFormedToken() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
assertThat(validator.validate(token().build()).hasErrors()).isFalse();
}
@Test
void defaultStackDoesNotCheckAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
Jwt wrongAudience = token().audience(List.of("billing-api")).build();
// This is the whole reason the audience check has to be added deliberately.
assertThat(validator.validate(wrongAudience).hasErrors()).isFalse();
}
@Test
void addingJwtAudienceValidatorRefusesTheSameToken() {
OAuth2TokenValidator<Jwt> validator = JwtValidators
.createDefaultWithValidators(new JwtIssuerValidator(ISSUER), new JwtAudienceValidator("reports-api"));
Jwt wrongAudience = token().audience(List.of("billing-api")).build();
OAuth2TokenValidatorResult result = validator.validate(wrongAudience);
assertThat(result.hasErrors()).isTrue();
assertThat(result.getErrors()).anySatisfy((error) -> assertThat(error.getDescription()).contains("aud"));
}
@Test
void defaultStackRefusesRfc9068AccessTokens() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
Jwt atJwt = token().headers((h) -> h.put("typ", "at+jwt")).build();
// JwtTypeValidator.jwt() accepts an absent typ or typ=JWT and nothing else, so the
// media type RFC 9068 defines for access tokens is refused by the default stack.
assertThat(validator.validate(atJwt).hasErrors()).isTrue();
}
@Test
void aPermissiveTypeValidatorAcceptsThem() {
JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt");
types.setAllowEmpty(true);
OAuth2TokenValidator<Jwt> validator = JwtValidators
.createDefaultWithValidators(new JwtIssuerValidator(ISSUER), types);
Jwt atJwt = token().headers((h) -> h.put("typ", "at+jwt")).build();
assertThat(validator.validate(atJwt).hasErrors()).isFalse();
}
@Test
void issuerComparisonIsExactStringEquality() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
// A trailing slash is a different issuer. This is the single most common cause of
// "the token is signed correctly but the iss claim is not valid".
Jwt trailingSlash = token().issuer(ISSUER + "/").build();
assertThat(validator.validate(trailingSlash).hasErrors()).isTrue();
}
@Test
void defaultClockSkewIsSixtySeconds() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
Instant now = Instant.now();
Jwt expired30sAgo = token().issuedAt(now.minusSeconds(60)).expiresAt(now.minusSeconds(30)).build();
Jwt expired90sAgo = token().issuedAt(now.minusSeconds(120)).expiresAt(now.minusSeconds(90)).build();
assertThat(validator.validate(expired30sAgo).hasErrors()).isFalse();
assertThat(validator.validate(expired90sAgo).hasErrors()).isTrue();
}
@Test
void audienceValidatorMatchesAnyEntryNotAllOfThem() {
OAuth2TokenValidator<Jwt> validator = new JwtAudienceValidator("reports-api");
Jwt multipleAudiences = token().audience(List.of("billing-api", "reports-api")).build();
assertThat(validator.validate(multipleAudiences).hasErrors()).isFalse();
}
@Test
void aMissingAudienceClaimIsRefusedNotIgnored() {
OAuth2TokenValidator<Jwt> validator = new JwtAudienceValidator("reports-api");
Jwt noAudience = token().claims((c) -> c.remove("aud")).build();
assertThat(validator.validate(noAudience).hasErrors()).isTrue();
}
@Test
void nestedKeycloakRolesAreInvisibleToTheDefaultAuthoritiesConverter() {
var converter = new org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter();
Jwt keycloakish = token().claim("realm_access", Map.of("roles", List.of("ADMIN"))).build();
// No scope claim, roles one level down: the default converter finds nothing at all.
assertThat(converter.convert(keycloakish)).isEmpty();
}
}