From a2861171919de98dee33609ffb93b5307f8a1092 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Mon, 26 Oct 2026 22:47:02 -0400 Subject: [PATCH 1/6] fix(ai): brand OAuth callback pages as Diffex - Direct users back to the terminal for final sign-in status - Keep callback validation and timing while hiding provider error details --- packages/ai/src/auth/oauth/anthropic.ts | 4 +- packages/ai/src/auth/oauth/oauth-page.ts | 28 ++- packages/ai/src/auth/oauth/openai-codex.ts | 2 +- packages/ai/src/auth/oauth/openrouter.ts | 6 +- packages/ai/test/anthropic-oauth.test.ts | 10 + packages/ai/test/oauth-callback-test-utils.ts | 200 ++++++++++++++++++ packages/ai/test/oauth-page.test.ts | 13 ++ packages/ai/test/openai-codex-oauth.test.ts | 10 + packages/ai/test/openrouter-oauth.test.ts | 20 +- 9 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 packages/ai/test/oauth-callback-test-utils.ts create mode 100644 packages/ai/test/oauth-page.test.ts diff --git a/packages/ai/src/auth/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts index 980ee7d0a..cdeec8789 100644 --- a/packages/ai/src/auth/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -125,7 +125,7 @@ async function startCallbackServer(expectedState: string): Promise @@ -37,6 +44,7 @@ function renderPage(options: { title: string; heading: string; message: string; display: flex; align-items: center; justify-content: center; + overflow-wrap: anywhere; padding: 24px; background: var(--page-bg); color: var(--text); @@ -63,6 +71,7 @@ function renderPage(options: { title: string; heading: string; message: string; line-height: 1.15; font-weight: 650; color: var(--text); + text-wrap: balance; } p { margin: 0; @@ -78,6 +87,7 @@ function renderPage(options: { title: string; heading: string; message: string; white-space: pre-wrap; word-break: break-word; } + .status { margin-top: 16px; } @@ -85,25 +95,27 @@ function renderPage(options: { title: string; heading: string; message: string;

${heading}

${message}

+ ${status ? `

${status}

` : ""} ${details ? `
${details}
` : ""} `; } -export function oauthSuccessHtml(message: string): string { +export function oauthSuccessHtml(message = "You can close this tab and return to Diffex."): string { return renderPage({ - title: "Authentication successful", - heading: "Authentication successful", + title: "Continue in Diffex", + heading: "Continue in Diffex", message, + status: "Check the terminal for sign-in status.", }); } export function oauthErrorHtml(message: string, details?: string): string { return renderPage({ - title: "Authentication failed", - heading: "Authentication failed", - message, - details, + title: "Diffex sign-in error", + heading: "Could not complete sign-in", + message: "Return to Diffex and run /login to try again.", + details: [message, details].filter(Boolean).join("\n"), }); } diff --git a/packages/ai/src/auth/oauth/openai-codex.ts b/packages/ai/src/auth/oauth/openai-codex.ts index af529c55f..acbe26871 100644 --- a/packages/ai/src/auth/oauth/openai-codex.ts +++ b/packages/ai/src/auth/oauth/openai-codex.ts @@ -356,7 +356,7 @@ function startLocalOAuthServer(state: string): Promise { } res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); + res.end(oauthSuccessHtml()); settleWait?.({ code }); } catch { res.statusCode = 500; diff --git a/packages/ai/src/auth/oauth/openrouter.ts b/packages/ai/src/auth/oauth/openrouter.ts index 0054ebf1f..44d61e555 100644 --- a/packages/ai/src/auth/oauth/openrouter.ts +++ b/packages/ai/src/auth/oauth/openrouter.ts @@ -181,7 +181,7 @@ async function startCallbackServer( 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)); + sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.")); finish({ error: new Error(`OpenRouter authorization failed: ${description}`) }); return; } @@ -195,11 +195,11 @@ async function startCallbackServer( try { const result = await exchangeAuthorizationCode(code, verifier, signal); - sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page.")); + sendHtml(response, 200, oauthSuccessHtml()); 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)); + sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed. Check the terminal for details.")); finish({ error: error instanceof Error ? error : new Error(message) }); } })(); diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 9dc227b93..d0ccddeb1 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,6 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts"; +import { defineOAuthCallbackTests } from "./oauth-callback-test-utils.ts"; + +defineOAuthCallbackTests(anthropicOAuth, { + name: "Anthropic", + authorizeUrl: "https://claude.ai/oauth/authorize", + tokenUrl: "https://platform.claude.com/v1/oauth/token", + clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload", + redirectUri: "http://localhost:53692/callback", +}); const neverAbortedSignal = new AbortController().signal; diff --git a/packages/ai/test/oauth-callback-test-utils.ts b/packages/ai/test/oauth-callback-test-utils.ts new file mode 100644 index 000000000..2030600c3 --- /dev/null +++ b/packages/ai/test/oauth-callback-test-utils.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OAuthAuth } from "../src/auth/types.ts"; + +const nativeFetch = globalThis.fetch; +const privateDetail = ''; + +export function defineOAuthCallbackTests( + provider: OAuthAuth, + options: { + name: string; + authorizeUrl: string; + tokenUrl: string; + clientId?: string; + scope?: string; + redirectUri?: string; + }, +): void { + describe.sequential(`${options.name} browser callback`, () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps callback validation, PKCE, and exchange timing while returning Diffex pages", async () => { + let finishExchange: (response: Response) => void = () => { + throw new Error("No pending exchange"); + }; + let exchangeBody: Record | undefined; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe(options.tokenUrl); + expect(init?.method).toBe("POST"); + exchangeBody = + init?.body instanceof URLSearchParams + ? Object.fromEntries(init.body) + : (JSON.parse(String(init?.body)) as Record); + return new Promise((resolve, reject) => { + finishExchange = resolve; + init?.signal?.addEventListener("abort", () => reject(new Error("Login cancelled")), { once: true }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + let authorize: URL | undefined; + let callback: URL | undefined; + let manualSignal: AbortSignal | undefined; + let settled = false; + const controller = new AbortController(); + const login = provider.login({ + signal: controller.signal, + notify: (event) => { + if (event.type !== "auth_url") return; + authorize = new URL(event.url); + callback = new URL( + authorize.searchParams.get("redirect_uri") ?? authorize.searchParams.get("callback_url") ?? "", + ); + callback.hostname = "127.0.0.1"; + }, + prompt: async (prompt) => { + if (prompt.type === "select") return "browser"; + manualSignal = prompt.signal; + return waitForCancellation(controller.signal); + }, + }); + const outcome = login.then( + (credential) => { + settled = true; + return credential; + }, + (error: unknown) => { + settled = true; + throw error; + }, + ); + try { + await vi.waitFor(() => expect(callback).toBeDefined()); + if (!callback || !authorize) throw new Error("No browser callback"); + expect(`${authorize.origin}${authorize.pathname}`).toBe(options.authorizeUrl); + if (options.clientId) expect(authorize.searchParams.get("client_id")).toBe(options.clientId); + if (options.scope) expect(authorize.searchParams.get("scope")).toBe(options.scope); + if (options.redirectUri) expect(authorize.searchParams.get("redirect_uri")).toBe(options.redirectUri); + expect(authorize.searchParams.get("code_challenge_method")).toBe("S256"); + const state = authorize.searchParams.get("state"); + if (state) callback.searchParams.set("state", state); + + const invalid = new URL(callback); + invalid.pathname = "/invalid-route"; + let response = await nativeFetch(invalid); + expect(response.status).toBe(404); + assertErrorPage(await response.text()); + + response = await nativeFetch(callback); + expect(response.status).toBe(400); + assertErrorPage(await response.text()); + if (state) { + invalid.pathname = callback.pathname; + invalid.searchParams.set("code", "mock-code"); + invalid.searchParams.set("state", "wrong-state"); + response = await nativeFetch(invalid); + expect(response.status).toBe(400); + expect(await response.text()).toContain("State mismatch."); + } + expect(fetchMock).not.toHaveBeenCalled(); + callback.searchParams.set("code", "mock-code"); + let callbackArrived = false; + const callbackResponse = nativeFetch(callback).then((result) => { + callbackArrived = true; + return result; + }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + // OpenRouter replies after exchange; Codex and Anthropic reply before it completes. + if (options.name === "OpenRouter") expect(callbackArrived).toBe(false); + else assertSuccessPage(await (await callbackResponse).text()); + expect(settled).toBe(false); + expect(exchangeBody?.code).toBe("mock-code"); + const verifier = exchangeBody?.code_verifier; + expect(verifier).toBeTruthy(); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + expect(Buffer.from(digest).toString("base64url")).toBe(authorize.searchParams.get("code_challenge")); + if (options.clientId) expect(exchangeBody?.client_id).toBe(options.clientId); + if (options.redirectUri) expect(exchangeBody?.redirect_uri).toBe(options.redirectUri); + const payload = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "mock-account" } }), + ).toString("base64"); + finishExchange( + Response.json({ + key: "mock-key", + access_token: `e30.${payload}.signature`, + refresh_token: "mock-refresh", + expires_in: 3600, + }), + ); + await expect(outcome).resolves.toMatchObject({ type: "oauth" }); + const success = await callbackResponse; + expect(success.status).toBe(200); + if (options.name === "OpenRouter") assertSuccessPage(await success.text()); + expect(manualSignal?.aborted).toBe(true); + } finally { + controller.abort(); + await outcome.catch(() => {}); + } + }); + + if (options.name !== "OpenAI Codex") { + it("omits raw provider error content from the browser", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + let callback: URL | undefined; + const controller = new AbortController(); + const login = provider.login({ + signal: controller.signal, + prompt: async () => waitForCancellation(controller.signal), + notify: (event) => { + if (event.type !== "auth_url") return; + const auth = new URL(event.url); + callback = new URL( + auth.searchParams.get("redirect_uri") ?? auth.searchParams.get("callback_url") ?? "", + ); + callback.hostname = "127.0.0.1"; + callback.searchParams.set("error", privateDetail); + callback.searchParams.set("error_description", privateDetail); + }, + }); + const outcome = login.catch((error: unknown) => error); + try { + await vi.waitFor(() => expect(callback).toBeDefined()); + if (!callback) throw new Error("No callback"); + const response = await nativeFetch(callback); + expect(response.status).toBe(400); + const html = await response.text(); + assertErrorPage(html); + expect(html).not.toContain("private-token"); + expect(fetchMock).not.toHaveBeenCalled(); + if (options.name === "OpenRouter") expect(await outcome).toBeInstanceOf(Error); + } finally { + controller.abort(); + await outcome; + } + }); + } + }); +} + +function waitForCancellation(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(new Error("Login cancelled")), { once: true }); + }); +} + +function assertSuccessPage(html: string): void { + expect(html).toContain("Continue in Diffex"); + expect(html).toContain("

Continue in Diffex

"); + expect(html).toContain("You can close this tab and return to Diffex."); + expect(html).toContain("Check the terminal for sign-in status."); + expect(html).not.toMatch(/authentication (successful|completed)|signed in|window\.close|