import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; import { CHUTES_AUTHORIZE_ENDPOINT, exchangeChutesCodeForTokens, generateChutesPkce, parseOAuthCallbackInput, } from "../agents/chutes-oauth.js"; type OAuthPrompt = { message: string; placeholder?: string; }; function buildAuthorizeUrl(params: { clientId: string; redirectUri: string; scopes: string[]; state: string; challenge: string; }): string { const qs = new URLSearchParams({ client_id: params.clientId, redirect_uri: params.redirectUri, response_type: "code", scope: params.scopes.join(" "), state: params.state, code_challenge: params.challenge, code_challenge_method: "S256", }); return `${CHUTES_AUTHORIZE_ENDPOINT}?${qs.toString()}`; } async function waitForLocalCallback(params: { redirectUri: string; expectedState: string; timeoutMs: number; onProgress?: (message: string) => void; }): Promise<{ code: string; state: string }> { const redirectUrl = new URL(params.redirectUri); if (redirectUrl.protocol !== "http:") { throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`); } const hostname = redirectUrl.hostname || "127.0.0.1"; const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; const expectedPath = redirectUrl.pathname || "/"; return await 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 ?? "/", redirectUrl.origin); if (requestUrl.pathname !== expectedPath) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not found"); return; } const code = requestUrl.searchParams.get("code")?.trim(); const state = requestUrl.searchParams.get("state")?.trim(); if (!code) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Missing code"); return; } if (!state || state !== params.expectedState) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Invalid state"); return; } res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end( [ "", "
", "You can close this window and return to clawdbot.
", ].join(""), ); if (timeout) clearTimeout(timeout); server.close(); resolve({ code, state }); } catch (err) { if (timeout) clearTimeout(timeout); server.close(); reject(err); } }); server.once("error", (err) => { if (timeout) clearTimeout(timeout); server.close(); reject(err); }); server.listen(port, hostname, () => { params.onProgress?.(`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`); }); timeout = setTimeout(() => { try { server.close(); } catch {} reject(new Error("OAuth callback timeout")); }, params.timeoutMs); }); } export async function loginChutes(params: { app: ChutesOAuthAppConfig; manual?: boolean; timeoutMs?: number; createPkce?: typeof generateChutesPkce; createState?: () => string; onAuth: (event: { url: string }) => Promise