diff --git a/.changeset/xai-grok-oauth.md b/.changeset/xai-grok-oauth.md new file mode 100644 index 0000000000..ca333dfa9a --- /dev/null +++ b/.changeset/xai-grok-oauth.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Support xAI Grok OAuth and device-code login for SuperGrok users. diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index bf6b1ebcee..f1c023caaa 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,13 +1,17 @@ # Source Code Links - + +- + - - +- + - - @@ -24,6 +28,14 @@ - +- + +- + +- + +- + - - diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 2cbbc5e032..d47acd53f9 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -19,6 +19,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { AzureAuthPlugin } from "./azure" +import { XaiAuthPlugin } from "./xai" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" @@ -68,6 +69,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, + XaiAuthPlugin, ] // kilocode_change end diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts new file mode 100644 index 0000000000..455647e417 --- /dev/null +++ b/packages/opencode/src/plugin/xai.ts @@ -0,0 +1,739 @@ +import type { Hooks, PluginInput } from "@kilocode/plugin" // kilocode_change +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 { + 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 { + 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 +} + +async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise { + 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 +} + +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 { + 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 { + await new Promise((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; now?: () => number } = {}, +): Promise { + 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 = ` + + + OpenCode - xAI Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to OpenCode.

+
+ + +` + +const HTML_ERROR = (error: string) => ` + + + OpenCode - xAI Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${escapeHtml(error)}
+
+ +` + +// 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 | 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((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 { + // 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 { + 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 | 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) + 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", + }, + ], + }, + } +} diff --git a/packages/opencode/test/plugin/xai.test.ts b/packages/opencode/test/plugin/xai.test.ts new file mode 100644 index 0000000000..741f29e86b --- /dev/null +++ b/packages/opencode/test/plugin/xai.test.ts @@ -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> = [] + return { + input: { + client: { + auth: { + set: async (req: Record) => { + setCalls.push(req) + if (opts?.failSet) throw new Error("auth.set boom") + }, + }, + }, + } as any, + setCalls, + } +} + +function makeServer(handler: (request: Request, url: URL) => Response | Promise) { + return Bun.serve({ + port: 0, + fetch: (request) => handler(request, new URL(request.url)), + }) +} + +function serverOptions(server: ReturnType) { + 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(`
`)).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 = { 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 => 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 => 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 = [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 => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + )! + expect(await (await headless.authorize!() as any).callback()).toEqual({ type: "failed" }) + }) + }) +})