OAUTH 2.0 · LAB
See PKCE, single use and short lifetimes refuse a misplaced code
Redeem a code with another attempt's verifier, redeem a public client's code without one, present a code twice and let one expire, and read each real refusal.
ReadyUses your lab tenant
The lesson
Builds on: Public and confidential clients.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Redeem a code with another attempt's verifier
Recorded as
oauth.tokenrejected (pkce_failed) forlab-printer.Redeem the same code with its own verifier
Recorded as
oauth.tokensucceeded forlab-printerabout[email protected].Redeem a public client's code without a verifier
Recorded as
oauth.tokenrejected (pkce_failed) forlab-printer-app.Present a redeemed code a second time
Recorded as
oauth.tokenrejected (code_replayed) forlab-printer.Shorten the authorization code lifetime
Recorded as
tenant.oauth.policy.updatesucceeded.Redeem a code after it expired
Recorded as
oauth.tokenrejected (code_expired) forlab-printer.
Setup
Choose Lab Photos as the lab tenant and press Start.
Confirm
lab-printerhas PKCE for confidential clients Required, andlab-printer-appis Public. LoadCLIENT_IDandCLIENT_SECRETforlab-printerandAPP_IDforlab-printer-app. Runbtl-lab callbackin a second terminal before each sign-in.Prepare two attempts' values and keep them apart:
eval "$(btl-lab pkce)"; V1=$VERIFIER; C1=$CHALLENGE
eval "$(btl-lab pkce)"; V2=$VERIFIER; C2=$CHALLENGE
url() { # $1 = client ID, $2 = challenge
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$1&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=photos.read&state=$(openssl rand -hex 8)&code_challenge=$2&code_challenge_method=S256"
}
redeem() { # $1 = code, $2 = verifier (optional); uses lab-printer unless AS_APP=1
if [ -n "$AS_APP" ]; then auth=(-d "client_id=$APP_ID"); else auth=(-u "$CLIENT_ID:$CLIENT_SECRET"); fi
curl -s "${auth[@]}" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$1" \
--data-urlencode "redirect_uri=http://127.0.0.1:8765/callback" ${2:+-d "code_verifier=$2"} | jq -c '{error, error_description, scope}'
}
Walkthrough
Play the injection. Open
url "$CLIENT_ID" "$C1", sign in as Ava and approve; keep the code asCODE1. Now redeem it as the printer would when finishing a different attempt, with the other verifier:redeem "$CODE1" "$V2". Returnsinvalid_grant, Audit reasonpkce_failed.
Why it matters: the printer is correctly authenticated and the code was issued to it, so client authentication cannot catch this. The code belongs to one attempt and the verifier to another, and only PKCE notices.
See the comparison the server made:
s256() { printf %s "$1" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='; }
echo "stored with CODE1: $C1"; echo "S256 of V2: $(s256 "$V2")"; echo "S256 of V1: $(s256 "$V1")"
Redeem
CODE1with its own verifier:redeem "$CODE1" "$V1". It succeeds. A failed PKCE check does not consume the code.
Why it matters: the binding is between the code and the attempt that started it, and nothing else.
Play the interception. Open
url "$APP_ID" "$C2", approve, and keepCODE3. Redeem it as an app that caught the response but never had the verifier:AS_APP=1 redeem "$CODE3". Returnsinvalid_grant,pkce_failed.
Why it matters: a public client needs only its client ID at the token endpoint, so an app that intercepted the code could otherwise redeem it at once. The verifier never travelled through the redirect.
Present a code twice. Get a fresh code for
lab-printerwithurl "$CLIENT_ID" "$C1"and redeem it withV1, keeping the access token from the full response. Introspect it aslab-printer: active. Redeem the same code again:invalid_grant, saying the tokens issued from it were revoked. Introspect the access token again:{"active": false}.
Why it matters: the server cannot tell which presentation was legitimate, only that a copy exists outside the client, so it ends what the code produced. Whoever was slower comes away with nothing that lasts.
Shorten the code lifetime. In Flow policy, set the authorization code lifetime to
60seconds and save. Get a code, wait 90 seconds, then redeem it with its verifier:invalid_grant,code_expired.
Why it matters: a code copied from a load balancer log is worth little if it expires within a minute and the client redeems it within seconds.
Restore: set the authorization code lifetime in Flow policy back to 300 seconds and save.
Break it
Steps 1, 4, 5 and 6 are the failure cases. Each uses ordinary wrong inputs: a verifier from another attempt, a missing verifier, a second presentation and a late one. The only setting changed is the code lifetime, restored in step 6.
Check your work
Press Check my progress. The checks look for, in order: pkce_failed for lab-printer, the matched redemption, pkce_failed for lab-printer-app, code_replayed, the tenant.oauth.policy.update that shortened the code lifetime, and code_expired.
Audit also shows a second tenant.oauth.policy.update for the restore.
Cleanup
Confirm the authorization code lifetime in Flow policy is 300 seconds. Run unset V1 V2 C1 C2 CODE1 CODE3 AS_APP.