/** * OpenRouter OAuth PKCE flow. * * OpenRouter exchanges an authorization code for a permanent, user-controlled * API key rather than an expiring access/refresh token pair. The callback is * handled by a one-shot loopback server on an ephemeral port. * * NOTE: This module uses Node.js http.createServer for the OAuth callback server. * It is only intended for CLI use, not browser environments. */ import { createServer, type Server, type ServerResponse } from "node:http"; import { getProviderEnvValue } from "../../utils/provider-env.ts"; import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; const AUTHORIZE_URL = "https://openrouter.ai/auth"; const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys"; const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000; function getCallbackHost(): string { return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; } type JsonObject = Record; type OpenRouterCallbackServer = { callbackUrl: string; credential: Promise; close(): void; }; function sendHtml(response: ServerResponse, status: number, html: string): void { response.statusCode = status; response.setHeader("content-type", "text/html; charset=utf-8"); response.setHeader("cache-control", "no-store"); response.end(html); } function errorDetail(body: JsonObject): string | undefined { if (typeof body.error_description === "string") return body.error_description; if (typeof body.message === "string") return body.message; if (typeof body.error === "string") return body.error; if (body.error && typeof body.error === "object" && !Array.isArray(body.error)) { const message = (body.error as JsonObject).message; if (typeof message === "string") return message; } return undefined; } async function exchangeAuthorizationCode( code: string, verifier: string, signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new Error("Login cancelled"); const controller = new AbortController(); const onAbort = () => controller.abort(signal?.reason); signal?.addEventListener("abort", onAbort, { once: true }); const timeout = setTimeout( () => controller.abort(new Error("OpenRouter OAuth token exchange timed out")), TOKEN_EXCHANGE_TIMEOUT_MS, ); let response: Response; let body: JsonObject = {}; try { response = await fetch(TOKEN_URL, { method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }), signal: controller.signal, }); try { const parsed = (await response.json()) as unknown; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as JsonObject; } catch { if (response.ok) throw new Error("OpenRouter OAuth returned invalid JSON"); } } catch (error) { if (signal?.aborted) throw new Error("Login cancelled"); if (controller.signal.aborted) throw new Error("OpenRouter OAuth token exchange timed out"); throw error; } finally { clearTimeout(timeout); signal?.removeEventListener("abort", onAbort); } if (!response.ok) { const detail = errorDetail(body); throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`); } if (typeof body.key !== "string" || body.key.length === 0) { throw new Error('OpenRouter OAuth response carries no "key"'); } return { type: "oauth", access: body.key, refresh: "", expires: Number.MAX_SAFE_INTEGER, }; } async function startCallbackServer( callbackPath: string, verifier: string, signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new Error("Login cancelled"); const callbackHost = getCallbackHost(); let resolveCredential: (credential: OAuthCredential) => void = () => {}; let rejectCredential: (error: Error) => void = () => {}; const credential = new Promise((resolve, reject) => { resolveCredential = resolve; rejectCredential = reject; }); let server: Server; let claimed = false; let settled = false; let timeout: ReturnType | undefined; let onAbort: (() => void) | undefined; const finish = (result: { credential: OAuthCredential } | { error: Error }): void => { if (settled) return; settled = true; if (timeout) clearTimeout(timeout); if (onAbort) signal?.removeEventListener("abort", onAbort); server.close(); if ("credential" in result) resolveCredential(result.credential); else rejectCredential(result.error); }; server = createServer((request, response) => { void (async () => { const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`); if (request.method !== "GET" || requestUrl.pathname !== callbackPath) { sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found.")); return; } if (claimed || settled) { sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used.")); return; } const oauthError = requestUrl.searchParams.get("error"); if (oauthError) { const description = requestUrl.searchParams.get("error_description") ?? oauthError; sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description)); finish({ error: new Error(`OpenRouter authorization failed: ${description}`) }); return; } const code = requestUrl.searchParams.get("code"); if (!code) { sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code.")); return; } claimed = true; try { const result = await exchangeAuthorizationCode(code, verifier, signal); sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page.")); finish({ credential: result }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown token exchange error"; sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message)); finish({ error: error instanceof Error ? error : new Error(message) }); } })(); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, callbackHost, () => { server.removeListener("error", reject); resolve(); }); }); server.on("error", (error) => finish({ error })); onAbort = () => finish({ error: new Error("Login cancelled") }); signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) { signal.removeEventListener("abort", onAbort); server.close(); throw new Error("Login cancelled"); } timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS); const address = server.address(); if (!address || typeof address === "string") { finish({ error: new Error("Could not determine the OpenRouter OAuth callback port") }); throw new Error("Could not determine the OpenRouter OAuth callback port"); } return { callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`, credential, close: () => finish({ error: new Error("Login cancelled") }), }; } async function loginOpenRouter(interaction: AuthInteraction): Promise { const { verifier, challenge } = await generatePKCE(); const callbackPath = `/oauth/callback/${crypto.randomUUID()}`; const callback = await startCallbackServer(callbackPath, verifier, interaction.signal); const authorizeUrl = new URL(AUTHORIZE_URL); authorizeUrl.search = new URLSearchParams({ callback_url: callback.callbackUrl, code_challenge: challenge, code_challenge_method: "S256", }).toString(); interaction.notify({ type: "progress", message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`, }); interaction.notify({ type: "auth_url", url: authorizeUrl.toString(), instructions: "Complete sign-in in your browser.", }); try { return await callback.credential; } finally { callback.close(); } } export const openRouterOAuth: OAuthAuth = { name: "OpenRouter OAuth", loginLabel: "Sign in with OpenRouter", login: loginOpenRouter, async refresh(credential) { return credential; }, async toAuth(credential) { return { apiKey: credential.access }; }, };