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

Run one relying party against two providers and stop a mix-up at the callback

Sign the collage app in with Lab Photos and Lab Mail from fully separate configurations, refuse a mixed-up response before the code goes anywhere, and key accounts by issuer and subject.

Partly readyIncludes a simulationUses both lab tenants

The lesson

Builds on: Provider discovery.

New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.

Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.

Needs a second tenant. This lab also uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so you may not be able to do the Lab Mail steps yet (gap G66).

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. Sign Ava in through the Lab Photos entry

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

  2. Redeem the Lab Photos code with that provider's credential

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

  3. Lab Photos issues a code that your handler never redeems

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

Setup

  1. You need lab-collage and Ava in Lab Photos, lab-mail-collage and Ava in Lab Mail (with ISSUER2, MAIL_CLIENT_ID, MAIL_CLIENT_SECRET), and rp_discover from the provider discovery lab. lab-collage returns to the loopback listener and lab-mail-collage returns to the hosted callback page, so each provider has its own return address.

  2. Write one configuration entry per provider. Each is loaded from its own discovery document and has its own client, credential, return address and key cache:

use_provider() { case $1 in
  photos) rp_discover "$ISSUER"  && export P_CLIENT=$CLIENT_ID P_SECRET_VAR=CLIENT_SECRET P_RETURN='http://127.0.0.1:8765/callback' P_CACHE="$HOME/jwks-lab-photos.json" ;;
  mail)   rp_discover "$ISSUER2" && export P_CLIENT=$MAIL_CLIENT_ID   P_SECRET_VAR=MAIL_CLIENT_SECRET   P_RETURN='https://beyondthelogin.dev/lab/callback/' P_CACHE="$HOME/jwks-lab-mail.json" ;;
  *) echo "unknown provider"; return 1 ;; esac; }
  1. Write the pending attempt and the callback handler. The attempt records the provider, its expected issuer and its return address. The handler follows the lesson's fixed order and makes no token request until both callback checks pass:

start_attempt() { use_provider "$1" || return 1; eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
  jq -n --arg p "$1" --arg s "$STATE" --arg n "$NONCE" --arg v "$VERIFIER" --arg i "$EXPECTED_ISSUER" --arg r "$P_RETURN" \
    '{provider: $p, state: $s, nonce: $n, verifier: $v, expected_issuer: $i, return_address: $r, status: "pending"}' > "$HOME/attempt-$STATE.json"
  echo "$AUTHORIZATION_ENDPOINT?response_type=code&client_id=$P_CLIENT&redirect_uri=$(jq -rn --arg r "$P_RETURN" '$r|@uri')&scope=$2&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256"; }
handle_callback() { local at=$1 state=$2 iss=$3 code=$4 f="$HOME/attempt-$2.json" bad=0
  [ -f "$f" ] && [ "$(jq -r .status "$f")" = pending ] || { echo "REJECT: no pending attempt"; return 1; }
  jq '.status = "claimed"' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
  [ "$at" = "$(jq -r .return_address "$f")" ] || { echo "REJECT: arrived at the wrong return address"; bad=1; }
  [ "$iss" = "$(jq -r .expected_issuer "$f")" ] || { echo "REJECT: iss is not the expected issuer"; bad=1; }
  [ $bad = 0 ] || return 1
  use_provider "$(jq -r .provider "$f")"
  RESP=$(curl -s -u "$P_CLIENT:${!P_SECRET_VAR}" "$TOKEN_ENDPOINT" -d grant_type=authorization_code --data-urlencode "code=$code" --data-urlencode "redirect_uri=$P_RETURN" --data-urlencode "code_verifier=$(jq -r .verifier "$f")")
  ID_TOKEN=$(jq -r .id_token <<<"$RESP")
  btl-lab verify "$ID_TOKEN" --issuer "$EXPECTED_ISSUER" --audience "$P_CLIENT" --algs RS256 --type id --nonce "$(jq -r .nonce "$f")" --jwks-cache "$P_CACHE"; }
  1. Keep btl-lab callback running, and in Lab Photos press Start.

