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.
- 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.
Connect the collage app through Lab Photos
Recorded as
oauth.authorizesucceeded (code_issued) forlab-collage.Redeem it at Lab Photos' own token endpoint
Recorded as
oauth.tokensucceeded forlab-collageabout[email protected].Get the genuine Lab Photos response used in the test
Recorded as
oauth.authorizesucceeded (code_issued) forlab-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
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.
In Lab Photos, confirm
lab-collage(Confidential, redirect URIhttp://127.0.0.1:8765/callback). In Lab Mail, confirmlab-mail-collagewith the same redirect URI, and a user you can sign in as. Both registrations share one callback, as the lesson's printer does.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
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'sisswith 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/'));
Start it:
node twoissuers.mjs.
Walkthrough
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.1authorization_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.
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=mailand 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.
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 thestatevalue from the address bar and keep it as the Mail state. Do not sign in.
Get a genuine, unredeemed Lab Photos response in the same browser. Build an ordinary request for
lab-collageand 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.
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.
Confirm the code went nowhere. In Lab Photos Audit, find the
oauth.authorizecode_issuedevent from step 4: there is nooauth.tokenevent for it. In Lab Mail Audit, there is no token request either.
Read
issthe way the Token Decoder does. Open$ISSUER/token-decoderand complete a sign-in. Its response checks showissmatched. It discards a response naming another issuer, and one withoutisswhen 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.