Skip to content

Commit cb88db6

Browse files
authored
tweak(opencode): make xAI OAuth device-only to reduce confusion w/ headless environments (anomalyco#40537)
1 parent 66fdd51 commit cb88db6

3 files changed

Lines changed: 15 additions & 345 deletions

File tree

‎packages/opencode/src/plugin/xai.ts‎

Lines changed: 3 additions & 290 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
22
import { OAUTH_DUMMY_KEY } from "../auth"
3-
import { createServer } from "http"
43
import { InstallationVersion } from "@opencode-ai/core/installation/version"
5-
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
64

7-
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
8-
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
9-
// for desktop OAuth flows. Source of truth: hermes-agent PR #26534.
5+
// Public Grok-CLI OAuth client.
106
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
11-
const AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
127
const TOKEN_URL = "https://auth.x.ai/oauth2/token"
138
// RFC 8628 device authorization grant. Confirmed exposed by xAI's
149
// /.well-known/openid-configuration as `device_authorization_endpoint`
@@ -30,51 +25,15 @@ const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000
3025
const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000
3126
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000
3227

33-
// xAI rejects redirect_uris that don't match what was registered for the
34-
// Grok-CLI client. The host:port pair is part of the registration, so we have
35-
// to bind the loopback server to this exact port.
36-
const OAUTH_HOST = "127.0.0.1"
37-
const OAUTH_PORT = 56121
38-
const OAUTH_REDIRECT_PATH = "/callback"
39-
const REDIRECT_URI = `http://${OAUTH_HOST}:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
40-
4128
// Refresh the access token a little before it actually expires so a single
4229
// long-running tool call doesn't have to recover from a mid-flight 401.
4330
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
4431

4532
interface XaiAuthPluginOptions {
46-
authorizeUrl?: string
4733
tokenUrl?: string
4834
deviceAuthorizationUrl?: string
4935
}
5036

51-
interface PkceCodes {
52-
verifier: string
53-
challenge: string
54-
}
55-
56-
async function generatePKCE(): Promise<PkceCodes> {
57-
const verifier = generateRandomString(64)
58-
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
59-
return { verifier, challenge: base64UrlEncode(hash) }
60-
}
61-
62-
function generateRandomString(length: number): string {
63-
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
64-
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
65-
.map((b) => chars[b % chars.length])
66-
.join("")
67-
}
68-
69-
function base64UrlEncode(buffer: ArrayBuffer): string {
70-
const binary = String.fromCharCode(...new Uint8Array(buffer))
71-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
72-
}
73-
74-
function generateState(): string {
75-
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
76-
}
77-
7837
interface TokenResponse {
7938
access_token: string
8039
refresh_token: string
@@ -115,55 +74,6 @@ export function accessTokenIsExpiring(
11574
}
11675
}
11776

118-
export function buildAuthorizeUrl(
119-
pkce: PkceCodes,
120-
state: string,
121-
nonce: string,
122-
options: XaiAuthPluginOptions = {},
123-
): string {
124-
// `plan=generic` opts the consent screen into xAI's generic OAuth plan tier;
125-
// without it, accounts.x.ai rejects loopback OAuth from non-allowlisted
126-
// clients. `referrer=opencode` lets xAI attribute opencode-originated
127-
// logins in their OAuth server logs (best-effort attribution while we
128-
// continue to reuse the Grok-CLI client_id).
129-
const params = new URLSearchParams({
130-
response_type: "code",
131-
client_id: CLIENT_ID,
132-
redirect_uri: REDIRECT_URI,
133-
scope: SCOPE,
134-
code_challenge: pkce.challenge,
135-
code_challenge_method: "S256",
136-
state,
137-
nonce,
138-
plan: "generic",
139-
referrer: "opencode",
140-
})
141-
return `${options.authorizeUrl ?? AUTHORIZE_URL}?${params.toString()}`
142-
}
143-
144-
async function exchangeCodeForTokens(
145-
code: string,
146-
pkce: PkceCodes,
147-
options: XaiAuthPluginOptions = {},
148-
): Promise<TokenResponse> {
149-
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
150-
method: "POST",
151-
headers: authHeaders(),
152-
body: new URLSearchParams({
153-
grant_type: "authorization_code",
154-
code,
155-
redirect_uri: REDIRECT_URI,
156-
client_id: CLIENT_ID,
157-
code_verifier: pkce.verifier,
158-
}).toString(),
159-
})
160-
if (!response.ok) {
161-
const detail = await response.text().catch(() => "")
162-
throw new Error(`xAI token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
163-
}
164-
return response.json() as Promise<TokenResponse>
165-
}
166-
16777
async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise<TokenResponse> {
16878
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
16979
method: "POST",
@@ -202,6 +112,7 @@ export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Pro
202112
body: new URLSearchParams({
203113
client_id: CLIENT_ID,
204114
scope: SCOPE,
115+
referrer: "opencode",
205116
}).toString(),
206117
})
207118
if (!response.ok) {
@@ -285,170 +196,6 @@ export async function pollDeviceCodeToken(
285196
throw new Error("xAI device authorization timed out")
286197
}
287198

288-
// CORS allowlist for the loopback callback. The redirect_uri itself is
289-
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
290-
// xAI's own auth origins for additional defense-in-depth on the OPTIONS
291-
// preflight.
292-
const CORS_ALLOWED_ORIGINS = new Set(["https://accounts.x.ai", "https://auth.x.ai"])
293-
294-
interface PendingOAuth {
295-
pkce: PkceCodes
296-
state: string
297-
resolve: (tokens: TokenResponse) => void
298-
reject: (error: Error) => void
299-
}
300-
301-
let oauthServer: ReturnType<typeof createServer> | undefined
302-
let pendingOAuth: PendingOAuth | undefined
303-
304-
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
305-
if (oauthServer) return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
306-
307-
const server = createServer((req, res) => {
308-
const reqUrl = req.url || "/"
309-
const url = new URL(reqUrl, `http://${OAUTH_HOST}:${OAUTH_PORT}`)
310-
311-
const origin = req.headers["origin"]
312-
const allowOrigin = typeof origin === "string" && CORS_ALLOWED_ORIGINS.has(origin) ? origin : ""
313-
if (allowOrigin) {
314-
res.setHeader("Access-Control-Allow-Origin", allowOrigin)
315-
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
316-
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
317-
res.setHeader("Access-Control-Allow-Private-Network", "true")
318-
res.setHeader("Vary", "Origin")
319-
}
320-
321-
if (req.method === "OPTIONS") {
322-
res.writeHead(204)
323-
res.end()
324-
return
325-
}
326-
327-
if (url.pathname === OAUTH_REDIRECT_PATH) {
328-
const code = url.searchParams.get("code")
329-
const state = url.searchParams.get("state")
330-
const error = url.searchParams.get("error")
331-
const errorDescription = url.searchParams.get("error_description")
332-
333-
if (error) {
334-
const errorMsg = errorDescription || error
335-
pendingOAuth?.reject(new Error(errorMsg))
336-
pendingOAuth = undefined
337-
res.writeHead(200, { "Content-Type": "text/html" })
338-
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
339-
return
340-
}
341-
342-
if (!code) {
343-
const errorMsg = "Missing authorization code"
344-
pendingOAuth?.reject(new Error(errorMsg))
345-
pendingOAuth = undefined
346-
res.writeHead(400, { "Content-Type": "text/html" })
347-
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
348-
return
349-
}
350-
351-
if (!pendingOAuth || state !== pendingOAuth.state) {
352-
const errorMsg = "Invalid state - potential CSRF attack"
353-
pendingOAuth?.reject(new Error(errorMsg))
354-
pendingOAuth = undefined
355-
res.writeHead(400, { "Content-Type": "text/html" })
356-
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
357-
return
358-
}
359-
360-
const current = pendingOAuth
361-
pendingOAuth = undefined
362-
363-
exchangeCodeForTokens(code, current.pkce)
364-
.then((tokens) => current.resolve(tokens))
365-
.catch((err) => current.reject(err))
366-
367-
res.writeHead(200, { "Content-Type": "text/html" })
368-
res.end(OauthCallbackPage.success({ provider: "xAI" }))
369-
return
370-
}
371-
372-
if (url.pathname === "/cancel") {
373-
pendingOAuth?.reject(new Error("Login cancelled"))
374-
pendingOAuth = undefined
375-
res.writeHead(200)
376-
res.end("Login cancelled")
377-
return
378-
}
379-
380-
res.writeHead(404)
381-
res.end("Not found")
382-
})
383-
384-
// listen() failures (e.g. EADDRINUSE because Grok-CLI is bound to the same
385-
// pinned port) must clear `oauthServer` and remove our error listener,
386-
// otherwise the next startOAuthServer() short-circuits on the truthy check
387-
// and returns a redirect_uri pointing at nothing.
388-
await new Promise<void>((resolve, reject) => {
389-
const onError = (err: Error) => {
390-
oauthServer = undefined
391-
reject(err)
392-
}
393-
server.once("error", onError)
394-
server.listen(OAUTH_PORT, OAUTH_HOST, () => {
395-
server.removeListener("error", onError)
396-
// After listen() succeeds, install a permanent log-only listener so
397-
// that subsequent server errors (e.g. accept() failures, socket-level
398-
// errors) don't trip Node's default "unhandled error event = throw"
399-
// behavior and crash the entire opencode process. Matches the silent-
400-
// swallow behavior the Codex plugin gets from its permanent
401-
// `oauthServer!.on("error", reject)`.
402-
resolve()
403-
})
404-
oauthServer = server
405-
})
406-
407-
return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
408-
}
409-
410-
function stopOAuthServer() {
411-
if (oauthServer) {
412-
oauthServer.close()
413-
oauthServer = undefined
414-
}
415-
}
416-
417-
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
418-
// A previous in-flight authorize() that the user abandoned (or that is
419-
// being superseded by a fresh attempt) still owns `pendingOAuth`. Reject
420-
// it eagerly so its caller stops waiting on a state value that can never
421-
// match the next callback.
422-
if (pendingOAuth) {
423-
pendingOAuth.reject(new Error("Superseded by a newer xAI authorize request"))
424-
pendingOAuth = undefined
425-
}
426-
return new Promise((resolve, reject) => {
427-
const timeout = setTimeout(
428-
() => {
429-
if (pendingOAuth) {
430-
pendingOAuth = undefined
431-
reject(new Error("OAuth callback timeout - authorization took too long"))
432-
}
433-
},
434-
5 * 60 * 1000,
435-
)
436-
437-
pendingOAuth = {
438-
pkce,
439-
state,
440-
resolve: (tokens) => {
441-
clearTimeout(timeout)
442-
resolve(tokens)
443-
},
444-
reject: (error) => {
445-
clearTimeout(timeout)
446-
reject(error)
447-
},
448-
}
449-
})
450-
}
451-
452199
interface RefreshResult {
453200
access: string
454201
refresh: string
@@ -548,40 +295,6 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
548295
}
549296
},
550297
methods: [
551-
{
552-
label: "xAI Grok OAuth (SuperGrok Subscription)",
553-
type: "oauth",
554-
authorize: async () => {
555-
await startOAuthServer()
556-
const pkce = await generatePKCE()
557-
const state = generateState()
558-
const nonce = generateState()
559-
const authUrl = buildAuthorizeUrl(pkce, state, nonce, options)
560-
561-
const callbackPromise = waitForOAuthCallback(pkce, state)
562-
563-
return {
564-
url: authUrl,
565-
instructions: "Complete authorization in your browser. This window will close automatically.",
566-
method: "auto" as const,
567-
callback: async () => {
568-
try {
569-
const tokens = await callbackPromise
570-
return {
571-
type: "success" as const,
572-
refresh: tokens.refresh_token,
573-
access: tokens.access_token,
574-
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
575-
}
576-
} catch (err) {
577-
return { type: "failed" as const }
578-
} finally {
579-
stopOAuthServer()
580-
}
581-
},
582-
}
583-
},
584-
},
585298
{
586299
// RFC 8628 device-code flow. The CLI prints a verification URL
587300
// and a short user_code that the user enters in a browser on
@@ -591,7 +304,7 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
591304
// user's browser. Defends the only attack surface (the polling
592305
// loop) with the standard authorization_pending / slow_down
593306
// backoff and a hard deadline from xAI's `expires_in`.
594-
label: "xAI Grok OAuth (Headless / Remote / VPS)",
307+
label: "SuperGrok Subscription",
595308
type: "oauth",
596309
authorize: async () => {
597310
const device = await requestDeviceCode(options)

0 commit comments

Comments
 (0)