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}
+
+ ${ICON_SPINNER}
+ ${ICON_CHECK}
+ ${ICON_CROSS}
+
+ ${escapeHtml(input.headline)}
+ ${input.message}
+ ${detail ? escapeHtml(detail) : ""}
+
+ `
+}
+
+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 () => {