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.
- G66 Second lab tenant for every learner: additional tenants need a paid subscription or a BTL grant, so labs that use Lab Mail cannot be completed by an ordinary learner yet
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.
Sign in to start this lab and check your progress. Log in or create an account.
Sign Ava in through the Lab Photos entry
Recorded as
oauth.authorizesucceeded (code_issued) forlab-collageabout[email protected].Redeem the Lab Photos code with that provider's credential
Recorded as
oauth.tokensucceeded forlab-collageabout[email protected].Lab Photos issues a code that your handler never redeems
Recorded as
oauth.authorizesucceeded (code_issued) forlab-collageabout[email protected].
Setup
You need
lab-collageand Ava in Lab Photos,lab-mail-collageand Ava in Lab Mail (withISSUER2,MAIL_CLIENT_ID,MAIL_CLIENT_SECRET), andrp_discoverfrom the provider discovery lab.lab-collagereturns to the loopback listener andlab-mail-collagereturns to the hosted callback page, so each provider has its own return address.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; }
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"; }
Keep
btl-lab callbackrunning, and in Lab Photos press Start.
Walkthrough
Compare the two entries side by side, as the lesson does. Run
use_provider photos, printEXPECTED_ISSUER,P_CLIENT,P_RETURNandJWKS_URI, then do the same formail. Different issuers, client IDs, key sets and return addresses. Both documents sayauthorization_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.
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 showscode,stateandiss. 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.
Sign in with the photo provider the same way:
start_attempt photos 'openid%20email', sign in as the Lab Photos Ava, thenhandle_callback 'http://127.0.0.1:8765/callback' '<state>' '<iss>' '<code>'with the values the listener printed.ACCEPTagain.
Read the callback order in
handle_callbackand match it to the lesson's list: find the attempt bystate; confirm the return address andiss; 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.
One person, two subjects. Decode both accepted tokens and compare
iss,subandemail: the same email address and unrelatedsubvalues. Record the links keyed by the pair, never bysubalone 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.
Keys stay separate. List the
kidvalues in both key sets withbtl-lab discover "$ISSUER"andbtl-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
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.
One defense at a time. Edit
handle_callbackso it skips the return address check, and repeat Break it 1. It still refuses, oniss. Now imagine a provider that sends noiss, like the lesson's mail service: only the separate return address would remain, which is why the lesson prefersisswhere 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.