/** * Loopback HTTP server for the OAuth authorization-code callback. * * The CLI binds a server to `127.0.0.1:0` (ephemeral port), sends the * user's browser to `/v1/oauth/authorize?redirect_uri=…` pointing at * this server, and waits for the redirect carrying `?code=…&state=…`. * * The backend wildcards localhost ports for public clients * (`movio/model/oauth2.py:check_redirect_uri`), so the registered * redirect URI's port is just a placeholder — the actual port the * server lands on is what matters at runtime. * * Times out after 120s. Validates `state` matches the value we * generated. Renders a small "you can close this window" page back * to the browser before shutting down. */ import { timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { AddressInfo } from "node:net"; export interface LoopbackOptions { /** Expected `state` value — flow fails if it doesn't match. */ state: string; /** Timeout in ms (default 120s). */ timeoutMs?: number; /** Override port for tests (default 0 = ephemeral). */ port?: number; } export interface LoopbackResult { /** Authorization code from the IdP. */ code: string; /** The full redirect_uri (with port) we listened on. */ redirectUri: string; } export interface LoopbackHandle { /** Promise that resolves with the captured code, or rejects on timeout/error. */ result: Promise; /** Redirect URI to pass to /v1/oauth/authorize. */ redirectUri: string; /** Stop the server early (e.g. user cancels). */ close: () => Promise; } const CALLBACK_PATH = "/oauth/callback"; const DEFAULT_TIMEOUT_MS = 120_000; export async function startLoopback(opts: LoopbackOptions): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; let resolveResult!: (value: LoopbackResult) => void; let rejectResult!: (err: Error) => void; const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; }); // redirectUri is the value the IdP sees on /authorize. RFC 6749 §4.1.3 // requires the token exchange's redirect_uri to be byte-identical to // it, so we capture this string once and reuse it on both hops — never // reconstructing from req.socket.localAddress later (which can drift // on dual-stack hosts). let redirectUri = ""; const server = createServer((req, res) => handleRequest(req, res, opts.state, redirectUri, resolveResult, rejectResult), ); await listen(server, opts.port ?? 0); const address = server.address() as AddressInfo; redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`; let closed = false; const close = async (): Promise => { if (closed) return; closed = true; clearTimeout(timer); // `server.close()` only refuses NEW connections — it does NOT // terminate existing keep-alive sockets, which browsers default to // and idle for minutes (Chrome ~5min). Without `closeAllConnections` // the CLI process hangs after "Signed in" until the browser closes // its idle socket. `respond()` also emits `Connection: close` so the // browser doesn't try to keep-alive in the first place. server.closeAllConnections?.(); await new Promise((resolve) => server.close(() => resolve())); }; const timer = setTimeout(() => { rejectResult(new Error(`OAuth callback timed out after ${timeoutMs}ms`)); void close(); }, timeoutMs); // When result settles, drain the timer + shutdown. result.finally(close).catch(() => {}); return { result, redirectUri, close }; } async function listen(server: Server, port: number): Promise { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); } // fallow-ignore-next-line complexity function handleRequest( req: IncomingMessage, res: ServerResponse, expectedState: string, redirectUri: string, resolveResult: (value: LoopbackResult) => void, rejectResult: (err: Error) => void, ): void { // Only GET is part of the OAuth redirect contract. Anything else is // probe-traffic on the ephemeral port; reject without leaking that a // CLI is listening there. if (req.method !== "GET") { res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed"); return; } const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (url.pathname !== CALLBACK_PATH) { res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); return; } const params = url.searchParams; const error = params.get("error"); if (error) { const desc = params.get("error_description") ?? ""; respond(res, 400, errorPage(error, desc)); rejectResult(new Error(`OAuth authorize returned error: ${error}${desc ? ` — ${desc}` : ""}`)); return; } const state = params.get("state"); if (!state || !stateMatches(state, expectedState)) { respond(res, 400, errorPage("invalid_state", "State parameter did not match.")); rejectResult(new Error("OAuth state mismatch — possible CSRF, aborting.")); return; } const code = params.get("code"); if (!code) { respond( res, 400, errorPage("missing_code", "Authorization code is missing from the redirect."), ); rejectResult(new Error("OAuth redirect did not include `code`.")); return; } respond(res, 200, successPage()); resolveResult({ code, redirectUri }); } /** * Constant-time comparison for the OAuth `state` parameter. Real * exploitability is very low (loopback, 256-bit entropy, narrow flow * window), but the rest of the auth path uses crypto-grade primitives * and a `!==` here would be a gratuitous deviation in security review. */ function stateMatches(actual: string, expected: string): boolean { const a = Buffer.from(actual, "utf8"); const b = Buffer.from(expected, "utf8"); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } function respond(res: ServerResponse, status: number, body: string): void { res .writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", // Tell the browser not to keep the TCP socket alive — otherwise // `server.close()` blocks on the idle keep-alive timeout // (Chrome ~5min). Combined with `server.closeAllConnections()` // in `close()` this guarantees the CLI exits promptly after the // user sees the success page. connection: "close", }) .end(body); } function successPage(): string { return `Signed in to HeyGen

You're signed in.

You can close this tab and return to your terminal.

`; } function errorPage(code: string, description: string): string { const safeCode = escapeHtml(code); const safeDesc = escapeHtml(description); return `Sign-in failed

Sign-in failed

${safeCode}

${safeDesc}

`; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }