OPENID CONNECT · LAB
Build a safe login initiation endpoint
Run a local /signin/start that accepts only your configured issuer, checks the target, starts a fresh code flow with the hint, and refuses to be framed, then send it the requests an attacker could.
Partly readyIncludes a simulationUses your lab tenant
The lesson
Builds on: Starting sign-in from another service.
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.
- G65 Third-party initiated login (`initiate_login_uri`, tenant app launcher)
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.
Start a fresh authorization request from the endpoint
Recorded as
oauth.authorizesucceeded (request_started) forlab-collage.Receive a code from Ava's session
Recorded as
oauth.authorizesucceeded (code_issued) forlab-collageabout[email protected].Redeem it with the endpoint's own verifier
Recorded as
oauth.tokensucceeded forlab-collage.Sign in on the form the hint prefilled
Recorded as
oauth.authorizesucceeded (user_signed_in) forlab-collageabout[email protected].
Setup
The relying party's processing runs fully against the tenant. The initiator, a tenant dashboard, does not exist yet (G65), so you play it by opening URLs yourself.
Save the endpoint as
initiate.mjs. It accepts GET and POST, takes the issuer only if it exactly matches a provider you configured (it never runs discovery for a requested issuer), keeps a hint only if it is short and printable, keeps a target only if it is one of your own pages, creates freshstate,nonceand PKCE values itself, and refuses to be framed. A production endpoint must use HTTPS; this one listens on loopback.
import { createServer } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
const providers = {[process.env.ISSUER]: process.env.CLIENT_ID};
const host = '127.0.0.1:8766', redirectUri = 'http://127.0.0.1:8765/callback', pages = ['/projects/'];
const b64 = buffer => buffer.toString('base64url');
const send = (res, status, location, body = '') => {
res.writeHead(status, {...(location ? {Location: location} : {}), 'Content-Security-Policy': "frame-ancestors 'none'",
'X-Frame-Options': 'DENY', 'Cache-Control': 'no-store', 'Content-Type': 'text/plain'});
res.end(body);
};
createServer(async (req, res) => {
const url = new URL(req.url, 'http://' + host);
if (url.pathname !== '/signin/start') return send(res, 404, null, 'Not found');
let params = url.searchParams;
if (req.method === 'POST') {
let body = '';
for await (const chunk of req) { body += chunk; if (body.length > 4096) return send(res, 413, null); }
params = new URLSearchParams(body);
}
const iss = params.get('iss') ?? '';
if (!Object.hasOwn(providers, iss)) return send(res, 200, null, 'Choose how to sign in on our own page.');
const hint = params.get('login_hint') ?? '';
const safeHint = hint.length > 0 && hint.length <= 254 && !/[\u0000-\u001f\u007f]/.test(hint) ? hint : null;
let destination = '/';
try {
const target = new URL(params.get('target_link_uri') ?? '');
if (target.protocol === 'http:' && target.host === host && pages.some(page => target.pathname.startsWith(page))) destination = target.pathname;
} catch { destination = '/'; }
const state = b64(randomBytes(16)), nonce = b64(randomBytes(16)), verifier = b64(randomBytes(32));
const pending = existsSync('pending.json') ? JSON.parse(readFileSync('pending.json', 'utf8')) : {};
pending[state] = {issuer: iss, nonce, verifier, return: destination};
writeFileSync('pending.json', JSON.stringify(pending));
const request = new URLSearchParams({response_type: 'code', client_id: providers[iss], redirect_uri: redirectUri, scope: 'openid profile',
state, nonce, code_challenge: b64(createHash('sha256').update(verifier).digest()), code_challenge_method: 'S256', ...(safeHint ? {login_hint: safeHint} : {})});
console.log(JSON.stringify({pending: state, return: destination}));
send(res, 302, iss + '/oauth/authorize?' + request);
}).listen(8766, '127.0.0.1');
In a third terminal, with
ISSUERandCLIENT_IDexported for Lab Photos andlab-collage, runnode initiate.mjs.pending.jsonis your relying party's server-side store of attempts.Sign Ava in to Lab Photos in your lab browser,
source ~/btl-oidc.sh, runbtl-lab callback, and press Start.
Walkthrough
Play the dashboard.
Simulation. no tenant dashboard exists yet (G65), so you build the initiation link yourself. Everything from the endpoint's redirect onward is real.
echo "http://127.0.0.1:8766/signin/start?iss=$(enc "$ISSUER")&login_hint=ava%40example.com&target_link_uri=$(enc http://127.0.0.1:8766/projects/autumn-catalogue)"
Open the printed URL. The endpoint logs a pending attempt with return /projects/autumn-catalogue, the tenant answers at once from Ava's session, and the listener receives a code whose state is in pending.json.
Why it matters: from the redirect on, this is an ordinary sign-in the relying party began, with values it created itself.
Finish the sign-in from the pending attempt, then delete it:
S='<state from the listener>'
VERIFIER=$(jq -r --arg s "$S" '.[$s].verifier' pending.json); NONCE=$(jq -r --arg s "$S" '.[$s].nonce' pending.json)
redeem '<code>'
btl-lab verify "$ID_TOKEN" --issuer "$ISSUER" --audience "$CLIENT_ID" --type id --nonce "$NONCE"
jq -r --arg s "$S" '.[$s].return' pending.json
jq --arg s "$S" 'del(.[$s])' pending.json > p.tmp && mv p.tmp pending.json
The return path comes from the pending attempt, never from the callback.
Why it matters: the destination was checked when the attempt began and kept on the server, so nothing in the callback can change where the person lands.
Sign out of the tenant at
$ISSUER/accountand open step 1's link again. The tenant's form opens with[email protected]prefilled. Sign in and finish as in step 2.
Why it matters: the hint was passed along as a suggestion only. The tenant still authenticated whoever was at the browser.
Check the framing defense:
curl -sI "http://127.0.0.1:8766/signin/start?iss=x" | grep -iE 'frame|content-security'
Why it matters: a sign-in that can complete without showing any page must not be framed by another site, or clickjacking can act with the new session.
Break it
All of these are requests anyone could send to your endpoint. None needs a weakened setting.
An issuer you never configured, such as
iss=https%3A%2F%2Ftenant-other.example: the plain "choose how to sign in" page, no redirect, and no request frominitiate.mjsto that address. Try your real Lab Mail issuer ($ISSUER2) too: refused the same way, because only Lab Photos is configured here.Targets that must be ignored, each ending at
/after sign-in:target_link_urion another host:https://other-site.example/projects/xa host that only starts with yours:
http://127.0.0.1:8766.other-site.example/projects/xa host hidden behind user information:
http://127.0.0.1:[email protected]/projects/x
Extra parameters named
stateandnonceon the initiation URL: ignored.pending.jsonholds only values the endpoint generated.
Check your work
Press Check my progress. The checks look for an authorization request started by your endpoint, a code from Ava's session, its redemption with the endpoint's own verifier, and a real sign-in on the prefilled form.
In Audit, there is one request_started per accepted initiation and none for the refused ones. pending.json is empty after each completed sign-in.
Cleanup
Stop initiate.mjs and delete pending.json.
Missing infrastructure
G65 (third-party initiated login). With it, step 1 would start from a real tile on the tenant's app launcher, which sends GET or POST to the
initiate_login_uriregistered onlab-collage, and Audit would record the launch.