UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

209 lines (208 loc) 8.97 kB
//#region src/infra/oauth-loopback-callback.ts function unbracket(hostname) { return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; } function isLoopbackAddress(address) { if (address === "::1") return true; const octets = address.split(".").map(Number); return octets.length === 4 && octets[0] === 127 && octets.every((octet) => octet >= 0 && octet <= 255); } function resolveLoopbackHostname(hostname, lookupOverride) { if (hostname === "127.0.0.1" || hostname === "::1") return [hostname]; if (hostname !== "localhost") throw new Error("OAuth callback redirect must use localhost, 127.0.0.1, or ::1"); return (lookupOverride ? Promise.resolve(lookupOverride) : import("node:dns/promises").then(({ lookup }) => lookup)).then(async (lookup) => { const addresses = [...new Set((await lookup("localhost", { all: true, verbatim: true })).map(({ address }) => address))]; if (addresses.length === 0 || addresses.some((address) => !isLoopbackAddress(address))) throw new Error("localhost did not resolve exclusively to loopback addresses"); return addresses; }); } function resolveBindAddresses(redirectUrl, bindHostname, lookup) { const redirectHostname = unbracket(redirectUrl.hostname); const redirectAddresses = resolveLoopbackHostname(redirectHostname, lookup); const requestedHostname = bindHostname ? unbracket(bindHostname) : redirectHostname; if (requestedHostname === redirectHostname) return redirectAddresses; const requestedAddresses = resolveLoopbackHostname(requestedHostname, lookup); return Promise.all([redirectAddresses, requestedAddresses]).then(([redirect, requested]) => [.../* @__PURE__ */ new Set([...requested, ...redirect])]); } async function waitForAbortable(promise, signal) { if (!signal) return await promise; return await new Promise((resolve, reject) => { const abort = () => reject(/* @__PURE__ */ new Error("OAuth callback cancelled")); signal.addEventListener("abort", abort, { once: true }); promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); if (signal.aborted) abort(); }); } function resolveOAuthLoopbackPort(redirectUrl) { const port = redirectUrl.port ? Number(redirectUrl.port) : 80; if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error("OAuth callback redirect must use a valid TCP port"); return port; } function prepareResponse(request, response, resolveCorsOrigin) { response.setHeader("Cache-Control", "no-store"); response.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'"); response.setHeader("Referrer-Policy", "no-referrer"); response.setHeader("X-Content-Type-Options", "nosniff"); const origin = resolveCorsOrigin?.(request.headers.origin); if (!origin) return; response.setHeader("Access-Control-Allow-Origin", origin); response.setHeader("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"); response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); response.setHeader("Access-Control-Allow-Headers", typeof request.headers["access-control-request-headers"] === "string" ? request.headers["access-control-request-headers"] : "content-type"); response.setHeader("Access-Control-Allow-Private-Network", "true"); response.setHeader("Access-Control-Max-Age", "600"); } async function closeServers(servers) { await Promise.all(servers.map((server) => new Promise((resolve) => { if (!server.listening) { resolve(); return; } server.close(() => resolve()); server.closeAllConnections?.(); }))); } /** Binds the authoritative loopback redirect before returning, then waits separately. */ async function startOAuthLoopbackCallbackServer(params) { const redirectUrl = new URL(params.redirectUrl); const redirectHostname = unbracket(redirectUrl.hostname); if (redirectUrl.protocol !== "http:" || ![ "localhost", "127.0.0.1", "::1" ].includes(redirectHostname)) throw new Error("OAuth callback redirect must use HTTP on a loopback address"); if (!params.expectedState || !Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0) throw new Error("OAuth callback requires state and a positive timeout"); if (params.signal?.aborted) throw new Error("OAuth callback cancelled"); const resolvedAddresses = resolveBindAddresses(redirectUrl, params.bindHostname, params.lookup); const addresses = Array.isArray(resolvedAddresses) ? resolvedAddresses : await waitForAbortable(resolvedAddresses, params.signal); const port = resolveOAuthLoopbackPort(redirectUrl); const callbackPath = redirectUrl.pathname || "/"; const createServer = params.createServer ?? (await import("node:http")).createServer; const servers = []; let settled = false; let binding = true; const timeoutRef = {}; let closePromise; let resolveWait; let rejectWait; const waitPromise = new Promise((resolve, reject) => { resolveWait = resolve; rejectWait = reject; }); waitPromise.catch(() => void 0); const close = () => binding ? Promise.resolve() : closePromise ??= closeServers(servers); const cleanup = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); params.signal?.removeEventListener("abort", onAbort); }; const settleError = (error) => { if (settled) return; settled = true; cleanup(); rejectWait(error instanceof Error ? error : /* @__PURE__ */ new Error("OAuth callback failed")); close(); }; const onAbort = () => settleError(/* @__PURE__ */ new Error("OAuth callback cancelled")); const settleResult = (result, response) => { if (settled) return; settled = true; cleanup(); let finished = false; const finish = () => { if (finished) return; finished = true; resolveWait(result); close(); }; response.once("finish", finish); response.once("close", finish); }; const renderSuccess = params.renderSuccess ?? (() => ({ body: "Authorization received; return to the terminal while OpenClaw finishes.", contentType: "text/plain; charset=utf-8" })); const renderError = params.renderError ?? ((message) => ({ body: message, contentType: "text/plain; charset=utf-8" })); const respond = (response, status, rendered) => { response.writeHead(status, { "Content-Type": rendered.contentType }); response.end(rendered.body); }; const handleRequest = (request, response) => { try { prepareResponse(request, response, params.resolveCorsOrigin); if (settled) respond(response, 409, renderError("OAuth callback was already received.")); else if (request.method === "OPTIONS") response.writeHead(204).end(); else { const url = new URL(request.url ?? "/", redirectUrl.origin); if (url.pathname !== callbackPath) respond(response, 404, renderError("Callback route not found.")); else if (request.method !== "GET") { response.setHeader("Allow", "GET, OPTIONS"); respond(response, 405, renderError("Method not allowed.")); } else if (url.searchParams.get("state") !== params.expectedState) respond(response, 400, renderError("Invalid OAuth state.")); else if (url.searchParams.has("error")) { const error = url.searchParams.get("error"); const errorDescription = url.searchParams.get("error_description") ?? void 0; settleResult({ type: "oauth_error", error, ...errorDescription ? { errorDescription } : {} }, response); respond(response, 400, renderError("Authorization was not completed.")); } else { const code = url.searchParams.get("code")?.trim(); if (!code) respond(response, 400, renderError("Missing OAuth authorization code.")); else { settleResult({ type: "authorization_code", code, state: params.expectedState }, response); respond(response, 200, renderSuccess()); } } } } catch (error) { if (!response.headersSent) respond(response, 500, renderError("OAuth callback failed.")); settleError(error); } }; params.signal?.addEventListener("abort", onAbort, { once: true }); if (params.signal?.aborted) { onAbort(); throw new Error("OAuth callback cancelled"); } try { for (const address of addresses) { const server = createServer(handleRequest); servers.push(server); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, address, resolve); }); server.removeAllListeners("error"); server.on("error", settleError); if (settled) throw new Error("OAuth callback cancelled"); } } catch (error) { binding = false; cleanup(); await closeServers(servers); throw error; } binding = false; timeoutRef.current = setTimeout(() => settleError(/* @__PURE__ */ new Error("OAuth callback timeout")), params.timeoutMs); return { waitForCallback: () => waitPromise, close: async () => { if (!settled) settleError(/* @__PURE__ */ new Error("OAuth callback cancelled")); await close(); } }; } //#endregion export { startOAuthLoopbackCallbackServer as t };