OPENID CONNECT · LAB
Deliver a code to the wrong attempt and see PKCE, then the nonce, catch it
Hand a real code from Ava's attempt to a second attempt of your own, watch the token endpoint refuse it, then remove PKCE briefly and let the nonce check refuse the token.
ReadyUses your lab tenant
The lesson
Builds on: The authentication request.
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.
The token endpoint refuses Ava's code redeemed for another attempt
Recorded as
oauth.tokenrejected (pkce_failed) forlab-collageabout[email protected].Make PKCE optional on lab-collage for one step
Recorded as
tenant.oauth.clients.updatesucceeded.Redeem the misdelivered code without PKCE
Recorded as
oauth.tokensucceeded forlab-collageabout[email protected].Require PKCE on lab-collage again
Recorded as
tenant.oauth.clients.updatesucceeded.
Setup
You need
lab-collage, Ava, Ben and thesignin_urlandexchangehelpers from the authentication request lab.lab-collagerequires PKCE.Keep
btl-lab callbackrunning in a second terminal. It exits after each callback, so start it again before each attempt.Press Start.
Note: both attempts in this lab are yours. A code issued for one of your attempts is delivered to another of your attempts, the situation the lesson describes, so you can see which check catches it. No token is altered.
Walkthrough
Attempt A, Ava's. In a normal window run
signin_url 'openid'and keep its values. Sign in as Ava and note the code from the listener, but do not redeem it.
A_STATE=$STATE; A_NONCE=$NONCE; A_VERIFIER=$VERIFIER
A_CODE='<code from the listener>'
Attempt B, a different browser session. In a private window run
signin_url 'openid'again and keep its values. Sign in as Ben. Ignore this callback.
B_STATE=$STATE; B_NONCE=$NONCE; B_VERIFIER=$VERIFIER
The misdelivered code. Ava's code arrives at the callback together with attempt B's
state. Your state check passes, because attempt B is a real pending attempt in that browser. Redeem the code with attempt B's verifier, as your backend would:
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$A_CODE" --data-urlencode "redirect_uri=http://127.0.0.1:8765/callback" --data-urlencode "code_verifier=$B_VERIFIER" | jq .
The tenant answers 400 with invalid_grant.
Why it matters: PKCE caught it at the token endpoint. Attempt B's verifier does not match the challenge in Ava's request, so the provider issues nothing.
Take PKCE away for one step. In OAuth > Clients, set
lab-collagePKCE to optional. Changing this setting ends the client's existing tokens and codes.
Repeat steps 1 and 2 with fresh attempts that send no challenge. Build each request without the PKCE parameters:
eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=openid&state=$STATE&nonce=$NONCE"
Run it once as Ava in the normal window (keep A_NONCE=$NONCE and the code as A_CODE), then once as Ben in the private window (keep B_NONCE=$NONCE). Redeem Ava's code for attempt B, now with no verifier at all:
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$A_CODE" --data-urlencode "redirect_uri=http://127.0.0.1:8765/callback")
ID_TOKEN=$(jq -r .id_token <<<"$RESP"); btl-lab decode "$ID_TOKEN"
Tokens are issued. The ID token names Ava's sub and carries attempt A's nonce.
Now the relying party does what the lesson asks. Validate the token against the attempt it arrived for:
btl-lab verify "$ID_TOKEN" --issuer "$ISSUER" --audience "$CLIENT_ID" --type id --nonce "$B_NONCE"
btl-lab verify "$ID_TOKEN" --issuer "$ISSUER" --audience "$CLIENT_ID" --type id --nonce "$A_NONCE"
The first run passes the signature, issuer and audience checks and stops at the nonce check. The second shows that the token itself is genuine: it simply belongs to attempt A.
Why it matters: the nonce, chosen for one attempt and copied into a signed token, exposes the misdelivered code when PKCE is absent. Attempt B signs no one in, and the access token from this response is not used either.
Restore PKCE now.
Restore: in OAuth > Clients, set lab-collage PKCE back to required. Confirm that a request without code_challenge is now refused at the authorization endpoint with error=pkce_required.
Why it matters: the two checks happen in different places, by different parties. With both in place, a mistake in one still leaves the sign-in protected.
Break it
A request without a nonce. Run
signin_url 'openid', delete&nonce=...from the printed URL, sign in and redeem. The tenant accepts the request, because the nonce is optional in the code flow, and the ID token has nononceclaim. Your rule: if your request sent a nonce and the token has none, that is a failure. Runbtl-lab verifywith your--nonce "$NONCE"and see it stop at the nonce check.
One use only. Validate a fresh, accepted token, then mark its attempt claimed as in the complete sign-in lab. A second token presented for the same attempt finds nothing pending, so its nonce has nothing to match.
Check your work
Press Check my progress. The checks look for, in order: oauth.token rejected with pkce_failed for lab-collage, a tenant.oauth.clients.update, oauth.token succeeded for the code redeemed without PKCE, and a second tenant.oauth.clients.update.
Your nonce refusals in step 6 and Break it appear nowhere at the tenant: the provider cannot see a relying party's validation. Check in OAuth > Clients that lab-collage requires PKCE.
Cleanup
Confirm
lab-collagePKCE is required.Run
unset A_STATE A_NONCE A_VERIFIER A_CODE B_STATE B_NONCE B_VERIFIER RESP ID_TOKEN.