#!/usr/bin/env -S npx tsx --tsconfig tsconfig.scripts.json /** * Manual OAuth consent flow harness. Unlike scripts/test-client/run.ts * (which self-bootstraps a `skip_consent: true` client to fully automate * the protocol), this one is for *seeing* the consent UI: it stops short * of registering the OAuth client so the operator can do that step by * hand at /admin/clients with skipConsent unchecked. * * Usage: * npm run dev # in another terminal * CLIENT_ID=... CLIENT_SECRET=... npm run test:consent * * Required env: * CLIENT_ID, CLIENT_SECRET — from the client you registered in * /admin/clients (skipConsent UNCHECKED). * * Optional env: * ISSUER Tessera base URL — default http://localhost:5174. * PORT Local listener port — default 7878. The script prints * the exact redirect_uri you must register for the client. * Use 127.0.0.1 exactly; localhost is a different host for * OAuth redirect_uri matching. */ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; const LOOPBACK_HOST = "127.0.0.1"; const ISSUER = (process.env.ISSUER ?? "http://localhost:5174").replace(/\/+$/, ""); const PORT = Number.parseInt(process.env.PORT ?? "7878", 10); const CLIENT_ID = process.env.CLIENT_ID; const CLIENT_SECRET = process.env.CLIENT_SECRET; const REDIRECT_URI = `http://${LOOPBACK_HOST}:${PORT}/cb`; const LOCALHOST_REDIRECT_URI = `http://localhost:${PORT}/cb`; if (!CLIENT_ID || !CLIENT_SECRET) { console.error("✗ CLIENT_ID and CLIENT_SECRET are required."); console.error(" Register a client at /admin/clients with skipConsent UNCHECKED,"); console.error(` redirect_uri=${REDIRECT_URI}, then re-run with the creds.`); console.error(` Do not register ${LOCALHOST_REDIRECT_URI}; localhost != 127.0.0.1 here.`); process.exit(2); } const b64url = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); interface PkcePair { verifier: string; challenge: string; } const newPkce = async (): Promise => { const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))); return { verifier, challenge }; }; // One verifier per /authorize click. Stored in-memory between the // /authorize redirect and the /cb callback. Single concurrent flow only. let pendingPkce: { verifier: string; state: string } | null = null; let lastResult: | { ok: true; claims: JWTPayload; idTokenRaw: string; userinfo: Record; } | { ok: false; error: string } | null = null; const sendHtml = (res: ServerResponse, status: number, body: string) => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); res.end(body); }; const renderShell = (heading: string, content: string): string => ` tessera consent harness

${heading}

${content}
`; const renderHome = (): string => { const lastBlock = renderLast(); return renderShell( "tessera consent harness", `

Manual OAuth flow — see the /oauth/consent page that skipConsent: true normally hides.

1. Register a client

Open ${escapeHtml(ISSUER)}/admin/clients and register a client with these settings:

Important: register ${escapeHtml(REDIRECT_URI)}, not ${escapeHtml(LOCALHOST_REDIRECT_URI)}. OAuth redirect URI matching treats localhost and 127.0.0.1 as different hosts; the localhost version will fail with invalid_redirect.

Copy the client_id and client_secret the admin UI returns; the plaintext secret is shown only once.

2. Restart the harness with creds

CLIENT_ID=<copied> CLIENT_SECRET=<copied> npm run test:consent

You're already running with CLIENT_ID=${escapeHtml(CLIENT_ID)}.

3. Drive the flow

Start /authorize →

This redirects to ${escapeHtml(ISSUER)} with a PKCE challenge. If you're not signed in, you'll hit /sign-in first; once signed in, /authorize 302s to /oauth/consent. Approve there and the browser bounces back to ${escapeHtml(REDIRECT_URI)}.

${lastBlock}`, ); }; const renderLast = (): string => { if (!lastResult) return ""; if (!lastResult.ok) { return `

Last result

${escapeHtml(lastResult.error)}

`; } const { claims, idTokenRaw, userinfo } = lastResult; return `

Last result ✓ ok

ID token claims:

${escapeHtml(JSON.stringify(claims, null, 2))}

userinfo response:

${escapeHtml(JSON.stringify(userinfo, null, 2))}

Raw JWT: ${escapeHtml(idTokenRaw.slice(0, 32))}…

`; }; const handleAuthorize = async (res: ServerResponse) => { const { verifier, challenge } = await newPkce(); const state = b64url(crypto.getRandomValues(new Uint8Array(16))); pendingPkce = { verifier, state }; const url = new URL(`${ISSUER}/api/auth/oauth2/authorize`); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", CLIENT_ID!); url.searchParams.set("redirect_uri", REDIRECT_URI); url.searchParams.set("scope", "openid profile email"); url.searchParams.set("state", state); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); res.writeHead(302, { location: url.toString() }); res.end(); }; const handleCallback = async (req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${PORT}`); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const error = url.searchParams.get("error"); if (error) { lastResult = { ok: false, error: `authorize returned error=${error}: ${url.searchParams.get("error_description") ?? "(none)"}`, }; res.writeHead(302, { location: "/" }); res.end(); return; } if (!code || !state) { lastResult = { ok: false, error: "callback missing code or state" }; res.writeHead(302, { location: "/" }); res.end(); return; } if (!pendingPkce || pendingPkce.state !== state) { lastResult = { ok: false, error: "state mismatch — start the flow from the home page" }; res.writeHead(302, { location: "/" }); res.end(); return; } const { verifier } = pendingPkce; pendingPkce = null; try { const tokenRes = await fetch(`${ISSUER}/api/auth/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`, }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: verifier, }), }); if (!tokenRes.ok) { lastResult = { ok: false, error: `token endpoint ${tokenRes.status}: ${await tokenRes.text()}` }; } else { const tokens = (await tokenRes.json()) as { id_token: string; access_token: string }; const jwks = createRemoteJWKSet(new URL(`${ISSUER}/api/auth/jwks`)); const { payload } = await jwtVerify(tokens.id_token, jwks, { issuer: ISSUER, audience: CLIENT_ID, }); const userinfoRes = await fetch(`${ISSUER}/api/auth/oauth2/userinfo`, { headers: { authorization: `Bearer ${tokens.access_token}` }, }); const userinfo = (await userinfoRes.json()) as Record; lastResult = { ok: true, claims: payload, idTokenRaw: tokens.id_token, userinfo }; } } catch (e) { lastResult = { ok: false, error: e instanceof Error ? e.message : String(e) }; } res.writeHead(302, { location: "/" }); res.end(); }; const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${PORT}`); if (url.pathname === "/" || url.pathname === "") return sendHtml(res, 200, renderHome()); if (url.pathname === "/authorize") return handleAuthorize(res); if (url.pathname === "/cb") return handleCallback(req, res); res.writeHead(404, { "content-type": "text/plain" }); res.end("not found"); }); server.listen(PORT, LOOPBACK_HOST, () => { console.log(`◇ tessera consent harness ready`); console.log(` open http://${LOOPBACK_HOST}:${PORT}/`); console.log(` redirect_uri to register: ${REDIRECT_URI}`); console.log(` do not register ${LOCALHOST_REDIRECT_URI}; localhost != 127.0.0.1 for redirect_uri matching`); });