diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index 687cfa3ca..48032f1e4 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -6,6 +6,7 @@ */ import type { Server } from "node:http"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js"; @@ -33,18 +34,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"; -const SUCCESS_HTML = ` - - - - - Authentication successful - - -

Authentication successful. Return to your terminal to continue.

- -`; - async function getNodeApis(): Promise { if (nodeApis) return nodeApis; if (!nodeApisPromise) { @@ -110,15 +99,22 @@ async function startCallbackServer(expectedState: string): Promise { - let result: { code: string; state: string } | null = null; - let cancelled = false; + 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/plain; charset=utf-8" }); - res.end("Not found"); + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Callback route not found.")); return; } @@ -128,27 +124,25 @@ async function startCallbackServer(expectedState: string): Promise

Authentication Failed

Error: ${error}

`); + 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( - `

Authentication Failed

Missing code or state parameter.

`, - ); + res.end(oauthErrorHtml("Missing code or state parameter.")); return; } if (state !== expectedState) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(`

Authentication Failed

State mismatch.

`); + res.end(oauthErrorHtml("State mismatch.")); return; } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(SUCCESS_HTML); - result = { code, state }; + 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"); @@ -164,15 +158,9 @@ async function startCallbackServer(expectedState: string): Promise { - cancelled = true; - }, - waitForCode: async () => { - const sleep = () => new Promise((r) => setTimeout(r, 100)); - while (!result && !cancelled) { - await sleep(); - } - return result; + settleWait?.(null); }, + waitForCode: () => waitForCodePromise, }); }); }); diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/utils/oauth/google-antigravity.ts index e9ef3c740..b601f886c 100644 --- a/packages/ai/src/utils/oauth/google-antigravity.ts +++ b/packages/ai/src/utils/oauth/google-antigravity.ts @@ -7,6 +7,7 @@ */ import type { Server } from "node:http"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; @@ -67,8 +68,15 @@ async function startCallbackServer(): Promise { const createServer = await getNodeCreateServer(); return new Promise((resolve, reject) => { - let result: { code: string; state: string } | null = null; - let cancelled = false; + 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) => { const url = new URL(req.url || "", `http://localhost:51121`); @@ -79,28 +87,22 @@ async function startCallbackServer(): Promise { const error = url.searchParams.get("error"); if (error) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end( - `

Authentication Failed

Error: ${error}

You can close this window.

`, - ); + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Google authentication did not complete.", `Error: ${error}`)); return; } if (code && state) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - `

Authentication Successful

You can close this window and return to the terminal.

`, - ); - result = { code, state }; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthSuccessHtml("Google authentication completed. You can close this window.")); + settleWait?.({ code, state }); } else { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end( - `

Authentication Failed

Missing code or state parameter.

`, - ); + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Missing code or state parameter.")); } } else { - res.writeHead(404); - res.end(); + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Callback route not found.")); } }); @@ -112,15 +114,9 @@ async function startCallbackServer(): Promise { resolve({ server, cancelWait: () => { - cancelled = true; - }, - waitForCode: async () => { - const sleep = () => new Promise((r) => setTimeout(r, 100)); - while (!result && !cancelled) { - await sleep(); - } - return result; + settleWait?.(null); }, + waitForCode: () => waitForCodePromise, }); }); }); diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts index a90524fea..2ca6dd6a1 100644 --- a/packages/ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/ai/src/utils/oauth/google-gemini-cli.ts @@ -7,6 +7,7 @@ */ import type { Server } from "node:http"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; @@ -59,8 +60,15 @@ async function startCallbackServer(): Promise { const createServer = await getNodeCreateServer(); return new Promise((resolve, reject) => { - let result: { code: string; state: string } | null = null; - let cancelled = false; + 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) => { const url = new URL(req.url || "", `http://localhost:8085`); @@ -71,28 +79,22 @@ async function startCallbackServer(): Promise { const error = url.searchParams.get("error"); if (error) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end( - `

Authentication Failed

Error: ${error}

You can close this window.

`, - ); + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Google authentication did not complete.", `Error: ${error}`)); return; } if (code && state) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - `

Authentication Successful

You can close this window and return to the terminal.

`, - ); - result = { code, state }; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthSuccessHtml("Google authentication completed. You can close this window.")); + settleWait?.({ code, state }); } else { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end( - `

Authentication Failed

Missing code or state parameter.

`, - ); + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Missing code or state parameter.")); } } else { - res.writeHead(404); - res.end(); + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Callback route not found.")); } }); @@ -104,15 +106,9 @@ async function startCallbackServer(): Promise { resolve({ server, cancelWait: () => { - cancelled = true; - }, - waitForCode: async () => { - const sleep = () => new Promise((r) => setTimeout(r, 100)); - while (!result && !cancelled) { - await sleep(); - } - return result; + settleWait?.(null); }, + waitForCode: () => waitForCodePromise, }); }); }); diff --git a/packages/ai/src/utils/oauth/oauth-page.ts b/packages/ai/src/utils/oauth/oauth-page.ts new file mode 100644 index 000000000..a2a1c4d8a --- /dev/null +++ b/packages/ai/src/utils/oauth/oauth-page.ts @@ -0,0 +1,109 @@ +const LOGO_SVG = ``; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderPage(options: { title: string; heading: string; message: string; details?: string }): string { + const title = escapeHtml(options.title); + const heading = escapeHtml(options.heading); + const message = escapeHtml(options.message); + const details = options.details ? escapeHtml(options.details) : undefined; + + return ` + + + + + ${title} + + + +
+ +

${heading}

+

${message}

+ ${details ? `
${details}
` : ""} +
+ +`; +} + +export function oauthSuccessHtml(message: string): string { + return renderPage({ + title: "Authentication successful", + heading: "Authentication successful", + message, + }); +} + +export function oauthErrorHtml(message: string, details?: string): string { + return renderPage({ + title: "Authentication failed", + heading: "Authentication failed", + message, + details, + }); +} diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 0d8c1a832..86ccb1076 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -17,6 +17,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js"; @@ -27,18 +28,6 @@ const REDIRECT_URI = "http://localhost:1455/auth/callback"; const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; -const SUCCESS_HTML = ` - - - - - Authentication successful - - -

Authentication successful. Return to your terminal to continue.

- -`; - type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; type TokenFailure = { type: "failed" }; type TokenResult = TokenSuccess | TokenFailure; @@ -229,27 +218,31 @@ function startLocalOAuthServer(state: string): Promise { const url = new URL(req.url || "", "http://localhost"); if (url.pathname !== "/auth/callback") { res.statusCode = 404; - res.end("Not found"); + 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.end("State mismatch"); + 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.end("Missing authorization code"); + 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(SUCCESS_HTML); + res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); settleWait?.({ code }); } catch { res.statusCode = 500; - res.end("Internal error"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); } });