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

OAUTH 2.0 · LAB

Keep the issuer check strict when configuration drifts

Add a per-provider return address, see which checks stop a code from the right provider but the wrong attempt, and fix real configuration drift without loosening the issuer check.

Partly readyUses both lab tenants

The lesson

Builds on: Checking the response issuer.

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. Get a second Lab Photos code while the first attempt is pending

    Recorded as oauth.authorize succeeded (code_issued) for lab-collage.

  2. Redeem it with the other attempt's verifier and be refused

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

  3. Redeem it with its own attempt's verifier

    Recorded as oauth.token succeeded for lab-collage.

Setup

You need collage.sh, servers.json and the shell variables from Build the collage app's callback issuer check.

  1. In Lab Mail, open OAuth > Clients > lab-mail-collage and add the redirect URI https://beyondthelogin.dev/lab/callback/, the hosted lab callback page. Keep the loopback URI. The hosted page only displays code, state, iss and errors; it never exchanges or stores them.

  2. In ~/btl-issuer, add a check for where a response arrived:

cat >> collage.sh <<'EOF'
check_arrival() {  # check_arrival ADDRESS: the pending attempt implies the address its response must reach
  [ "$1" = "$(srv "$PENDING_SERVER" callback)" ] || { reject wrong_callback; return 1; }
  check_response
}
EOF
. ./collage.sh
  1. Press Start on this page with Lab Photos selected.

Walkthrough

  1. Give Lab Mail its own return address, as the fallback defense for a server without issuer identification.

add_server mail "$ISSUER2" "$MAIL_CLIENT_ID" https://beyondthelogin.dev/lab/callback/
start_attempt mail openid

Open the URL and approve as Ava at Lab Mail. The response lands on the hosted callback page instead of your listener. Run read_response with the values the page shows, then check_arrival https://beyondthelogin.dev/lab/callback/: accepted.

Now the case the fallback exists for. Start a Lab Photos attempt, but open Lab Mail's URL with that attempt's state and challenge, so a genuine Lab Mail code comes back:

start_attempt photos openid > /dev/null
echo "$(srv mail authorization_endpoint)?response_type=code&client_id=$(srv mail client_id)&redirect_uri=$(uri "$(srv mail callback)")&scope=openid&state=$PENDING_STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"

The response lands on the hosted page, not the listener the Lab Photos attempt expects. read_response, then check_arrival https://beyondthelogin.dev/lab/callback/: wrong_callback, before the issuer is even read.

Why it matters: each pending attempt records its expected issuer and, through it, the address its response must arrive at. A code from the other server arrives at the other address, so this defense works even for a server that sends no iss.

  1. Retire the fallback when it is no longer needed. Lab Mail sends iss and says so in its metadata, so return it to the shared callback:

add_server mail "$ISSUER2" "$MAIL_CLIENT_ID"

Why it matters: current guidance prefers the issuer-based defense, and the separate-address arrangement should not outlive its reason.

  1. Right server, wrong attempt. Start two Lab Photos attempts and keep the first one's record:

start_attempt photos openid > /dev/null; P1_STATE=$PENDING_STATE P1_VERIFIER=$PENDING_VERIFIER
start_attempt photos openid; P2_STATE=$PENDING_STATE P2_VERIFIER=$PENDING_VERIFIER

Start the listener, open the second URL, approve as Ava and run read_response. Now judge the response against the first attempt:

PENDING_STATE=$P1_STATE PENDING_VERIFIER=$P1_VERIFIER; check_response

The issuer check passes, because Lab Photos really issued this code, and the check stops on state_mismatch.

Why it matters: iss says which server issued a code, not whose code it is or which attempt it belongs to. An attacker's own genuine Lab Photos code would carry exactly the right iss. State and PKCE cover that.

  1. The layer behind state. Redeem the code with the first attempt's verifier, as a client with a missing state check would:

redeem "$RESP_CODE" | jq .

The result is {"error":"invalid_grant",...}. Lab Photos records oauth.token rejected with pkce_failed. Now switch back to the attempt that really created the code and redeem it:

PENDING_STATE=$P2_STATE PENDING_VERIFIER=$P2_VERIFIER; check_response && redeem "$RESP_CODE" | jq '{token_type, expires_in}'

Why it matters: PKCE ties the code to the attempt that started it, so a code from another attempt fails at the token endpoint even after a client bug.

  1. Diagnose the way a careful operator does after any mismatch: compare each configured issuer with what the server publishes.

for s in photos mail; do printf '%s configured=%s published=%s\n' "$s" "$(srv $s issuer)" \
  "$(curl -s "$(srv $s issuer)/.well-known/oauth-authorization-server" | jq -r .issuer)"; done

Both lines match.

Why it matters: the fix for drift is to correct configuration, checked at deploy time and refreshed on a schedule, never to relax the comparison.

Break it

  1. A typing error in a configured issuer. add_server photos "$ISSUER/" "$CLIENT_ID" is refused with issuer_mismatch, because the published issuer has no trailing slash. Hand-edit the record instead, as an administrator typing configuration might:

jq --arg i "$ISSUER/" '.photos.issuer = $i' servers.json > servers.new && mv servers.new servers.json
start_attempt photos openid

Approve as Ava, read_response, check_response: the honest server now fails with issuer_mismatch on every response.

Restore: run add_server photos "$ISSUER" "$CLIENT_ID" to rebuild the record from metadata. The fix is the configured value, never a normalized comparison.

  1. An environment mix-up, like a test site configured with the production issuer: add_server photos "$ISSUER2" "$CLIENT_ID" is refused with duplicate_issuer, because Lab Mail already owns that issuer in your configuration.

  1. A server starts sending iss after the client last read its metadata. In Lab Mail, clear authorization_code in OAuth > Flow policy and save, then refresh the record: add_server mail "$ISSUER2" "$MAIL_CLIENT_ID". The record now says "sends_iss": false.

Restore: re-enable authorization_code in Lab Mail's Flow policy and save.

Do not refresh the record yet. Run a real Lab Mail attempt (start_attempt mail openid, approve, read_response, check_response): unexpected_iss, because Lab Mail now sends a parameter your stale record says it never promised. Refresh the record with add_server mail "$ISSUER2" "$MAIL_CLIENT_ID" and repeat: accepted.

Why it matters: most issuer rejections involve no attacker, only configuration that drifted from what a server sends. From inside one callback a mistake and an attack look identical, so the code goes unused until the configuration is fixed.

Check your work

Press Check my progress. The checks read Lab Photos:

  • oauth.authorize succeeded with code_issued for the second attempt in step 3.

  • oauth.token rejected with pkce_failed, then oauth.token succeeded, both for lab-collage.

In Lab Mail's Audit, find tenant.oauth.clients.update for the added redirect URI and two tenant.oauth.policy.update events from Break it.

Cleanup

In Lab Mail, remove https://beyondthelogin.dev/lab/callback/ from lab-mail-collage unless you want to keep it, and confirm the Flow policy allows authorization_code. Confirm jq . servers.json shows both records with "sends_iss": true and the loopback callback. Run unset RESP_CODE P1_VERIFIER P2_VERIFIER.

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