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

Build the collage app's callback issuer check

Write the client check that remembers which provider was asked, compares iss exactly, and rejects a genuine Lab Mail response that arrives for a Lab Photos attempt.

Partly readyIncludes a simulationUses both lab tenants

The lesson

Builds on: Identifying the authorization server.

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. Start an attempt at Lab Photos

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

  2. Accept a Lab Photos response whose iss matches

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

  3. Redeem the accepted code at Lab Photos

    Recorded as oauth.token succeeded for lab-collage.

Setup

You need both tenants, lab-collage, lab-mail-collage, Ava's two accounts and the shell variables from Read the issuer in every authorization response.

  1. Make a working folder outside any repository, for example mkdir -p ~/btl-issuer && cd ~/btl-issuer.

  2. Save the collage app's authorization client as collage.sh. It keeps public configuration (issuers, client IDs, endpoints) in servers.json; the client secrets stay in the shell.

uri() { jq -rn --arg v "$1" '$v|@uri'; }
srv() { jq -r --arg s "$1" --arg f "$2" '.[$s][$f]' servers.json; }

add_server() {  # add_server NAME ISSUER CLIENT_ID [CALLBACK]: a record built from that issuer's own metadata
  local meta; meta=$(curl -sf "${2%/}/.well-known/oauth-authorization-server") || { echo "refused: metadata_unavailable"; return 1; }
  [ "$(jq -r .issuer <<<"$meta")" = "$2" ] || { echo "refused: issuer_mismatch"; return 1; }
  [ -f servers.json ] || echo '{}' > servers.json
  if jq -e --arg n "$1" --arg i "$2" 'any(to_entries[]; .key != $n and .value.issuer == $i)' servers.json >/dev/null; then
    echo "refused: duplicate_issuer"; return 1; fi
  jq --arg n "$1" --arg c "$3" --arg cb "${4:-http://127.0.0.1:8765/callback}" --argjson m "$meta" \
    '.[$n] = {issuer: $m.issuer, client_id: $c, callback: $cb, authorization_endpoint: $m.authorization_endpoint,
      token_endpoint: $m.token_endpoint, sends_iss: ($m.authorization_response_iss_parameter_supported == true)}' \
    servers.json > servers.new && mv servers.new servers.json && echo "saved $1"
}

start_attempt() {  # start_attempt SERVER SCOPES: remembers which server was asked, prints the URL to open
  eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
  PENDING_SERVER=$1 PENDING_STATE=$STATE PENDING_VERIFIER=$VERIFIER
  echo "$(srv "$1" authorization_endpoint)?response_type=code&client_id=$(srv "$1" client_id)&redirect_uri=$(uri "$(srv "$1" callback)")&scope=$(uri "$2")&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
}

read_response() {  # paste what the callback showed; press Enter for a value that is absent
  read -rsp 'code: ' RESP_CODE; echo; read -rp 'state: ' RESP_STATE; read -rp 'iss: ' RESP_ISS; read -rp 'error: ' RESP_ERROR
}

reject() {  # one log line per rejection: stage, reason, expected issuer, attempt. Never the code.
  jq -nc --arg r "$1" --arg e "$(srv "$PENDING_SERVER" issuer)" --arg a "${PENDING_STATE:0:8}" \
    '{message: "Authorization response rejected", stage: "callback", reason: $r, expected_issuer: $e, attempt: $a}' >&2
  return 1
}

check_response() {  # judges RESP_ISS, RESP_STATE and RESP_ERROR against the pending attempt
  local expected sends
  expected=$(srv "$PENDING_SERVER" issuer); sends=$(srv "$PENDING_SERVER" sends_iss)   # from the attempt, never the response
  if [ -z "$RESP_ISS" ]; then
    [ "$sends" = true ] && { reject missing_iss; return 1; }
    # Without iss, this server needs a callback that no other configured server shares.
    if jq -e --arg s "$PENDING_SERVER" '.[$s].callback as $c | any(to_entries[]; .key != $s and .value.callback == $c)' servers.json >/dev/null; then
      reject no_mix_up_defense; return 1; fi
  elif [ "$sends" != true ]; then reject unexpected_iss; return 1
  elif [ "$RESP_ISS" != "$expected" ]; then reject issuer_mismatch; return 1; fi
  [ "$RESP_STATE" = "$PENDING_STATE" ] || { reject state_mismatch; return 1; }
  [ -z "$RESP_ERROR" ] || { echo "$expected refused the request: $RESP_ERROR"; return 1; }
  echo "accepted"
}

redeem() {  # redeem CODE: at the pending server's token endpoint, as that server's client
  local secret=$CLIENT_SECRET; [ "$PENDING_SERVER" = mail ] && secret=$MAIL_CLIENT_SECRET
  curl -s -u "$(srv "$PENDING_SERVER" client_id):$secret" "$(srv "$PENDING_SERVER" token_endpoint)" -d grant_type=authorization_code \
    --data-urlencode "code=$1" --data-urlencode "redirect_uri=$(srv "$PENDING_SERVER" callback)" --data-urlencode "code_verifier=$PENDING_VERIFIER"
}
  1. Run . ./collage.sh in every new shell, then press Start on this page with Lab Photos selected.

