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:
34
oauth2-resource-server/docker/compose.yaml
Normal file
34
oauth2-resource-server/docker/compose.yaml
Normal 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
|
||||
129
oauth2-resource-server/docker/realm-demo.json
Normal file
129
oauth2-resource-server/docker/realm-demo.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
74
oauth2-resource-server/pom.xml
Normal file
74
oauth2-resource-server/pom.xml
Normal 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>
|
||||
58
oauth2-resource-server/scripts/amplification-demo.sh
Executable file
58
oauth2-resource-server/scripts/amplification-demo.sh
Executable 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"
|
||||
20
oauth2-resource-server/scripts/converter-demo.sh
Executable file
20
oauth2-resource-server/scripts/converter-demo.sh
Executable 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"
|
||||
9
oauth2-resource-server/scripts/decoder-chain.sh
Executable file
9
oauth2-resource-server/scripts/decoder-chain.sh
Executable 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
|
||||
51
oauth2-resource-server/scripts/issuer-audience-demo.sh
Executable file
51
oauth2-resource-server/scripts/issuer-audience-demo.sh
Executable 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"
|
||||
35
oauth2-resource-server/scripts/keycloak-demo.sh
Executable file
35
oauth2-resource-server/scripts/keycloak-demo.sh
Executable 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"
|
||||
50
oauth2-resource-server/scripts/lib.sh
Executable file
50
oauth2-resource-server/scripts/lib.sh
Executable 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
|
||||
}
|
||||
56
oauth2-resource-server/scripts/retired-key-demo.sh
Executable file
56
oauth2-resource-server/scripts/retired-key-demo.sh
Executable 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."
|
||||
58
oauth2-resource-server/scripts/rotation-demo.sh
Executable file
58
oauth2-resource-server/scripts/rotation-demo.sh
Executable 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."
|
||||
100
oauth2-resource-server/scripts/run-all.sh
Executable file
100
oauth2-resource-server/scripts/run-all.sh
Executable 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."
|
||||
16
oauth2-resource-server/scripts/run-rs.sh
Executable file
16
oauth2-resource-server/scripts/run-rs.sh
Executable 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
|
||||
14
oauth2-resource-server/scripts/run-stub-issuer.sh
Executable file
14
oauth2-resource-server/scripts/run-stub-issuer.sh
Executable 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
|
||||
@@ -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 — 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);
|
||||
}
|
||||
}
|
||||
@@ -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<>(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’s own
|
||||
* five-minute cache <em>off</em>, leaving your cache’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’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’s {@code JwtDecoderConfiguration} collects every {@code OAuth2TokenValidator<Jwt>}
|
||||
* 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’s own
|
||||
* {@code DefaultResourceRetriever}, not Spring’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’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;
|
||||
}
|
||||
}
|
||||
@@ -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’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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 — 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’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;
|
||||
}
|
||||
}
|
||||
@@ -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> — the new key appears in the JWK Set, nothing signs with it yet</li>
|
||||
* <li><b>activate</b> — the issuer starts signing with the new key</li>
|
||||
* <li><b>retire</b> — 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);
|
||||
}
|
||||
}
|
||||
@@ -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’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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]"
|
||||
@@ -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]"
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# The stub authorization server itself. Nothing here is a resource server.
|
||||
server:
|
||||
port: 9000
|
||||
stub:
|
||||
issuer: http://localhost:9000
|
||||
@@ -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
|
||||
@@ -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
|
||||
16
oauth2-resource-server/src/main/resources/application.yaml
Normal file
16
oauth2-resource-server/src/main/resources/application.yaml
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user