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

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.

  1. Complete a sign-in through the handler

    Recorded as oauth.authorize succeeded (code_issued) for lab-collage about [email protected].

  2. Exchange the code inside the handler

    Recorded as oauth.token succeeded for lab-collage.

  3. Match UserInfo to the ID token

    Recorded as oidc.userinfo succeeded (userinfo_served).

  4. Turn a denial into an outcome

    Recorded as oauth.authorize rejected (access_denied) for lab-collage about [email protected].

  5. See the tenant refuse a code presented twice

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

  6. See the tenant refuse an expired code

    Recorded as oauth.token rejected (code_expired) for lab-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.

  1. 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"
  1. Record attempts by state, each with its own nonce, verifier and expected issuer, instead of one global NONCE:

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; }
  1. Press Start and run btl-lab callback before each sign-in.

Walkthrough

  1. 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, code and error values 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.

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

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

  1. 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_denied prints outcome: access_denied.

Why it matters: a denial is an answer to show, not a crash.

  1. Two tabs. Run begin twice, 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.

  1. A clock seam in your own checks. Your time check reads NOW when 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.

  1. The provider seam, with real inputs. Sign Ben in through begin and callback, 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

  1. Remove the claim-once line. Comment out the jq ... del(.[$s]) line in callback, redefine it, and present a fresh sign-in's callback twice. The second run redeems the code again: the tenant answers invalid_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.

  1. 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 run callback: {"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

  1. Make sure the code lifetime is restored and the claim-once line is back.

  2. Keep callback, begin and pending.json: the next implementation labs use them.

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