diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f837885ab..a78e43eee 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -21,6 +21,11 @@ - Added `onProviderStreamEvent` to observe parsed provider stream events before normalization, including provider-specific fields not retained in assistant messages ([#9784](https://github.com/earendil-works/pi/issues/9784)). - Added Claude Sonnet 5.5 to the built-in Anthropic model catalog with adaptive thinking, mid-conversation effort, 1M context, and official pricing metadata. +### Changed + +- Unified the Anthropic, OpenAI Codex, OpenRouter, and Radius browser sign-in callback servers into one shared implementation with the same browser pages. The OAuth page helpers are now available as `@earendil-works/pi-ai/utils/oauth-page`. +- Changed Radius browser sign-in to exchange the authorization code before showing the browser page, so token exchange failures are shown in the browser. + ### Fixed - Fixed 1-hour Anthropic cache writes reported by Vercel AI Gateway in streaming deltas being priced at the 5-minute rate ([#9210](https://github.com/earendil-works/pi/issues/9210)). @@ -30,6 +35,8 @@ - Fixed Mistral reasoning models ignoring the requested thinking level: GLM 5.3 now uses `reasoning_effort` instead of `prompt_mode`, GLM 5.2 accepts `max`, and Mistral models only offer the effort levels the API supports ([#9678](https://github.com/earendil-works/pi/issues/9678)). - Fixed OpenCode Zen and OpenCode Go `qwen3.8-flash` thinking being replayed as plain text on later turns because the endpoint returns empty thinking signatures ([#10047](https://github.com/earendil-works/pi/issues/10047)). - Fixed OpenAI Responses streams returning unfinished tool calls as runnable, which made servers that omit `output_index` (such as llama.cpp) run mixed-up commands; such streams now end with an error ([#9974](https://github.com/earendil-works/pi/issues/9974)). +- Fixed Anthropic and OpenAI Codex browser sign-in waiting indefinitely after the provider redirected with an authorization error; sign-in now fails with the provider's error description. +- Fixed Anthropic browser sign-in failing when its callback port is in use; it now falls back to pasting the redirect URL. ## [0.87.1] - 2026-09-22 diff --git a/packages/ai/src/auth/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts index a816669d2..7cb90201e 100644 --- a/packages/ai/src/auth/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -5,26 +5,11 @@ * It is only intended for CLI use, not browser environments. */ -import type { Server } from "node:http"; import { getProviderEnvValue } from "../../utils/provider-env.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts"; import { generatePKCE } from "./pkce.ts"; -type CallbackServerInfo = { - server: Server; - redirectUri: string; - cancelWait: () => void; - waitForCode: () => Promise<{ code: string; state: string } | null>; -}; - -type NodeApis = { - createServer: typeof import("node:http").createServer; -}; - -let nodeApis: NodeApis | null = null; -let nodeApisPromise: Promise | null = null; - const decode = (s: string) => atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; @@ -35,19 +20,6 @@ const CALLBACK_PATH = "/callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; -async function getNodeApis(): Promise { - if (nodeApis) return nodeApis; - if (!nodeApisPromise) { - if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { - throw new Error("Anthropic OAuth is only available in Node.js environments"); - } - nodeApisPromise = import("node:http").then((httpModule) => ({ - createServer: httpModule.createServer, - })); - } - nodeApis = await nodeApisPromise; - return nodeApis; -} function parseAuthorizationInput(input: string): { code?: string; state?: string } { const value = input.trim(); @@ -96,77 +68,6 @@ function formatErrorDetails(error: unknown): string { return String(error); } -async function startCallbackServer(expectedState: string): Promise { - const { createServer } = await getNodeApis(); - - return new Promise((resolve, reject) => { - let settleWait: ((value: { code: string; state: string } | null) => void) | undefined; - const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => { - let settled = false; - settleWait = (value) => { - if (settled) return; - settled = true; - resolveWait(value); - }; - }); - - const server = createServer((req, res) => { - try { - const url = new URL(req.url || "", "http://localhost"); - if (url.pathname !== CALLBACK_PATH) { - res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("Callback route not found.")); - return; - } - - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - if (error) { - res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`)); - return; - } - - if (!code || !state) { - res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("Missing code or state parameter.")); - return; - } - - if (state !== expectedState) { - res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("State mismatch.")); - return; - } - - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window.")); - settleWait?.({ code, state }); - } catch { - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Internal error"); - } - }); - - server.on("error", (err) => { - reject(err); - }); - - server.listen(CALLBACK_PORT, CALLBACK_HOST, () => { - resolve({ - server, - redirectUri: REDIRECT_URI, - cancelWait: () => { - settleWait?.(null); - }, - waitForCode: () => waitForCodePromise, - }); - }); - }); -} - async function postJson(url: string, body: Record, signal: AbortSignal): Promise { const response = await fetch(url, { method: "POST", @@ -233,15 +134,15 @@ async function exchangeAuthorizationCode( async function loginAnthropic(interaction: ProviderAuthInteraction): Promise { const { verifier, challenge } = await generatePKCE(); - const server = await startCallbackServer(verifier); - const manualAbort = new AbortController(); - const onAbort = () => server.cancelWait(); - interaction.signal.addEventListener("abort", onAbort, { once: true }); - if (interaction.signal.aborted) onAbort(); - let code: string | undefined; - let state: string | undefined; - let manualInput: string | undefined; - let manualError: Error | undefined; + const callback = await startOAuthCallbackServer({ + providerName: "Anthropic", + host: CALLBACK_HOST, + port: CALLBACK_PORT, + path: CALLBACK_PATH, + state: verifier, + complete: async (code) => code, + signal: interaction.signal, + }).catch(() => undefined); try { const authParams = new URLSearchParams({ @@ -261,53 +162,26 @@ async function loginAnthropic(interaction: ProviderAuthInteraction): Promise { - manualInput = input; - server.cancelWait(); - }) - .catch((error) => { - manualError = error instanceof Error ? error : new Error(String(error)); - server.cancelWait(); - }); - - const result = await server.waitForCode(); - if (manualError) throw manualError; - if (result?.code) { - code = result.code; - state = result.state; - } else if (manualInput) { - const parsed = parseAuthorizationInput(manualInput); + const result = await waitForCallbackOrManualInput(interaction, callback, { + message: "Complete login in your browser, or paste the authorization code / redirect URL here:", + placeholder: REDIRECT_URI, + }); + let code: string | undefined; + let state = verifier; + if (result.type === "callback") { + code = result.value; + } else { + const parsed = parseAuthorizationInput(result.input); if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch"); code = parsed.code; state = parsed.state ?? verifier; } - if (!code) { - await manualPromise; - if (manualError) throw manualError; - if (manualInput) { - const parsed = parseAuthorizationInput(manualInput); - if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch"); - code = parsed.code; - state = parsed.state ?? verifier; - } - } - if (!code) throw new Error("Missing authorization code"); - if (!state) throw new Error("Missing OAuth state"); interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." }); - return exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, interaction.signal); + return await exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, interaction.signal); } finally { - interaction.signal.removeEventListener("abort", onAbort); - manualAbort.abort(); - server.server.close(); + callback?.close(); } } diff --git a/packages/ai/src/auth/oauth/callback-server.ts b/packages/ai/src/auth/oauth/callback-server.ts new file mode 100644 index 000000000..00aa6aea1 --- /dev/null +++ b/packages/ai/src/auth/oauth/callback-server.ts @@ -0,0 +1,183 @@ +/** + * Loopback OAuth redirect handler shared by the browser sign-in flows. + * + * NOTE: This module uses node:http. It is only reachable through the lazily + * loaded OAuth flow modules, never from browser-facing entry points. + */ + +import { createServer, type ServerResponse } from "node:http"; +import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts"; +import type { ProviderAuthInteraction } from "../types.ts"; + +export interface OAuthCallbackServerOptions { + /** Provider name used on the browser page, for example `OpenAI`. */ + providerName: string; + /** Address to listen on. */ + host: string; + /** Port to listen on; `0` picks a free port. */ + port: number; + path: string; + /** Host in `redirectUri` when it differs from `host`, for example `localhost`. */ + redirectHost?: string; + /** Expected `state` parameter. Omit when the provider does not send one. */ + state?: string; + /** + * Finishes the sign-in with the received code before the browser page is sent, so the page can + * show exchange failures. Pass `async (code) => code` to exchange the code later. + */ + complete: (code: string) => Promise; + signal?: AbortSignal; + timeoutMs?: number; +} + +export interface OAuthCallbackServer { + redirectUri: string; + /** + * Resolves with the result of `complete`, or `undefined` after `cancel()`. Rejects when the provider + * redirects with an error, `complete` fails, the signal aborts, or the timeout elapses. + */ + wait(): Promise; + /** Stop waiting for the browser unless a callback is already being completed. */ + cancel(): void; + close(): void; +} + +function sendPage(response: ServerResponse, status: number, html: string): void { + response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + response.end(html); +} + +export async function startOAuthCallbackServer( + options: OAuthCallbackServerOptions, +): Promise> { + const { providerName, signal } = options; + if (signal?.aborted) throw new Error("Login cancelled"); + + let resolveWait: (value: T | undefined) => void = () => {}; + let rejectWait: (error: Error) => void = () => {}; + const waitPromise = new Promise((resolve, reject) => { + resolveWait = resolve; + rejectWait = reject; + }); + // A cancelled or closed wait may never be observed. + waitPromise.catch(() => undefined); + + let claimed = false; + let settled = false; + let timer: ReturnType | undefined; + const onAbort = () => finish({ error: new Error("Login cancelled") }); + const finish = (result: { value: T | undefined } | { error: Error }): void => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + if ("error" in result) rejectWait(result.error); + else resolveWait(result.value); + }; + + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (request.method !== "GET" || url.pathname !== options.path) { + sendPage(response, 404, oauthErrorHtml("Callback route not found.")); + return; + } + if (options.state !== undefined && url.searchParams.get("state") !== options.state) { + sendPage(response, 400, oauthErrorHtml("State mismatch.")); + return; + } + if (claimed || settled) { + sendPage(response, 409, oauthErrorHtml("This sign-in has already been handled.")); + return; + } + const error = url.searchParams.get("error"); + if (error) { + const description = url.searchParams.get("error_description") ?? error; + sendPage(response, 400, oauthErrorHtml(`${providerName} authorization failed.`, description)); + finish({ error: new Error(`${providerName} authorization failed: ${description}`) }); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + sendPage(response, 400, oauthErrorHtml("Missing authorization code.")); + return; + } + claimed = true; + try { + const value = await options.complete(code); + sendPage(response, 200, oauthSuccessHtml(`Signed in to ${providerName}. You may now close this page.`)); + finish({ value }); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + sendPage(response, 502, oauthErrorHtml(`${providerName} sign-in failed.`, failure.message)); + finish({ error: failure }); + } + })(); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port, options.host, () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("OAuth callback server did not bind to TCP"); + } + + server.on("error", (error) => finish({ error })); + signal?.addEventListener("abort", onAbort, { once: true }); + if (options.timeoutMs !== undefined) { + timer = setTimeout(() => finish({ error: new Error(`${providerName} sign-in timed out`) }), options.timeoutMs); + } + const redirectHost = options.redirectHost ?? options.host; + return { + redirectUri: `http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${options.path}`, + wait: () => waitPromise, + cancel: () => { + if (!claimed) finish({ value: undefined }); + }, + close: () => { + finish({ error: new Error("OAuth callback server closed") }); + server.close(); + }, + }; +} + +/** + * Wait for the browser callback, or for the user to paste the code or redirect URL when the browser + * cannot reach the loopback server (for example over SSH). Without a callback server only the manual + * prompt is used. + */ +export async function waitForCallbackOrManualInput( + interaction: ProviderAuthInteraction, + callback: OAuthCallbackServer | undefined, + prompt: { message: string; placeholder: string }, +): Promise<{ type: "callback"; value: T } | { type: "manual"; input: string }> { + const manualAbort = new AbortController(); + let manualError: Error | undefined; + const manual = interaction + .prompt({ type: "manual_code", ...prompt, signal: manualAbort.signal }) + .then((input) => { + callback?.cancel(); + return input; + }) + .catch((error: unknown) => { + manualError = error instanceof Error ? error : new Error(String(error)); + callback?.cancel(); + return undefined; + }); + try { + const value = await callback?.wait(); + if (manualError) throw manualError; + if (value !== undefined) return { type: "callback", value }; + const input = await manual; + if (manualError) throw manualError; + return { type: "manual", input: input ?? "" }; + } finally { + manualAbort.abort(); + } +} diff --git a/packages/ai/src/auth/oauth/openai-codex.ts b/packages/ai/src/auth/oauth/openai-codex.ts index 96e861a94..d2917f1e1 100644 --- a/packages/ai/src/auth/oauth/openai-codex.ts +++ b/packages/ai/src/auth/oauth/openai-codex.ts @@ -1,26 +1,22 @@ /** * OpenAI Codex (ChatGPT OAuth) flow * - * NOTE: This module uses Node.js crypto and http for the OAuth callback. + * NOTE: This module uses Node.js crypto and http (via callback-server.ts) for the OAuth callback. * It is only intended for CLI use, not browser environments. */ // NEVER convert to top-level imports - breaks browser/Vite builds let _randomBytes: typeof import("node:crypto").randomBytes | null = null; -let _http: typeof import("node:http") | null = null; if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { import("node:crypto").then((m) => { _randomBytes = m.randomBytes; }); - import("node:http").then((m) => { - _http = m; - }); } import { getProviderEnvValue } from "../../utils/provider-env.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; +import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -311,88 +307,6 @@ async function createAuthorizationFlow( return { verifier, state, url: url.toString() }; } -type OAuthServerInfo = { - close: () => void; - cancelWait: () => void; - waitForCode: () => Promise<{ code: string } | null>; -}; - -function startLocalOAuthServer(state: string): Promise { - if (!_http) { - throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); - } - - let settleWait: ((value: { code: string } | null) => void) | undefined; - const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => { - let settled = false; - settleWait = (value) => { - if (settled) return; - settled = true; - resolve(value); - }; - }); - - const server = _http.createServer((req, res) => { - try { - const url = new URL(req.url || "", "http://localhost"); - if (url.pathname !== "/auth/callback") { - res.statusCode = 404; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Callback route not found.")); - return; - } - if (url.searchParams.get("state") !== state) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("State mismatch.")); - return; - } - const code = url.searchParams.get("code"); - if (!code) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Missing authorization code.")); - return; - } - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); - settleWait?.({ code }); - } catch { - res.statusCode = 500; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); - } - }); - - return new Promise((resolve) => { - server - .listen(1455, getCallbackHost(), () => { - resolve({ - close: () => server.close(), - cancelWait: () => { - settleWait?.(null); - }, - waitForCode: () => waitForCodePromise, - }); - }) - .on("error", (_err: NodeJS.ErrnoException) => { - settleWait?.(null); - resolve({ - close: () => { - try { - server.close(); - } catch { - // ignore - } - }, - cancelWait: () => {}, - waitForCode: async () => null, - }); - }); - }); -} - function getAccountId(accessToken: string): string | null { const payload = decodeJwt(accessToken); const auth = payload?.[JWT_CLAIM_PATH]; @@ -444,14 +358,16 @@ async function loginOpenAICodexDeviceCode(interaction: ProviderAuthInteraction): async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise { const { verifier, state, url } = await createAuthorizationFlow(); - const server = await startLocalOAuthServer(state); - const manualAbort = new AbortController(); - const onAbort = () => server.cancelWait(); - interaction.signal.addEventListener("abort", onAbort, { once: true }); - if (interaction.signal.aborted) onAbort(); - let code: string | undefined; - let manualCode: string | undefined; - let manualError: Error | undefined; + // Port 1455 is shared with the Codex CLI; when it is taken, fall back to the pasted redirect URL. + const callback = await startOAuthCallbackServer({ + providerName: "OpenAI", + host: getCallbackHost(), + port: 1455, + path: "/auth/callback", + state, + complete: async (code) => code, + signal: interaction.signal, + }).catch(() => undefined); interaction.notify({ type: "auth_url", @@ -460,48 +376,23 @@ async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise { - manualCode = input; - server.cancelWait(); - }) - .catch((error) => { - manualError = error instanceof Error ? error : new Error(String(error)); - server.cancelWait(); - }); - - const result = await server.waitForCode(); - if (manualError) throw manualError; - if (result?.code) { - code = result.code; - } else if (manualCode) { - const parsed = parseAuthorizationInput(manualCode); + const result = await waitForCallbackOrManualInput(interaction, callback, { + message: "Complete login in your browser, or paste the authorization code / redirect URL here:", + placeholder: REDIRECT_URI, + }); + let code: string | undefined; + if (result.type === "callback") { + code = result.value; + } else { + const parsed = parseAuthorizationInput(result.input); if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); code = parsed.code; } - if (!code) { - await manualPromise; - if (manualError) throw manualError; - if (manualCode) { - const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); - code = parsed.code; - } - } - if (!code) throw new Error("Missing authorization code"); - return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal); + return await exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal); } finally { - interaction.signal.removeEventListener("abort", onAbort); - manualAbort.abort(); - server.close(); + callback?.close(); } } diff --git a/packages/ai/src/auth/oauth/openrouter.ts b/packages/ai/src/auth/oauth/openrouter.ts index b16be2a5b..7d6e2863c 100644 --- a/packages/ai/src/auth/oauth/openrouter.ts +++ b/packages/ai/src/auth/oauth/openrouter.ts @@ -7,14 +7,13 @@ * manual prompt so remote/headless sessions can paste the redirect URL when * the browser cannot reach the loopback server. * - * NOTE: This module uses Node.js http.createServer for the OAuth callback server. + * NOTE: This module uses node:http (via callback-server.ts) 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 { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts"; import { generatePKCE } from "./pkce.ts"; const AUTHORIZE_URL = "https://openrouter.ai/auth"; @@ -28,27 +27,6 @@ function getCallbackHost(): string { type JsonObject = Record; -type OpenRouterCallbackServer = { - callbackUrl: string; - /** Stop listening and release timers without settling `waitForCredential`. */ - close: () => void; - /** Hand the login over to manual code entry unless a callback already claimed the exchange. */ - cancelWait: () => void; - /** - * Resolves with the credential once a browser callback completes the key - * exchange, or with null once `cancelWait` hands the login over to manual - * code entry. Rejects on timeout, cancellation, or a failed exchange. - */ - waitForCredential: () => Promise; -}; - -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 parseAuthorizationInput(input: string): string | undefined { const value = input.trim(); if (!value) return undefined; @@ -132,132 +110,30 @@ async function exchangeAuthorizationCode( }; } -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 | null) => 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 close = (): void => { - if (timeout) clearTimeout(timeout); - if (onAbort) signal.removeEventListener("abort", onAbort); - server.close(); - }; - - const finish = (result: { credential: OAuthCredential | null } | { error: Error }): void => { - if (settled) return; - settled = true; - 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) { - 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") { - close(); - throw new Error("Could not determine the OpenRouter OAuth callback port"); - } - - return { - callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`, - close, - // A claimed callback is already exchanging its code; let that exchange settle the login. - cancelWait: () => { - if (!claimed) finish({ credential: null }); - }, - waitForCredential: () => credential, - }; -} - async function loginOpenRouter(interaction: ProviderAuthInteraction): Promise { const { verifier, challenge } = await generatePKCE(); - const callbackPath = `/oauth/callback/${crypto.randomUUID()}`; - const callback = await startCallbackServer(callbackPath, verifier, interaction.signal); - const manualAbort = new AbortController(); - let manualInput: string | undefined; - let manualError: Error | undefined; + // OpenRouter sends no `state`; the random path keeps stray requests from completing the sign-in. + const callback = await startOAuthCallbackServer({ + providerName: "OpenRouter", + host: getCallbackHost(), + port: 0, + path: `/oauth/callback/${crypto.randomUUID()}`, + complete: (code) => exchangeAuthorizationCode(code, verifier, interaction.signal), + signal: interaction.signal, + timeoutMs: LOGIN_TIMEOUT_MS, + }); try { const authorizeUrl = new URL(AUTHORIZE_URL); authorizeUrl.search = new URLSearchParams({ - callback_url: callback.callbackUrl, + callback_url: callback.redirectUri, code_challenge: challenge, code_challenge_method: "S256", }).toString(); interaction.notify({ type: "progress", - message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`, + message: `Listening for OpenRouter OAuth callback on ${callback.redirectUri}`, }); interaction.notify({ type: "auth_url", @@ -266,34 +142,16 @@ async function loginOpenRouter(interaction: ProviderAuthInteraction): Promise { - manualInput = input; - callback.cancelWait(); - }) - .catch((error) => { - manualError = error instanceof Error ? error : new Error(String(error)); - callback.cancelWait(); - }); - - const credential = await callback.waitForCredential(); - if (manualError) throw manualError; - if (credential) return credential; - - await manualPromise; - if (manualError) throw manualError; - const code = manualInput ? parseAuthorizationInput(manualInput) : undefined; + const result = await waitForCallbackOrManualInput(interaction, callback, { + message: "Complete sign-in in your browser, or paste the authorization code / redirect URL here:", + placeholder: callback.redirectUri, + }); + if (result.type === "callback") return result.value; + const code = parseAuthorizationInput(result.input); if (!code) throw new Error("Missing authorization code"); interaction.notify({ type: "progress", message: "Exchanging authorization code for an API key..." }); return await exchangeAuthorizationCode(code, verifier, interaction.signal); } finally { - manualAbort.abort(); callback.close(); } } diff --git a/packages/ai/src/auth/oauth/radius.ts b/packages/ai/src/auth/oauth/radius.ts index 776ec6d65..0b82b9d78 100644 --- a/packages/ai/src/auth/oauth/radius.ts +++ b/packages/ai/src/auth/oauth/radius.ts @@ -5,22 +5,14 @@ * gateway; only the interactive browser authorization endpoint is discovered. * Model catalog loading is owned by the Radius provider. * - * NOTE: This module uses node:http for the OAuth callback server. + * NOTE: This module uses node:http (via callback-server.ts) for the OAuth callback server. * It is only intended for CLI use, not browser environments. */ -// NEVER convert to top-level imports - breaks browser/Vite builds -let _http: typeof import("node:http") | null = null; -if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - import("node:http").then((m) => { - _http = m; - }); -} - import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; +import { startOAuthCallbackServer } from "./callback-server.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; const CALLBACK_HOST = "127.0.0.1"; @@ -139,84 +131,6 @@ async function requestOAuthToken( }; } -type OAuthCallbackServer = { - waitForCode(): Promise; - close(): void; -}; - -function startOAuthCallbackServer(expectedState: string, signal: AbortSignal): Promise { - if (!_http) { - throw new Error("Radius OAuth is only available in Node.js environments"); - } - - let settle: (code: string | null) => void = () => {}; - let settled = false; - const wait = new Promise((resolve) => { - settle = resolve; - }); - const finish = (code: string | null) => { - if (settled) { - return; - } - settled = true; - signal.removeEventListener("abort", onAbort); - settle(code); - }; - const onAbort = () => finish(null); - signal.addEventListener("abort", onAbort, { once: true }); - - const sendPage = (response: import("node:http").ServerResponse, status: number, html: string) => { - response.statusCode = status; - response.setHeader("content-type", "text/html; charset=utf-8"); - response.end(html); - }; - - const server = _http.createServer((request, response) => { - const url = new URL(request.url ?? "/", REDIRECT_URI); - if (url.pathname !== CALLBACK_PATH) { - sendPage(response, 404, oauthErrorHtml("Callback route not found.")); - return; - } - if (url.searchParams.get("state") !== expectedState) { - sendPage(response, 400, oauthErrorHtml("OAuth state mismatch.")); - return; - } - - const error = url.searchParams.get("error"); - if (error) { - sendPage(response, 400, oauthErrorHtml(url.searchParams.get("error_description") ?? error)); - finish(null); - return; - } - - const code = url.searchParams.get("code"); - if (!code) { - sendPage(response, 400, oauthErrorHtml("Missing authorization code.")); - return; - } - - sendPage(response, 200, oauthSuccessHtml("Signed in to Radius. You may now close this page.")); - finish(code); - }); - - return new Promise((resolve) => { - server - .listen(CALLBACK_PORT, CALLBACK_HOST, () => { - resolve({ - waitForCode: () => wait, - close: () => { - finish(null); - server.close(); - }, - }); - }) - .once("error", () => { - finish(null); - resolve({ waitForCode: async () => null, close: () => {} }); - }); - }); -} - async function loginWithBrowser( gateway: string, authorizationEndpoint: string, @@ -236,7 +150,26 @@ async function loginWithBrowser( state, }).toString(); - const callbackServer = await startOAuthCallbackServer(state, interaction.signal); + const callback = await startOAuthCallbackServer({ + providerName: "Radius", + host: CALLBACK_HOST, + port: CALLBACK_PORT, + path: CALLBACK_PATH, + state, + complete: (code) => + requestOAuthToken( + gateway, + new URLSearchParams({ + grant_type: "authorization_code", + client_id: OAUTH_CLIENT_ID, + redirect_uri: REDIRECT_URI, + code, + code_verifier: verifier, + }), + interaction.signal, + ), + signal: interaction.signal, + }); interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` }); interaction.notify({ type: "auth_url", @@ -245,26 +178,11 @@ async function loginWithBrowser( }); try { - const code = await callbackServer.waitForCode(); - if (!code) { - if (interaction.signal.aborted) { - throw new Error("Login cancelled"); - } - throw new Error("OAuth callback did not complete."); - } - return await requestOAuthToken( - gateway, - new URLSearchParams({ - grant_type: "authorization_code", - client_id: OAUTH_CLIENT_ID, - redirect_uri: REDIRECT_URI, - code, - code_verifier: verifier, - }), - interaction.signal, - ); + const credential = await callback.wait(); + if (!credential) throw new Error("OAuth callback did not complete."); + return credential; } finally { - callbackServer.close(); + callback.close(); } } diff --git a/packages/ai/src/auth/oauth/oauth-page.ts b/packages/ai/src/utils/oauth-page.ts similarity index 100% rename from packages/ai/src/auth/oauth/oauth-page.ts rename to packages/ai/src/utils/oauth-page.ts diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 9dc227b93..2eae7113e 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -3,6 +3,7 @@ import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts"; const neverAbortedSignal = new AbortController().signal; +const nativeFetch = globalThis.fetch; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { @@ -141,4 +142,37 @@ describe.sequential("Anthropic OAuth", () => { // the prompt's signal is aborted once login settles, so UIs can dismiss it expect(manualSignal?.aborted).toBe(true); }); + + it("completes login through the browser callback and shows the sign-in page", async () => { + let exchangedCode: string | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token") + return nativeFetch(input as string, init); + exchangedCode = getJsonBody(init).code; + return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }); + }), + ); + + let callbackPage: Promise | undefined; + const credential = await anthropicOAuth.login({ + signal: neverAbortedSignal, + notify: (event) => { + if (event.type !== "auth_url") return; + const state = new URL(event.url).searchParams.get("state") ?? ""; + callbackPage = nativeFetch(`http://127.0.0.1:53692/callback?code=browser-code&state=${state}`); + }, + prompt: (prompt) => + new Promise((_, reject) => { + prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + }); + + expect(credential.access).toBe("access"); + expect(exchangedCode).toBe("browser-code"); + const response = await callbackPage; + expect(response?.status).toBe(200); + expect(await response?.text()).toContain("Signed in to Anthropic."); + }); }); diff --git a/packages/ai/test/oauth-callback-server.test.ts b/packages/ai/test/oauth-callback-server.test.ts new file mode 100644 index 000000000..befa1e142 --- /dev/null +++ b/packages/ai/test/oauth-callback-server.test.ts @@ -0,0 +1,255 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "../src/auth/oauth/callback-server.ts"; +import type { AuthPrompt, ProviderAuthInteraction } from "../src/auth/types.ts"; + +const nativeFetch = globalThis.fetch; + +function callbackUrl(redirectUri: string, params: Record): string { + const url = new URL(redirectUri); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + return url.toString(); +} + +async function page(response: Response): Promise<{ status: number; contentType: string | null; body: string }> { + return { status: response.status, contentType: response.headers.get("content-type"), body: await response.text() }; +} + +function interaction(prompt: (prompt: AuthPrompt) => Promise, signal?: AbortSignal): ProviderAuthInteraction { + return { signal: signal ?? new AbortController().signal, notify: () => {}, prompt }; +} + +/** A manual prompt that stays open until its signal aborts. */ +function pendingPrompt(onPrompt?: (prompt: AuthPrompt) => void): (prompt: AuthPrompt) => Promise { + return (prompt) => { + onPrompt?.(prompt); + return new Promise((_, reject) => { + prompt.signal?.addEventListener("abort", () => reject(new Error("prompt aborted")), { once: true }); + }); + }; +} + +describe.sequential("OAuth callback server", () => { + const servers: { close(): void }[] = []; + const start = async (options: Partial>[0]> = {}) => { + const server = await startOAuthCallbackServer({ + providerName: "Example", + host: "127.0.0.1", + port: 0, + path: "/callback", + state: "expected-state", + complete: async (code) => `completed:${code}` as T, + ...options, + }); + servers.push(server); + return server; + }; + + afterEach(() => { + for (const server of servers.splice(0)) server.close(); + }); + + it("ignores stray requests and resolves with the completed code", async () => { + const server = await start(); + expect(server.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); + + const wrongPath = await page(await nativeFetch(new URL("/other", server.redirectUri))); + expect(wrongPath.status).toBe(404); + const wrongState = await page(await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "other" }))); + expect(wrongState).toMatchObject({ status: 400, contentType: "text/html; charset=utf-8" }); + expect(wrongState.body).toContain("State mismatch."); + const post = await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" }), { + method: "POST", + }); + expect(post.status).toBe(404); + const missingCode = await page(await nativeFetch(callbackUrl(server.redirectUri, { state: "expected-state" }))); + expect(missingCode.status).toBe(400); + + const success = await page( + await nativeFetch(callbackUrl(server.redirectUri, { code: "the-code", state: "expected-state" })), + ); + expect(success).toMatchObject({ status: 200, contentType: "text/html; charset=utf-8" }); + expect(success.body).toContain("Authentication successful"); + expect(success.body).toContain("Signed in to Example."); + await expect(server.wait()).resolves.toBe("completed:the-code"); + }); + + it("uses the redirect host and skips the state check when none is expected", async () => { + const server = await start({ redirectHost: "localhost", state: undefined }); + expect(server.redirectUri).toMatch(/^http:\/\/localhost:\d+\/callback$/); + const response = await nativeFetch(callbackUrl(server.redirectUri, { code: "no-state" })); + expect(response.status).toBe(200); + await expect(server.wait()).resolves.toBe("completed:no-state"); + }); + + it("shows completion failures on the page and rejects the wait", async () => { + const server = await start({ + complete: async () => { + throw new Error("token exchange failed"); + }, + }); + const failure = await page( + await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" })), + ); + expect(failure.status).toBe(502); + expect(failure.body).toContain("Example sign-in failed."); + expect(failure.body).toContain("token exchange failed"); + await expect(server.wait()).rejects.toThrow("token exchange failed"); + }); + + it("rejects the wait when the provider redirects with an error", async () => { + const server = await start(); + const failure = await page( + await nativeFetch( + callbackUrl(server.redirectUri, { + error: "access_denied", + error_description: "User denied access", + state: "expected-state", + }), + ), + ); + expect(failure.status).toBe(400); + expect(failure.body).toContain("User denied access"); + await expect(server.wait()).rejects.toThrow("Example authorization failed: User denied access"); + }); + + it("completes only the first callback", async () => { + let finishExchange: ((value: string) => void) | undefined; + const server = await start({ + complete: () => + new Promise((resolve) => { + finishExchange = resolve; + }), + }); + const url = callbackUrl(server.redirectUri, { code: "c", state: "expected-state" }); + const first = nativeFetch(url); + await vi.waitFor(() => expect(finishExchange).toBeDefined()); + const second = await nativeFetch(url); + expect(second.status).toBe(409); + // A claimed callback keeps completing even when the caller switches to manual input. + server.cancel(); + finishExchange?.("done"); + expect((await first).status).toBe(200); + await expect(server.wait()).resolves.toBe("done"); + }); + + it("resolves with undefined after cancel", async () => { + const server = await start(); + server.cancel(); + await expect(server.wait()).resolves.toBeUndefined(); + const late = await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" })); + expect(late.status).toBe(409); + }); + + it("rejects the wait on abort and on timeout", async () => { + const controller = new AbortController(); + const aborted = await start({ signal: controller.signal }); + controller.abort(); + await expect(aborted.wait()).rejects.toThrow("Login cancelled"); + + const timedOut = await start({ timeoutMs: 10 }); + await expect(timedOut.wait()).rejects.toThrow("Example sign-in timed out"); + + const alreadyAborted = new AbortController(); + alreadyAborted.abort(); + await expect(start({ signal: alreadyAborted.signal })).rejects.toThrow("Login cancelled"); + }); + + it("fails instead of picking another port when the requested port is taken", async () => { + const blocker: Server = createServer(); + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)); + try { + const address = blocker.address(); + if (!address || typeof address === "string") throw new Error("blocker did not bind to TCP"); + await expect(start({ port: address.port })).rejects.toMatchObject({ code: "EADDRINUSE" }); + } finally { + blocker.close(); + } + }); +}); + +describe.sequential("waitForCallbackOrManualInput", () => { + it("returns the browser callback and aborts the manual prompt", async () => { + let manualSignal: AbortSignal | undefined; + const server = await startOAuthCallbackServer({ + providerName: "Example", + host: "127.0.0.1", + port: 0, + path: "/callback", + complete: async (code) => code, + }); + try { + const result = waitForCallbackOrManualInput( + interaction( + pendingPrompt((prompt) => { + manualSignal = prompt.signal; + }), + ), + server, + { message: "paste", placeholder: server.redirectUri }, + ); + await nativeFetch(callbackUrl(server.redirectUri, { code: "from-browser" })); + await expect(result).resolves.toEqual({ type: "callback", value: "from-browser" }); + expect(manualSignal?.aborted).toBe(true); + } finally { + server.close(); + } + }); + + it("returns pasted input and stops waiting for the browser", async () => { + const server = await startOAuthCallbackServer({ + providerName: "Example", + host: "127.0.0.1", + port: 0, + path: "/callback", + complete: async (code) => code, + }); + try { + const result = await waitForCallbackOrManualInput( + interaction(async () => "pasted"), + server, + { + message: "paste", + placeholder: server.redirectUri, + }, + ); + expect(result).toEqual({ type: "manual", input: "pasted" }); + } finally { + server.close(); + } + }); + + it("uses only the manual prompt without a callback server", async () => { + const result = await waitForCallbackOrManualInput( + interaction(async () => "pasted"), + undefined, + { + message: "paste", + placeholder: "http://localhost/callback", + }, + ); + expect(result).toEqual({ type: "manual", input: "pasted" }); + }); + + it("propagates manual prompt failures", async () => { + const server = await startOAuthCallbackServer({ + providerName: "Example", + host: "127.0.0.1", + port: 0, + path: "/callback", + complete: async (code) => code, + }); + try { + const result = waitForCallbackOrManualInput( + interaction(async () => { + throw new Error("prompt cancelled"); + }), + server, + { message: "paste", placeholder: server.redirectUri }, + ); + await expect(result).rejects.toThrow("prompt cancelled"); + } finally { + server.close(); + } + }); +}); diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index f45d4aeea..16ec6c76f 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,3 +1,4 @@ +import { createServer } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; @@ -483,4 +484,48 @@ describe("OpenAI Codex OAuth", () => { ).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/); expect(consoleError).not.toHaveBeenCalled(); }); + + it("falls back to the pasted redirect URL when the fixed callback port is taken", async () => { + // Port 1455 is registered with OpenAI; the Codex CLI may hold it. Occupy it unless it already is. + const blocker = createServer(); + await new Promise((resolve) => { + blocker.once("error", () => resolve()); + blocker.listen(1455, "127.0.0.1", () => resolve()); + }); + try { + let exchangeBody: URLSearchParams | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit) => { + expect(getUrl(input)).toBe("https://auth.openai.com/oauth/token"); + exchangeBody = new URLSearchParams(String(init?.body)); + return jsonResponse({ + access_token: createAccessToken("acct"), + refresh_token: "refresh", + expires_in: 3600, + }); + }), + ); + + let authUrl = ""; + const credential = await openaiCodexOAuth.login({ + signal: neverAbortedSignal, + notify: (event) => { + if (event.type === "auth_url") authUrl = event.url; + }, + prompt: async (prompt) => { + if (prompt.type === "select") return "browser"; + if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`); + const state = new URL(authUrl).searchParams.get("state"); + return `http://localhost:1455/auth/callback?code=pasted-code&state=${state}`; + }, + }); + + expect(credential.accountId).toBe("acct"); + expect(exchangeBody?.get("code")).toBe("pasted-code"); + expect(exchangeBody?.get("redirect_uri")).toBe("http://localhost:1455/auth/callback"); + } finally { + blocker.close(); + } + }); }); diff --git a/packages/ai/test/radius-oauth.test.ts b/packages/ai/test/radius-oauth.test.ts index ac613520d..793647762 100644 --- a/packages/ai/test/radius-oauth.test.ts +++ b/packages/ai/test/radius-oauth.test.ts @@ -3,6 +3,7 @@ import { createRadiusOAuth } from "../src/auth/oauth/radius.ts"; import type { AuthEvent, ProviderAuthInteraction } from "../src/auth/types.ts"; const GATEWAY = "https://radius.example"; +const nativeFetch = globalThis.fetch; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -126,4 +127,56 @@ describe("Radius OAuth", () => { await expect(oauth.login(interaction("browser"))).rejects.toThrow(`Invalid Radius OAuth config from ${GATEWAY}`); expect(fetchMock).toHaveBeenCalledOnce(); }); + + it("exchanges the browser callback code before showing the sign-in page", async () => { + let tokenStatus = 400; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit) => { + const url = requestUrl(input); + if (url === `${GATEWAY}/v1/oauth`) return jsonResponse({ authorizationEndpoint: `${GATEWAY}/authorize` }); + if (url === `${GATEWAY}/v1/oauth/token`) { + const body = new URLSearchParams(String(init?.body)); + expect(body.get("code")).toBe("browser-code"); + return tokenStatus === 200 + ? jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }) + : jsonResponse({ error: "invalid_grant", error_description: "code expired" }, tokenStatus); + } + return nativeFetch(url, init); + }), + ); + + const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY }); + const login = async () => { + let callbackPage: Promise | undefined; + const result = oauth.login({ + ...interaction("browser"), + notify: (event) => { + if (event.type !== "auth_url") return; + const authorize = new URL(event.url); + const callback = new URL(authorize.searchParams.get("redirect_uri") ?? ""); + callback.searchParams.set("code", "browser-code"); + callback.searchParams.set("state", authorize.searchParams.get("state") ?? ""); + callbackPage = nativeFetch(callback); + }, + }); + const outcome = await result.then( + (credential) => ({ credential }), + (error: Error) => ({ error }), + ); + const response = await callbackPage; + return { ...outcome, status: response?.status, page: await response?.text() }; + }; + + const failed = await login(); + expect("error" in failed && failed.error.message).toContain("invalid_grant: code expired"); + expect(failed.status).toBe(502); + expect(failed.page).toContain("code expired"); + + tokenStatus = 200; + const succeeded = await login(); + expect("credential" in succeeded && succeeded.credential.access).toBe("access"); + expect(succeeded.status).toBe(200); + expect(succeeded.page).toContain("Signed in to Radius."); + }); }); diff --git a/packages/coding-agent/src/extensions/mcp/oauth.ts b/packages/coding-agent/src/extensions/mcp/oauth.ts index c1c98f45f..6c63ca80f 100644 --- a/packages/coding-agent/src/extensions/mcp/oauth.ts +++ b/packages/coding-agent/src/extensions/mcp/oauth.ts @@ -12,6 +12,7 @@ import { createHash } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; +import { oauthErrorHtml, oauthSuccessHtml } from "@earendil-works/pi-ai/utils/oauth-page"; import type { AuthProvider, McpFetch } from "@earendil-works/pi-mcp"; import { authorizeMcp, @@ -19,6 +20,7 @@ import { McpOAuthProvider, type McpOAuthState, type McpOAuthStateStore, + type OAuthCallbackPage, OAuthCallbackServer, type OAuthChallenge, type OAuthClientInformationMixed, @@ -346,7 +348,15 @@ async function listenForCallback( port: number | undefined, required: boolean, ): Promise { - const options = { host: settings.host, redirectHost: settings.redirectHost, path: settings.path }; + const options = { + host: settings.host, + redirectHost: settings.redirectHost, + path: settings.path, + renderPage: (page: OAuthCallbackPage) => + page.ok + ? oauthSuccessHtml("Signed in to the MCP server. You may now close this page.") + : oauthErrorHtml(page.message, page.details), + }; try { return await OAuthCallbackServer.listen({ ...options, port: port ?? 0 }); } catch (error) { diff --git a/packages/mcp/src/oauth/callback.ts b/packages/mcp/src/oauth/callback.ts index 0c344239a..4155825ca 100644 --- a/packages/mcp/src/oauth/callback.ts +++ b/packages/mcp/src/oauth/callback.ts @@ -6,6 +6,9 @@ export interface OAuthCallback { iss?: string; } +/** Outcome shown on the browser page after the redirect. */ +export type OAuthCallbackPage = { ok: true } | { ok: false; message: string; details?: string }; + export interface OAuthCallbackServerOptions { /** Address to listen on. Default: `127.0.0.1`. */ host?: string; @@ -17,10 +20,13 @@ export interface OAuthCallbackServerOptions { port?: number; path?: string; timeoutMs?: number; + /** Render the browser page as HTML. Default: a plain-text message. */ + renderPage?: (page: OAuthCallbackPage) => string; } -function reply(response: ServerResponse, status: number, text: string): void { - response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(text); +function plainText(page: OAuthCallbackPage): string { + if (page.ok) return "Authorization complete. You may close this window."; + return page.details ? `${page.message}\n\n${page.details}` : page.message; } export class OAuthCallbackServer { @@ -28,6 +34,7 @@ export class OAuthCallbackServer { private server: Server; private path: string; private timeoutMs: number; + private renderPage: ((page: OAuthCallbackPage) => string) | undefined; private pending = new Map< string, { @@ -37,11 +44,18 @@ export class OAuthCallbackServer { } >(); - private constructor(server: Server, redirectUrl: string, path: string, timeoutMs: number) { + private constructor( + server: Server, + redirectUrl: string, + path: string, + timeoutMs: number, + renderPage: ((page: OAuthCallbackPage) => string) | undefined, + ) { this.server = server; this.redirectUrl = redirectUrl; this.path = path; this.timeoutMs = timeoutMs; + this.renderPage = renderPage; } static async listen(options: OAuthCallbackServerOptions = {}): Promise { @@ -64,6 +78,7 @@ export class OAuthCallbackServer { `http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${path}`, path, options.timeoutMs ?? 5 * 60_000, + options.renderPage, ); return instance; } @@ -90,34 +105,48 @@ export class OAuthCallbackServer { }); } + private reply(response: ServerResponse, status: number, page: OAuthCallbackPage): void { + if (this.renderPage) { + response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + response.end(this.renderPage(page)); + } else { + response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(plainText(page)); + } + } + private handle(rawUrl: string, response: ServerResponse): void { const url = new URL(rawUrl, this.redirectUrl); if (url.pathname !== this.path) { - reply(response, 404, "Not found"); + this.reply(response, 404, { ok: false, message: "Not found" }); return; } const state = url.searchParams.get("state"); const pending = state ? this.pending.get(state) : undefined; if (!state || !pending) { - reply(response, 400, "Invalid or expired OAuth state"); + this.reply(response, 400, { ok: false, message: "Invalid or expired OAuth state" }); return; } clearTimeout(pending.timer); this.pending.delete(state); const error = url.searchParams.get("error"); if (error) { - pending.reject(new Error(url.searchParams.get("error_description") ?? error)); - reply(response, 200, "Authorization failed. You may close this window."); + const description = url.searchParams.get("error_description") ?? error; + pending.reject(new Error(description)); + this.reply(response, 200, { + ok: false, + message: "Authorization failed. You may close this window.", + details: description, + }); return; } const code = url.searchParams.get("code"); if (!code) { pending.reject(new Error("OAuth callback did not include an authorization code")); - reply(response, 400, "Missing authorization code"); + this.reply(response, 400, { ok: false, message: "Missing authorization code" }); return; } const iss = url.searchParams.get("iss"); pending.resolve({ code, state, ...(iss ? { iss } : {}) }); - reply(response, 200, "Authorization complete. You may close this window."); + this.reply(response, 200, { ok: true }); } } diff --git a/packages/mcp/src/oauth/index.ts b/packages/mcp/src/oauth/index.ts index d475d300e..0e19478ba 100644 --- a/packages/mcp/src/oauth/index.ts +++ b/packages/mcp/src/oauth/index.ts @@ -1,4 +1,9 @@ -export { type OAuthCallback, OAuthCallbackServer, type OAuthCallbackServerOptions } from "./callback.ts"; +export { + type OAuthCallback, + type OAuthCallbackPage, + OAuthCallbackServer, + type OAuthCallbackServerOptions, +} from "./callback.ts"; export { buildAuthorizationServerDiscoveryUrls, discoverAuthorizationServerMetadata, diff --git a/packages/mcp/test/oauth.test.ts b/packages/mcp/test/oauth.test.ts index f6ca554ff..3afc2cede 100644 --- a/packages/mcp/test/oauth.test.ts +++ b/packages/mcp/test/oauth.test.ts @@ -8,6 +8,7 @@ import { McpOAuthAuthorizationRequiredError, McpOAuthProvider, MemoryOAuthStateStore, + type OAuthCallbackPage, OAuthCallbackServer, type OAuthClientInformationMixed, type OAuthClientProvider, @@ -399,3 +400,47 @@ describe("MCP OAuth", () => { await expect(discoverAuthorizationServerMetadata(origin)).rejects.toBeInstanceOf(OAuthIssuerMismatchError); }); }); + +describe("OAuthCallbackServer pages", () => { + it("renders plain text by default", async () => { + const callback = await OAuthCallbackServer.listen(); + try { + const pending = callback.waitForCallback("s1"); + const response = await fetch(`${callback.redirectUrl}?code=abc&state=s1`); + expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(await response.text()).toBe("Authorization complete. You may close this window."); + expect((await pending).code).toBe("abc"); + } finally { + await callback.close(); + } + }); + + it("renders pages through renderPage", async () => { + const pages: OAuthCallbackPage[] = []; + const callback = await OAuthCallbackServer.listen({ + renderPage: (page) => { + pages.push(page); + return page.ok ? "

ok

" : `

${page.message}

`; + }, + }); + try { + const denied = callback.waitForCallback("s1"); + denied.catch(() => undefined); + const failure = await fetch(`${callback.redirectUrl}?error=access_denied&error_description=Denied&state=s1`); + expect(failure.headers.get("content-type")).toBe("text/html; charset=utf-8"); + await expect(denied).rejects.toThrow("Denied"); + expect(pages.at(-1)).toEqual({ + ok: false, + message: "Authorization failed. You may close this window.", + details: "Denied", + }); + + const pending = callback.waitForCallback("s2"); + const success = await fetch(`${callback.redirectUrl}?code=abc&state=s2`); + expect(await success.text()).toBe("

ok

"); + expect((await pending).code).toBe("abc"); + } finally { + await callback.close(); + } + }); +});