OPENID CONNECT · LAB
Run a complete OpenID Connect sign-in by hand, one message at a time
Create a pending sign-in, send the authentication request, handle the callback, redeem the code and start your own session, then watch single sign-on and a replayed code.
ReadyUses your lab tenant
The lesson
Builds on: From delegated access to sign-in.
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 authentication request reaches the provider
Recorded as
oauth.authorizesucceeded (request_started) forlab-collage.Ava signs in at the provider
Recorded as
oauth.authorizesucceeded (user_signed_in) forlab-collageabout[email protected].The provider sends a code back to lab-collage
Recorded as
oauth.authorizesucceeded (code_issued) forlab-collageabout[email protected].lab-collage redeems the code for tokens
Recorded as
oauth.tokensucceeded forlab-collageabout[email protected].A second redemption of the same code is refused
Recorded as
oauth.tokenrejected (code_replayed) forlab-collage.
Request console
Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.
Setup
You need
lab-collage, Ava and the shell variables from the first lab in this track. Keepbtl-lab callbackrunning in a second terminal.Close any private windows, so the next one has no tenant session.
Press Start.
Walkthrough
Create the pending sign-in. Generate fresh values and record the attempt as the lesson's printer records
demo-signin-3. The verifier stays in your shell, like a value held privately on the backend.
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
jq -n --arg s "$STATE" --arg n "$NONCE" --arg i "$ISSUER" \
'{state: $s, nonce: $n, expected_issuer: $i, redirect_uri: "http://127.0.0.1:8765/callback", return_to: "/collages/new", status: "pending"}' > pending.json
cat pending.json
Why it matters: compared with an OAuth connection, the only new line in the record is the nonce.
Send the authentication request. Open this request in a new private window (the console opens authorization requests as a browser navigation):
GET$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=openid%20profile%20email&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256
Open in console
GET $ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=openid%20profile%20email&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256 HTTP/1.1It asks for sign-in, the basic profile and the email address, and says nothing about photos.
At the provider. There is no tenant session in the private window, so the tenant asks Ava to sign in. Then the consent page asks whether
lab-collagemay see her profile and email address. Allow.
Why it matters: the provider authenticates Ava and asks for her agreement. The collage app never sees her password or how she signed in.
Back at the relying party. The listener prints
code,stateandiss. Handle the callback as the lesson's printer does, before the code goes anywhere:
CODE='<code>'; CB_STATE='<state>'; CB_ISS='<iss>'
[ "$CB_STATE" = "$(jq -r .state pending.json)" ] && [ "$CB_ISS" = "$(jq -r .expected_issuer pending.json)" ] \
&& [ "$(jq -r .status pending.json)" = pending ] && jq '.status = "claimed"' pending.json > p.tmp && mv p.tmp pending.json && echo claimed
Why it matters: finding the attempt by state, comparing iss and claiming the attempt are pure OAuth, and they happen before any token request.
Exchange the code with client authentication and the verifier:
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$CODE" --data-urlencode "redirect_uri=http://127.0.0.1:8765/callback" --data-urlencode "code_verifier=$VERIFIER")
jq 'del(.access_token, .id_token)' <<<"$RESP"; ID_TOKEN=$(jq -r .id_token <<<"$RESP"); TOKEN=$(jq -r .access_token <<<"$RESP"); FIRST_CODE=$CODE
The response now contains id_token beside the access token. This is where OpenID Connect becomes visible.
Validate before believing anything:
btl-lab verify "$ID_TOKEN" --issuer "$(jq -r .expected_issuer pending.json)" --audience "$CLIENT_ID" --type id --nonce "$(jq -r .nonce pending.json)" --access-token "$TOKEN"
Every check passes and the run ends in ACCEPT. The expected issuer and nonce come from your pending record, never from the token. The Validation section of this track takes each check apart.
Find the account. The lookup key is the pair
($ISSUER, sub). Write it down from the accepted token. If no collage account were linked to it, this would be Ava's first visit.
Start your own session with a new random identifier, never the anonymous attempt's values, and go to the return destination:
jq -n --arg sid "$(openssl rand -hex 16)" --arg iss "$ISSUER" --arg sub '<sub from step 7>' --arg at '<auth_time>' \
'{session_id: $sid, iss: $iss, sub: $sub, auth_time: ($at | tonumber)}' > session.json
echo "redirect to $(jq -r .return_to pending.json)"
Why it matters: until this step Ava was signed in at the provider but not at the collage app. Validation and a session of its own are the two jobs OpenID Connect adds to the relying party.
Single sign-on. In the same private window, repeat steps 1, 2 and 4 to 6 with fresh values. The tenant asks nothing: its session is still valid and consent is remembered, so the callback arrives at once.
Break it
Replay the first code. Send the token request from step 5 again with the code you already redeemed:
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$FIRST_CODE" --data-urlencode "redirect_uri=http://127.0.0.1:8765/callback" --data-urlencode "code_verifier=$VERIFIER" | jq .
Expect 400 with invalid_grant. The tenant records code_replayed and revokes the tokens issued from that code. The verifier no longer matters: a used code is refused first.
Feed the step 4 callback to a new attempt: run step 1 again so
pending.jsonholds a different state, then repeat the check in step 4 with the oldCB_STATE. Nothing printsclaimed, so no token request is made.
Check your work
Press Check my progress. In Audit, under protocol activity, the first sign-in reads in order: oauth.authorize request_started, user_signed_in, code_issued, then oauth.token succeeded, and later oauth.token rejected with code_replayed.
The single sign-on run in step 9 shows request_started then code_issued with no user_signed_in between them. The consent page itself is counted in Logs as oauth.authorize with consent_required.
Cleanup
Delete pending.json, p.tmp and session.json, and run unset TOKEN ID_TOKEN RESP CODE FIRST_CODE.