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

This commit is contained in:
Aiden Cline 2026-06-26 00:36:40 -05:00 committed by GitHub
commit e8fea9e63a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 322 additions and 329 deletions

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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) => {

View file

@ -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
}

View file

@ -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 () => {