From 5dddf5c41babdfa228fc190ef6adfbab2cdc7bbd Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Sat, 11 Jul 2026 10:13:42 -0600 Subject: [PATCH] feat(core): port xAI SuperGrok OAuth to v2 Register browser PKCE and headless device-code SuperGrok login methods on the v2 XAI plugin, with refresh that falls back when refresh_token is omitted and expires derived from expires_in or JWT exp. Closes #34778 --- packages/core/src/plugin/provider/xai.ts | 425 +++++++++++++++++- .../core/test/plugin/provider-xai.test.ts | 340 +++++++++++++- 2 files changed, 757 insertions(+), 8 deletions(-) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 724a3d2567db..c5b44c6bcbf9 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,10 +1,270 @@ -import { Effect } from "effect" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Deferred, Effect, Schema } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" +import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" + +// 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 clientID = "b1a00492-073a-47ea-816f-4c329264a828" +const defaultAuthorizeURL = "https://auth.x.ai/oauth2/authorize" +const defaultTokenURL = "https://auth.x.ai/oauth2/token" +// RFC 8628 device authorization grant. Confirmed exposed by xAI's +// /.well-known/openid-configuration as `device_authorization_endpoint`. +const defaultDeviceAuthorizationURL = "https://auth.x.ai/oauth2/device/code" +const deviceCodeGrant = "urn:ietf:params:oauth:grant-type:device_code" +const scope = "openid profile email offline_access grok-cli:access api:access" + +// 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. +const oauthHost = "127.0.0.1" +const oauthPort = 56121 +const oauthRedirectPath = "/callback" +const redirectURI = `http://${oauthHost}:${oauthPort}${oauthRedirectPath}` +const corsAllowedOrigins = new Set(["https://accounts.x.ai", "https://auth.x.ai"]) + +const deviceDefaultIntervalMs = 5_000 +const deviceMinIntervalMs = 1_000 +const deviceSlowDownIncrementMs = 5_000 +const deviceDefaultExpiresMs = 5 * 60 * 1000 +const pollingSafetyMarginMs = 3_000 +// Refresh a little before the real deadline so resolve does not hand the +// runner a token that dies mid-flight. Matches v1's 120s proactive skew. +const accessTokenRefreshSkewMs = 120_000 +const defaultAccessTtlSeconds = 3600 + +const browserMethodID = Integration.MethodID.make("browser") +const headlessMethodID = Integration.MethodID.make("headless") + +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + access_token: string + refresh_token?: string + expires_in?: number +} + +/** Optional endpoint overrides for unit tests with a local mock server. */ +export type XaiOAuthEndpoints = { + authorizeURL?: string + tokenURL?: string + deviceAuthorizationURL?: string + /** Floor for device poll interval. Defaults to 1s. Tests may set 0. */ + deviceMinIntervalMs?: number + /** Extra wait added after each pending/slow_down. Defaults to 3s. */ + pollingSafetyMarginMs?: number + /** RFC 8628 minimum slow_down bump. Defaults to 5s. Tests may set 0. */ + deviceSlowDownIncrementMs?: number +} + +// Keep required device fields strict; leave interval/expires_in untyped so +// positiveSecondsToMs can coerce string/NaN garbage the same way v1 does. +const Device = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.String, + verification_uri: Schema.String, + verification_uri_complete: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Unknown), + interval: Schema.optional(Schema.Unknown), +}) + +const Token = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Unknown), +}) + +const DeviceTokenError = Schema.Struct({ + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), + interval: Schema.optional(Schema.Unknown), +}) + +/** Build browser + headless SuperGrok OAuth method registrations. */ +export function oauthMethods(endpoints: XaiOAuthEndpoints = {}) { + const authorizeURL = endpoints.authorizeURL ?? defaultAuthorizeURL + const tokenURL = endpoints.tokenURL ?? defaultTokenURL + const deviceAuthorizationURL = endpoints.deviceAuthorizationURL ?? defaultDeviceAuthorizationURL + const minIntervalMs = endpoints.deviceMinIntervalMs ?? deviceMinIntervalMs + const safetyMarginMs = endpoints.pollingSafetyMarginMs ?? pollingSafetyMarginMs + const slowDownIncrementMs = endpoints.deviceSlowDownIncrementMs ?? deviceSlowDownIncrementMs + + const browser = { + integrationID: Integration.ID.make("xai"), + method: { + id: browserMethodID, + type: "oauth", + label: "xAI Grok OAuth (SuperGrok Subscription)", + }, + authorize: (_inputs) => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const nonce = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + const server = createServer((request, response) => { + const origin = request.headers.origin + const allowOrigin = typeof origin === "string" && corsAllowedOrigins.has(origin) ? origin : "" + if (allowOrigin) { + response.setHeader("Access-Control-Allow-Origin", allowOrigin) + response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS") + response.setHeader("Access-Control-Allow-Headers", "Content-Type") + response.setHeader("Access-Control-Allow-Private-Network", "true") + response.setHeader("Vary", "Origin") + } + if (request.method === "OPTIONS") { + response.writeHead(204).end() + return + } + + const url = new URL(request.url ?? "/", `http://${oauthHost}:${oauthPort}`) + if (url.pathname !== oauthRedirectPath) { + response.writeHead(404).end("Not found") + return + } + + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "xAI" })) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "xAI" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" })) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(oauthPort, oauthHost, () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: buildAuthorizeURL(pkce, state, nonce, authorizeURL), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, pkce, tokenURL)), + Effect.map((tokens) => credential(browserMethodID, tokens)), + ), + } + }), + refresh: (value) => refresh(browserMethodID, value, tokenURL), + } satisfies IntegrationOAuthMethodRegistration + + const headless = { + integrationID: Integration.ID.make("xai"), + method: { + id: headlessMethodID, + type: "oauth", + label: "xAI Grok OAuth (Headless / Remote / VPS)", + }, + authorize: (_inputs) => + Effect.gen(function* () { + const device = yield* request(deviceAuthorizationURL, { + method: "POST", + headers: headers(), + body: new URLSearchParams({ client_id: clientID, scope }).toString(), + }).pipe(Effect.map(Schema.decodeUnknownSync(Device))) + const interval = Math.max(positiveSecondsToMs(device.interval, deviceDefaultIntervalMs), minIntervalMs) + const expiresInMs = positiveSecondsToMs(device.expires_in, deviceDefaultExpiresMs) + const deadline = Date.now() + expiresInMs + + const poll = (wait: number): Effect.Effect => + Effect.gen(function* () { + if (Date.now() >= deadline) return yield* Effect.fail(new Error("xAI device authorization timed out")) + const remaining = Math.max(0, deadline - Date.now()) + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(tokenURL, { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: deviceCodeGrant, + client_id: clientID, + device_code: device.device_code, + }).toString(), + signal, + }), + catch: (cause) => cause, + }) + if (response.ok) { + const tokens = Schema.decodeUnknownSync(Token)(yield* Effect.promise(() => response.json())) + if (!tokens.refresh_token) { + return yield* Effect.fail(new Error("xAI device token response is missing refresh_token")) + } + return credential(headlessMethodID, { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_in: numberOrUndefined(tokens.expires_in), + }) + } + const body = Schema.decodeUnknownSync(DeviceTokenError)( + yield* Effect.promise(() => response.json().catch(() => ({}))), + ) + if (body.error === "authorization_pending") { + yield* Effect.sleep(Math.min(wait + safetyMarginMs, remaining)) + return yield* poll(wait) + } + if (body.error === "slow_down") { + const next = Math.max( + positiveSecondsToMs(body.interval, wait + slowDownIncrementMs), + wait + slowDownIncrementMs, + ) + yield* Effect.sleep(Math.min(next + safetyMarginMs, remaining)) + return yield* poll(next) + } + if (body.error === "access_denied" || body.error === "authorization_denied") { + return yield* Effect.fail(new Error("xAI device authorization was denied")) + } + if (body.error === "expired_token") { + return yield* Effect.fail(new Error("xAI device code expired - please re-run login")) + } + const detail = body.error_description ?? body.error ?? "" + return yield* Effect.fail( + new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`), + ) + }) + + return { + mode: "auto" as const, + url: device.verification_uri_complete ?? device.verification_uri, + instructions: `Open ${device.verification_uri} on any device and enter code: ${device.user_code}`, + callback: poll(interval), + } + }), + refresh: (value) => refresh(headlessMethodID, value, tokenURL), + } satisfies IntegrationOAuthMethodRegistration + + return { browser, headless } +} export const XAIPlugin = define({ id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { + const methods = oauthMethods() + yield* ctx.integration.transform((draft) => { + draft.method.update(methods.browser) + draft.method.update(methods.headless) + }) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { @@ -21,4 +281,165 @@ export const XAIPlugin = define({ }), ) }), -}) +} satisfies PluginInternal.InternalPlugin) + +/** Build the SuperGrok authorize URL. Exported for unit tests. */ +export function buildAuthorizeURL( + pkce: Pkce, + state: string, + nonce: string, + authorizeURL: string = defaultAuthorizeURL, +) { + // `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. + return `${authorizeURL}?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirectURI, + scope, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + state, + nonce, + plan: "generic", + referrer: "opencode", + })}` +} + +/** Normalize a server-supplied seconds value to ms. Exported for unit tests. */ +export function positiveSecondsToMs(value: unknown, defaultMs: number) { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : defaultMs +} + +/** + * Absolute expiry timestamp for a stored OAuth credential. + * Prefers a positive `expires_in`, else JWT `exp`, else a 1h default. + * Subtracts the refresh skew so Integration.resolve refreshes before mid-flight expiry. + */ +export function credentialExpires( + access: string, + expiresIn: unknown, + now: number = Date.now(), + skewMs: number = accessTokenRefreshSkewMs, +) { + const fromExpiresIn = positiveSecondsToMs(expiresIn, 0) + if (fromExpiresIn > 0) return now + fromExpiresIn - Math.max(0, skewMs) + const exp = accessTokenExp(access) + if (exp !== undefined) return exp * 1000 - Math.max(0, skewMs) + return now + defaultAccessTtlSeconds * 1000 - Math.max(0, skewMs) +} + +/** + * Parse the `exp` claim out of a JWT access_token without verifying the + * signature. Used only to schedule refresh, never for trust decisions. + */ +export function accessTokenExp(token: string | undefined): number | undefined { + if (!token || typeof token !== "string") return undefined + const parts = token.split(".") + if (parts.length < 2) return undefined + 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")) as { exp?: unknown } + return typeof claims.exp === "number" && Number.isFinite(claims.exp) ? claims.exp : undefined + } catch { + return undefined + } +} + +function headers() { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +function exchange(code: string, pkce: Pkce, tokenURL: string) { + return request(tokenURL, { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectURI, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }) +} + +function refresh( + methodID: Integration.MethodID, + value: Pick, + tokenURL: string, +) { + return request(tokenURL, { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }).pipe( + Effect.map((tokens) => { + // Apply the refresh-token fallback before Credential.OAuth.make validates + // required fields. xAI may omit refresh_token on refresh responses. + const next = credential(methodID, { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || value.refresh, + expires_in: tokens.expires_in, + }) + return Credential.OAuth.make({ + ...next, + metadata: next.metadata ?? value.metadata, + }) + }), + ) +} + +function request(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`Request failed: ${response.status}${detail ? `: ${detail}` : ""}`) + } + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function credential(methodID: Integration.MethodID, tokens: TokenResponse) { + if (!tokens.refresh_token) { + throw new Error("xAI token response is missing refresh_token") + } + return Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: credentialExpires(tokens.access_token, tokens.expires_in), + }) +} + +function numberOrUndefined(value: unknown) { + const n = Number(value) + return Number.isFinite(n) ? n : undefined +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(64)), (byte) => chars[byte % chars.length]).join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 94824af3396f..8a6ed0c427c8 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -2,10 +2,18 @@ import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" -import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" +import { + accessTokenExp, + buildAuthorizeURL, + credentialExpires, + oauthMethods, + positiveSecondsToMs, + XAIPlugin, +} from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -16,7 +24,8 @@ const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const host = yield* PluginHost.make(plugin) - yield* XAIPlugin.effect(host) + const integrations = yield* Integration.Service + yield* XAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) }) function fakeSelectorSdk(calls: string[]) { @@ -32,10 +41,47 @@ function fakeSelectorSdk(calls: string[]) { } } +function makeJwt(payload: object) { + 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 eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + describe("XAIPlugin", () => { + it.effect("registers browser and headless SuperGrok OAuth methods", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("xai")))?.methods).toEqual([ + { + id: Integration.MethodID.make("browser"), + type: "oauth", + label: "xAI Grok OAuth (SuperGrok Subscription)", + }, + { + id: Integration.MethodID.make("headless"), + type: "oauth", + label: "xAI Grok OAuth (Headless / Remote / VPS)", + }, + ]) + }), + ) + it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() @@ -66,7 +112,6 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() @@ -86,7 +131,6 @@ describe("XAIPlugin", () => { it.effect("uses responses with the model modelID for xAI language models", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const calls: string[] = [] @@ -108,7 +152,6 @@ describe("XAIPlugin", () => { it.effect("ignores non-xAI providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const calls: string[] = [] @@ -128,3 +171,288 @@ describe("XAIPlugin", () => { }), ) }) + +describe("xAI OAuth helpers", () => { + it.effect("builds the SuperGrok authorize URL with Grok-CLI client params", () => + Effect.sync(() => { + const url = new URL( + buildAuthorizeURL({ verifier: "verifier", challenge: "challenge" }, "state-value", "nonce-value"), + ) + expect(url.origin + url.pathname).toBe("https://auth.x.ai/oauth2/authorize") + expect(url.searchParams.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828") + expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback") + expect(url.searchParams.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access") + expect(url.searchParams.get("code_challenge")).toBe("challenge") + expect(url.searchParams.get("code_challenge_method")).toBe("S256") + expect(url.searchParams.get("state")).toBe("state-value") + expect(url.searchParams.get("nonce")).toBe("nonce-value") + expect(url.searchParams.get("plan")).toBe("generic") + expect(url.searchParams.get("referrer")).toBe("opencode") + expect(url.searchParams.get("response_type")).toBe("code") + }), + ) + + it.effect("normalizes device interval seconds to milliseconds", () => + Effect.sync(() => { + expect(positiveSecondsToMs(5, 1000)).toBe(5000) + expect(positiveSecondsToMs("10", 1000)).toBe(10000) + expect(positiveSecondsToMs(0, 1000)).toBe(1000) + expect(positiveSecondsToMs(-1, 1000)).toBe(1000) + expect(positiveSecondsToMs(Number.NaN, 1000)).toBe(1000) + expect(positiveSecondsToMs(undefined, 1000)).toBe(1000) + expect(positiveSecondsToMs(null, 1000)).toBe(1000) + }), + ) + + it.effect("parses JWT exp for scheduling and ignores opaque tokens", () => + Effect.sync(() => { + const exp = Math.floor(Date.now() / 1000) + 600 + expect(accessTokenExp(makeJwt({ exp }))).toBe(exp) + expect(accessTokenExp("opaque-token")).toBeUndefined() + expect(accessTokenExp(undefined)).toBeUndefined() + expect(accessTokenExp(makeJwt({ sub: "user" }))).toBeUndefined() + }), + ) + + it.effect("derives credentialExpires from expires_in, JWT exp, or default", () => + Effect.sync(() => { + const now = 1_700_000_000_000 + const skew = 120_000 + expect(credentialExpires("opaque", 3600, now, skew)).toBe(now + 3600 * 1000 - skew) + + const exp = Math.floor(now / 1000) + 900 + expect(credentialExpires(makeJwt({ exp }), undefined, now, skew)).toBe(exp * 1000 - skew) + + // Positive expires_in wins over JWT exp. + expect(credentialExpires(makeJwt({ exp }), 60, now, skew)).toBe(now + 60_000 - skew) + + // Opaque + no expires_in falls back to 1h. + expect(credentialExpires("opaque", undefined, now, skew)).toBe(now + 3600 * 1000 - skew) + + // String expires_in is coerced. + expect(credentialExpires("opaque", "120", now, skew)).toBe(now + 120_000 - skew) + }), + ) +}) + +describe("xAI OAuth refresh + device flow", () => { + it.live("refresh reuses the previous refresh_token when the response omits it", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const bodies: string[] = [] + const server = Bun.serve({ + port: 0, + fetch: async (request) => { + bodies.push(await request.text()) + return Response.json({ access_token: "new-access", expires_in: 3600 }) + }, + }) + return { bodies, server } + }), + ({ bodies, server }) => + Effect.gen(function* () { + const methods = oauthMethods({ tokenURL: new URL("/oauth2/token", server.url).toString() }) + const refreshed = yield* methods.browser.refresh!({ + type: "oauth", + methodID: Integration.MethodID.make("browser"), + access: "old-access", + refresh: "rt-old", + expires: Date.now() - 1, + }) + expect(refreshed.access).toBe("new-access") + expect(refreshed.refresh).toBe("rt-old") + expect(bodies[0]).toContain("refresh_token=rt-old") + expect(refreshed.expires).toBeGreaterThan(Date.now()) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("refresh persists a rotated refresh_token when present", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 }), + }), + ), + (server) => + Effect.gen(function* () { + const methods = oauthMethods({ tokenURL: new URL("/oauth2/token", server.url).toString() }) + const refreshed = yield* methods.headless.refresh!({ + type: "oauth", + methodID: Integration.MethodID.make("headless"), + access: "old-access", + refresh: "rt-old", + expires: Date.now() - 1, + }) + expect(refreshed.access).toBe("new-access") + expect(refreshed.refresh).toBe("rt-new") + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("refresh derives expires from JWT exp when expires_in is absent", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const exp = Math.floor(Date.now() / 1000) + 900 + const access = makeJwt({ exp }) + return { + access, + exp, + server: Bun.serve({ + port: 0, + fetch: () => Response.json({ access_token: access, refresh_token: "rt-new" }), + }), + } + }), + ({ access, exp, server }) => + Effect.gen(function* () { + const methods = oauthMethods({ tokenURL: new URL("/oauth2/token", server.url).toString() }) + const refreshed = yield* methods.browser.refresh!({ + type: "oauth", + methodID: Integration.MethodID.make("browser"), + access: "old", + refresh: "rt-old", + expires: Date.now() - 1, + }) + expect(refreshed.access).toBe(access) + // 120s skew applied against JWT exp. + expect(refreshed.expires).toBe(exp * 1000 - 120_000) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("device-code flow completes after authorization_pending and slow_down", () => + Effect.acquireUseRelease( + Effect.sync(() => { + let tokenHits = 0 + const server = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + if (url.pathname.endsWith("/device/code")) { + return Response.json({ + device_code: "DC", + user_code: "UC-1", + verification_uri: "https://accounts.x.ai/device", + verification_uri_complete: "https://accounts.x.ai/device?user_code=UC-1", + // String interval must still be accepted (positiveSecondsToMs). + interval: "0.001", + expires_in: "600", + }) + } + if (url.pathname.endsWith("/token")) { + tokenHits += 1 + if (tokenHits === 1) return Response.json({ error: "authorization_pending" }, { status: 400 }) + if (tokenHits === 2) return Response.json({ error: "slow_down", interval: 0.001 }, { status: 400 }) + return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) + } + return new Response("not found", { status: 404 }) + }, + }) + return { server, tokenHits: () => tokenHits } + }), + ({ server, tokenHits }) => + Effect.gen(function* () { + const methods = oauthMethods({ + deviceAuthorizationURL: new URL("/oauth2/device/code", server.url).toString(), + tokenURL: new URL("/oauth2/token", server.url).toString(), + // Collapse RFC backoff waits so the pending/slow_down branches stay unit-testable. + deviceMinIntervalMs: 0, + pollingSafetyMarginMs: 0, + deviceSlowDownIncrementMs: 0, + }) + const authorization = yield* Effect.scoped(methods.headless.authorize({})) + expect(authorization.url).toBe("https://accounts.x.ai/device?user_code=UC-1") + expect(authorization.instructions).toContain("UC-1") + const credential = yield* authorization.callback + expect(credential.access).toBe("AT") + expect(credential.refresh).toBe("RT") + expect(tokenHits()).toBe(3) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("device-code flow fails on access_denied", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + if (url.pathname.endsWith("/device/code")) { + return Response.json({ + device_code: "DC", + user_code: "UC", + verification_uri: "https://accounts.x.ai/device", + interval: 0, + expires_in: 60, + }) + } + return Response.json({ error: "access_denied" }, { status: 400 }) + }, + }), + ), + (server) => + Effect.gen(function* () { + const methods = oauthMethods({ + deviceAuthorizationURL: new URL("/oauth2/device/code", server.url).toString(), + tokenURL: new URL("/oauth2/token", server.url).toString(), + }) + const authorization = yield* Effect.scoped(methods.headless.authorize({})) + const error = yield* authorization.callback.pipe(Effect.flip) + expect(String(error)).toContain("denied") + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("integration connection resolves a headless SuperGrok login against a mock server", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + if (url.pathname.endsWith("/device/code")) { + return Response.json({ + device_code: "DC", + user_code: "UC", + verification_uri: "https://accounts.x.ai/device", + interval: 0, + expires_in: 60, + }) + } + return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) + }, + }), + ), + (server) => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const methods = oauthMethods({ + deviceAuthorizationURL: new URL("/oauth2/device/code", server.url).toString(), + tokenURL: new URL("/oauth2/token", server.url).toString(), + }) + yield* integrations.transform((draft) => { + draft.method.update(methods.headless) + }) + const attempt = yield* integrations.connection.oauth({ + integrationID: Integration.ID.make("xai"), + methodID: Integration.MethodID.make("headless"), + inputs: {}, + }) + yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete") + const connection = yield* integrations.connection.active(Integration.ID.make("xai")) + expect(connection?.type).toBe("credential") + const credential = yield* integrations.connection.resolve(connection!) + expect(credential).toMatchObject({ type: "oauth", access: "AT", refresh: "RT" }) + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) +})