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

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.

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 a fresh authorization request from the endpoint

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

  2. Receive a code from Ava's session

    Recorded as oauth.authorize succeeded (code_issued) for lab-collage about [email protected].

  3. Redeem it with the endpoint's own verifier

    Recorded as oauth.token succeeded for lab-collage.

  4. Sign in on the form the hint prefilled

    Recorded as oauth.authorize succeeded (user_signed_in) for lab-collage about [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.

  1. 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 fresh state, nonce and 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');
  1. In a third terminal, with ISSUER and CLIENT_ID exported for Lab Photos and lab-collage, run node initiate.mjs. pending.json is your relying party's server-side store of attempts.

  2. Sign Ava in to Lab Photos in your lab browser, source ~/btl-oidc.sh, run btl-lab callback, and press Start.

Walkthrough

  1. 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.

  1. 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.

  1. Sign out of the tenant at $ISSUER/account and 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.

  1. 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.

  1. 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 from initiate.mjs to that address. Try your real Lab Mail issuer ($ISSUER2) too: refused the same way, because only Lab Photos is configured here.

  2. Targets that must be ignored, each ending at / after sign-in:

    • target_link_uri on another host: https://other-site.example/projects/x

    • a host that only starts with yours: http://127.0.0.1:8766.other-site.example/projects/x

    • a host hidden behind user information: http://127.0.0.1:[email protected]/projects/x

  3. Extra parameters named state and nonce on the initiation URL: ignored. pending.json holds 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_uri registered on lab-collage, and Audit would record the launch.

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