import { createServer, type Server } from "node:http"; import { formatHostForUrl } from "../../node/hostUrl.js"; import { parseOAuthCallbackParams } from "../utils.js"; import { generateOAuthErrorDescription } from "../utils.js"; const DEFAULT_HOSTNAME = "127.0.0.1"; const DEFAULT_CALLBACK_PATH = "/oauth/callback"; const SUCCESS_HTML = ` OAuth complete

OAuth complete. You can close this window.

`; function errorHtml(message: string): string { return ` OAuth error

OAuth failed: ${escapeHtml(message)}

`; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } export type OAuthCallbackHandler = (params: { code: string; state?: string; /** RFC 9207 `iss`, when the authorization server sends it. */ iss?: string; }) => Promise; export type OAuthErrorHandler = (params: { error: string; error_description?: string | null; }) => void; export interface OAuthCallbackServerStartOptions { port?: number; hostname?: string; path?: string; onCallback?: OAuthCallbackHandler; onError?: OAuthErrorHandler; } export interface OAuthCallbackServerStartResult { port: number; redirectUrl: string; } /** * Minimal HTTP server that receives OAuth 2.1 redirects at GET /oauth/callback. * Used by TUI/CLI to complete the authorization code flow. * Caller provides onCallback/onError; typically onCallback calls * InspectorClient.completeOAuthFlow(code) then stops the server. */ export class OAuthCallbackServer { private server: Server | null = null; private port: number = 0; private callbackPath: string = DEFAULT_CALLBACK_PATH; private handled = false; private onCallback?: OAuthCallbackHandler; private onError?: OAuthErrorHandler; /** * Start the server. Port defaults to 0 (random) when omitted; TUI/CLI callers * pass the resolved port from {@link parseRunnerOAuthCallbackUrl} (6276 by default). * Returns port and redirectUrl for use as oauth.redirectUrl. */ async start( options: OAuthCallbackServerStartOptions = {}, ): Promise { const { port = 0, hostname = DEFAULT_HOSTNAME, path = DEFAULT_CALLBACK_PATH, onCallback, onError, } = options; if (!path.startsWith("/")) { return Promise.reject( new Error("Callback path must start with '/' (absolute path)"), ); } this.onCallback = onCallback; this.onError = onError; this.handled = false; this.callbackPath = path; return new Promise((resolve, reject) => { this.server = createServer((req, res) => this.handleRequest(req, res)); this.server.on("error", reject); this.server.listen(port, hostname, () => { const a = this.server!.address(); /* v8 ignore next 4 -- defensive: a TCP server that has fired its `listen` callback always reports an AddressInfo object (never null and never a string, which only happens for a pipe/unix socket), so this guard is unreachable from a successful `listen(port, host)`. */ if (!a || typeof a === "string") { reject(new Error("Failed to get server address")); return; } this.port = a.port; resolve({ port: this.port, redirectUrl: buildRedirectUrl(hostname, this.port, path), }); }); }); } /** * Stop the server. Idempotent. */ async stop(): Promise { if (!this.server) return; await new Promise((resolve) => { this.server!.close(() => resolve()); }); this.server = null; } private handleRequest( req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse< import("node:http").IncomingMessage >, ): void { const needJson = req.headers["accept"]?.includes("application/json"); const send = ( status: number, body: string, contentType = "text/html; charset=utf-8", ) => { res.writeHead(status, { "Content-Type": contentType }); res.end(body); }; if (req.method !== "GET") { send(405, needJson ? '{"error":"Method Not Allowed"}' : SUCCESS_HTML); return; } let pathname: string; let search: string; let state: string | undefined; try { const u = new URL( /* v8 ignore next -- defensive: Node always populates req.url for an HTTP request, so the `?? ""` fallback is never taken. */ req.url ?? "", "http://placeholder", ); pathname = u.pathname; search = u.search; state = u.searchParams.get("state") ?? undefined; } catch { send(400, needJson ? '{"error":"Bad Request"}' : SUCCESS_HTML); return; } if (pathname !== this.callbackPath) { send(404, needJson ? '{"error":"Not Found"}' : SUCCESS_HTML); return; } if (this.handled) { send( 409, needJson ? '{"error":"Callback already handled"}' : SUCCESS_HTML, ); return; } const params = parseOAuthCallbackParams(search); if (params.successful) { this.handled = true; const cb = this.onCallback; if (cb) { cb({ code: params.code, state, iss: params.iss }) .then(() => { send(200, SUCCESS_HTML); void this.stop(); }) .catch((err) => { const msg = err instanceof Error ? err.message : String(err); this.onError?.({ error: "callback_error", error_description: msg }); send(500, errorHtml(msg)); void this.stop(); }); } else { send(200, SUCCESS_HTML); void this.stop(); } return; } this.handled = true; const msg = generateOAuthErrorDescription(params); this.onError?.({ error: params.error, error_description: params.error_description ?? undefined, }); send(400, errorHtml(msg)); } } /** * Create an OAuth callback server instance. * Use start() then stop() when the OAuth flow is done. */ export function createOAuthCallbackServer(): OAuthCallbackServer { return new OAuthCallbackServer(); } function buildRedirectUrl(host: string, port: number, path: string): string { return `http://${formatHostForUrl(host)}:${port}${path}`; }