import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { isWSL2Sync } from "../../src/infra/wsl.js"; import { resolveOAuthClientConfig } from "./oauth.credentials.js"; import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js"; export function shouldUseManualOAuthFlow(isRemote: boolean): boolean { return isRemote || isWSL2Sync(); } export function generatePkce(): { verifier: string; challenge: string } { const verifier = randomBytes(32).toString("hex"); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } export function buildAuthUrl(challenge: string, verifier: string): string { const { clientId } = resolveOAuthClientConfig(); const params = new URLSearchParams({ client_id: clientId, response_type: "code", redirect_uri: REDIRECT_URI, scope: SCOPES.join(" "), code_challenge: challenge, code_challenge_method: "S256", state: verifier, access_type: "offline", prompt: "consent", }); return `${AUTH_URL}?${params.toString()}`; } export function parseCallbackInput( input: string, expectedState: string, ): { code: string; state: string } | { error: string } { const trimmed = input.trim(); if (!trimmed) { return { error: "No input provided" }; } try { const url = new URL(trimmed); const code = url.searchParams.get("code"); const state = url.searchParams.get("state") ?? expectedState; if (!code) { return { error: "Missing 'code' parameter in URL" }; } if (!state) { return { error: "Missing 'state' parameter. Paste the full URL." }; } return { code, state }; } catch { if (!expectedState) { return { error: "Paste the full redirect URL, not just the code." }; } return { code: trimmed, state: expectedState }; } } export async function waitForLocalCallback(params: { expectedState: string; timeoutMs: number; onProgress?: (message: string) => void; }): Promise<{ code: string; state: string }> { const port = 8085; const hostname = "localhost"; const expectedPath = "/oauth2callback"; return new Promise<{ code: string; state: string }>((resolve, reject) => { let timeout: NodeJS.Timeout | null = null; const server = createServer((req, res) => { try { const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); if (requestUrl.pathname !== expectedPath) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain"); res.end("Not found"); return; } const error = requestUrl.searchParams.get("error"); const code = requestUrl.searchParams.get("code")?.trim(); const state = requestUrl.searchParams.get("state")?.trim(); if (error) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); res.end(`Authentication failed: ${error}`); finish(new Error(`OAuth error: ${error}`)); return; } if (!code || !state) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); res.end("Missing code or state"); finish(new Error("Missing OAuth code or state")); return; } if (state !== params.expectedState) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); res.end("Invalid state"); finish(new Error("OAuth state mismatch")); return; } res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end( "
" + "You can close this window and return to OpenClaw.
", ); finish(undefined, { code, state }); } catch (err) { finish(err instanceof Error ? err : new Error("OAuth callback failed")); } }); const finish = (err?: Error, result?: { code: string; state: string }) => { if (timeout) { clearTimeout(timeout); } try { server.close(); } catch { // ignore close errors } if (err) { reject(err); } else if (result) { resolve(result); } }; server.once("error", (err) => { finish(err instanceof Error ? err : new Error("OAuth callback server error")); }); server.listen(port, hostname, () => { params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); }); timeout = setTimeout(() => { finish(new Error("OAuth callback timeout")); }, params.timeoutMs); }); }