Walkthrough

  1. Compare the two entries side by side, as the lesson does. Run use_provider photos, print EXPECTED_ISSUER, P_CLIENT, P_RETURN and JWKS_URI, then do the same for mail. Different issuers, client IDs, key sets and return addresses. Both documents say authorization_response_iss_parameter_supported: true.

Why it matters: each provider is a separate trust relationship. Merging the entries, for example into one combined list of trusted keys, would let one provider's key vouch for the other's tokens.

  1. Sign in with the mail provider. Run start_attempt mail 'openid%20email', open the URL and sign in as the Lab Mail Ava. The hosted callback page shows code, state and iss. Hand them to your handler with the address they arrived at:

handle_callback 'https://beyondthelogin.dev/lab/callback/' '<state>' '<iss>' '<code>'

The run ends in ACCEPT.

  1. Sign in with the photo provider the same way: start_attempt photos 'openid%20email', sign in as the Lab Photos Ava, then handle_callback 'http://127.0.0.1:8765/callback' '<state>' '<iss>' '<code>' with the values the listener printed. ACCEPT again.

  1. Read the callback order in handle_callback and match it to the lesson's list: find the attempt by state; confirm the return address and iss; redeem at that provider's token endpoint with that provider's credential; validate with that provider's issuer, key set and client ID and the attempt's nonce.

  1. One person, two subjects. Decode both accepted tokens and compare iss, sub and email: the same email address and unrelated sub values. Record the links keyed by the pair, never by sub alone or by email:

jq -n --arg pi "$ISSUER" --arg ps '<photos sub>' --arg mi "$ISSUER2" --arg ms '<mail sub>' \
  '[{iss: $pi, sub: $ps, account: "collage-account-1"}, {iss: $mi, sub: $ms, account: "not linked yet"}]' > links.json

Why it matters: a subject means nothing without its issuer, and a matching email address is not a link. Linking the two is a later lesson's decision.

  1. Keys stay separate. List the kid values in both key sets with btl-lab discover "$ISSUER" and btl-lab discover "$ISSUER2". Each provider chooses its own identifiers, and nothing stops two providers from using the same one, which is why each entry keeps its own cache.

Break it

  1. The mix-up, stopped at the callback.

Simulation. both tenants stay honest. You play the compromised provider only by constructing its redirect by hand, which is the one step a real attack needs and your own tenants will not perform.

Start a mail attempt with start_attempt mail 'openid' and keep its state with S=$STATE. Then run start_attempt photos 'openid', take the printed photo URL and replace its state value with S. Open that URL, which is where a compromised mail provider would send your browser, and approve at Lab Photos. The real Lab Photos returns a genuine code to the loopback listener with iss set to its own issuer. Give that callback to your handler:

handle_callback 'http://127.0.0.1:8765/callback' "$S" '<iss from the listener>' '<code from the listener>'

The state finds the mail attempt, and the handler refuses on two counts: the wrong return address, and an iss that is not the mail issuer. No token request is made to either provider.

Why it matters: the ID token cannot catch a mix-up, because by the time it arrives the code has already been sent somewhere. The check has to happen at the callback.

  1. One defense at a time. Edit handle_callback so it skips the return address check, and repeat Break it 1. It still refuses, on iss. Now imagine a provider that sends no iss, like the lesson's mail service: only the separate return address would remain, which is why the lesson prefers iss where the provider supports it.

Restore: put the return address check back in handle_callback.

Check your work

Press Check my progress in Lab Photos. It looks for oauth.authorize with code_issued and oauth.token succeeded for lab-collage from your photo sign-in, then the code_issued from the mix-up attempt. In Audit, that last code has no matching oauth.token request: your handler never redeemed it, and it expires on its own.

In Lab Mail, Audit shows oauth.authorize and oauth.token for lab-mail-collage from your mail sign-ins only. links.json holds two entries with different iss values.

Cleanup

Keep both tenants, use_provider, links.json and the handler for the Claims labs. Delete the attempt-*.json files in your home directory.

Missing infrastructure

  • G66 Second lab tenant: this lab uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so an ordinary learner can do only the Lab Photos steps until every learner can have a second lab tenant.

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