Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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.

  1. Redeem a code with another attempt's verifier

    Recorded as oauth.token rejected (pkce_failed) for lab-printer.

  2. Redeem the same code with its own verifier

    Recorded as oauth.token succeeded for lab-printer about [email protected].

  3. Redeem a public client's code without a verifier

    Recorded as oauth.token rejected (pkce_failed) for lab-printer-app.

  4. Present a redeemed code a second time

    Recorded as oauth.token rejected (code_replayed) for lab-printer.

  5. Shorten the authorization code lifetime

    Recorded as tenant.oauth.policy.update succeeded.

  6. Redeem a code after it expired

    Recorded as oauth.token rejected (code_expired) for lab-printer.

Setup

  1. Choose Lab Photos as the lab tenant and press Start.

  2. Confirm lab-printer has PKCE for confidential clients Required, and lab-printer-app is Public. Load CLIENT_ID and CLIENT_SECRET for lab-printer and APP_ID for lab-printer-app. Run btl-lab callback in a second terminal before each sign-in.

  3. 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

  1. Play the injection. Open url "$CLIENT_ID" "$C1", sign in as Ava and approve; keep the code as CODE1. Now redeem it as the printer would when finishing a different attempt, with the other verifier: redeem "$CODE1" "$V2". Returns invalid_grant, Audit reason pkce_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.

  1. 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")"
  1. Redeem CODE1 with 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.

  1. Play the interception. Open url "$APP_ID" "$C2", approve, and keep CODE3. Redeem it as an app that caught the response but never had the verifier: AS_APP=1 redeem "$CODE3". Returns invalid_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.

  1. Present a code twice. Get a fresh code for lab-printer with url "$CLIENT_ID" "$C1" and redeem it with V1, keeping the access token from the full response. Introspect it as lab-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.

  1. Shorten the code lifetime. In Flow policy, set the authorization code lifetime to 60 seconds 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.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab