Skip to content

Commit e8fea9e

Browse files
authored
feat(oauth): unify OAuth callback browser pages (anomalyco#34025)
1 parent 1153726 commit e8fea9e

9 files changed

Lines changed: 322 additions & 329 deletions

File tree

packages/core/src/oauth/page.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Branded HTML pages for local OAuth callback servers.
2+
//
3+
// These are served by the loopback HTTP servers that finish an OAuth exchange
4+
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
5+
// a fully self-contained HTML string with no external assets, so they work
6+
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
7+
// etc.).
8+
//
9+
// The visual language mirrors the opencode app: the design tokens are a curated
10+
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
11+
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
12+
// Keep this file in sync with those sources when the brand changes.
13+
14+
export interface CallbackPageOptions {
15+
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
16+
provider?: string
17+
/** Attempt to close the window shortly after success. Defaults to true. */
18+
autoClose?: boolean
19+
}
20+
21+
export function success(options?: CallbackPageOptions) {
22+
const provider = options?.provider
23+
return renderDocument({
24+
title: "Authorization successful",
25+
body: renderCard({
26+
status: "success",
27+
headline: "Authorization successful",
28+
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
29+
footnote: "You can close this window and return to opencode.",
30+
}),
31+
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
32+
})
33+
}
34+
35+
export function error(detail: string, options?: CallbackPageOptions) {
36+
const provider = options?.provider
37+
return renderDocument({
38+
title: "Authorization failed",
39+
body: renderCard({
40+
status: "error",
41+
headline: "Authorization failed",
42+
message: provider
43+
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
44+
: "opencode couldn't complete authorization.",
45+
detail,
46+
footnote: "Close this window and try again from opencode.",
47+
}),
48+
})
49+
}
50+
51+
export interface BootstrapOptions {
52+
/** Same-origin path the in-browser script POSTs the parsed callback to. */
53+
tokenPath: string
54+
provider?: string
55+
}
56+
57+
// For flows where the credential arrives in the URL fragment (implicit grant),
58+
// the browser must relay it back to the loopback server. This renders a pending
59+
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
60+
// to the success or error state in place.
61+
export function bootstrap(options: BootstrapOptions) {
62+
return renderDocument({
63+
title: "Finishing sign-in",
64+
body: renderCard({
65+
status: "pending",
66+
headline: "Finishing sign-in",
67+
message: options.provider
68+
? `Completing your ${escapeHtml(options.provider)} authorization.`
69+
: "Completing authorization.",
70+
footnote: "You can close this window once sign-in finishes.",
71+
}),
72+
script: bootstrapScript(options),
73+
})
74+
}
75+
76+
export * as OauthCallbackPage from "./page"
77+
78+
type Status = "pending" | "success" | "error"
79+
80+
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
81+
const detail = input.detail?.trim()
82+
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
83+
<div class="brand">${WORDMARK}</div>
84+
<div class="status" aria-hidden="true">
85+
<span class="icon icon-pending">${ICON_SPINNER}</span>
86+
<span class="icon icon-success">${ICON_CHECK}</span>
87+
<span class="icon icon-error">${ICON_CROSS}</span>
88+
</div>
89+
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
90+
<p class="message" id="oc-message">${input.message}</p>
91+
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
92+
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
93+
</main>`
94+
}
95+
96+
function renderDocument(input: { title: string; body: string; script?: string }) {
97+
return `<!doctype html>
98+
<html lang="en">
99+
<head>
100+
<meta charset="utf-8" />
101+
<meta name="viewport" content="width=device-width, initial-scale=1" />
102+
<meta name="robots" content="noindex" />
103+
<title>${escapeHtml(input.title)} · opencode</title>
104+
<style>${STYLES}</style>
105+
</head>
106+
<body>
107+
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
108+
</body>
109+
</html>`
110+
}
111+
112+
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
113+
114+
function bootstrapScript(options: BootstrapOptions) {
115+
return `var PROVIDER=${scriptString(options.provider ?? "")};
116+
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
117+
(function(){
118+
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");
119+
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."}
120+
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)}
121+
try{
122+
var hash=new URLSearchParams((window.location.hash||"").slice(1));
123+
var search=new URLSearchParams(window.location.search||"");
124+
var err=hash.get("error")||search.get("error");
125+
var errDescription=hash.get("error_description")||search.get("error_description");
126+
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")||""};
127+
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
128+
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
129+
if(err){fail(errDescription||err);return}
130+
ok();
131+
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
132+
}catch(e){fail(String(e&&e.message?e.message:e))}
133+
})()`
134+
}
135+
136+
function scriptString(value: string) {
137+
return JSON.stringify(value).replaceAll("<", "\\u003c")
138+
}
139+
140+
function escapeHtml(value: string) {
141+
return value
142+
.replaceAll("&", "&amp;")
143+
.replaceAll("<", "&lt;")
144+
.replaceAll(">", "&gt;")
145+
.replaceAll('"', "&quot;")
146+
.replaceAll("'", "&#39;")
147+
}
148+
149+
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
150+
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
151+
// host force a scheme without changing the default.
152+
const LIGHT_VARS = `
153+
--oc-bg: #f8f8f8;
154+
--oc-card: #fcfcfc;
155+
--oc-text-strong: #171717;
156+
--oc-text-base: #6f6f6f;
157+
--oc-text-weak: #8f8f8f;
158+
--oc-border-weak: #e5e5e5;
159+
--oc-icon-strong: #171717;
160+
--oc-icon-base: #8f8f8f;
161+
--oc-icon-weak: #dbdbdb;
162+
--oc-success: #2dba26;
163+
--oc-error: #ed4831;
164+
--oc-detail-bg: #fff8f6;
165+
--oc-detail-border: #fdc3b7;
166+
--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);`
167+
168+
const DARK_VARS = `
169+
--oc-bg: #101010;
170+
--oc-card: #161616;
171+
--oc-text-strong: rgba(255,255,255,.936);
172+
--oc-text-base: rgba(255,255,255,.618);
173+
--oc-text-weak: rgba(255,255,255,.422);
174+
--oc-border-weak: #282828;
175+
--oc-icon-strong: #ededed;
176+
--oc-icon-base: #7e7e7e;
177+
--oc-icon-weak: #343434;
178+
--oc-success: #12c905;
179+
--oc-error: #fc533a;
180+
--oc-detail-bg: #28110c;
181+
--oc-detail-border: #6a1206;
182+
--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);`
183+
184+
const STYLES = `
185+
:root { color-scheme: light dark;${LIGHT_VARS}
186+
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
187+
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
188+
}
189+
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
190+
:root[data-theme="dark"] {${DARK_VARS} }
191+
:root[data-theme="light"] {${LIGHT_VARS} }
192+
193+
* { box-sizing: border-box; }
194+
html, body { margin: 0; height: 100%; }
195+
body {
196+
min-height: 100vh;
197+
display: grid;
198+
place-items: center;
199+
padding: 24px;
200+
background: var(--oc-bg);
201+
color: var(--oc-text-base);
202+
font-family: var(--oc-font-sans);
203+
line-height: 1.5;
204+
-webkit-font-smoothing: antialiased;
205+
text-rendering: optimizeLegibility;
206+
}
207+
.card {
208+
width: min(100%, 25rem);
209+
padding: 2.25rem 2rem 1.75rem;
210+
background: var(--oc-card);
211+
border: 1px solid var(--oc-border-weak);
212+
border-radius: 14px;
213+
box-shadow: var(--oc-shadow);
214+
text-align: center;
215+
}
216+
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
217+
.brand svg { height: 19px; width: auto; }
218+
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
219+
.icon { display: none; line-height: 0; }
220+
.icon svg { display: block; }
221+
.card[data-status="pending"] .icon-pending,
222+
.card[data-status="success"] .icon-success,
223+
.card[data-status="error"] .icon-error { display: block; }
224+
.icon-success { color: var(--oc-success); }
225+
.icon-error { color: var(--oc-error); }
226+
.icon-pending { color: var(--oc-text-weak); }
227+
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
228+
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
229+
.detail {
230+
margin: 1.25rem 0 0;
231+
padding: 0.75rem 0.875rem;
232+
text-align: left;
233+
font-family: var(--oc-font-mono);
234+
font-size: 0.8125rem;
235+
line-height: 1.55;
236+
color: var(--oc-text-strong);
237+
background: var(--oc-detail-bg);
238+
border: 1px solid var(--oc-detail-border);
239+
border-radius: 8px;
240+
white-space: pre-wrap;
241+
word-break: break-word;
242+
max-height: 9.5rem;
243+
overflow: auto;
244+
}
245+
.detail[hidden] { display: none; }
246+
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
247+
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
248+
@keyframes oc-spin { to { transform: rotate(360deg); } }
249+
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
250+
`
251+
252+
// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
253+
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
254+
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
255+
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
256+
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
257+
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
258+
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
259+
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
260+
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
261+
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
262+
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
263+
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
264+
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
265+
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
266+
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
267+
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
268+
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
269+
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
270+
</svg>`
271+
272+
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>`
273+
274+
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>`
275+
276+
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>`

packages/core/src/plugin/provider/openai.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Credential } from "../../credential"
77
import { InstallationVersion } from "../../installation/version"
88
import { Integration } from "../../integration"
99
import { ModelV2 } from "../../model"
10+
import { OauthCallbackPage } from "../../oauth/page"
1011
import { ProviderV2 } from "../../provider"
1112
import type { PluginInternal } from "../internal"
1213

@@ -58,17 +59,21 @@ const browser = {
5859
const value = url.searchParams.get("code")
5960
if (error) {
6061
Effect.runFork(Deferred.fail(code, new Error(error)))
61-
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
62+
response
63+
.writeHead(400, { "Content-Type": "text/html" })
64+
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
6265
return
6366
}
6467
if (!value || url.searchParams.get("state") !== state) {
6568
const message = value ? "Invalid OAuth state" : "Missing authorization code"
6669
Effect.runFork(Deferred.fail(code, new Error(message)))
67-
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
70+
response
71+
.writeHead(400, { "Content-Type": "text/html" })
72+
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
6873
return
6974
}
7075
Effect.runFork(Deferred.succeed(code, value))
71-
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
76+
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
7277
})
7378
yield* Effect.callback<void, Error>((resume) => {
7479
server.once("error", (error) => resume(Effect.fail(error)))
@@ -285,8 +290,3 @@ function claim(token: string) {
285290
return
286291
}
287292
}
288-
289-
const successPage =
290-
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
291-
const errorPage = (message: string) =>
292-
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { OauthCallbackPage } from "../src/oauth/page"
3+
4+
describe("OauthCallbackPage", () => {
5+
test("escapes bootstrap options embedded in the inline script", () => {
6+
const html = OauthCallbackPage.bootstrap({
7+
provider: `xAI</script><script>alert("provider")</script>`,
8+
tokenPath: `/token</script><script>alert("path")</script>`,
9+
})
10+
11+
expect(html.match(/<\/script>/g)).toHaveLength(1)
12+
expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`)
13+
expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`)
14+
})
15+
})

0 commit comments

Comments
 (0)