Browse Source

feat(oauth): unify OAuth callback browser pages (#34025)

Aiden Cline 1 tháng trước cách đây
mục cha
commit
e8fea9e63a

+ 276 - 0
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 `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
+      <div class="brand">${WORDMARK}</div>
+      <div class="status" aria-hidden="true">
+        <span class="icon icon-pending">${ICON_SPINNER}</span>
+        <span class="icon icon-success">${ICON_CHECK}</span>
+        <span class="icon icon-error">${ICON_CROSS}</span>
+      </div>
+      <h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
+      <p class="message" id="oc-message">${input.message}</p>
+      <pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
+      <p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
+    </main>`
+}
+
+function renderDocument(input: { title: string; body: string; script?: string }) {
+  return `<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="robots" content="noindex" />
+    <title>${escapeHtml(input.title)} · opencode</title>
+    <style>${STYLES}</style>
+  </head>
+  <body>
+    ${input.body}${input.script ? `\n    <script>${input.script}</script>` : ""}
+  </body>
+</html>`
+}
+
+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("&", "&amp;")
+    .replaceAll("<", "&lt;")
+    .replaceAll(">", "&gt;")
+    .replaceAll('"', "&quot;")
+    .replaceAll("'", "&#39;")
+}
+
+// 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 = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
+        <path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
+        <path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
+        <path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
+        <path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
+        <path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
+        <path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
+        <path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
+        <path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
+        <path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
+        <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
+        <path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
+        <path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
+        <path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
+        <path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
+        <path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
+        <path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
+      </svg>`
+
+const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
+
+const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
+
+const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`

+ 8 - 8
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<void, Error>((resume) => {
         server.once("error", (error) => resume(Effect.fail(error)))
@@ -285,8 +290,3 @@ function claim(token: string) {
     return
   }
 }
-
-const successPage =
-  "<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
-const errorPage = (message: string) =>
-  `<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`

+ 15 - 0
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</script><script>alert("provider")</script>`,
+      tokenPath: `/token</script><script>alert("path")</script>`,
+    })
+
+    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>`)
+  })
+})

+ 6 - 47
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 = `<!DOCTYPE html>
-<html>
-<head>
-  <title>OpenCode - Authorization Successful</title>
-  <style>
-    body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
-    .container { text-align: center; padding: 2rem; }
-    h1 { color: #4ade80; margin-bottom: 1rem; }
-    p { color: #aaa; }
-  </style>
-</head>
-<body>
-  <div class="container">
-    <h1>Authorization Successful</h1>
-    <p>You can close this window and return to OpenCode.</p>
-  </div>
-  <script>setTimeout(() => window.close(), 2000);</script>
-</body>
-</html>`
-
-const HTML_ERROR = (error: string) => `<!DOCTYPE html>
-<html>
-<head>
-  <title>OpenCode - Authorization Failed</title>
-  <style>
-    body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
-    .container { text-align: center; padding: 2rem; }
-    h1 { color: #f87171; margin-bottom: 1rem; }
-    p { color: #aaa; }
-    .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
-  </style>
-</head>
-<body>
-  <div class="container">
-    <h1>Authorization Failed</h1>
-    <p>An error occurred during authorization.</p>
-    <div class="error">${escapeHtml(error)}</div>
-  </div>
-</body>
-</html>`
-
 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()
 }
 

