#!/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= $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<>' "${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 \1/p' BODY=${NOVERIFIER%%$'\n'</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"