From e8fea9e63a437fb839fa925a6b63ace31b243471 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:36:40 -0500 Subject: [PATCH] feat(oauth): unify OAuth callback browser pages (#34025) --- packages/core/src/oauth/page.ts | 276 ++++++++++++++++++ packages/core/src/plugin/provider/openai.ts | 16 +- packages/core/test/oauth-page.test.ts | 15 + packages/opencode/src/mcp/oauth-callback.ts | 53 +--- packages/opencode/src/plugin/digitalocean.ts | 62 +--- packages/opencode/src/plugin/openai/codex.ts | 95 +----- .../opencode/src/plugin/snowflake-cortex.ts | 32 +- packages/opencode/src/plugin/xai.ts | 100 +------ .../opencode/test/mcp/oauth-callback.test.ts | 2 +- 9 files changed, 322 insertions(+), 329 deletions(-) create mode 100644 packages/core/src/oauth/page.ts create mode 100644 packages/core/test/oauth-page.test.ts diff --git a/packages/core/src/oauth/page.ts b/packages/core/src/oauth/page.ts new file mode 100644 index 0000000000..2d03e765a0 --- /dev/null +++ b/packages/core/src/oauth/page.ts @@ -0,0 +1,276 @@ +// Branded HTML pages for local OAuth callback servers. +// +// These are served by the loopback HTTP servers that finish an OAuth exchange +// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return +// a fully self-contained HTML string with no external assets, so they work +// offline and drop into any transport (`res.end(...)`, Effect `response.end`, +// etc.). +// +// The visual language mirrors the opencode app: the design tokens are a curated +// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and +// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`. +// Keep this file in sync with those sources when the brand changes. + +export interface CallbackPageOptions { + /** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */ + provider?: string + /** Attempt to close the window shortly after success. Defaults to true. */ + autoClose?: boolean +} + +export function success(options?: CallbackPageOptions) { + const provider = options?.provider + return renderDocument({ + title: "Authorization successful", + body: renderCard({ + status: "success", + headline: "Authorization successful", + message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.", + footnote: "You can close this window and return to opencode.", + }), + script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT, + }) +} + +export function error(detail: string, options?: CallbackPageOptions) { + const provider = options?.provider + return renderDocument({ + title: "Authorization failed", + body: renderCard({ + status: "error", + headline: "Authorization failed", + message: provider + ? `opencode couldn't finish connecting to ${escapeHtml(provider)}.` + : "opencode couldn't complete authorization.", + detail, + footnote: "Close this window and try again from opencode.", + }), + }) +} + +export interface BootstrapOptions { + /** Same-origin path the in-browser script POSTs the parsed callback to. */ + tokenPath: string + provider?: string +} + +// For flows where the credential arrives in the URL fragment (implicit grant), +// the browser must relay it back to the loopback server. This renders a pending +// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves +// to the success or error state in place. +export function bootstrap(options: BootstrapOptions) { + return renderDocument({ + title: "Finishing sign-in", + body: renderCard({ + status: "pending", + headline: "Finishing sign-in", + message: options.provider + ? `Completing your ${escapeHtml(options.provider)} authorization.` + : "Completing authorization.", + footnote: "You can close this window once sign-in finishes.", + }), + script: bootstrapScript(options), + }) +} + +export * as OauthCallbackPage from "./page" + +type Status = "pending" | "success" | "error" + +function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) { + const detail = input.detail?.trim() + return `
+
${WORDMARK}
+ +

${escapeHtml(input.headline)}

+

${input.message}

+
${detail ? escapeHtml(detail) : ""}
+

${escapeHtml(input.footnote)}

+
` +} + +function renderDocument(input: { title: string; body: string; script?: string }) { + return ` + + + + + + ${escapeHtml(input.title)} · opencode + + + + ${input.body}${input.script ? `\n ` : ""} + +` +} + +const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)` + +function bootstrapScript(options: BootstrapOptions) { + return `var PROVIDER=${scriptString(options.provider ?? "")}; +var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href; +(function(){ + var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote"); + function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("opencode couldn't finish connecting to "+PROVIDER+"."):"opencode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from opencode."} + function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("opencode is now connected to "+PROVIDER+"."):"opencode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)} + try{ + var hash=new URLSearchParams((window.location.hash||"").slice(1)); + var search=new URLSearchParams(window.location.search||""); + var err=hash.get("error")||search.get("error"); + var errDescription=hash.get("error_description")||search.get("error_description"); + var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""}; + fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){ + if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))}); + if(err){fail(errDescription||err);return} + ok(); + }).catch(function(e){fail(String(e&&e.message?e.message:e))}); + }catch(e){fail(String(e&&e.message?e.message:e))} +})()` +} + +function scriptString(value: string) { + return JSON.stringify(value).replaceAll("<", "\\u003c") +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + +// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is +// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a +// host force a scheme without changing the default. +const LIGHT_VARS = ` + --oc-bg: #f8f8f8; + --oc-card: #fcfcfc; + --oc-text-strong: #171717; + --oc-text-base: #6f6f6f; + --oc-text-weak: #8f8f8f; + --oc-border-weak: #e5e5e5; + --oc-icon-strong: #171717; + --oc-icon-base: #8f8f8f; + --oc-icon-weak: #dbdbdb; + --oc-success: #2dba26; + --oc-error: #ed4831; + --oc-detail-bg: #fff8f6; + --oc-detail-border: #fdc3b7; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);` + +const DARK_VARS = ` + --oc-bg: #101010; + --oc-card: #161616; + --oc-text-strong: rgba(255,255,255,.936); + --oc-text-base: rgba(255,255,255,.618); + --oc-text-weak: rgba(255,255,255,.422); + --oc-border-weak: #282828; + --oc-icon-strong: #ededed; + --oc-icon-base: #7e7e7e; + --oc-icon-weak: #343434; + --oc-success: #12c905; + --oc-error: #fc533a; + --oc-detail-bg: #28110c; + --oc-detail-border: #6a1206; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);` + +const STYLES = ` + :root { color-scheme: light dark;${LIGHT_VARS} + --oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } } + :root[data-theme="dark"] {${DARK_VARS} } + :root[data-theme="light"] {${LIGHT_VARS} } + + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; } + body { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--oc-bg); + color: var(--oc-text-base); + font-family: var(--oc-font-sans); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + .card { + width: min(100%, 25rem); + padding: 2.25rem 2rem 1.75rem; + background: var(--oc-card); + border: 1px solid var(--oc-border-weak); + border-radius: 14px; + box-shadow: var(--oc-shadow); + text-align: center; + } + .brand { display: flex; justify-content: center; margin-bottom: 1.75rem; } + .brand svg { height: 19px; width: auto; } + .status { display: flex; justify-content: center; margin-bottom: 1.125rem; } + .icon { display: none; line-height: 0; } + .icon svg { display: block; } + .card[data-status="pending"] .icon-pending, + .card[data-status="success"] .icon-success, + .card[data-status="error"] .icon-error { display: block; } + .icon-success { color: var(--oc-success); } + .icon-error { color: var(--oc-error); } + .icon-pending { color: var(--oc-text-weak); } + .headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); } + .message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); } + .detail { + margin: 1.25rem 0 0; + padding: 0.75rem 0.875rem; + text-align: left; + font-family: var(--oc-font-mono); + font-size: 0.8125rem; + line-height: 1.55; + color: var(--oc-text-strong); + background: var(--oc-detail-bg); + border: 1px solid var(--oc-detail-border); + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; + max-height: 9.5rem; + overflow: auto; + } + .detail[hidden] { display: none; } + .footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); } + .spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; } + @keyframes oc-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } +` + +// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo). +const WORDMARK = ` + + + + + + + + + + + + + + + + + ` + +const ICON_CHECK = `` + +const ICON_CROSS = `` + +const ICON_SPINNER = `` diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index e89382440e..46553eaff4 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -7,6 +7,7 @@ import { Credential } from "../../credential" import { InstallationVersion } from "../../installation/version" import { Integration } from "../../integration" import { ModelV2 } from "../../model" +import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" import type { PluginInternal } from "../internal" @@ -58,17 +59,21 @@ const browser = { const value = url.searchParams.get("code") if (error) { Effect.runFork(Deferred.fail(code, new Error(error))) - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error)) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "ChatGPT" })) return } if (!value || url.searchParams.get("state") !== state) { const message = value ? "Invalid OAuth state" : "Missing authorization code" Effect.runFork(Deferred.fail(code, new Error(message))) - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message)) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "ChatGPT" })) return } Effect.runFork(Deferred.succeed(code, value)) - response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" })) }) yield* Effect.callback((resume) => { server.once("error", (error) => resume(Effect.fail(error))) @@ -285,8 +290,3 @@ function claim(token: string) { return } } - -const successPage = - "OpenCode

Authorization successful

You can close this window.

" -const errorPage = (message: string) => - `OpenCode

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/test/oauth-page.test.ts b/packages/core/test/oauth-page.test.ts new file mode 100644 index 0000000000..880a6892f0 --- /dev/null +++ b/packages/core/test/oauth-page.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test" +import { OauthCallbackPage } from "../src/oauth/page" + +describe("OauthCallbackPage", () => { + test("escapes bootstrap options embedded in the inline script", () => { + const html = OauthCallbackPage.bootstrap({ + provider: `xAI`, + tokenPath: `/token`, + }) + + expect(html.match(/<\/script>/g)).toHaveLength(1) + expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`) + expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`) + }) +}) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 9624481ff8..84007902b8 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,6 +1,6 @@ import { createConnection } from "net" import { createServer } from "http" -import { escapeHtml } from "@/util/html" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const OAUTH_CALLBACK_HOST = "127.0.0.1" @@ -9,47 +9,6 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1" let currentPort = OAUTH_CALLBACK_PORT let currentPath = OAUTH_CALLBACK_PATH -const HTML_SUCCESS = ` - - - OpenCode - Authorization Successful - - - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -const HTML_ERROR = (error: string) => ` - - - OpenCode - Authorization Failed - - - -
-

Authorization Failed

-

An error occurred during authorization.

-
${escapeHtml(error)}
-
- -` - interface PendingAuth { resolve: (code: string) => void reject: (error: Error) => void @@ -98,7 +57,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). if (!state) { const errorMsg = "Missing required state parameter - potential CSRF attack" res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) return } @@ -112,14 +71,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). pending.reject(new Error(errorMsg)) } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) stopIfIdle() return } if (!code) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR("No authorization code provided")) + res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" })) return } @@ -127,7 +86,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). if (!pendingAuths.has(state)) { const errorMsg = "Invalid or expired state parameter - potential CSRF attack" res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) return } @@ -139,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). pending.resolve(code) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "MCP" })) stopIfIdle() } diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts index 9c3557a765..af241781e6 100644 --- a/packages/opencode/src/plugin/digitalocean.ts +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Model } from "@opencode-ai/sdk/v2" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { createServer } from "http" import open from "open" @@ -58,65 +59,6 @@ function buildAuthorizeUrl(state: string): string { return `${DO_AUTHORIZE_URL}?${params.toString()}` } -const HTML_CALLBACK = ` - - - - OpenCode - DigitalOcean Authorization - - - -
-

Finishing sign-in...

-

You can close this window once it says you're signed in.

-
- - -` - async function startOAuthServer(): Promise { if (oauthServer) return oauthServer = createServer((req, res) => { @@ -124,7 +66,7 @@ async function startOAuthServer(): Promise { if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_CALLBACK) + res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" })) return } diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index c13a9c439d..f14d126976 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -5,7 +5,7 @@ import os from "os" import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { OpenAIWebSocketPool } from "./ws-pool" -import { escapeHtml } from "@/util/html" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" @@ -138,95 +138,8 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis return response.json() } -const HTML_SUCCESS = ` - - - OpenCode - Codex Authorization Successful - - - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -export const renderOAuthError = (error: string) => ` - - - OpenCode - Codex Authorization Failed - - - -
-

Authorization Failed

-

An error occurred during authorization.

-
${escapeHtml(error)}
-
- -` +// Kept as a named export for plugin.codex tests; delegates to the shared branded page. +export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" }) interface PendingOAuth { pkce: PkceCodes @@ -287,7 +200,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .catch((err) => current.reject(err)) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "ChatGPT" })) return } diff --git a/packages/opencode/src/plugin/snowflake-cortex.ts b/packages/opencode/src/plugin/snowflake-cortex.ts index d40ebb6ac9..09f107ed7b 100644 --- a/packages/opencode/src/plugin/snowflake-cortex.ts +++ b/packages/opencode/src/plugin/snowflake-cortex.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { createServer } from "http" import open from "open" @@ -156,29 +157,6 @@ async function refreshAccessToken(account: string, refreshToken: string) { return token } -const HTML_SUCCESS = ` - - OpenCode - Snowflake Authorization Successful - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -const htmlError = (message: string) => ` - - OpenCode - Snowflake Authorization Failed - -
-

Authorization Failed

-
${message}
-
- -` - async function startOAuthServer() { if (oauthServer) return @@ -203,7 +181,7 @@ async function startOAuthServer() { pendingOAuth?.reject(new Error(message)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -214,7 +192,7 @@ async function startOAuthServer() { const message = errorDescription || error current.reject(new Error(message)) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -222,7 +200,7 @@ async function startOAuthServer() { const message = "Missing authorization code" current.reject(new Error(message)) res.writeHead(400, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -231,7 +209,7 @@ async function startOAuthServer() { .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err)))) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "Snowflake" })) }) await new Promise((resolve, reject) => { diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts index 8340794da7..23233b5df7 100644 --- a/packages/opencode/src/plugin/xai.ts +++ b/packages/opencode/src/plugin/xai.ts @@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { createServer } from "http" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { escapeHtml } from "@/util/html" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships @@ -285,96 +285,6 @@ export async function pollDeviceCodeToken( throw new Error("xAI device authorization timed out") } -const HTML_SUCCESS = ` - - - OpenCode - xAI Authorization Successful - - - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -const HTML_ERROR = (error: string) => ` - - - OpenCode - xAI Authorization Failed - - - -
-

Authorization Failed

-

An error occurred during authorization.

-
${escapeHtml(error)}
-
- -` - // CORS allowlist for the loopback callback. The redirect_uri itself is // already bound to 127.0.0.1 and gated by PKCE+state, so we only accept // xAI's own auth origins for additional defense-in-depth on the OPTIONS @@ -425,7 +335,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) return } @@ -434,7 +344,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) return } @@ -443,7 +353,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) return } @@ -455,7 +365,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .catch((err) => current.reject(err)) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "xAI" })) return } diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index 552bd26ca7..1666a37142 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -101,7 +101,7 @@ describe("McpOAuthCallback.ensureRunning", () => { `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`, ) - expect(await response.text()).toContain('
The user denied access
') + expect(await response.text()).toContain('
The user denied access
') }) test("binds the callback server to IPv4 loopback", async () => {