+ 2 - 60
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 = `<!doctype html>
-<html>
-  <head>
-    <meta charset="utf-8" />
-    <title>OpenCode - DigitalOcean Authorization</title>
-    <style>
-      body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
-      .container { text-align: center; padding: 2rem; max-width: 32rem; }
-      h1 { color: #e8eef9; margin-bottom: 1rem; }
-      p { color: #9aa9c0; }
-      .error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
-    </style>
-  </head>
-  <body>
-    <div class="container">
-      <h1 id="title">Finishing sign-in...</h1>
-      <p id="msg">You can close this window once it says you're signed in.</p>
-    </div>
-    <script>
-      (async function() {
-        const params = new URLSearchParams((window.location.hash || "").slice(1))
-        const search = new URLSearchParams(window.location.search)
-        const error = params.get("error") || search.get("error")
-        const errorDescription = params.get("error_description") || search.get("error_description")
-        const titleEl = document.getElementById("title")
-        const msgEl = document.getElementById("msg")
-        const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
-        try {
-          const body = error
-            ? { error, error_description: errorDescription || "" }
-            : { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
-          const res = await fetch(tokenUrl, {
-            method: "POST",
-            headers: { "Content-Type": "application/json" },
-            body: JSON.stringify(body),
-          })
-          if (!res.ok) {
-            const detail = await res.text().catch(function () { return "" })
-            throw new Error(detail || ("callback failed (" + res.status + ")"))
-          }
-          if (error) {
-            titleEl.textContent = "Authorization Failed"
-            msgEl.textContent = errorDescription || error
-            msgEl.className = "error"
-            return
-          }
-          titleEl.textContent = "Authorization Successful"
-          msgEl.textContent = "You can close this window and return to OpenCode."
-          setTimeout(function () { window.close() }, 2000)
-        } catch (e) {
-          titleEl.textContent = "Authorization Failed"
-          msgEl.textContent = String(e && e.message ? e.message : e)
-          msgEl.className = "error"
-        }
-      })()
-    </script>
-  </body>
-</html>`
-
 async function startOAuthServer(): Promise<void> {
   if (oauthServer) return
   oauthServer = createServer((req, res) => {
@@ -124,7 +66,7 @@ async function startOAuthServer(): Promise<void> {
 
     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
     }
 

+ 4 - 91
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 = `<!doctype html>
-<html>
-  <head>
-    <title>OpenCode - Codex Authorization Successful</title>
-    <style>
-      body {
-        font-family:
-          system-ui,
-          -apple-system,
-          sans-serif;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        height: 100vh;
-        margin: 0;
-        background: #131010;
-        color: #f1ecec;
-      }
-      .container {
-        text-align: center;
-        padding: 2rem;
-      }
-      h1 {
-        color: #f1ecec;
-        margin-bottom: 1rem;
-      }
-      p {
-        color: #b7b1b1;
-      }
-    </style>
-  </head>
-  <body>
-    <div class="container">
-      <h1>Authorization Successful</h1>
-      <p>You can close this window and return to OpenCode.</p>
-    </div>
-    <script>
-      setTimeout(() => window.close(), 2000)
-    </script>
-  </body>
-</html>`
-
-export const renderOAuthError = (error: string) => `<!doctype html>
-<html>
-  <head>
-    <title>OpenCode - Codex Authorization Failed</title>
-    <style>
-      body {
-        font-family:
-          system-ui,
-          -apple-system,
-          sans-serif;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        height: 100vh;
-        margin: 0;
-        background: #131010;
-        color: #f1ecec;
-      }
-      .container {
-        text-align: center;
-        padding: 2rem;
-      }
-      h1 {
-        color: #fc533a;
-        margin-bottom: 1rem;
-      }
-      p {
-        color: #b7b1b1;
-      }
-      .error {
-        color: #ff917b;
-        font-family: monospace;
-        margin-top: 1rem;
-        padding: 1rem;
-        background: #3c140d;
-        border-radius: 0.5rem;
-      }
-    </style>
-  </head>
-  <body>
-    <div class="container">
-      <h1>Authorization Failed</h1>
-      <p>An error occurred during authorization.</p>
-      <div class="error">${escapeHtml(error)}</div>
-    </div>
-  </body>
-</html>`
+// 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
     }
 

+ 5 - 27
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 = `<!doctype html>
-<html>
-  <head><title>OpenCode - Snowflake Authorization Successful</title></head>
-  <body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
-    <div style="text-align:center; max-width:36rem; padding:2rem;">
-      <h1 style="color:#7ee787;">Authorization Successful</h1>
-      <p>You can close this window and return to OpenCode.</p>
-    </div>
-    <script>setTimeout(() => window.close(), 1500)</script>
-  </body>
-</html>`
-
-const htmlError = (message: string) => `<!doctype html>
-<html>
-  <head><title>OpenCode - Snowflake Authorization Failed</title></head>
-  <body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
-    <div style="text-align:center; max-width:48rem; padding:2rem;">
-      <h1 style="color:#ff7b72;">Authorization Failed</h1>
-      <pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
-    </div>
-  </body>
-</html>`
-
 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<void>((resolve, reject) => {

+ 5 - 95
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 = `<!doctype html>
-<html>
-  <head>
-    <title>OpenCode - xAI Authorization Successful</title>
-    <style>
-      body {
-        font-family:
-          system-ui,
-          -apple-system,
-          sans-serif;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        height: 100vh;
-        margin: 0;
-        background: #131010;
-        color: #f1ecec;
-      }
-      .container {
-        text-align: center;
-        padding: 2rem;
-      }
-      h1 {
-        color: #f1ecec;
-        margin-bottom: 1rem;
-      }
-      p {
-        color: #b7b1b1;
-      }
-    </style>
-  </head>
-  <body>
-    <div class="container">
-      <h1>Authorization Successful</h1>
-      <p>You can close this window and return to OpenCode.</p>
-    </div>
-    <script>
-      setTimeout(() => window.close(), 2000)
-    </script>
-  </body>
-</html>`
-
-const HTML_ERROR = (error: string) => `<!doctype html>
-<html>
-  <head>
-    <title>OpenCode - xAI Authorization Failed</title>
-    <style>
-      body {
-        font-family:
-          system-ui,
-          -apple-system,
-          sans-serif;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        height: 100vh;
-        margin: 0;
-        background: #131010;
-        color: #f1ecec;
-      }
-      .container {
-        text-align: center;
-        padding: 2rem;
-      }
-      h1 {
-        color: #fc533a;
-        margin-bottom: 1rem;
-      }
-      p {
-        color: #b7b1b1;
-      }
-      .error {
-        color: #ff917b;
-        font-family: monospace;
-        margin-top: 1rem;
-        padding: 1rem;
-        background: #3c140d;
-        border-radius: 0.5rem;
-      }
-    </style>
-  </head>
-  <body>
-    <div class="container">
-      <h1>Authorization Failed</h1>
-      <p>An error occurred during authorization.</p>
-      <div class="error">${escapeHtml(error)}</div>
-    </div>
-  </body>
-</html>`
-
 // 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
     }
 

+ 1 - 1
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('<div class="error">The user denied access</div>')
+    expect(await response.text()).toContain('<pre class="detail" id="oc-detail">The user denied access</pre>')
   })
 
   test("binds the callback server to IPv4 loopback", async () => {