1
0
Files
spring-auth-demo/authorization-server/scripts/authcode-pkce.sh
Ankur Mhatre e9381dc5be 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.
2026-08-24 08:20:38 +05:30

257 lines
11 KiB
Bash
Executable File

#!/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"