File
Blob: scripts/test-consent/run.ts
| 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 | */ |
| 24 | import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; |
| 25 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; |
| 26 | |
| 27 | const LOOPBACK_HOST = "127.0.0.1"; |
| 28 | const ISSUER = (process.env.ISSUER ?? "http://localhost:5174").replace(/\/+$/, ""); |
| 29 | const PORT = Number.parseInt(process.env.PORT ?? "7878", 10); |
| 30 | const CLIENT_ID = process.env.CLIENT_ID; |
| 31 | const CLIENT_SECRET = process.env.CLIENT_SECRET; |
| 32 | const REDIRECT_URI = `http://${LOOPBACK_HOST}:${PORT}/cb`; |
| 33 | const LOCALHOST_REDIRECT_URI = `http://localhost:${PORT}/cb`; |
| 34 | |
| 35 | if (!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 | |
| 43 | const b64url = (bytes: Uint8Array): string => |
| 44 | Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 45 | |
| 46 | const escapeHtml = (s: string): string => |
| 47 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); |
| 48 | |
| 49 | interface PkcePair { |
| 50 | verifier: string; |
| 51 | challenge: string; |
| 52 | } |
| 53 | |
| 54 | const 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. |
| 62 | let pendingPkce: { verifier: string; state: string } | null = null; |
| 63 | let 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 | |
| 73 | const sendHtml = (res: ServerResponse, status: number, body: string) => { |
| 74 | res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); |
| 75 | res.end(body); |
| 76 | }; |
| 77 | |
| 78 | const 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 | |
| 130 | const 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=<copied> CLIENT_SECRET=<copied> 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 | |
| 157 | const 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 | |
| 171 | const 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 | |
| 189 | const 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 | |
| 257 | const 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 | |
| 266 | server.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 | }); |