Skip to content
File

Blob: scripts/test-consent/run.ts

typescript272 lines
1#!/usr/bin/env -S npx tsx --tsconfig tsconfig.scripts.json
2/**
3 * Manual OAuth consent flow harness. Unlike scripts/test-client/run.ts
4 * (which self-bootstraps a `skip_consent: true` client to fully automate
5 * the protocol), this one is for *seeing* the consent UI: it stops short
6 * of registering the OAuth client so the operator can do that step by
7 * hand at /admin/clients with skipConsent unchecked.
8 *
9 * Usage:
10 * npm run dev # in another terminal
11 * CLIENT_ID=... CLIENT_SECRET=... npm run test:consent
12 *
13 * Required env:
14 * CLIENT_ID, CLIENT_SECRET — from the client you registered in
15 * /admin/clients (skipConsent UNCHECKED).
16 *
17 * Optional env:
18 * ISSUER Tessera base URL — default http://localhost:5174.
19 * PORT Local listener port — default 7878. The script prints
20 * the exact redirect_uri you must register for the client.
21 * Use 127.0.0.1 exactly; localhost is a different host for
22 * OAuth redirect_uri matching.
23 */
24import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
25import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
26 
27const LOOPBACK_HOST = "127.0.0.1";
28const ISSUER = (process.env.ISSUER ?? "http://localhost:5174").replace(/\/+$/, "");
29const PORT = Number.parseInt(process.env.PORT ?? "7878", 10);
30const CLIENT_ID = process.env.CLIENT_ID;
31const CLIENT_SECRET = process.env.CLIENT_SECRET;
32const REDIRECT_URI = `http://${LOOPBACK_HOST}:${PORT}/cb`;
33const LOCALHOST_REDIRECT_URI = `http://localhost:${PORT}/cb`;
34 
35if (!CLIENT_ID || !CLIENT_SECRET) {
36 console.error("✗ CLIENT_ID and CLIENT_SECRET are required.");
37 console.error(" Register a client at /admin/clients with skipConsent UNCHECKED,");
38 console.error(` redirect_uri=${REDIRECT_URI}, then re-run with the creds.`);
39 console.error(` Do not register ${LOCALHOST_REDIRECT_URI}; localhost != 127.0.0.1 here.`);
40 process.exit(2);
41}
42 
43const b64url = (bytes: Uint8Array): string =>
44 Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
45 
46const escapeHtml = (s: string): string =>
47 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
48 
49interface PkcePair {
50 verifier: string;
51 challenge: string;
52}
53 
54const newPkce = async (): Promise<PkcePair> => {
55 const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
56 const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
57 return { verifier, challenge };
58};
59 
60// One verifier per /authorize click. Stored in-memory between the
61// /authorize redirect and the /cb callback. Single concurrent flow only.
62let pendingPkce: { verifier: string; state: string } | null = null;
63let lastResult:
64 | {
65 ok: true;
66 claims: JWTPayload;
67 idTokenRaw: string;
68 userinfo: Record<string, unknown>;
69 }
70 | { ok: false; error: string }
71 | null = null;
72 
73const sendHtml = (res: ServerResponse, status: number, body: string) => {
74 res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
75 res.end(body);
76};
77 
78const renderShell = (heading: string, content: string): string => `<!doctype html>
79<html lang="en">
80<head>
81<meta charset="utf-8" />
82<title>tessera consent harness</title>
83<style>
84 :root { color-scheme: dark; }
85 body {
86 margin: 0;
87 font: 14px/1.55 ui-sans-serif, system-ui, sans-serif;
88 background: #18181b;
89 color: #e4e4e7;
90 }
91 main { max-width: 720px; margin: 4rem auto; padding: 0 1.5rem; }
92 h1 { font: 600 1.75rem/1.2 "Spectral", ui-serif, Georgia, serif; margin: 0 0 1rem; color: #fafafa; }
93 h2 { font-size: 0.75rem; letter-spacing: 0.18em; text-transform: uppercase; color: #a1a1aa; margin: 2rem 0 0.5rem; }
94 ol, ul { padding-left: 1.25rem; }
95 li { margin-bottom: 0.5rem; }
96 code { font-family: ui-monospace, SFMono-Regular, monospace; background: #27272a; padding: 0.1em 0.4em; border-radius: 0.25rem; font-size: 0.95em; }
97 pre { background: #0c0c0d; border: 1px solid #27272a; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; font-size: 0.85em; }
98 a.btn {
99 display: inline-block;
100 margin-top: 1rem;
101 padding: 0.6rem 1rem;
102 background: #d4a017;
103 color: #18181b;
104 border-radius: 0.5rem;
105 font-weight: 600;
106 text-decoration: none;
107 }
108 a.btn:hover { background: #b8881e; }
109 .ok { color: #84cc16; }
110 .err { color: #f87171; }
111 .warn {
112 border-left: 3px solid #f59e0b;
113 margin: 1rem 0 0;
114 padding: 0.75rem 1rem;
115 background: #451a03;
116 color: #fed7aa;
117 }
118 .warn code { background: #78350f; color: #ffedd5; }
119 .hint { color: #71717a; font-size: 0.85em; }
120</style>
121</head>
122<body>
123<main>
124 <h1>${heading}</h1>
125 ${content}
126</main>
127</body>
128</html>`;
129 
130const renderHome = (): string => {
131 const lastBlock = renderLast();
132 return renderShell(
133 "tessera consent harness",
134 `<p>Manual OAuth flow — see the <code>/oauth/consent</code> page that <code>skipConsent: true</code> normally hides.</p>
135
136<h2>1. Register a client</h2>
137<p>Open <a href="${escapeHtml(ISSUER)}/admin/clients" target="_blank" rel="noopener">${escapeHtml(ISSUER)}/admin/clients</a> and register a client with these settings:</p>
138<ul>
139 <li><strong>Redirect URI</strong>: <code>${escapeHtml(REDIRECT_URI)}</code> exactly</li>
140 <li><strong>Skip consent</strong>: <em>unchecked</em></li>
141</ul>
142<p class="warn"><strong>Important:</strong> register <code>${escapeHtml(REDIRECT_URI)}</code>, not <code>${escapeHtml(LOCALHOST_REDIRECT_URI)}</code>. OAuth redirect URI matching treats <code>localhost</code> and <code>127.0.0.1</code> as different hosts; the localhost version will fail with <code>invalid_redirect</code>.</p>
143<p class="hint">Copy the <code>client_id</code> and <code>client_secret</code> the admin UI returns; the plaintext secret is shown only once.</p>
144
145<h2>2. Restart the harness with creds</h2>
146<pre>CLIENT_ID=&lt;copied&gt; CLIENT_SECRET=&lt;copied&gt; npm run test:consent</pre>
147<p class="hint">You're already running with <code>CLIENT_ID=${escapeHtml(CLIENT_ID)}</code>.</p>
148
149<h2>3. Drive the flow</h2>
150<a class="btn" href="/authorize">Start /authorize →</a>
151<p class="hint">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 <code>${escapeHtml(REDIRECT_URI)}</code>.</p>
152
153${lastBlock}`,
154 );
155};
156 
157const renderLast = (): string => {
158 if (!lastResult) return "";
159 if (!lastResult.ok) {
160 return `<h2>Last result</h2><p class="err">${escapeHtml(lastResult.error)}</p>`;
161 }
162 const { claims, idTokenRaw, userinfo } = lastResult;
163 return `<h2>Last result <span class="ok">✓ ok</span></h2>
164<p>ID token claims:</p>
165<pre>${escapeHtml(JSON.stringify(claims, null, 2))}</pre>
166<p>userinfo response:</p>
167<pre>${escapeHtml(JSON.stringify(userinfo, null, 2))}</pre>
168<p class="hint">Raw JWT: <code>${escapeHtml(idTokenRaw.slice(0, 32))}…</code></p>`;
169};
170 
171const handleAuthorize = async (res: ServerResponse) => {
172 const { verifier, challenge } = await newPkce();
173 const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
174 pendingPkce = { verifier, state };
175 
176 const url = new URL(`${ISSUER}/api/auth/oauth2/authorize`);
177 url.searchParams.set("response_type", "code");
178 url.searchParams.set("client_id", CLIENT_ID!);
179 url.searchParams.set("redirect_uri", REDIRECT_URI);
180 url.searchParams.set("scope", "openid profile email");
181 url.searchParams.set("state", state);
182 url.searchParams.set("code_challenge", challenge);
183 url.searchParams.set("code_challenge_method", "S256");
184 
185 res.writeHead(302, { location: url.toString() });
186 res.end();
187};
188 
189const handleCallback = async (req: IncomingMessage, res: ServerResponse) => {
190 const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${PORT}`);
191 const code = url.searchParams.get("code");
192 const state = url.searchParams.get("state");
193 const error = url.searchParams.get("error");
194 
195 if (error) {
196 lastResult = {
197 ok: false,
198 error: `authorize returned error=${error}: ${url.searchParams.get("error_description") ?? "(none)"}`,
199 };
200 res.writeHead(302, { location: "/" });
201 res.end();
202 return;
203 }
204 if (!code || !state) {
205 lastResult = { ok: false, error: "callback missing code or state" };
206 res.writeHead(302, { location: "/" });
207 res.end();
208 return;
209 }
210 if (!pendingPkce || pendingPkce.state !== state) {
211 lastResult = { ok: false, error: "state mismatch — start the flow from the home page" };
212 res.writeHead(302, { location: "/" });
213 res.end();
214 return;
215 }
216 
217 const { verifier } = pendingPkce;
218 pendingPkce = null;
219 
220 try {
221 const tokenRes = await fetch(`${ISSUER}/api/auth/oauth2/token`, {
222 method: "POST",
223 headers: {
224 "content-type": "application/x-www-form-urlencoded",
225 authorization: `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`,
226 },
227 body: new URLSearchParams({
228 grant_type: "authorization_code",
229 code,
230 redirect_uri: REDIRECT_URI,
231 code_verifier: verifier,
232 }),
233 });
234 if (!tokenRes.ok) {
235 lastResult = { ok: false, error: `token endpoint ${tokenRes.status}: ${await tokenRes.text()}` };
236 } else {
237 const tokens = (await tokenRes.json()) as { id_token: string; access_token: string };
238 const jwks = createRemoteJWKSet(new URL(`${ISSUER}/api/auth/jwks`));
239 const { payload } = await jwtVerify(tokens.id_token, jwks, {
240 issuer: ISSUER,
241 audience: CLIENT_ID,
242 });
243 const userinfoRes = await fetch(`${ISSUER}/api/auth/oauth2/userinfo`, {
244 headers: { authorization: `Bearer ${tokens.access_token}` },
245 });
246 const userinfo = (await userinfoRes.json()) as Record<string, unknown>;
247 lastResult = { ok: true, claims: payload, idTokenRaw: tokens.id_token, userinfo };
248 }
249 } catch (e) {
250 lastResult = { ok: false, error: e instanceof Error ? e.message : String(e) };
251 }
252 
253 res.writeHead(302, { location: "/" });
254 res.end();
255};
256 
257const server = createServer(async (req, res) => {
258 const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${PORT}`);
259 if (url.pathname === "/" || url.pathname === "") return sendHtml(res, 200, renderHome());
260 if (url.pathname === "/authorize") return handleAuthorize(res);
261 if (url.pathname === "/cb") return handleCallback(req, res);
262 res.writeHead(404, { "content-type": "text/plain" });
263 res.end("not found");
264});
265 
266server.listen(PORT, LOOPBACK_HOST, () => {
267 console.log(`◇ tessera consent harness ready`);
268 console.log(` open http://${LOOPBACK_HOST}:${PORT}/`);
269 console.log(` redirect_uri to register: ${REDIRECT_URI}`);
270 console.log(` do not register ${LOCALHOST_REDIRECT_URI}; localhost != 127.0.0.1 for redirect_uri matching`);
271});