Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server
Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.
Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:
- OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
in 7.0, and both configuration classes moved into spring-security-config
- ClientSettings.requireProofKey flipped from false to true, on the authorization server
(1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
- requireProofKey(false) does not make PKCE optional for a public client; the code
verifier is that client's only authentication at the token endpoint
- MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called
Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
This commit is contained in:
256
authorization-server/scripts/authcode-pkce.sh
Executable file
256
authorization-server/scripts/authcode-pkce.sh
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
# The full authorization code flow with PKCE, driven entirely by curl so that every
|
||||
# redirect, form and parameter is visible. A browser hides all of this.
|
||||
#
|
||||
# ./scripts/authcode-pkce.sh [output-name] [client]
|
||||
#
|
||||
# client defaults to demo-spa (public, PKCE required). Pass demo-web for the confidential
|
||||
# client with a secret.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
|
||||
NAME="${1:-as-authcode-pkce}"
|
||||
CLIENT="${2:-demo-spa}"
|
||||
# NO_CHALLENGE=1 sends an authorization request with no code_challenge at all. That is a
|
||||
# different thing from sending one and then omitting the verifier: the server only demands
|
||||
# a verifier if the authorization request carried a challenge, OR if the client is
|
||||
# registered with requireProofKey(true).
|
||||
NO_CHALLENGE="${NO_CHALLENGE:-0}"
|
||||
OUT="../docs/output/${NAME}.txt"
|
||||
mkdir -p ../docs/output
|
||||
|
||||
if [ "$CLIENT" = "demo-web" ]; then
|
||||
REDIRECT="http://127.0.0.1:8080/login/oauth2/code/demo-web"
|
||||
SCOPE="openid orders.read orders.write"
|
||||
else
|
||||
REDIRECT="http://127.0.0.1:8080/authorized"
|
||||
SCOPE="openid orders.read"
|
||||
fi
|
||||
|
||||
JAR=$(mktemp)
|
||||
trap 'rm -f "$JAR" /tmp/as-page.html' EXIT
|
||||
|
||||
# --- PKCE parameters. RFC 7636: verifier is 43-128 chars of unreserved characters,
|
||||
# --- challenge is BASE64URL(SHA256(verifier)) with the padding stripped.
|
||||
read -r VERIFIER CHALLENGE <<<"$(python3 - <<'PY'
|
||||
import base64, hashlib, secrets
|
||||
v = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip('=')
|
||||
c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).decode().rstrip('=')
|
||||
print(v, c)
|
||||
PY
|
||||
)"
|
||||
|
||||
{
|
||||
section "PKCE parameters (RFC 7636)"
|
||||
echo "code_verifier ${VERIFIER} (${#VERIFIER} chars)"
|
||||
echo "code_challenge ${CHALLENGE}"
|
||||
echo "code_challenge_method S256"
|
||||
echo
|
||||
echo "The verifier never leaves the client until the token request. The challenge is"
|
||||
echo "all the authorization request carries, and it is a one-way hash of the verifier."
|
||||
|
||||
section "1. Log in to the authorization server (browser session)"
|
||||
# The login page carries a CSRF token; the form chain has CSRF enabled, as it should.
|
||||
curl -s -c "$JAR" "$AS/login" -o /tmp/as-page.html
|
||||
CSRF=$(form_value /tmp/as-page.html _csrf)
|
||||
echo "\$ curl -c jar -d username=alice -d password=password -d _csrf=<token> $AS/login"
|
||||
curl -s -i -b "$JAR" -c "$JAR" \
|
||||
-d "username=alice" -d "password=password" -d "_csrf=$CSRF" \
|
||||
"$AS/login" | sed -n '1p;/^[Ll]ocation:/p'
|
||||
|
||||
section "2. GET /oauth2/authorize (client=$CLIENT)"
|
||||
PKCE_PARAMS="&code_challenge=$CHALLENGE&code_challenge_method=S256"
|
||||
if [ "$NO_CHALLENGE" = "1" ]; then
|
||||
PKCE_PARAMS=""
|
||||
echo "NO_CHALLENGE=1: the authorization request carries no code_challenge."
|
||||
echo
|
||||
fi
|
||||
AUTHZ="$AS/oauth2/authorize?response_type=code&client_id=$CLIENT&redirect_uri=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$REDIRECT")&scope=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$SCOPE")&state=xyz123${PKCE_PARAMS}"
|
||||
echo "\$ curl -b jar '$AUTHZ'"
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
echo "-> 302 $LOC"
|
||||
|
||||
if [ -z "$LOC" ]; then
|
||||
echo "no redirect - the authorization endpoint rendered a page instead:"
|
||||
curl -s -b "$JAR" "$AUTHZ" | head -30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$LOC" in
|
||||
*/oauth2/consent*)
|
||||
section "3. The consent page"
|
||||
echo "The authorization endpoint redirected to OUR page, at the path given to"
|
||||
echo ".consentPage(\"/oauth2/consent\"). Note the query string it hands over:"
|
||||
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
|
||||
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
|
||||
echo
|
||||
echo "Scopes rendered as checkboxes (openid deliberately not among them):"
|
||||
form_values /tmp/as-page.html scope | sed 's/^/ /'
|
||||
CSRF=$(form_value /tmp/as-page.html _csrf)
|
||||
STATE=$(form_value /tmp/as-page.html state)
|
||||
echo
|
||||
echo "The hidden state the form must echo back: $STATE"
|
||||
echo "(this is NOT the client's state=xyz123 - it is the server's own correlation"
|
||||
echo " handle for the pending authorization request, and sending the client's value"
|
||||
echo " instead is what produces the consent redirect loop)"
|
||||
|
||||
section "4. POST the approval to /oauth2/authorize"
|
||||
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
|
||||
for s in $(form_values /tmp/as-page.html scope); do
|
||||
ARGS+=(-d "scope=$s")
|
||||
done
|
||||
echo "\$ curl -b jar -X POST ${ARGS[*]} $AS/oauth2/authorize"
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
|
||||
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
echo "-> 302 $LOC"
|
||||
;;
|
||||
*)
|
||||
section "3. No consent page"
|
||||
echo "The authorization endpoint went straight back to the client. Either consent is"
|
||||
echo "off for this client, or every requested scope was already approved."
|
||||
;;
|
||||
esac
|
||||
|
||||
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
|
||||
RETURNED_STATE=$(echo "$LOC" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')
|
||||
section "5. The authorization code"
|
||||
echo "code = $CODE"
|
||||
echo "state = $RETURNED_STATE (the client's own value, returned untouched - compare it)"
|
||||
if [ -z "$CODE" ]; then
|
||||
echo "no code in the redirect. The error was:"
|
||||
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
section "6a. Exchange the code WITHOUT the verifier"
|
||||
echo "This is the request an attacker who stole the code can make."
|
||||
AUTH_ARGS=()
|
||||
[ "$CLIENT" = "demo-web" ] && AUTH_ARGS=(-u demo-web:web-secret)
|
||||
NOVERIFIER=$(curl -s -w '\n<<HTTP %{http_code}>>' "${AUTH_ARGS[@]}" \
|
||||
-d grant_type=authorization_code -d "code=$CODE" \
|
||||
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
|
||||
"$AS/oauth2/token")
|
||||
echo "$NOVERIFIER" | sed -n 's/^<<HTTP \(.*\)>>$/HTTP \1/p'
|
||||
BODY=${NOVERIFIER%%$'\n'<<HTTP*}
|
||||
if [ -n "$BODY" ]; then
|
||||
echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
|
||||
else
|
||||
echo "(empty response body)"
|
||||
fi
|
||||
echo
|
||||
case "$NOVERIFIER" in
|
||||
*access_token*)
|
||||
echo ">>> A TOKEN WAS ISSUED. The code alone was sufficient. This is what"
|
||||
echo ">>> requireProofKey(false) on a public client means in practice."
|
||||
;;
|
||||
*)
|
||||
echo ">>> Rejected. invalid_grant is deliberately vague: the server will not tell"
|
||||
echo ">>> a caller whether the code was wrong, expired, already used, or missing a"
|
||||
echo ">>> verifier, because each of those is information an attacker can use."
|
||||
;;
|
||||
esac
|
||||
echo "Note: this consumed the code. Authorization codes are single-use, so the"
|
||||
echo "successful exchange below needs a fresh one."
|
||||
|
||||
section "6b. A fresh code, exchanged properly"
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
case "$LOC" in
|
||||
*/oauth2/consent*)
|
||||
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
|
||||
CSRF=$(form_value /tmp/as-page.html _csrf)
|
||||
STATE=$(form_value /tmp/as-page.html state)
|
||||
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
|
||||
for s in $(form_values /tmp/as-page.html scope); do
|
||||
ARGS+=(-d "scope=$s")
|
||||
done
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
|
||||
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
;;
|
||||
esac
|
||||
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
|
||||
echo "fresh code = $CODE"
|
||||
echo
|
||||
if [ "$NO_CHALLENGE" = "1" ]; then
|
||||
echo "\$ curl -d grant_type=authorization_code -d code=... $AS/oauth2/token"
|
||||
echo " (no code_verifier - there was no challenge to verify against)"
|
||||
else
|
||||
echo "\$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... $AS/oauth2/token"
|
||||
fi
|
||||
VERIFIER_ARG=(-d "code_verifier=$VERIFIER")
|
||||
[ "$NO_CHALLENGE" = "1" ] && VERIFIER_ARG=()
|
||||
HTTPCODE=$(curl -s -o /tmp/as-tok.json -w '%{http_code}' "${AUTH_ARGS[@]}" \
|
||||
-d grant_type=authorization_code -d "code=$CODE" \
|
||||
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
|
||||
"${VERIFIER_ARG[@]}" \
|
||||
"$AS/oauth2/token")
|
||||
RESP=$(cat /tmp/as-tok.json)
|
||||
echo "HTTP $HTTPCODE"
|
||||
if [ -s /tmp/as-tok.json ]; then
|
||||
python3 -m json.tool < /tmp/as-tok.json 2>/dev/null || cat /tmp/as-tok.json
|
||||
else
|
||||
echo "(empty response body)"
|
||||
fi
|
||||
if [ "$HTTPCODE" != "200" ] && [ "$NO_CHALLENGE" = "1" ]; then
|
||||
echo
|
||||
echo ">>> No token, even though the client is registered with requireProofKey(false)"
|
||||
echo ">>> and the authorization request carried no challenge. The reason is that a"
|
||||
echo ">>> public client has no other way to authenticate at the token endpoint:"
|
||||
echo ">>> PublicClientAuthenticationProvider delegates entirely to"
|
||||
echo ">>> CodeVerifierAuthenticator, and raises invalid_client when there is nothing"
|
||||
echo ">>> to verify. requireProofKey(false) relaxes the AUTHORIZATION endpoint only."
|
||||
fi
|
||||
|
||||
read_claim() { python3 -c 'import sys,json
|
||||
try: print(json.load(sys.stdin).get(sys.argv[1],""))
|
||||
except Exception: print("")' "$1" < /tmp/as-tok.json; }
|
||||
AT=$(read_claim access_token)
|
||||
IDT=$(read_claim id_token)
|
||||
RT=$(read_claim refresh_token)
|
||||
|
||||
if [ -n "$AT" ]; then
|
||||
section "7. The access token"
|
||||
jwt_header "$AT"
|
||||
jwt_payload "$AT"
|
||||
fi
|
||||
if [ -n "$IDT" ]; then
|
||||
section "8. The id_token - a different token, for a different audience"
|
||||
jwt_payload "$IDT"
|
||||
echo
|
||||
echo "aud is the CLIENT here, not the API. Sending this to a resource server is the"
|
||||
echo "classic mix-up: it verifies (same issuer, same key) and then fails the audience"
|
||||
echo "check, or worse, passes it if nobody checks audience."
|
||||
fi
|
||||
|
||||
if [ -n "$AT" ]; then
|
||||
section "9. Calling the resource server"
|
||||
for path in /api/orders /api/admin; do
|
||||
CODE_HTTP=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $AT" "$RS$path")
|
||||
echo "GET $path -> $CODE_HTTP"
|
||||
head -c 500 /tmp/rsbody; echo
|
||||
done
|
||||
|
||||
section "10. Sending the id_token instead"
|
||||
if [ -n "$IDT" ]; then
|
||||
curl -s -i -H "Authorization: Bearer $IDT" "$RS/api/orders" \
|
||||
| sed -n '1p;/^WWW-Authenticate/p'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$RT" ]; then
|
||||
section "11. Refresh, with rotation"
|
||||
echo "old refresh token: ${RT:0:24}..."
|
||||
R2=$(curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
|
||||
-d "client_id=$CLIENT" "$AS/oauth2/token")
|
||||
NEW=$(echo "$R2" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("refresh_token",""))')
|
||||
echo "new refresh token: ${NEW:0:24}..."
|
||||
[ "$RT" = "$NEW" ] && echo "SAME - reuseRefreshTokens(true)" || echo "DIFFERENT - reuseRefreshTokens(false), the old one is now dead"
|
||||
echo
|
||||
echo "Replaying the old one:"
|
||||
curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
|
||||
-d "client_id=$CLIENT" "$AS/oauth2/token"
|
||||
echo
|
||||
fi
|
||||
} > "$OUT" 2>&1
|
||||
sed -i 's/[[:space:]]*$//' "$OUT"
|
||||
echo "wrote $OUT"; tail -5 "$OUT"
|
||||
Reference in New Issue
Block a user