OPENID CONNECT · LAB
Build a callback handler that fails closed
Replace copy-paste sign-ins with a callback handler that claims the pending attempt first, checks iss, treats errors as outcomes, validates, matches UserInfo and finds the account by issuer and subject.
ReadyUses your lab tenant
The lesson
Builds on: Connecting a sign-in to an account.
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.
Complete a sign-in through the handler
Recorded as
oauth.authorizesucceeded (code_issued) forlab-collageabout[email protected].Exchange the code inside the handler
Recorded as
oauth.tokensucceeded forlab-collage.Match UserInfo to the ID token
Recorded as
oidc.userinfosucceeded (userinfo_served).Turn a denial into an outcome
Recorded as
oauth.authorizerejected (access_denied) forlab-collageabout[email protected].See the tenant refuse a code presented twice
Recorded as
oauth.tokenrejected (code_replayed) forlab-collage.See the tenant refuse an expired code
Recorded as
oauth.tokenrejected (code_expired) forlab-collage.
Setup
Every stage runs against the real tenant. Your terminal is the relying party, and btl-lab verify plays the maintained library: it validates the ID token and names the check that failed.
Keep the relying party's decisions, and only decisions, in one place. Endpoints and keys come from discovery; the secret stays in
CLIENT_SECRET.
source ~/btl-oidc.sh
jq -n --arg iss "$ISSUER" --arg id "$CLIENT_ID" '{issuer: $iss, client_id: $id, client_auth: "client_secret_basic",
redirect_uri: "http://127.0.0.1:8765/callback", scope: "openid profile email", id_token_signing_algorithms: ["RS256"]}' > config.json
[ "$(curl -s "$ISSUER/.well-known/openid-configuration" | jq -r .issuer)" = "$(jq -r .issuer config.json)" ] && echo "discovery issuer ok" || echo "FAIL discovery issuer_mismatch"
Record attempts by
state, each with its own nonce, verifier and expected issuer, instead of one globalNONCE:
echo '{}' > pending.json
begin() { signin "$@"; jq --arg s "$STATE" --arg n "$NONCE" --arg v "$VERIFIER" --arg i "$ISSUER" \
'. + {($s): {nonce: $n, verifier: $v, issuer: $i}}' pending.json > p.tmp && mv p.tmp pending.json; }
Press Start and run
btl-lab callbackbefore each sign-in.
Walkthrough
Write the handler in the lesson's order. Each line either continues or prints a failure naming its stage and check, then stops. It takes the
state,iss,codeanderrorvalues the listener printed.
fail() { echo "{\"stage\":\"$1\",\"check\":\"$2\",\"issuer\":\"$ISSUER\"}"; }
callback() {
local state=$1 iss=$2 code=$3 error=$4 attempt sub ui
attempt=$(jq -c --arg s "$state" '.[$s] // empty' pending.json)
[ -n "$attempt" ] || { fail callback no_pending_attempt; return 1; }
jq --arg s "$state" 'del(.[$s])' pending.json > p.tmp && mv p.tmp pending.json
[ "$iss" = "$(jq -r .issuer <<<"$attempt")" ] || { fail callback issuer_mismatch; return 1; }
[ -z "$error" ] || { echo "outcome: $error"; return 0; }
VERIFIER=$(jq -r .verifier <<<"$attempt"); redeem "$code" > /dev/null
[ -n "$ID_TOKEN" ] || { fail token_exchange "$(jq -r .error <<<"$RESP")"; return 1; }
btl-lab verify "$ID_TOKEN" --issuer "$ISSUER" --audience "$CLIENT_ID" --type id --nonce "$(jq -r .nonce <<<"$attempt")" > /dev/null \
|| { fail id_token_validation see_verify_output; return 1; }
sub=$(part "$ID_TOKEN" | jq -r .sub)
ui=$(curl -s -H "Authorization: Bearer $TOKEN" "$ISSUER/oidc/userinfo")
[ "$(jq -r .sub <<<"$ui")" = "$sub" ] || { fail userinfo subject_mismatch; return 1; }
sqlite3 accounts.db "SELECT account_id FROM sign_in_identities WHERE issuer='$ISSUER' AND subject='$sub';"
}
Why it matters: written in the order the work happens, a skipped check is visible, and no branch carries on with claims that failed.
The happy path. Run
begin, sign in as Ava, then hand the listener's values to the handler:
begin
callback '<state>' '<iss>' '<code>'
It prints acct-8812.
Why it matters: the handler now does everything the first lab did by hand, in a fixed order, from a pending attempt it found by state.
Replay the same callback from step 2:
{"stage":"callback","check":"no_pending_attempt",...}, before anything reaches the tenant.
Why it matters: claiming the attempt first means a callback from browser history finds nothing to finish.
Deny at the consent page. Run
begin prompt=consent, choose Deny, then pass the listener's values with the error as the fourth argument:callback '<state>' '<iss>' '' access_deniedprintsoutcome: access_denied.
Why it matters: a denial is an answer to show, not a crash.
Two tabs. Run
begintwice, open both URLs in two tabs, and finish the second tab first, then the first. Both succeed.
Why it matters: keeping the nonce with its attempt lets sign-ins run side by side. A single global nonce would fail the first tab.
A clock seam in your own checks. Your time check reads
NOWwhen a test sets it:
times_ok() { local now=${NOW:-$(date +%s)}; part "$1" | jq -e --argjson now "$now" '.exp > ($now - 30) and .iat <= ($now + 30)' > /dev/null && echo ok || echo "FAIL id_token_validation exp_or_iat"; }
times_ok "$ID_TOKEN"
NOW=$(( $(date +%s) + 3600 )) times_ok "$ID_TOKEN"
ok, then FAIL id_token_validation exp_or_iat.
Why it matters: tests can move the clock without waiting, and can assert which check failed, not merely that something did.
The provider seam, with real inputs. Sign Ben in through
beginandcallback, keep his access token (BEN_TOKEN=$TOKEN), then run the UserInfo stage with Ben's token beside Ava's ID token:
[ "$(curl -s -H "Authorization: Bearer $BEN_TOKEN" "$ISSUER/oidc/userinfo" | jq -r .sub)" = "$(part "$ID_TOKEN_AVA" | jq -r .sub)" ] || fail userinfo subject_mismatch
Why it matters: UserInfo about a different sub must be discarded, and this test proves the comparison is switched on.
Break it
Remove the claim-once line. Comment out the
jq ... del(.[$s])line incallback, redefine it, and present a fresh sign-in's callback twice. The second run redeems the code again: the tenant answersinvalid_grant, and the tokens from the first redemption are revoked with it.
Restore: put the line back and redefine callback. The replay then stops at no_pending_attempt before reaching the tenant.
An expired code. In OAuth > Flow policy, note the authorization code lifetime and set it to 60 seconds. Run
begin, sign in, wait 70 seconds, then runcallback:{"stage":"token_exchange","check":"invalid_grant",...}.
Restore: set the authorization code lifetime back to the value you noted.
Check your work
Press Check my progress. The checks look for a sign-in completed through the handler, its exchange and UserInfo call, the denial, the code presented twice, and the expired code.
Your handler printed a stage and check for every failure above, and no line contains a code or token.
Cleanup
Make sure the code lifetime is restored and the claim-once line is back.
Keep
callback,beginandpending.json: the next implementation labs use them.