mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
feat(opencode): add xAI Grok OAuth (SuperGrok) + device-code login (#28557)
Co-authored-by: Jaaneek <Jaaneek@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
committed by
GitHub
parent
7ff392abe3
commit
b32debb8a3
@@ -19,6 +19,7 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { AzureAuthPlugin } from "./azure"
|
||||
import { DigitalOceanAuthPlugin } from "./digitalocean"
|
||||
import { XaiAuthPlugin } from "./xai"
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -66,6 +67,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { createServer } from "http"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
const log = Log.create({ service: "plugin.xai" })
|
||||
|
||||
// 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
|
||||
// for desktop OAuth flows. Source of truth: hermes-agent PR #26534.
|
||||
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
const AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
|
||||
const TOKEN_URL = "https://auth.x.ai/oauth2/token"
|
||||
// RFC 8628 device authorization grant. Confirmed exposed by xAI's
|
||||
// /.well-known/openid-configuration as `device_authorization_endpoint`
|
||||
// with the matching `urn:ietf:params:oauth:grant-type:device_code` grant
|
||||
// in `grant_types_supported`. This is the headless / VPS path: no
|
||||
// loopback callback server, no SSH port forwarding, no inbound firewall
|
||||
// holes — the user opens the URL on any device with a browser, types
|
||||
// the short user_code, and the CLI long-polls the token endpoint.
|
||||
const DEVICE_AUTHORIZATION_URL = "https://auth.x.ai/oauth2/device/code"
|
||||
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
const SCOPE = "openid profile email offline_access grok-cli:access api:access"
|
||||
|
||||
// Bounds for the device-code poll loop. xAI returns `interval` (seconds)
|
||||
// but we floor it to avoid hammering and we add the spec's slow_down
|
||||
// increment when xAI explicitly asks us to back off.
|
||||
const DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000
|
||||
const DEVICE_CODE_MIN_INTERVAL_MS = 1_000
|
||||
const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000
|
||||
const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000
|
||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000
|
||||
|
||||
// xAI rejects redirect_uris that don't match what was registered for the
|
||||
// Grok-CLI client. The host:port pair is part of the registration, so we have
|
||||
// to bind the loopback server to this exact port.
|
||||
const OAUTH_HOST = "127.0.0.1"
|
||||
const OAUTH_PORT = 56121
|
||||
const OAUTH_REDIRECT_PATH = "/callback"
|
||||
const REDIRECT_URI = `http://${OAUTH_HOST}:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
|
||||
|
||||
// Refresh the access token a little before it actually expires so a single
|
||||
// long-running tool call doesn't have to recover from a mid-flight 401.
|
||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
|
||||
|
||||
interface XaiAuthPluginOptions {
|
||||
authorizeUrl?: string
|
||||
tokenUrl?: string
|
||||
deviceAuthorizationUrl?: string
|
||||
}
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(64)
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
|
||||
return { verifier, challenge: base64UrlEncode(hash) }
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const binary = String.fromCharCode(...new Uint8Array(buffer))
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&"
|
||||
case "<":
|
||||
return "<"
|
||||
case ">":
|
||||
return ">"
|
||||
case '"':
|
||||
return """
|
||||
case "'":
|
||||
return "'"
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
id_token?: string
|
||||
token_type?: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the `exp` claim out of a JWT access_token without verifying the
|
||||
// signature. We only use this to decide whether to proactively refresh, never
|
||||
// to make trust decisions, so unsigned decode is safe. Returns false for
|
||||
// opaque tokens (no JWT shape), which conservatively skips the proactive
|
||||
// refresh and lets the 401-on-call path drive the refresh instead.
|
||||
export function accessTokenIsExpiring(token: string | undefined, skewMs: number = ACCESS_TOKEN_REFRESH_SKEW_MS): boolean {
|
||||
if (!token || typeof token !== "string") return false
|
||||
const parts = token.split(".")
|
||||
if (parts.length < 2) return false
|
||||
try {
|
||||
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
|
||||
while (payload.length % 4 !== 0) payload += "="
|
||||
const claims = JSON.parse(Buffer.from(payload, "base64").toString("utf8"))
|
||||
if (typeof claims?.exp !== "number") return false
|
||||
return claims.exp * 1000 <= Date.now() + Math.max(0, skewMs)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl(
|
||||
pkce: PkceCodes,
|
||||
state: string,
|
||||
nonce: string,
|
||||
options: XaiAuthPluginOptions = {},
|
||||
): string {
|
||||
// `plan=generic` opts the consent screen into xAI's generic OAuth plan tier;
|
||||
// without it, accounts.x.ai rejects loopback OAuth from non-allowlisted
|
||||
// clients. `referrer=opencode` lets xAI attribute opencode-originated
|
||||
// logins in their OAuth server logs (best-effort attribution while we
|
||||
// continue to reuse the Grok-CLI client_id).
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPE,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
plan: "generic",
|
||||
referrer: "opencode",
|
||||
})
|
||||
return `${options.authorizeUrl ?? AUTHORIZE_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
pkce: PkceCodes,
|
||||
options: XaiAuthPluginOptions = {},
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`xAI token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
return response.json() as Promise<TokenResponse>
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise<TokenResponse> {
|
||||
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`xAI token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
return response.json() as Promise<TokenResponse>
|
||||
}
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete?: string
|
||||
expires_in?: number
|
||||
interval?: number
|
||||
}
|
||||
|
||||
interface DeviceTokenErrorBody {
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Promise<DeviceCodeResponse> {
|
||||
const response = await fetch(options.deviceAuthorizationUrl ?? DEVICE_AUTHORIZATION_URL, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
scope: SCOPE,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`xAI device code request failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
const json = (await response.json()) as DeviceCodeResponse
|
||||
if (!json.device_code || !json.user_code || !json.verification_uri) {
|
||||
throw new Error("xAI device code response is missing device_code / user_code / verification_uri")
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
// Default sleep used between device-code polls. Test-injectable so we can
|
||||
// exercise authorization_pending / slow_down branches without real waits.
|
||||
async function defaultSleep(ms: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Normalize a server-supplied seconds value to milliseconds, falling back to
|
||||
// the supplied default when the input is missing, non-positive, or not a
|
||||
// finite number. Defends the polling loop against garbage like `NaN`, `"NaN"`,
|
||||
// `null`, or `-5` from a misbehaving device-code endpoint — without this,
|
||||
// a NaN interval would slip through `?? default` (NaN is typeof number),
|
||||
// reach `setTimeout(_, NaN)` which is treated as 0, and busy-loop until the
|
||||
// hard deadline. Matches the defensive normalization Codex uses for the same
|
||||
// field (`parseInt(deviceData.interval) || 5`).
|
||||
function positiveSecondsToMs(value: unknown, defaultMs: number): number {
|
||||
const seconds = Number(value)
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : defaultMs
|
||||
}
|
||||
|
||||
export async function pollDeviceCodeToken(
|
||||
device: DeviceCodeResponse,
|
||||
options: XaiAuthPluginOptions & { sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
|
||||
): Promise<TokenResponse> {
|
||||
const sleep = options.sleep ?? defaultSleep
|
||||
const now = options.now ?? (() => Date.now())
|
||||
const expiresInMs = positiveSecondsToMs(device.expires_in, DEVICE_CODE_DEFAULT_EXPIRES_MS)
|
||||
const deadline = now() + expiresInMs
|
||||
let intervalMs = Math.max(
|
||||
positiveSecondsToMs(device.interval, DEVICE_CODE_DEFAULT_INTERVAL_MS),
|
||||
DEVICE_CODE_MIN_INTERVAL_MS,
|
||||
)
|
||||
|
||||
while (now() < deadline) {
|
||||
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: DEVICE_CODE_GRANT_TYPE,
|
||||
client_id: CLIENT_ID,
|
||||
device_code: device.device_code,
|
||||
}).toString(),
|
||||
})
|
||||
if (response.ok) return (await response.json()) as TokenResponse
|
||||
|
||||
const body = (await response.json().catch(() => ({}))) as DeviceTokenErrorBody
|
||||
const remaining = Math.max(0, deadline - now())
|
||||
// RFC 8628 §3.5: authorization_pending = keep polling at the same
|
||||
// interval; slow_down = bump the interval by ≥5s and keep polling.
|
||||
// Anything else is terminal.
|
||||
if (body.error === "authorization_pending") {
|
||||
await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining))
|
||||
continue
|
||||
}
|
||||
if (body.error === "slow_down") {
|
||||
intervalMs += DEVICE_CODE_SLOW_DOWN_INCREMENT_MS
|
||||
await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining))
|
||||
continue
|
||||
}
|
||||
if (body.error === "access_denied" || body.error === "authorization_denied") {
|
||||
throw new Error("xAI device authorization was denied")
|
||||
}
|
||||
if (body.error === "expired_token") {
|
||||
throw new Error("xAI device code expired - please re-run login")
|
||||
}
|
||||
const detail = body.error_description ?? body.error ?? ""
|
||||
throw new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
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
|
||||
// preflight.
|
||||
const CORS_ALLOWED_ORIGINS = new Set(["https://accounts.x.ai", "https://auth.x.ai"])
|
||||
|
||||
interface PendingOAuth {
|
||||
pkce: PkceCodes
|
||||
state: string
|
||||
resolve: (tokens: TokenResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
if (oauthServer) return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const reqUrl = req.url || "/"
|
||||
const url = new URL(reqUrl, `http://${OAUTH_HOST}:${OAUTH_PORT}`)
|
||||
|
||||
const origin = req.headers["origin"]
|
||||
const allowOrigin = typeof origin === "string" && CORS_ALLOWED_ORIGINS.has(origin) ? origin : ""
|
||||
if (allowOrigin) {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin)
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
res.setHeader("Access-Control-Allow-Private-Network", "true")
|
||||
res.setHeader("Vary", "Origin")
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === OAUTH_REDIRECT_PATH) {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
// listen() failures (e.g. EADDRINUSE because Grok-CLI is bound to the same
|
||||
// pinned port) must clear `oauthServer` and remove our error listener,
|
||||
// otherwise the next startOAuthServer() short-circuits on the truthy check
|
||||
// and returns a redirect_uri pointing at nothing.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
oauthServer = undefined
|
||||
reject(err)
|
||||
}
|
||||
server.once("error", onError)
|
||||
server.listen(OAUTH_PORT, OAUTH_HOST, () => {
|
||||
server.removeListener("error", onError)
|
||||
// After listen() succeeds, install a permanent log-only listener so
|
||||
// that subsequent server errors (e.g. accept() failures, socket-level
|
||||
// errors) don't trip Node's default "unhandled error event = throw"
|
||||
// behavior and crash the entire opencode process. Matches the silent-
|
||||
// swallow behavior the Codex plugin gets from its permanent
|
||||
// `oauthServer!.on("error", reject)`.
|
||||
server.on("error", (err) => log.warn("xai oauth server error", { error: err }))
|
||||
log.info("xai oauth server started", { host: OAUTH_HOST, port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer = server
|
||||
})
|
||||
|
||||
return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.close(() => log.info("xai oauth server stopped"))
|
||||
oauthServer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||
// A previous in-flight authorize() that the user abandoned (or that is
|
||||
// being superseded by a fresh attempt) still owns `pendingOAuth`. Reject
|
||||
// it eagerly so its caller stops waiting on a state value that can never
|
||||
// match the next callback.
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth.reject(new Error("Superseded by a newer xAI authorize request"))
|
||||
pendingOAuth = undefined
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth = undefined
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
|
||||
pendingOAuth = {
|
||||
pkce,
|
||||
state,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface RefreshResult {
|
||||
access: string
|
||||
refresh: string
|
||||
expires: number
|
||||
}
|
||||
|
||||
export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOptions = {}): Promise<Hooks> {
|
||||
return {
|
||||
auth: {
|
||||
provider: "xai",
|
||||
async loader(getAuth) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
// Single-flight refresh: collapse concurrent fetches from this loaded
|
||||
// provider onto one HTTP call so we don't replay a rotating refresh_token.
|
||||
let refreshPromise: Promise<RefreshResult> | undefined
|
||||
|
||||
return {
|
||||
// Dummy bearer keeps the AI SDK from bailing on "missing apiKey"; the
|
||||
// real OAuth token is injected by the fetch override below.
|
||||
// We intentionally do NOT set baseURL — @ai-sdk/xai already defaults
|
||||
// to https://api.x.ai/v1 and overriding here would silently route
|
||||
// around a user-configured gateway.
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
let currentAuth = await getAuth()
|
||||
// Auth can flip from oauth to api mid-session (user re-runs
|
||||
// /connect with a pasted key). When that happens, pass the
|
||||
// request through untouched so the AI SDK's own apiKey-based
|
||||
// Authorization header reaches xAI unmodified.
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
|
||||
// Refresh either when the stored expires timestamp is within the
|
||||
// skew window, or — for JWT access tokens — when the JWT exp
|
||||
// claim itself is. The stored expires field is best-effort
|
||||
// (xAI doesn't always return expires_in) so the JWT check is the
|
||||
// load-bearing one for tokens that lack a fresh stored deadline.
|
||||
const expiresSoon =
|
||||
!currentAuth.expires ||
|
||||
currentAuth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS ||
|
||||
accessTokenIsExpiring(currentAuth.access)
|
||||
if (expiresSoon) {
|
||||
if (!refreshPromise) {
|
||||
const refreshToken = currentAuth.refresh
|
||||
log.info("refreshing xai access token")
|
||||
refreshPromise = refreshAccessToken(refreshToken, options)
|
||||
.then(async (tokens) => {
|
||||
const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000
|
||||
const refreshedRefresh = tokens.refresh_token || refreshToken
|
||||
// Persist the rotated pair as best-effort. xAI has already consumed the
|
||||
// old refresh_token by the time we get here; an auth.set failure leaves
|
||||
// the on-disk state stale but the in-memory result is still valid for
|
||||
// this turn. The next live refresh against the stale disk state will
|
||||
// 4xx and force re-login — a known cross-process limitation.
|
||||
await input.client.auth
|
||||
.set({
|
||||
path: { id: "xai" },
|
||||
body: {
|
||||
type: "oauth",
|
||||
access: tokens.access_token,
|
||||
refresh: refreshedRefresh,
|
||||
expires: refreshedExpires,
|
||||
},
|
||||
})
|
||||
.catch((err) => log.warn("failed to persist refreshed xai tokens", { error: err }))
|
||||
return { access: tokens.access_token, refresh: refreshedRefresh, expires: refreshedExpires }
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = undefined
|
||||
})
|
||||
}
|
||||
const refreshed = await refreshPromise
|
||||
currentAuth = { ...currentAuth, ...refreshed }
|
||||
}
|
||||
|
||||
// Copy the caller's headers into a fresh Headers (case-insensitive)
|
||||
// so we never mutate the RequestInit the AI SDK may reuse on retry.
|
||||
// Headers.set overwrites case-insensitively, which kills the dummy
|
||||
// bearer the AI SDK injected from apiKey in a single line.
|
||||
const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined)
|
||||
if (init?.headers) {
|
||||
const entries =
|
||||
init.headers instanceof Headers
|
||||
? init.headers.entries()
|
||||
: Array.isArray(init.headers)
|
||||
? init.headers
|
||||
: Object.entries(init.headers as Record<string, string | undefined>)
|
||||
for (const [key, value] of entries) {
|
||||
if (value !== undefined) headers.set(key, String(value))
|
||||
}
|
||||
}
|
||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||
headers.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
|
||||
return fetch(requestInput, { ...init, headers })
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
label: "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const nonce = generateState()
|
||||
const authUrl = buildAuthorizeUrl(pkce, state, nonce, options)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
|
||||
return {
|
||||
url: authUrl,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
method: "auto" as const,
|
||||
callback: async () => {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("xai oauth callback failed", { error: err })
|
||||
return { type: "failed" as const }
|
||||
} finally {
|
||||
stopOAuthServer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// RFC 8628 device-code flow. The CLI prints a verification URL
|
||||
// and a short user_code that the user enters in a browser on
|
||||
// any device. No loopback callback server runs on the CLI host,
|
||||
// so this works on VPS / SSH / Docker / CI / WSL / any
|
||||
// environment where 127.0.0.1:56121 isn't reachable from the
|
||||
// user's browser. Defends the only attack surface (the polling
|
||||
// loop) with the standard authorization_pending / slow_down
|
||||
// backoff and a hard deadline from xAI's `expires_in`.
|
||||
label: "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
const device = await requestDeviceCode(options)
|
||||
const browserUrl = device.verification_uri_complete ?? device.verification_uri
|
||||
return {
|
||||
url: browserUrl,
|
||||
instructions: `Open ${device.verification_uri} on any device and enter code: ${device.user_code}`,
|
||||
method: "auto" as const,
|
||||
callback: async () => {
|
||||
try {
|
||||
const tokens = await pollDeviceCodeToken(device, options)
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("xai device code callback failed", { error: err })
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Manually enter API Key",
|
||||
type: "api",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { accessTokenIsExpiring, buildAuthorizeUrl, escapeHtml, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin } from "../../src/plugin/xai"
|
||||
import { OAUTH_DUMMY_KEY } from "../../src/auth"
|
||||
|
||||
function makeJwt(payload: object): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url")
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
|
||||
return `${header}.${body}.sig`
|
||||
}
|
||||
|
||||
function makeInput(opts?: { failSet?: boolean }) {
|
||||
const setCalls: Array<Record<string, unknown>> = []
|
||||
return {
|
||||
input: {
|
||||
client: {
|
||||
auth: {
|
||||
set: async (req: Record<string, unknown>) => {
|
||||
setCalls.push(req)
|
||||
if (opts?.failSet) throw new Error("auth.set boom")
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
setCalls,
|
||||
}
|
||||
}
|
||||
|
||||
function makeServer(handler: (request: Request, url: URL) => Response | Promise<Response>) {
|
||||
return Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => handler(request, new URL(request.url)),
|
||||
})
|
||||
}
|
||||
|
||||
function serverOptions(server: ReturnType<typeof Bun.serve>) {
|
||||
return {
|
||||
authorizeUrl: new URL("/oauth2/authorize", server.url).toString(),
|
||||
tokenUrl: new URL("/oauth2/token", server.url).toString(),
|
||||
deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin.xai", () => {
|
||||
describe("accessTokenIsExpiring", () => {
|
||||
test("returns true for an already-expired JWT", () => {
|
||||
expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 60 }), 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for a fresh JWT outside the skew window", () => {
|
||||
expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), 0)).toBe(false)
|
||||
})
|
||||
|
||||
test("honors the skew window", () => {
|
||||
const nearExpiry = makeJwt({ exp: Math.floor(Date.now() / 1000) + 30 })
|
||||
expect(accessTokenIsExpiring(nearExpiry, 60_000)).toBe(true)
|
||||
expect(accessTokenIsExpiring(nearExpiry, 0)).toBe(false)
|
||||
})
|
||||
|
||||
test("clamps negative skew to zero rather than refusing to refresh", () => {
|
||||
expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 1 }), -60_000)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for opaque and malformed tokens", () => {
|
||||
expect(accessTokenIsExpiring("opaque-token-no-dots", 0)).toBe(false)
|
||||
expect(accessTokenIsExpiring("", 0)).toBe(false)
|
||||
expect(accessTokenIsExpiring(undefined, 0)).toBe(false)
|
||||
expect(accessTokenIsExpiring(makeJwt({ sub: "user-1" }), 0)).toBe(false)
|
||||
expect(accessTokenIsExpiring(makeJwt({ exp: "1234" }), 0)).toBe(false)
|
||||
expect(accessTokenIsExpiring("header.!!!not-valid-base64-or-json!!!.sig", 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAuthorizeUrl", () => {
|
||||
const pkce = { verifier: "ver", challenge: "chal" }
|
||||
|
||||
test("includes required OAuth + PKCE + OIDC params", () => {
|
||||
const url = new URL(buildAuthorizeUrl(pkce, "state-abc", "nonce-xyz"))
|
||||
const params = url.searchParams
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://auth.x.ai/oauth2/authorize")
|
||||
expect(params.get("response_type")).toBe("code")
|
||||
expect(params.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828")
|
||||
expect(params.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback")
|
||||
expect(params.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access")
|
||||
expect(params.get("code_challenge")).toBe("chal")
|
||||
expect(params.get("code_challenge_method")).toBe("S256")
|
||||
expect(params.get("state")).toBe("state-abc")
|
||||
expect(params.get("nonce")).toBe("nonce-xyz")
|
||||
expect(params.get("plan")).toBe("generic")
|
||||
expect(params.get("referrer")).toBe("opencode")
|
||||
})
|
||||
|
||||
test("supports endpoint override for local integration tests", () => {
|
||||
const url = new URL(buildAuthorizeUrl(pkce, "s", "n", { authorizeUrl: "http://127.0.0.1/oauth2/authorize" }))
|
||||
expect(url.origin + url.pathname).toBe("http://127.0.0.1/oauth2/authorize")
|
||||
})
|
||||
})
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
test("escapes HTML metacharacters", () => {
|
||||
expect(escapeHtml(`</div><script>alert(1)</script><div class="x">`)).toBe(
|
||||
"</div><script>alert(1)</script><div class="x">",
|
||||
)
|
||||
expect(escapeHtml("a & b")).toBe("a & b")
|
||||
expect(escapeHtml("it's fine")).toBe("it's fine")
|
||||
expect(escapeHtml("invalid_grant")).toBe("invalid_grant")
|
||||
expect(escapeHtml("")).toBe("")
|
||||
expect(escapeHtml("&<")).toBe("&<")
|
||||
})
|
||||
})
|
||||
|
||||
describe("loader", () => {
|
||||
test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
|
||||
const hooks = await XaiAuthPlugin({} as any)
|
||||
expect(await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }), {} as any)).toEqual({})
|
||||
expect(await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any)).toEqual({})
|
||||
expect(hooks.auth!.methods.map((m) => [m.type, m.label])).toEqual([
|
||||
["oauth", "xAI Grok OAuth (SuperGrok Subscription)"],
|
||||
["oauth", "xAI Grok OAuth (Headless / Remote / VPS)"],
|
||||
["api", "Manually enter API Key"],
|
||||
])
|
||||
})
|
||||
|
||||
test("replaces the dummy bearer, sets User-Agent, and preserves caller headers", async () => {
|
||||
const { input } = makeInput()
|
||||
const captured: Headers[] = []
|
||||
using server = makeServer((request) => {
|
||||
captured.push(request.headers)
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const hooks = await XaiAuthPlugin(input)
|
||||
const opts = await hooks.auth!.loader!(
|
||||
async () => ({ type: "oauth", access: "live-token", refresh: "rt", expires: Date.now() + 3600_000 }),
|
||||
{} as any,
|
||||
)
|
||||
expect(opts.apiKey).toBe(OAUTH_DUMMY_KEY)
|
||||
expect(opts.baseURL).toBeUndefined()
|
||||
|
||||
await opts.fetch!(new URL("/chat/completions", server.url), {
|
||||
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" },
|
||||
})
|
||||
|
||||
expect(captured[0].get("authorization")).toBe("Bearer live-token")
|
||||
expect(captured[0].get("x-keep")).toBe("yes")
|
||||
expect(captured[0].get("user-agent")).toMatch(/^opencode\//)
|
||||
})
|
||||
|
||||
test("does not mutate caller headers and supports HeadersInit shapes", async () => {
|
||||
const { input } = makeInput()
|
||||
const captured: Headers[] = []
|
||||
using server = makeServer((request) => {
|
||||
captured.push(request.headers)
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const opts = await (await XaiAuthPlugin(input)).auth!.loader!(
|
||||
async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }),
|
||||
{} as any,
|
||||
)
|
||||
|
||||
const objHeaders: Record<string, string> = { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" }
|
||||
await opts.fetch!(new URL("/chat/completions", server.url), { headers: objHeaders })
|
||||
expect(objHeaders).toEqual({ Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" })
|
||||
|
||||
const arrayHeaders: [string, string][] = [["x-trace", "tuple-array"]]
|
||||
const arrayCopy = arrayHeaders.map(([key, value]) => [key, value] as [string, string])
|
||||
await opts.fetch!(new URL("/chat/completions", server.url), { headers: arrayHeaders })
|
||||
expect(arrayHeaders).toEqual(arrayCopy)
|
||||
|
||||
const headersInstance = new Headers({ "x-trace": "headers-instance" })
|
||||
await opts.fetch!(new URL("/chat/completions", server.url), { headers: headersInstance })
|
||||
expect(headersInstance.get("x-trace")).toBe("headers-instance")
|
||||
|
||||
expect(captured.map((headers) => headers.get("x-trace"))).toEqual(["plain-object", "tuple-array", "headers-instance"])
|
||||
for (const headers of captured) {
|
||||
expect(headers.get("authorization")).toBe("Bearer tok")
|
||||
expect(headers.get("user-agent")).toMatch(/^opencode\//)
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves headers from Request input and lets init headers override them", async () => {
|
||||
const { input } = makeInput()
|
||||
const captured: Headers[] = []
|
||||
using server = makeServer((request) => {
|
||||
captured.push(request.headers)
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const opts = await (await XaiAuthPlugin(input)).auth!.loader!(
|
||||
async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }),
|
||||
{} as any,
|
||||
)
|
||||
|
||||
await opts.fetch!(
|
||||
new Request(new URL("/chat/completions", server.url), {
|
||||
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "content-type": "application/json", "x-trace": "request" },
|
||||
}),
|
||||
{ headers: { "x-trace": "init", "x-extra": "yes" } },
|
||||
)
|
||||
|
||||
expect(captured[0].get("authorization")).toBe("Bearer tok")
|
||||
expect(captured[0].get("content-type")).toBe("application/json")
|
||||
expect(captured[0].get("x-trace")).toBe("init")
|
||||
expect(captured[0].get("x-extra")).toBe("yes")
|
||||
})
|
||||
|
||||
test("falls through to plain fetch when stored auth flips from oauth to api", async () => {
|
||||
const { input } = makeInput()
|
||||
const captured: Headers[] = []
|
||||
using server = makeServer((request) => {
|
||||
captured.push(request.headers)
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
let firstCall = true
|
||||
const opts = await (await XaiAuthPlugin(input)).auth!.loader!(async () => {
|
||||
if (firstCall) {
|
||||
firstCall = false
|
||||
return { type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }
|
||||
}
|
||||
return { type: "api", key: "sk-new" }
|
||||
}, {} as any)
|
||||
|
||||
await opts.fetch!(new URL("/chat/completions", server.url), {
|
||||
headers: { Authorization: "Bearer sk-from-aisdk", "x-keep": "v" },
|
||||
})
|
||||
expect(captured[0].get("authorization")).toBe("Bearer sk-from-aisdk")
|
||||
expect(captured[0].get("x-keep")).toBe("v")
|
||||
})
|
||||
|
||||
test("deduplicates concurrent refreshes within a loader instance", async () => {
|
||||
const { input, setCalls } = makeInput()
|
||||
let tokenRequests = 0
|
||||
const apiRequests: Headers[] = []
|
||||
using server = makeServer(async (request, url) => {
|
||||
if (url.pathname === "/oauth2/token") {
|
||||
tokenRequests++
|
||||
expect(await request.text()).toContain("refresh_token=rt-old")
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 })
|
||||
}
|
||||
apiRequests.push(request.headers)
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!(
|
||||
async () => ({ type: "oauth" as const, access: "old", refresh: "rt-old", expires: 0 }),
|
||||
{} as any,
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
|
||||
opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
|
||||
])
|
||||
|
||||
expect(tokenRequests).toBe(1)
|
||||
expect(apiRequests.map((headers) => headers.get("authorization"))).toEqual(["Bearer new-access", "Bearer new-access"])
|
||||
expect(setCalls).toHaveLength(1)
|
||||
expect((setCalls[0].body as any).refresh).toBe("rt-new")
|
||||
})
|
||||
|
||||
test("does not share refresh single-flight across loader instances", async () => {
|
||||
const { input } = makeInput()
|
||||
const tokenRequests: string[] = []
|
||||
const apiRequests: string[] = []
|
||||
using server = makeServer(async (request, url) => {
|
||||
if (url.pathname === "/oauth2/token") {
|
||||
const refreshToken = new URLSearchParams(await request.text()).get("refresh_token")!
|
||||
tokenRequests.push(refreshToken)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return Response.json({ access_token: `access-${refreshToken}`, refresh_token: `next-${refreshToken}`, expires_in: 3600 })
|
||||
}
|
||||
apiRequests.push(request.headers.get("authorization")!)
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const hooks = await XaiAuthPlugin(input, serverOptions(server))
|
||||
const first = await hooks.auth!.loader!(async () => ({ type: "oauth", access: "old-a", refresh: "rt-a", expires: 0 }), {} as any)
|
||||
const second = await hooks.auth!.loader!(async () => ({ type: "oauth", access: "old-b", refresh: "rt-b", expires: 0 }), {} as any)
|
||||
|
||||
await Promise.all([
|
||||
first.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
|
||||
second.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
|
||||
])
|
||||
|
||||
expect(tokenRequests.sort()).toEqual(["rt-a", "rt-b"])
|
||||
expect(apiRequests.sort()).toEqual(["Bearer access-rt-a", "Bearer access-rt-b"])
|
||||
})
|
||||
|
||||
test("starts a new refresh after success and clears the refresh promise after failure", async () => {
|
||||
const { input } = makeInput()
|
||||
let tokenRequests = 0
|
||||
using server = makeServer((_, url) => {
|
||||
if (url.pathname === "/oauth2/token") {
|
||||
tokenRequests++
|
||||
if (tokenRequests === 2) return new Response("temporarily unavailable", { status: 503 })
|
||||
return Response.json({ access_token: `new-${tokenRequests}`, refresh_token: `rt-${tokenRequests}`, expires_in: 3600 })
|
||||
}
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!(
|
||||
async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }),
|
||||
{} as any,
|
||||
)
|
||||
|
||||
await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
|
||||
await expect(opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })).rejects.toThrow(/xAI token refresh failed \(503\)/)
|
||||
await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
|
||||
expect(tokenRequests).toBe(3)
|
||||
})
|
||||
|
||||
test("handles refresh response variants and persistence failure", async () => {
|
||||
const { input, setCalls } = makeInput({ failSet: true })
|
||||
const captured: Headers[] = []
|
||||
using server = makeServer((request, url) => {
|
||||
if (url.pathname === "/oauth2/token") return Response.json({ access_token: "new-access", expires_in: 3600 })
|
||||
captured.push(request.headers)
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!(
|
||||
async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }),
|
||||
{} as any,
|
||||
)
|
||||
|
||||
const resp = await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
|
||||
expect(resp.status).toBe(200)
|
||||
expect(captured[0].get("authorization")).toBe("Bearer new-access")
|
||||
expect((setCalls[0].body as any).refresh).toBe("rt-old")
|
||||
})
|
||||
|
||||
test("refreshes based on stored expiry or JWT expiry and skips refresh when both are fresh", async () => {
|
||||
const { input, setCalls } = makeInput()
|
||||
let tokenRequests = 0
|
||||
using server = makeServer((_, url) => {
|
||||
if (url.pathname === "/oauth2/token") {
|
||||
tokenRequests++
|
||||
return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 })
|
||||
}
|
||||
return new Response("{}", { status: 200 })
|
||||
})
|
||||
const fresh = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!(
|
||||
async () => ({
|
||||
type: "oauth",
|
||||
access: makeJwt({ exp: Math.floor(Date.now() / 1000) + 24 * 3600 }),
|
||||
refresh: "rt",
|
||||
expires: Date.now() + 24 * 3600 * 1000,
|
||||
}),
|
||||
{} as any,
|
||||
)
|
||||
await fresh.fetch!(new URL("/chat/completions", server.url), { headers: {} })
|
||||
expect(tokenRequests).toBe(0)
|
||||
|
||||
const jwtExpiring = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!(
|
||||
async () => ({
|
||||
type: "oauth",
|
||||
access: makeJwt({ exp: Math.floor((Date.now() + 30_000) / 1000) }),
|
||||
refresh: "rt-old",
|
||||
expires: Date.now() + 24 * 3600 * 1000,
|
||||
}),
|
||||
{} as any,
|
||||
)
|
||||
const missingExpires = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!(
|
||||
async () => ({ type: "oauth", access: "opaque-token", refresh: "rt", expires: 0 }),
|
||||
{} as any,
|
||||
)
|
||||
await jwtExpiring.fetch!(new URL("/chat/completions", server.url), { headers: {} })
|
||||
await missingExpires.fetch!(new URL("/chat/completions", server.url), { headers: {} })
|
||||
expect(tokenRequests).toBe(2)
|
||||
expect(setCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("network failure during refresh surfaces the underlying fetch error", async () => {
|
||||
const { input } = makeInput()
|
||||
const opts = await (await XaiAuthPlugin(input, { tokenUrl: "http://127.0.0.1:9/oauth2/token" })).auth!.loader!(
|
||||
async () => ({ type: "oauth", access: "old", refresh: "rt", expires: 0 }),
|
||||
{} as any,
|
||||
)
|
||||
|
||||
await expect(opts.fetch!("https://api.x.ai/v1/chat/completions", { headers: {} })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("device code flow", () => {
|
||||
test("authorize advertises verification URL + user code and returns success on callback", async () => {
|
||||
using server = makeServer((_, url) => {
|
||||
if (url.pathname === "/oauth2/device/code") {
|
||||
return Response.json({
|
||||
device_code: "DEVICE-1",
|
||||
user_code: "ABCD-1234",
|
||||
verification_uri: "https://x.ai/device",
|
||||
verification_uri_complete: "https://x.ai/device?user_code=ABCD-1234",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/oauth2/token") {
|
||||
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 })
|
||||
})
|
||||
const hooks = await XaiAuthPlugin({} as any, serverOptions(server))
|
||||
const headless = hooks.auth!.methods.find((m): m is Extract<typeof m, { type: "oauth" }> => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)")!
|
||||
const result = await headless.authorize!()
|
||||
|
||||
expect(result.method).toBe("auto")
|
||||
expect(result.url).toBe("https://x.ai/device?user_code=ABCD-1234")
|
||||
expect(result.instructions).toContain("https://x.ai/device")
|
||||
expect(result.instructions).toContain("ABCD-1234")
|
||||
expect(await (result as any).callback()).toMatchObject({ type: "success", refresh: "RT", access: "AT" })
|
||||
})
|
||||
|
||||
test("authorize falls back to verification_uri when verification_uri_complete is absent", async () => {
|
||||
using server = makeServer((_, url) => {
|
||||
if (url.pathname === "/oauth2/device/code") {
|
||||
return Response.json({ device_code: "DEVICE-2", user_code: "WXYZ-9876", verification_uri: "https://x.ai/device" })
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 })
|
||||
})
|
||||
const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
|
||||
(m): m is Extract<typeof m, { type: "oauth" }> => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
)!
|
||||
expect((await headless.authorize!()).url).toBe("https://x.ai/device")
|
||||
})
|
||||
|
||||
test("requestDeviceCode posts form body, validates fields, and surfaces endpoint errors", async () => {
|
||||
let capturedBody = ""
|
||||
using server = makeServer(async (request, url) => {
|
||||
if (url.pathname === "/missing") return Response.json({ device_code: "x" })
|
||||
if (url.pathname === "/error") return new Response("rate limited", { status: 429 })
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded")
|
||||
expect(request.headers.get("accept")).toBe("application/json")
|
||||
expect(request.headers.get("user-agent")).toMatch(/^opencode\//)
|
||||
capturedBody = await request.text()
|
||||
return Response.json({ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device" })
|
||||
})
|
||||
|
||||
await requestDeviceCode({ deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString() })
|
||||
const parsed = new URLSearchParams(capturedBody)
|
||||
expect(parsed.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828")
|
||||
expect(parsed.get("scope")).toContain("offline_access")
|
||||
expect(parsed.get("scope")).toContain("grok-cli:access")
|
||||
expect(parsed.get("scope")).toContain("api:access")
|
||||
await expect(requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() })).rejects.toThrow(/429.*rate limited/)
|
||||
await expect(requestDeviceCode({ deviceAuthorizationUrl: new URL("/missing", server.url).toString() })).rejects.toThrow(/missing device_code/)
|
||||
})
|
||||
|
||||
test("pollDeviceCodeToken resolves on success and posts the device-code grant", async () => {
|
||||
let tokenCalls = 0
|
||||
using server = makeServer(async (request) => {
|
||||
tokenCalls++
|
||||
expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded")
|
||||
const body = new URLSearchParams(await request.text())
|
||||
expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code")
|
||||
expect(body.get("device_code")).toBe("DC-1")
|
||||
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
|
||||
})
|
||||
|
||||
const tokens = await pollDeviceCodeToken(
|
||||
{ device_code: "DC-1", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 600 },
|
||||
{ sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
|
||||
)
|
||||
expect(tokens.access_token).toBe("AT")
|
||||
expect(tokens.refresh_token).toBe("RT")
|
||||
expect(tokenCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("pollDeviceCodeToken honors authorization_pending and slow_down", async () => {
|
||||
let n = 0
|
||||
using server = makeServer(() => {
|
||||
n++
|
||||
if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 })
|
||||
if (n === 2) return Response.json({ error: "slow_down" }, { status: 400 })
|
||||
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
|
||||
})
|
||||
const sleeps: number[] = []
|
||||
const tokens = await pollDeviceCodeToken(
|
||||
{ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 5, expires_in: 600 },
|
||||
{ sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() },
|
||||
)
|
||||
expect(tokens.access_token).toBe("AT")
|
||||
expect(n).toBe(3)
|
||||
expect(sleeps).toEqual([8_000, 13_000])
|
||||
})
|
||||
|
||||
test("pollDeviceCodeToken handles terminal errors and timeout", async () => {
|
||||
for (const [body, error] of [
|
||||
[{ error: "access_denied" }, /authorization was denied/],
|
||||
[{ error: "expired_token" }, /device code expired/],
|
||||
[{ error: "server_error", error_description: "oops" }, /500.*oops/],
|
||||
] as const) {
|
||||
using server = makeServer(() => Response.json(body, { status: 500 }))
|
||||
await expect(
|
||||
pollDeviceCodeToken(
|
||||
{ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 600 },
|
||||
{ sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
|
||||
),
|
||||
).rejects.toThrow(error)
|
||||
}
|
||||
|
||||
using pending = makeServer(() => Response.json({ error: "authorization_pending" }, { status: 400 }))
|
||||
let tick = 0
|
||||
await expect(
|
||||
pollDeviceCodeToken(
|
||||
{ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 1 },
|
||||
{ sleep: async () => {}, now: () => 1_000_000 + tick++ * 600, tokenUrl: new URL("/oauth2/token", pending.url).toString() },
|
||||
),
|
||||
).rejects.toThrow(/timed out/)
|
||||
})
|
||||
|
||||
test("pollDeviceCodeToken normalizes bad interval and expires_in values", async () => {
|
||||
const badIntervals: Array<unknown> = [Number.NaN, "NaN", "garbage", -5, null, 0]
|
||||
for (const bad of badIntervals) {
|
||||
let n = 0
|
||||
using server = makeServer(() => {
|
||||
n++
|
||||
if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 })
|
||||
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
|
||||
})
|
||||
const sleeps: number[] = []
|
||||
await pollDeviceCodeToken(
|
||||
{ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: bad as number, expires_in: 600 },
|
||||
{ sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() },
|
||||
)
|
||||
expect(sleeps[0]).toBe(8_000)
|
||||
}
|
||||
|
||||
for (const bad of [Number.NaN, "NaN", "garbage", -5, null, 0]) {
|
||||
using server = makeServer(() => Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }))
|
||||
expect(
|
||||
(
|
||||
await pollDeviceCodeToken(
|
||||
{ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: bad as number },
|
||||
{ sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
|
||||
)
|
||||
).access_token,
|
||||
).toBe("AT")
|
||||
}
|
||||
})
|
||||
|
||||
test("device-code authorize callback returns failed when polling errors", async () => {
|
||||
using server = makeServer((_, url) => {
|
||||
if (url.pathname === "/oauth2/device/code") {
|
||||
return Response.json({ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 0, expires_in: 600 })
|
||||
}
|
||||
return Response.json({ error: "access_denied" }, { status: 400 })
|
||||
})
|
||||
const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
|
||||
(m): m is Extract<typeof m, { type: "oauth" }> => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
)!
|
||||
expect(await (await headless.authorize!() as any).callback()).toEqual({ type: "failed" })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user