import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { InstallationVersion } from "@opencode-ai/core/installation/version" // Public Grok-CLI OAuth client. const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" 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 // 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 { tokenUrl?: string deviceAuthorizationUrl?: string } 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 } } 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, referrer: "opencode", }).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") } 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 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(() => {}) 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: [ { // 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: "SuperGrok Subscription", 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) { return { type: "failed" as const } } }, } }, }, { label: "Manually enter API Key", type: "api", }, ], }, } }