diff --git a/changelog.d/features/7399-xai-oauth-pkce.md b/changelog.d/features/7399-xai-oauth-pkce.md new file mode 100644 index 0000000000..7057b62489 --- /dev/null +++ b/changelog.d/features/7399-xai-oauth-pkce.md @@ -0,0 +1 @@ +- **feat(providers):** Add a first-class xAI OAuth PKCE provider for `api.x.ai` models, including Grok 4.5 and refresh-token rotation ([#7399](https://github.com/diegosouzapw/OmniRoute/pull/7399)) — thanks @fenix007 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 0c7d6db2a5..115c7b67f7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -262,7 +262,8 @@ "src/lib/usage/providerLimits.ts": 1000, "src/lib/usage/usageHistory.ts": 988, "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", - "src/shared/components/OAuthModal.tsx": 993, + "_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.", + "src/shared/components/OAuthModal.tsx": 998, "src/shared/components/RequestLoggerV2.tsx": 1629, "src/shared/components/analytics/charts.tsx": 1558, "_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).", diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ab52cb3603..1729ddb951 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -131,6 +131,7 @@ import { vertexProvider } from "./registry/vertex/index.ts"; import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts"; import { felo_webProvider } from "./registry/felo-web/index.ts"; import { xaiProvider } from "./registry/xai/index.ts"; +import { xai_oauthProvider } from "./registry/xai-oauth/index.ts"; import { morphProvider } from "./registry/morph/index.ts"; import { siliconflowProvider } from "./registry/siliconflow/index.ts"; import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts"; @@ -328,6 +329,7 @@ export const REGISTRY: Record = { "duckduckgo-web": duckduckgo_webProvider, "felo-web": felo_webProvider, xai: xaiProvider, + "xai-oauth": xai_oauthProvider, morph: morphProvider, siliconflow: siliconflowProvider, "gitlab-duo": gitlab_duoProvider, diff --git a/open-sse/config/providers/registry/xai-oauth/index.ts b/open-sse/config/providers/registry/xai-oauth/index.ts new file mode 100644 index 0000000000..4ec030be28 --- /dev/null +++ b/open-sse/config/providers/registry/xai-oauth/index.ts @@ -0,0 +1,24 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { resolvePublicCred } from "../../shared.ts"; +import { xaiProvider } from "../xai/index.ts"; + +export const xai_oauthProvider: RegistryEntry = { + id: "xai-oauth", + alias: "xao", + format: "openai", + executor: "xai-oauth", + baseUrl: xaiProvider.baseUrl, + responsesBaseUrl: xaiProvider.responsesBaseUrl, + authType: "oauth", + authHeader: "bearer", + passthroughModels: true, + oauth: { + clientIdEnv: "GROK_OAUTH_CLIENT_ID", + clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + tokenUrl: "https://auth.x.ai/oauth2/token", + }, + models: [ + { id: "grok-4.5", name: "Grok 4.5", contextLength: 500000 }, + ...(xaiProvider.models || []), + ], +}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index e9f2d76a17..647991a13b 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -175,6 +175,8 @@ const executors = { zmf: new ZenmuxFreeExecutor(), // Alias for zenmux-free auggie: new AuggieExecutor(), xai: new XaiExecutor(), + "xai-oauth": new XaiExecutor("xai-oauth"), + xao: new XaiExecutor("xai-oauth"), }; const defaultCache = new Map(); diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts index e5de5c037a..5fc6217729 100644 --- a/open-sse/executors/xai.ts +++ b/open-sse/executors/xai.ts @@ -1,4 +1,4 @@ -import { BaseExecutor, type ProviderCredentials } from "./base.ts"; +import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; @@ -48,8 +48,8 @@ function asRecord(value: unknown): JsonRecord | null { * 3. Leaves unclassified models and bodies untouched otherwise. */ export class XaiExecutor extends BaseExecutor { - constructor() { - super("xai", PROVIDERS.xai); + constructor(provider = "xai") { + super(provider, PROVIDERS[provider]); } /** @@ -64,12 +64,58 @@ export class XaiExecutor extends BaseExecutor { * -pro heuristic in open-sse/executors/default.ts. */ buildUrl(model: string, _stream: boolean, _urlIndex = 0) { - if (getModelTargetFormat("xai", model) === "openai-responses") { + if (getModelTargetFormat(this.provider, model) === "openai-responses") { return this.config.responsesBaseUrl || this.config.baseUrl; } return this.config.baseUrl; } + async refreshCredentials( + credentials: ProviderCredentials, + log?: ExecutorLog | null + ): Promise | null> { + if (this.provider !== "xai-oauth" || !credentials.refreshToken) return null; + + try { + const response = await fetch(this.config.tokenUrl || "https://auth.x.ai/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.config.clientId || "", + refresh_token: credentials.refreshToken, + }), + }); + + if (!response.ok) { + log?.warn?.("TOKEN_REFRESH", `xAI OAuth refresh failed with status ${response.status}`); + return null; + } + + const data = await response.json(); + if (!data.access_token) { + log?.warn?.("TOKEN_REFRESH", "xAI OAuth refresh response omitted access_token"); + return null; + } + + const expiresIn = Number(data.expires_in) || 21600; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || credentials.refreshToken, + expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + }; + } catch (error) { + log?.warn?.( + "TOKEN_REFRESH", + `xAI OAuth refresh error: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + transformRequest( model: string, body: unknown, diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index c89336e487..1d6819cbe8 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -38,17 +38,13 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { keychainImportOnlyGuard } from "./keychainImportOnly"; import { buildRemoteOAuthHint } from "./remoteOAuthHint"; -// Use globalThis to persist callback server state across Next.js HMR reloads -if (!globalThis.__codexCallbackState) { - globalThis.__codexCallbackState = null; -} -// Windsurf / Devin CLI PKCE callback server state (separate from Codex) -if (!globalThis.__windsurfCallbackState) { - globalThis.__windsurfCallbackState = null; +// Persist one callback server per provider across Next.js HMR reloads. +if (!globalThis.__pkceCallbackStates) { + globalThis.__pkceCallbackStates = {}; } /** Providers that use the PKCE browser callback flow (like Codex). */ -const PKCE_CALLBACK_PROVIDERS = new Set(["codex"]); +const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth"]); /** * Providers whose device flow runs in the user's browser (auth.openai.com blocks @@ -270,8 +266,7 @@ export async function GET( } /** - * Start PKCE callback server for Codex, Windsurf, or Devin CLI. - * Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0). + * Start a provider-configured PKCE callback server. * Returns the auth URL and stores codeVerifier for later exchange. */ async function handleStartCallbackServer( @@ -286,50 +281,52 @@ async function handleStartCallbackServer( ); } - const isWindsurf = provider === "windsurf" || provider === "devin-cli"; - const stateKey = isWindsurf ? "__windsurfCallbackState" : "__codexCallbackState"; + const callbackStates = globalThis.__pkceCallbackStates; // Clean up existing server if any - if (globalThis[stateKey]?.close) { + if (callbackStates[provider]?.close) { try { - globalThis[stateKey].close(); + callbackStates[provider].close(); } catch (e) { /* ignore */ } } - globalThis[stateKey] = null; + delete callbackStates[provider]; try { - // Codex: fixed port 1455. Windsurf/Devin CLI: OS-assigned random port (0) - const serverPort = isWindsurf ? 0 : 1455; + const providerData = getProvider(provider); + const serverPort = providerData.fixedPort || 0; + const callbackPath = providerData.callbackPath || "/callback"; + const callbackHost = providerData.callbackHost || "localhost"; const { port, close } = await startLocalServer((params) => { - if (globalThis[stateKey]) { - globalThis[stateKey].callbackParams = params; + if (callbackStates[provider]) { + callbackStates[provider].callbackParams = params; } }, serverPort); - const redirectUri = `http://localhost:${port}/auth/callback`; + const redirectUri = `http://${callbackHost}:${port}${callbackPath}`; const authData = generateAuthData(provider, redirectUri); - globalThis[stateKey] = { + callbackStates[provider] = { callbackParams: null, close, port, redirectUri, codeVerifier: authData.codeVerifier, + state: authData.state, startedAt: Date.now(), }; // Auto-cleanup after 5 minutes const startedAt = Date.now(); setTimeout(() => { - if (globalThis[stateKey]?.startedAt === startedAt) { + if (callbackStates[provider]?.startedAt === startedAt) { try { close(); } catch (e) { /* ignore */ } - globalThis[stateKey] = null; + delete callbackStates[provider]; } }, 300000); @@ -660,10 +657,9 @@ export async function POST( ); } - // Windsurf and Devin CLI share __windsurfCallbackState; Codex uses its own slot - const stateKey = provider === "codex" ? "__codexCallbackState" : "__windsurfCallbackState"; + const callbackStates = globalThis.__pkceCallbackStates; - if (!globalThis[stateKey]) { + if (!callbackStates[provider]) { return NextResponse.json({ success: false, error: "no_server", @@ -671,13 +667,13 @@ export async function POST( }); } - if (!globalThis[stateKey].callbackParams) { + if (!callbackStates[provider].callbackParams) { return NextResponse.json({ success: false, pending: true }); } // Callback received! Extract code and exchange for tokens - const params = globalThis[stateKey].callbackParams; - const { redirectUri, codeVerifier, close } = globalThis[stateKey]; + const params = callbackStates[provider].callbackParams; + const { redirectUri, codeVerifier, state, close } = callbackStates[provider]; // Clean up server try { @@ -685,7 +681,7 @@ export async function POST( } catch (e) { /* ignore */ } - globalThis[stateKey] = null; + delete callbackStates[provider]; if (params.error) { return NextResponse.json({ @@ -703,6 +699,14 @@ export async function POST( }); } + if (!safeEqual(params.state, state)) { + return NextResponse.json({ + success: false, + error: "invalid_state", + errorDescription: "OAuth state mismatch", + }); + } + try { // Resolve proxy for this provider const proxy = await resolveProxyForProvider(provider); diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index b5012102fc..bf0dee233f 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -128,6 +128,21 @@ export const GROK_CLI_CONFIG = { tokenUrl: "https://auth.x.ai/oauth2/token", }; +// xAI API OAuth Configuration (Authorization Code Flow with PKCE) +// This intentionally uses a separate provider from Grok Build: both use the +// public Grok CLI OAuth client, but their inference endpoints and model +// entitlements differ (`api.x.ai` vs `cli-chat-proxy.grok.com`). +export const XAI_OAUTH_CONFIG = { + clientId: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + authorizeUrl: "https://auth.x.ai/oauth2/authorize", + tokenUrl: "https://auth.x.ai/oauth2/token", + scope: "openid profile email offline_access grok-cli:access api:access", + codeChallengeMethod: "S256", + loopbackPort: 56121, + callbackPath: "/callback", + callbackHost: "127.0.0.1", +}; + // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), @@ -492,6 +507,7 @@ export const PROVIDERS = { TRAE: "trae", CODEBUDDY_CN: "codebuddy-cn", GROK_CLI: "grok-cli", + XAI_OAUTH: "xai-oauth", ZED: "zed", ZED_HOSTED: "zed-hosted", }; diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index cc6ab06aec..38cdba9823 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -110,7 +110,7 @@ export function getProvider(name) { */ export function generateAuthData(providerName, redirectUri) { const provider = getProvider(providerName); - const pkce = generatePKCE(); + const pkce = generatePKCE(provider.pkceVerifierBytes || 32); let codeVerifier = pkce.codeVerifier; const { codeChallenge, state } = pkce; @@ -136,6 +136,7 @@ export function generateAuthData(providerName, redirectUri) { flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", + callbackHost: provider.callbackHost || "localhost", supported: false, error, }; @@ -176,6 +177,7 @@ export function generateAuthData(providerName, redirectUri) { flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", + callbackHost: provider.callbackHost || "localhost", }; } diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index e2f9e10847..a1a82c18dd 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -26,6 +26,7 @@ import { kilocode } from "./kilocode"; import { cline } from "./cline"; import { windsurf } from "./windsurf"; import { grokCli } from "./grok-cli"; +import { xaiOauth } from "./xai-oauth"; import { codebuddyCn } from "./codebuddy-cn"; import { zed } from "./zed"; import { zedHosted } from "./zed-hosted"; @@ -54,6 +55,7 @@ export const PROVIDERS = { // devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login) "devin-cli": windsurf, "grok-cli": grokCli, + "xai-oauth": xaiOauth, "codebuddy-cn": codebuddyCn, // Zed IDE credential bridge — uses keychain import, not standard OAuth zed, diff --git a/src/lib/oauth/providers/xai-oauth.ts b/src/lib/oauth/providers/xai-oauth.ts new file mode 100644 index 0000000000..fb7e829596 --- /dev/null +++ b/src/lib/oauth/providers/xai-oauth.ts @@ -0,0 +1,102 @@ +import crypto from "node:crypto"; + +import { XAI_OAUTH_CONFIG } from "../constants/oauth"; + +const BASE64_BLOCK_SIZE = 4; + +/** + * Extract display metadata from an id_token already returned by xAI's token + * endpoint. This is not used to authorize requests; xAI validates the access + * token upstream. + */ +export function decodeXaiIdTokenIdentity(idToken: unknown): { + email: string | null; + name: string | null; +} { + if (typeof idToken !== "string") return { email: null, name: null }; + const parts = idToken.split("."); + if (parts.length !== 3) return { email: null, name: null }; + + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; + const payload = JSON.parse( + Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8") + ); + return { + email: payload.email || payload.preferred_username || null, + name: payload.name || null, + }; + } catch { + return { email: null, name: null }; + } +} + +export const xaiOauth = { + config: XAI_OAUTH_CONFIG, + flowType: "authorization_code_pkce" as const, + fixedPort: XAI_OAUTH_CONFIG.loopbackPort, + callbackPath: XAI_OAUTH_CONFIG.callbackPath, + callbackHost: XAI_OAUTH_CONFIG.callbackHost, + // The official xAI flow uses a 96-byte random verifier (128 base64url chars). + pkceVerifierBytes: 96, + + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + nonce: crypto.randomBytes(16).toString("hex"), + plan: "generic", + referrer: "cli-proxy-api", + }; + const query = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${config.authorizeUrl}?${query}`; + }, + + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`xAI token exchange failed: ${error}`); + } + + return response.json(); + }, + + mapTokens: (tokens) => { + const identity = decodeXaiIdTokenIdentity(tokens.id_token); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + email: identity.email, + name: identity.name || identity.email, + providerSpecificData: { + scope: tokens.scope || XAI_OAUTH_CONFIG.scope, + tokenType: tokens.token_type || "Bearer", + }, + }; + }, +}; diff --git a/src/lib/oauth/utils/pkce.ts b/src/lib/oauth/utils/pkce.ts index 7c258caccb..6cefd195f5 100644 --- a/src/lib/oauth/utils/pkce.ts +++ b/src/lib/oauth/utils/pkce.ts @@ -3,8 +3,8 @@ import crypto from "crypto"; /** * Generate PKCE code verifier (43-128 characters) */ -export function generateCodeVerifier() { - return crypto.randomBytes(32).toString("base64url"); +export function generateCodeVerifier(bytes = 32) { + return crypto.randomBytes(bytes).toString("base64url"); } /** @@ -24,8 +24,8 @@ export function generateState() { /** * Generate complete PKCE pair */ -export function generatePKCE() { - const codeVerifier = generateCodeVerifier(); +export function generatePKCE(verifierBytes = 32) { + const codeVerifier = generateCodeVerifier(verifierBytes); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index f19db09152..07bb0a34ad 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -13,7 +13,7 @@ import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oaut const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]); /** Providers that use a local callback server on a random port (PKCE browser flow). */ -const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex"]); +const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]); /** * Phase 1 hotfix (2026-05-29): windsurf & devin-cli only support import-token. @@ -407,6 +407,11 @@ export default function OAuthModal({ let redirectUri: string; if (provider === "codex" || provider === "openai") { redirectUri = "http://localhost:1455/auth/callback"; + } else if (provider === "xai-oauth") { + // xAI registers a fixed native-app loopback callback. On remote installs + // the browser cannot reach OmniRoute there, so the user pastes the + // resulting callback URL into the existing manual-flow input. + redirectUri = "http://127.0.0.1:56121/callback"; } else if (provider === "windsurf" || provider === "devin-cli") { // Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects. // On true localhost this code is never reached (callback server handles the flow above). diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index 7eda70e794..0551e8938c 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -3,6 +3,19 @@ * Pure data literal; re-exported by the providers.ts barrel. No behavior change. */ export const OAUTH_PROVIDERS = { + "xai-oauth": { + id: "xai-oauth", + alias: "xao", + name: "xAI OAuth (Grok)", + icon: "auto_awesome", + color: "#1DA1F2", + textIcon: "XA", + website: "https://x.ai", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases.", + }, "grok-cli": { id: "grok-cli", alias: "gc", diff --git a/tests/unit/executor-xai.test.ts b/tests/unit/executor-xai.test.ts index f8a6031e5e..85342f1a89 100644 --- a/tests/unit/executor-xai.test.ts +++ b/tests/unit/executor-xai.test.ts @@ -20,6 +20,12 @@ test("XaiExecutor is registered under the 'xai' key and set as the registry exec assert.equal(xaiProvider.executor, "xai"); }); +test("XaiExecutor can target the separate xAI OAuth provider config", () => { + const executor = new XaiExecutor("xai-oauth"); + assert.equal(executor.getProvider(), "xai-oauth"); + assert.equal(executor.buildUrl("grok-4.5", false), "https://api.x.ai/v1/chat/completions"); +}); + test("strips a -{level} suffix from an allow-listed model and sets reasoning_effort", () => { const executor = new XaiExecutor(); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 54a1a4150f..04761ac65e 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -42,6 +42,7 @@ const { QWEN_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, + XAI_OAUTH_CONFIG, ZED_HOSTED_CONFIG, } = oauthModule; const { getAntigravityLoadCodeAssistMetadata } = antigravityHeadersModule; @@ -68,6 +69,7 @@ const EXPECTED_PROVIDER_KEYS = [ "windsurf", "devin-cli", "grok-cli", + "xai-oauth", "codebuddy-cn", "zed", "zed-hosted", @@ -100,6 +102,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { "devin-cli": WINDSURF_CONFIG, trae: TRAE_CONFIG, "grok-cli": GROK_CLI_CONFIG, + "xai-oauth": XAI_OAUTH_CONFIG, "codebuddy-cn": CODEBUDDY_CN_CONFIG, zed: ZED_CONFIG, "zed-hosted": ZED_HOSTED_CONFIG, @@ -144,6 +147,9 @@ const REQUIRED_FIELDS_BY_PROVIDER = { windsurf: ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], "devin-cli": ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], trae: ["apiEndpoint", "chatEndpoint", "webUrl"], + // prettier-ignore + "xai-oauth": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], + // prettier-ignore "zed-hosted": ["webBaseUrl", "cloudBaseUrl", "llmBaseUrl", "userInfoUrl", "llmTokenUrl", "modelsUrl"], }; @@ -351,7 +357,10 @@ test("zed-hosted buildAuthUrl returns {authUrl, codeVerifier, redirectUri} carry test("generateAuthData honors an object-returning buildAuthUrl (zed-hosted) without breaking string-returning providers", async () => { const oauthHelpers = await import("../../src/lib/oauth/providers.ts"); - const zedAuthData = oauthHelpers.generateAuthData("zed-hosted", "http://localhost:20128/callback"); + const zedAuthData = oauthHelpers.generateAuthData( + "zed-hosted", + "http://localhost:20128/callback" + ); assert.equal(zedAuthData.flowType, "authorization_code"); assert.ok(zedAuthData.authUrl.startsWith("https://zed.dev/native_app_signin?")); assert.ok(zedAuthData.codeVerifier.startsWith("zed-rsa-pkcs1:")); diff --git a/tests/unit/xai-oauth-provider.test.ts b/tests/unit/xai-oauth-provider.test.ts new file mode 100644 index 0000000000..6fdba9a1ec --- /dev/null +++ b/tests/unit/xai-oauth-provider.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { generateAuthData } from "../../src/lib/oauth/providers.ts"; +import { xaiOauth, decodeXaiIdTokenIdentity } from "../../src/lib/oauth/providers/xai-oauth.ts"; +import { XAI_OAUTH_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { XaiExecutor } from "../../open-sse/executors/xai.ts"; +import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai-oauth/index.ts"; + +const originalFetch = globalThis.fetch; + +function createJwt(payload: Record) { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.signature`; +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("xAI OAuth builds the official PKCE authorization request", () => { + const authData = generateAuthData("xai-oauth", "http://127.0.0.1:56121/callback"); + const url = new URL(authData.authUrl); + + assert.equal(url.origin, "https://auth.x.ai"); + assert.equal(url.pathname, "/oauth2/authorize"); + assert.equal(url.searchParams.get("client_id"), XAI_OAUTH_CONFIG.clientId); + assert.equal(url.searchParams.get("scope"), XAI_OAUTH_CONFIG.scope); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.equal(url.searchParams.get("plan"), "generic"); + assert.equal(url.searchParams.get("referrer"), "cli-proxy-api"); + assert.ok(url.searchParams.get("nonce")); + assert.equal(authData.codeVerifier.length, 128); + assert.equal(authData.fixedPort, 56121); + assert.equal(authData.callbackPath, "/callback"); + assert.equal(authData.callbackHost, "127.0.0.1"); +}); + +test("xAI OAuth exchanges a code with form-urlencoded PKCE fields", async () => { + globalThis.fetch = async (input, init) => { + assert.equal(String(input), XAI_OAUTH_CONFIG.tokenUrl); + assert.equal(init?.method, "POST"); + assert.equal(init?.headers?.["Content-Type"], "application/x-www-form-urlencoded"); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "authorization_code"); + assert.equal(body.get("client_id"), XAI_OAUTH_CONFIG.clientId); + assert.equal(body.get("code"), "auth-code"); + assert.equal(body.get("redirect_uri"), "http://127.0.0.1:56121/callback"); + assert.equal(body.get("code_verifier"), "verifier"); + return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }); + }; + + const tokens = await xaiOauth.exchangeToken( + XAI_OAUTH_CONFIG, + "auth-code", + "http://127.0.0.1:56121/callback", + "verifier" + ); + assert.equal(tokens.access_token, "access"); +}); + +test("xAI OAuth maps refreshable tokens and safe id_token display metadata", () => { + const idToken = createJwt({ email: "user@example.com", name: "Grok User" }); + assert.deepEqual(decodeXaiIdTokenIdentity(idToken), { + email: "user@example.com", + name: "Grok User", + }); + + const mapped = xaiOauth.mapTokens({ + access_token: "access", + refresh_token: "refresh", + id_token: idToken, + expires_in: 3600, + scope: XAI_OAUTH_CONFIG.scope, + }); + assert.equal(mapped.accessToken, "access"); + assert.equal(mapped.refreshToken, "refresh"); + assert.equal(mapped.email, "user@example.com"); + assert.equal(mapped.name, "Grok User"); +}); + +test("xAI OAuth is a distinct OAuth registry entry backed by the xAI executor", () => { + assert.equal(xai_oauthProvider.authType, "oauth"); + assert.equal(xai_oauthProvider.baseUrl, "https://api.x.ai/v1/chat/completions"); + assert.ok(xai_oauthProvider.models?.some((model) => model.id === "grok-4.5")); + assert.equal(hasSpecializedExecutor("xai-oauth"), true); + assert.ok(getExecutor("xai-oauth") instanceof XaiExecutor); + + const headers = getExecutor("xai-oauth").buildHeaders({ accessToken: "oauth-access" }, false); + assert.equal(headers.Authorization, "Bearer oauth-access"); +}); + +test("xAI OAuth executor rotates refresh tokens", async () => { + globalThis.fetch = async (input, init) => { + assert.equal(String(input), XAI_OAUTH_CONFIG.tokenUrl); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "refresh_token"); + assert.equal(body.get("client_id"), XAI_OAUTH_CONFIG.clientId); + assert.equal(body.get("refresh_token"), "old-refresh"); + return Response.json({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 7200, + }); + }; + + const executor = new XaiExecutor("xai-oauth"); + const refreshed = await executor.refreshCredentials({ refreshToken: "old-refresh" }, null); + assert.equal(refreshed?.accessToken, "new-access"); + assert.equal(refreshed?.refreshToken, "new-refresh"); + assert.ok(refreshed?.expiresAt); +});