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

Check iss at one callback shared by two real authorization servers

Run one collage client against Lab Photos and Lab Mail, record the expected issuer per attempt, and refuse a genuine response that names the other issuer before its code goes anywhere.

Partly readyUses both lab tenants

The lesson

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. Connect the collage app through Lab Photos

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

  2. Redeem it at Lab Photos' own token endpoint

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

  3. Get the genuine Lab Photos response used in the test

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

Request console

Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.

Setup

  1. Choose Lab Photos as the lab tenant and press Start. The checks read Lab Photos; you will look at Lab Mail's Audit by hand.

  2. In Lab Photos, confirm lab-collage (Confidential, redirect URI http://127.0.0.1:8765/callback). In Lab Mail, confirm lab-mail-collage with the same redirect URI, and a user you can sign in as. Both registrations share one callback, as the lesson's printer does.

  3. Load both configurations. Secrets are read without echo:

export ISSUER="<Lab Photos issuer>" ISSUER2="<Lab Mail issuer>"
export COLLAGE_ID="<lab-collage client ID>" MAIL_COLLAGE_ID="<lab-mail-collage client ID>"
read -rs COLLAGE_SECRET; export COLLAGE_SECRET; read -rs MAIL_COLLAGE_SECRET; export MAIL_COLLAGE_SECRET
mkdir -p ~/lab-mixup && cd ~/lab-mixup
  1. Save twoissuers.mjs. Each attempt records the issuer it was sent to, bound to this browser by a cookie, and the callback compares the response's iss with it before the code goes anywhere:

// One callback for two providers. Node 18+, no dependencies.
import http from 'node:http'; import crypto from 'node:crypto';
const CALLBACK = 'http://127.0.0.1:8765/callback';
const providers = {
  photos: { issuer: process.env.ISSUER, id: process.env.COLLAGE_ID, secret: process.env.COLLAGE_SECRET },
  mail: { issuer: process.env.ISSUER2, id: process.env.MAIL_COLLAGE_ID, secret: process.env.MAIL_COLLAGE_SECRET },
};
for (const p of Object.values(providers)) {   // the issuer stands for its whole configuration, endpoints included
  p.meta = await (await fetch(`${p.issuer}/.well-known/oauth-authorization-server`)).json();
  if (p.meta.issuer !== p.issuer) throw new Error(`metadata for ${p.issuer} names another issuer`);
}
const attempts = new Map(), random = () => crypto.randomBytes(24).toString('base64url');
const log = (event, detail) => console.log(JSON.stringify({ at: new Date().toISOString(), event, ...detail }));
http.createServer(async (req, res) => {
  const url = new URL(req.url, CALLBACK), cookie = /(?:^|;\s*)attempt=([\w-]+)/.exec(req.headers.cookie ?? '')?.[1];
  const end = (status, text, headers = {}) => { res.writeHead(status, { 'Content-Type': 'text/plain', ...headers }); res.end(text); };
  if (url.pathname === '/start') {
    const name = url.searchParams.get('provider'), p = providers[name];
    if (!p) return end(400, 'Choose provider=photos or provider=mail');
    const id = random(), state = random(), verifier = random();
    attempts.set(id, { state, verifier, provider: name });
    log('attempt_started', { provider: name, expected_issuer: p.issuer });
    return end(302, '', { 'Set-Cookie': `attempt=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`, Location: `${p.meta.authorization_endpoint}?` +
      new URLSearchParams({ response_type: 'code', client_id: p.id, redirect_uri: CALLBACK, scope: 'openid', state,
        code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' }) });
  }
  if (url.pathname !== '/callback') return end(404, 'Not found');
  const q = url.searchParams, attempt = attempts.get(cookie);
  if (!attempt || q.get('state') !== attempt.state) { log('refused', { reason: 'no_matching_attempt' }); return end(400, 'This response does not match a connection started in this browser.'); }
  attempts.delete(cookie);
  const p = providers[attempt.provider];
  if (!q.has('iss') && p.meta.authorization_response_iss_parameter_supported) { log('refused', { reason: 'issuer_missing', expected: p.issuer }); return end(400, 'The connection could not be completed.'); }
  if (q.get('iss') !== p.issuer) { log('refused', { reason: 'issuer_mismatch', expected: p.issuer, named: q.get('iss') }); return end(400, 'The connection could not be completed.'); }
  if (q.get('error')) return end(400, `Refused: ${q.get('error')}`);
  // Only the recorded issuer's own token endpoint ever receives the code.
  const r = await fetch(p.meta.token_endpoint, { method: 'POST', body: new URLSearchParams({ grant_type: 'authorization_code', code: q.get('code') ?? '',
    redirect_uri: CALLBACK, code_verifier: attempt.verifier }), headers: { Authorization: 'Basic ' + Buffer.from(`${encodeURIComponent(p.id)}:${encodeURIComponent(p.secret)}`).toString('base64') } });
  log('exchanged', { provider: attempt.provider, status: r.status });
  end(r.ok ? 200 : 502, r.ok ? `Connected through ${p.issuer}` : 'The code exchange was refused.');
}).listen(8765, '127.0.0.1', () => console.log('collage client on http://127.0.0.1:8765/'));
  1. Start it: node twoissuers.mjs.

Walkthrough

  1. Confirm both servers promise to name themselves in every response:

GET$ISSUER/.well-known/oauth-authorization-server Open in console
GET $ISSUER/.well-known/oauth-authorization-server HTTP/1.1

authorization_response_iss_parameter_supported is true. Repeat for $ISSUER2.

Why it matters: a client may rely on iss only from servers that send it, and metadata tells it which do.

  1. Connect normally through each. Open http://127.0.0.1:8765/start?provider=photos, sign in as Ava, approve: "Connected through" your Lab Photos issuer. Then /start?provider=mail and sign in at Lab Mail. The client's log shows each response carried its own issuer, and each code went to its own token endpoint.

Why it matters: one callback receives responses from both servers. state tells the client which attempt a response belongs to; iss is the response's own statement of who issued it.

  1. Start an attempt recorded for Lab Mail and leave it pending. Open http://127.0.0.1:8765/start?provider=mail. When Lab Mail's sign-in page appears, copy the state value from the address bar and keep it as the Mail state. Do not sign in.

  1. Get a genuine, unredeemed Lab Photos response in the same browser. Build an ordinary request for lab-collage and open it:

eval "$(btl-lab pkce)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$COLLAGE_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=openid&state=separate-$(openssl rand -hex 4)&code_challenge=$CHALLENGE&code_challenge_method=S256"

Sign in as Ava. Your client refuses this response because its state matches no attempt, and keeps the Mail attempt pending. Copy code and iss from the address bar.

  1. Deliver that genuine response to the pending Mail attempt. In the same browser, open:

http://127.0.0.1:8765/callback?code=<Lab Photos code>&state=<Mail state>&iss=<Lab Photos issuer, URL-encoded>

The page says the connection could not be completed. The log shows issuer_mismatch, expected Lab Mail, named Lab Photos, and no exchange.

Why it matters: in a mix-up, the state matches because the response really arrives in the browser that started the attempt, and PKCE can pass because the attacker wrote the request the honest server saw. Only the issuer comparison stops the client from sending the honest server's code to the wrong token endpoint.

  1. Confirm the code went nowhere. In Lab Photos Audit, find the oauth.authorize code_issued event from step 4: there is no oauth.token event for it. In Lab Mail Audit, there is no token request either.

  1. Read iss the way the Token Decoder does. Open $ISSUER/token-decoder and complete a sign-in. Its response checks show iss matched. It discards a response naming another issuer, and one without iss when discovery says every response carries it.

Break it

Repeat steps 3 to 5, but remove &iss=... from the address in step 5. Your client refuses it with issuer_missing, because metadata says this server always sends iss. A client that accepted a response without iss would let the attack through whenever an attacker stripped it.

Check your work

Press Check my progress. The checks look for, in order: oauth.authorize with code_issued for lab-collage in step 2, its code exchange for Ava, and the later code_issued from step 4.

By hand: the client's log shows issuer_mismatch and issuer_missing refusals, each without an exchange, and neither tenant's Audit shows an oauth.token event for the code from step 4.

Cleanup

Stop twoissuers.mjs and delete the folder: rm -rf ~/lab-mixup. Run unset COLLAGE_SECRET MAIL_COLLAGE_SECRET.

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