Walkthrough

  1. Record both servers from their own metadata.

add_server photos "$ISSUER" "$CLIENT_ID"
add_server mail "$ISSUER2" "$MAIL_CLIENT_ID"
jq . servers.json

Two records with different issuers, endpoints and client IDs, both with "sends_iss": true.

Why it matters: the client keeps a configuration record per authorization server, built from that server's metadata, and records the issuer because it stands for the whole configuration, including the token endpoint the code must go to.

  1. Read check_response. The expected issuer comes from PENDING_SERVER, set when the attempt started. RESP_ISS is only compared: it never selects a record, a token endpoint or a metadata address.

Why it matters: if the response could say which server answered, a wrong response would simply say the right thing.

  1. The matching case. Start btl-lab callback in a second terminal (once per attempt), then:

start_attempt photos openid

Open the URL, sign in as Ava at Lab Photos and approve. Run read_response and paste the listener's values, then:

check_response && redeem "$RESP_CODE" | jq '{token_type, expires_in, scope}'

accepted, then a token response. iss was present, expected and identical, so the client continued with its other checks, such as state.

  1. A response from your other configured server, for this same attempt. Start a fresh Lab Photos attempt but do not open its URL. Instead open Lab Mail's authorization URL with the same state and challenge:

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"

Sign in as Ava at Lab Mail and approve. Run read_response, then check_response. It prints {"message":"Authorization response rejected","stage":"callback","reason":"issuer_mismatch","expected_issuer":"<your ISSUER>",...} and the code is never redeemed. The log line has no code in it.

Why it matters: this is a genuine code from a server you trust, with the correct state, arriving for an attempt that asked the other server. Only the issuer check can say which server issued it, and it stops the response before any token request. A run of these lines is either an attack or a configuration change, and both need attention.

  1. The same misrouting with an error. Repeat step 4, but choose Deny at Lab Mail. The response carries error=access_denied and Lab Mail's issuer. check_response still reports issuer_mismatch, not a refusal.

Why it matters: the client must not tell Ava that Lab Photos declined, on the strength of a message Lab Photos did not send. It reports that the connection could not be completed and offers to start again.

  1. Exact comparison, row by row from the lesson's table.

Simulation. your tenant always sends exactly its own issuer, so these variants exist only as test inputs to your own function. No request is sent.

start_attempt photos openid > /dev/null; RESP_STATE=$PENDING_STATE; RESP_ERROR=
for v in "$ISSUER" "$ISSUER/" "${ISSUER/tenant-/TENANT-}" "$ISSUER:443" "$(uri "$ISSUER")" "$ISSUER2"; do
  RESP_ISS=$v; printf '%s -> ' "$v"; check_response 2>&1; done

Only the first line is accepted. The trailing slash, the upper-case host, the explicit default port, a value that was encoded twice (decoded once it still contains %3A) and the other server's issuer all fail with issuer_mismatch.

Why it matters: simple string comparison, one decode, no normalization. Leniency would give an attacker a rule to look for a way around.

  1. A missing iss from a server that promised it.

Simulation. Lab Photos always sends iss, so the absence is a test input.

RESP_ISS= check_response

The result is missing_iss.

Why it matters: when the server advertised support, absence is itself a warning.

  1. An iss the client did not expect. Mark Lab Mail, in your own configuration only, as a server that never advertised the parameter, then run a real Lab Mail attempt:

jq '.mail.sends_iss = false' servers.json > servers.new && mv servers.new servers.json
start_attempt mail openid

Approve as Ava, run read_response, then check_response: unexpected_iss. Now try the same attempt without iss (RESP_ISS= check_response, a test input as in step 7): no_mix_up_defense, because both records share one callback address.

Restore: run add_server mail "$ISSUER2" "$MAIL_CLIENT_ID" to rebuild the record from Lab Mail's metadata, which sets sends_iss back to true.

Why it matters: these are the lesson's last two rows. Discard an iss a server never promised, and accept a missing one only when another mix-up defense covers that server.

  1. Records must stay distinct. Try to add a second record with Lab Photos' issuer:

add_server photos-test "$ISSUER" "$CLIENT_ID"

The result is refused: duplicate_issuer.

Why it matters: if two records could share an issuer, the check would pass for whichever token endpoint the wrong record holds.

Break it

  1. Lose the pending attempt and check a response anyway: unset PENDING_SERVER PENDING_STATE; check_response. With nothing recorded there is no expected issuer, the check refuses, and redeem has no token endpoint to call. Start a new attempt with start_attempt to continue.

Why it matters: a client that loses its record of who it asked must not fall back to trusting the response.

Check your work

Press Check my progress. The checks read Lab Photos: the attempt start, code_issued for lab-collage, and the oauth.token success for the code you accepted in step 3.

Also look in Lab Mail's Audit: oauth.authorize with code_issued and with access_denied for lab-mail-collage from steps 4 and 5, and no oauth.token event for them. The misrouted codes were never redeemed, and they expire unused.

Cleanup

Keep collage.sh and servers.json for the next lab and the metadata labs. Run unset RESP_CODE and close the listener if it is still waiting.

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