From d976f4cc876b989359ac5ab618f4c79060bc82d3 Mon Sep 17 00:00:00 2001 From: Muhammad Mugni Hadi Date: Thu, 21 May 2026 10:18:12 +0700 Subject: [PATCH] feat(xai): add xAI Grok provider with OAuth + API key auth + image Adapted from PR #1286 (mugnimaestra/feat/xai-grok-provider) to match existing app architecture. Includes: - OAuth 2.0 with PKCE on loopback port 56121 (Grok Build) - API key auth path (console.x.ai) - Token refresh wiring (open-sse + sse tokenRefresh) - Dashboard OAuth modal with fixed-port flow + manual code fallback - Provider registry entries (OAuth + API key) - xAI image generation via OpenAI-compatible adapter (grok-2-image-1212 model, no size/quality/style params) Excludes (intentionally, to match app patterns): - Custom xAI Responses executor (DefaultExecutor handles /chat/completions) - xAI-specific translators (app uses OpenAI as intermediate format) - Image edits (not supported by current imageGenerationCore) - Video endpoints (app has no video subsystem yet) - CLI xai-login command Refs decolua#1286 Co-authored-by: Cursor --- open-sse/config/providerModels.js | 1 + open-sse/config/providers.js | 6 +- open-sse/handlers/imageProviders/index.js | 1 + open-sse/handlers/imageProviders/openai.js | 7 + open-sse/services/tokenRefresh.js | 34 ++- .../providers/[id]/AddApiKeyModal.js | 8 +- .../dashboard/providers/[id]/ConnectionRow.js | 15 +- .../dashboard/providers/[id]/page.js | 115 +++++++-- .../api/oauth/[provider]/[action]/route.js | 86 +++++-- src/lib/oauth/constants/xai.js | 61 +++++ src/lib/oauth/providers.js | 93 ++++++- src/lib/oauth/services/xai.js | 238 ++++++++++++++++++ src/lib/oauth/utils/pkce.js | 11 +- src/lib/oauth/utils/server.js | 139 ++++++++++ src/lib/providerNormalization.js | 9 + src/shared/components/OAuthModal.js | 99 +++++++- src/shared/constants/providers.js | 3 +- src/sse/services/auth.js | 1 + src/sse/services/tokenRefresh.js | 15 +- tests/unit/xai-oauth-service.test.js | 125 +++++++++ tests/unit/xai-tokenRefresh.test.js | 63 +++++ 21 files changed, 1058 insertions(+), 72 deletions(-) create mode 100644 src/lib/oauth/constants/xai.js create mode 100644 src/lib/oauth/services/xai.js create mode 100644 tests/unit/xai-oauth-service.test.js create mode 100644 tests/unit/xai-tokenRefresh.test.js diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 8259eb61..827701a6 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -462,6 +462,7 @@ export const PROVIDER_MODELS = { { id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" }, { id: "grok-code-fast-1", name: "Grok Code Fast" }, { id: "grok-3", name: "Grok 3" }, + { id: "grok-2-image-1212", name: "Grok 2 Image", type: "image", params: ["n", "response_format"] }, ], mistral: [ { id: "mistral-large-latest", name: "Mistral Large 3" }, diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 5f9d6a66..e3990d20 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -272,7 +272,11 @@ export const PROVIDERS = { }, xai: { baseUrl: "https://api.x.ai/v1/chat/completions", - format: "openai" + responsesUrl: "https://api.x.ai/v1/responses", + format: "openai", + clientId: "b1a00492-073a-47ea-816f-4c329264a828", + tokenUrl: "https://auth.x.ai/oauth2/token", + refreshUrl: "https://auth.x.ai/oauth2/token" }, mistral: { baseUrl: "https://api.mistral.ai/v1/chat/completions", diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js index 3f875432..7094a8ea 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.js @@ -17,6 +17,7 @@ const ADAPTERS = { minimax: createOpenAIAdapter("minimax"), openrouter: createOpenAIAdapter("openrouter"), recraft: createOpenAIAdapter("recraft"), + xai: createOpenAIAdapter("xai"), gemini, codex, sdwebui, diff --git a/open-sse/handlers/imageProviders/openai.js b/open-sse/handlers/imageProviders/openai.js index 2e034071..7c28a3a6 100644 --- a/open-sse/handlers/imageProviders/openai.js +++ b/open-sse/handlers/imageProviders/openai.js @@ -5,6 +5,7 @@ const ENDPOINTS = { minimax: "https://api.minimaxi.com/v1/images/generations", openrouter: "https://openrouter.ai/api/v1/images/generations", recraft: "https://external.api.recraft.ai/v1/images/generations", + xai: "https://api.x.ai/v1/images/generations", }; export default function createOpenAIAdapter(providerId) { @@ -22,6 +23,12 @@ export default function createOpenAIAdapter(providerId) { }, buildBody: (model, body) => { const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body; + // xAI only accepts prompt, model, n, response_format + if (providerId === "xai") { + const req = { model, prompt, n }; + if (response_format) req.response_format = response_format; + return req; + } const req = { model, prompt, n, size }; if (quality) req.quality = quality; if (style) req.style = style; diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index f3b21b69..c449559f 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -2,6 +2,33 @@ import { PROVIDERS } from "../config/providers.js"; import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; +// xAI refresh — wraps the class method from src/lib/oauth/services/xai.js so +// the token-refresh switches below can stay flat (one function per provider). +let _xaiServiceSingleton = null; +async function refreshXaiToken(refreshToken, log) { + if (!refreshToken) return null; + try { + if (!_xaiServiceSingleton) { + const mod = await import("../../src/lib/oauth/services/xai.js"); + _xaiServiceSingleton = new mod.XaiService(); + } + const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + idToken: tokens.id_token, + }; + } catch (e) { + log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`); + const msg = String(e?.message || ""); + if (msg.includes("invalid_grant") || msg.includes("invalid_request")) { + return { error: "invalid_grant" }; + } + return null; + } +} + // Default token expiry buffer (refresh if expires within 5 minutes) export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; @@ -567,6 +594,9 @@ async function _getAccessTokenInternal(provider, credentials, log) { log ); + case "xai": + return await refreshXaiToken(credentials.refreshToken, log); + case "vertex": case "vertex-partner": { const saJson = parseVertexSaJson(credentials.apiKey); @@ -611,6 +641,8 @@ export async function refreshTokenByProvider(provider, credentials, log) { credentials.providerSpecificData, log ); + case "xai": + return refreshXaiToken(credentials.refreshToken, log); case "vertex": case "vertex-partner": { const saJson = parseVertexSaJson(credentials.apiKey); @@ -651,6 +683,7 @@ export function formatProviderCredentials(provider, credentials, log) { case "iflow": case "openai": case "openrouter": + case "xai": return { apiKey: credentials.apiKey, accessToken: credentials.accessToken @@ -798,4 +831,3 @@ export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) { log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed`); return null; } - diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index 1a4c8b76..77fd9c07 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -11,10 +11,11 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const NONE_PROXY_POOL_VALUE = "__none__"; const isOllamaLocal = provider === "ollama-local"; const isCookie = authType === "cookie"; + const isXaiApiKey = provider === "xai" && !isCookie; const credentialLabel = isCookie ? "Cookie Value" : "API Key"; const credentialPlaceholder = isCookie ? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...") - : ""; + : (isXaiApiKey ? "xai-..." : ""); const isAzure = provider === "azure"; const isCloudflareAi = provider === "cloudflare-ai"; @@ -228,6 +229,11 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa )} + {isXaiApiKey && ( +

+ Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth. +

+ )} {isCookie && authHint && (

{authHint} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index b97d8814..dbf140d7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -65,10 +65,15 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst } }; + const rowAuthType = connection.authType || (isOAuth ? "oauth" : "apikey"); + const isOAuthConnection = rowAuthType === "oauth"; + const isCookieConnection = rowAuthType === "cookie"; + const authIcon = isCookieConnection ? "cookie" : isOAuthConnection ? "lock" : "key"; + const authLabel = isOAuthConnection ? "OAuth" : isCookieConnection ? "Cookie" : "API Key"; const isEmail = (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); - const displayName = isOAuth + const displayName = isOAuthConnection ? (isEmail(connection.email) ? connection.email : (isEmail(connection.name) ? connection.name : (connection.name || connection.email || connection.displayName || "OAuth Account"))) - : connection.name; + : (connection.name || connection.email || connection.displayName || "API Key"); // Use useState + useEffect for impure Date.now() to avoid calling during render const [isCooldown, setIsCooldown] = useState(false); @@ -130,7 +135,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst - {isOAuth ? "lock" : "key"} + {authIcon}

{displayName}

@@ -138,6 +143,9 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst {connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")} + + {authLabel} + {hasAnyProxy && ( Proxy @@ -259,4 +267,3 @@ ConnectionRow.propTypes = { onEdit: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, }; - diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index e4ce947d..bccf1c7c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -54,7 +54,11 @@ export default function ProviderDetailPage() { const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; - const triggerAddConnection = () => { + const openOAuthConnection = () => { + setShowOAuthModal(true); + }; + + const triggerOAuthConnection = () => { if (providerId === "antigravity" && typeof window !== "undefined") { const confirmed = window.localStorage.getItem(AG_RISK_STORAGE_KEY) === "true"; if (!confirmed) { @@ -63,24 +67,36 @@ export default function ProviderDetailPage() { } } if (isOAuth) { - setShowOAuthModal(true); + openOAuthConnection(); return; } setAddConnectionError(""); setShowAddApiKeyModal(true); }; + const triggerApiKeyConnection = () => { + setAddConnectionError(""); + setShowAddApiKeyModal(true); + }; + + const triggerAddConnection = () => { + if (isOAuth) { + triggerOAuthConnection(); + return; + } + triggerApiKeyConnection(); + }; + const handleAgRiskConfirm = () => { if (typeof window !== "undefined") { window.localStorage.setItem(AG_RISK_STORAGE_KEY, "true"); } setShowAgRiskModal(false); if (isOAuth) { - setShowOAuthModal(true); + openOAuthConnection(); return; } - setAddConnectionError(""); - setShowAddApiKeyModal(true); + triggerApiKeyConnection(); }; const providerInfo = providerNode @@ -94,7 +110,9 @@ export default function ProviderDetailPage() { type: providerNode.type, } : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId] || WEB_COOKIE_PROVIDERS[providerId]); - const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId]; + const authModes = providerInfo?.authModes || []; + const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth"); + const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey"); const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; const models = getModelsByProviderId(providerId); const providerAlias = getProviderAlias(providerId); @@ -102,6 +120,9 @@ export default function ProviderDetailPage() { const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); const isCompatible = isOpenAICompatible || isAnthropicCompatible; + const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth; + const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : "OAuth"; + const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key"; const thinkingConfig = AI_PROVIDERS[providerId]?.thinkingConfig || THINKING_CONFIG.extended; const providerStorageAlias = isCompatible ? providerId : providerAlias; @@ -1087,21 +1108,41 @@ export default function ProviderDetailPage() {
{isOAuth ? "lock" : "key"}
-

No connections yet

+
+

No connections yet

+ {hasDualAuthModes && ( +

+ Choose {oauthConnectionLabel} or {apiKeyConnectionLabel}. +

+ )} +
- {!isCompatible && providerId === "iflow" && ( - + {hasDualAuthModes ? ( + <> + + + + ) : ( + <> + {!isCompatible && providerId === "iflow" && ( + + )} + + )} -
) : ( @@ -1121,14 +1162,36 @@ export default function ProviderDetailPage() { Cookie )} - + {hasDualAuthModes ? ( + <> + + + + ) : ( + + )} )} diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index bcc0eab5..60dcef1e 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -13,8 +13,52 @@ import { registerCodexSession, getCodexSessionStatus, clearCodexSession, + startXaiProxy, + stopXaiProxy, + registerXaiSession, + getXaiSessionStatus, + clearXaiSession, } from "@/lib/oauth/utils/server"; +async function completeXaiManualCode(code, state) { + const session = state ? getXaiSessionStatus(state) : null; + if (!session) { + throw new Error("xAI OAuth session not found; restart the login flow and paste the code again"); + } + if (!code) throw new Error("Missing xAI authorization code"); + + try { + const tokenData = await exchangeTokens( + "xai", + code, + session.redirectUri, + session.codeVerifier, + state + ); + const connection = await createProviderConnection({ + provider: "xai", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + clearXaiSession(state); + stopXaiProxy(); + return { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }; + } catch (err) { + clearXaiSession(state); + stopXaiProxy(); + throw err; + } +} + /** * Dynamic OAuth API Route * Handles: authorize, exchange, device-code, poll @@ -33,53 +77,58 @@ export async function GET(request, { params }) { const reservedParams = new Set(["redirect_uri"]); const meta = {}; searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; }); - const authData = generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined); + const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined); return NextResponse.json(authData); } if (action === "start-proxy") { - if (provider !== "codex") { - return NextResponse.json({ error: "Proxy only supported for codex" }, { status: 400 }); + if (!["codex", "xai"].includes(provider)) { + return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); } const appPort = searchParams.get("app_port"); if (!appPort) { return NextResponse.json({ error: "Missing app_port" }, { status: 400 }); } - // Optional server-side mode params: register session for auto-exchange const state = searchParams.get("state"); const codeVerifier = searchParams.get("code_verifier"); const redirectUri = searchParams.get("redirect_uri"); - const result = await startCodexProxy(Number(appPort)); + const result = provider === "xai" + ? await startXaiProxy(Number(appPort)) + : await startCodexProxy(Number(appPort)); let serverSide = false; if (result.success && state && codeVerifier && redirectUri) { - serverSide = registerCodexSession({ state, codeVerifier, redirectUri }); + serverSide = provider === "xai" + ? registerXaiSession({ state, codeVerifier, redirectUri }) + : registerCodexSession({ state, codeVerifier, redirectUri }); } return NextResponse.json({ ...result, serverSide }); } if (action === "poll-status") { - if (provider !== "codex") { - return NextResponse.json({ error: "Poll only supported for codex" }, { status: 400 }); + if (!["codex", "xai"].includes(provider)) { + return NextResponse.json({ error: "Poll only supported for codex/xai" }, { status: 400 }); } const state = searchParams.get("state"); if (!state) { return NextResponse.json({ error: "Missing state" }, { status: 400 }); } - const session = getCodexSessionStatus(state); + const session = provider === "xai" ? getXaiSessionStatus(state) : getCodexSessionStatus(state); if (!session) return NextResponse.json({ status: "unknown" }); if (session.status === "done" || session.status === "error") { const payload = { ...session }; - clearCodexSession(state); + if (provider === "xai") clearXaiSession(state); + else clearCodexSession(state); return NextResponse.json(payload); } return NextResponse.json({ status: session.status }); } if (action === "stop-proxy") { - if (provider !== "codex") { - return NextResponse.json({ error: "Proxy only supported for codex" }, { status: 400 }); + if (!["codex", "xai"].includes(provider)) { + return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); } - stopCodexProxy(); + if (provider === "xai") stopXaiProxy(); + else stopCodexProxy(); return NextResponse.json({ success: true }); } @@ -89,7 +138,7 @@ export async function GET(request, { params }) { return NextResponse.json({ error: "Provider does not support device code flow" }, { status: 400 }); } - const authData = generateAuthData(provider, null); + const authData = await generateAuthData(provider, null); const startUrl = searchParams.get("start_url"); const region = searchParams.get("region"); const authMethod = searchParams.get("auth_method"); @@ -267,6 +316,15 @@ export async function POST(request, { params }) { }); } + if (action === "manual-code") { + if (provider !== "xai") { + return NextResponse.json({ error: "Manual code only supported for xai" }, { status: 400 }); + } + const { code, state } = body; + const connection = await completeXaiManualCode(String(code || "").trim(), String(state || "").trim()); + return NextResponse.json({ success: true, connection }); + } + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); } catch (error) { console.log("OAuth POST error:", error); diff --git a/src/lib/oauth/constants/xai.js b/src/lib/oauth/constants/xai.js new file mode 100644 index 00000000..9e45b123 --- /dev/null +++ b/src/lib/oauth/constants/xai.js @@ -0,0 +1,61 @@ +/** + * xAI (Grok) OAuth Configuration + * + * Source of truth: router-for-me/CLIProxyAPI internal/auth/xai/types.go + * Mirrors the upstream Go constants 1:1. + */ + +// xAI client_id for OAuth (PKCE public client) +export const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; + +// OAuth issuer + endpoints +export const XAI_ISSUER = "https://auth.x.ai"; +export const XAI_AUTH_ENDPOINT_PATH = "/oauth2/authorize"; +export const XAI_TOKEN_ENDPOINT_PATH = "/oauth2/token"; +export const XAI_DISCOVERY_PATH = "/.well-known/openid-configuration"; + +// Scopes (space-separated, matches Go upstream) +export const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access"; + +// xAI inference API base URL +export const XAI_API_BASE = "https://api.x.ai/v1"; + +// Loopback callback (PKCE) +export const XAI_LOOPBACK_PORT = 56121; +export const XAI_CALLBACK_PATH = "/callback"; +export const XAI_REDIRECT_URI = `http://127.0.0.1:${XAI_LOOPBACK_PORT}${XAI_CALLBACK_PATH}`; + +// PKCE verifier length (bytes pre-base64url) +export const XAI_PKCE_VERIFIER_BYTES = 96; + +// Refresh tokens this many seconds before expiry +export const XAI_REFRESH_LEAD_SECONDS = 5 * 60; + +// User-Agent — mirror Go grok-cli UA. Version is best-effort; xAI does not pin a specific version. +export const XAI_USER_AGENT = "grok-cli/9router"; + +/** + * Aggregated config object — mirrors the shape of CLAUDE_CONFIG/CODEX_CONFIG in oauth.js. + * Includes both the discovery-derived defaults and the static fallbacks used when + * discovery is unavailable. Discovery results override authorizeUrl/tokenUrl at runtime. + */ +export const XAI_CONFIG = { + clientId: XAI_CLIENT_ID, + issuer: XAI_ISSUER, + authEndpointPath: XAI_AUTH_ENDPOINT_PATH, + tokenEndpointPath: XAI_TOKEN_ENDPOINT_PATH, + discoveryPath: XAI_DISCOVERY_PATH, + // Static fallbacks (these are also the values returned by xAI discovery today) + authorizeUrl: `${XAI_ISSUER}${XAI_AUTH_ENDPOINT_PATH}`, + tokenUrl: `${XAI_ISSUER}${XAI_TOKEN_ENDPOINT_PATH}`, + discoveryUrl: `${XAI_ISSUER}${XAI_DISCOVERY_PATH}`, + scope: XAI_SCOPE, + apiBaseUrl: XAI_API_BASE, + redirectUri: XAI_REDIRECT_URI, + loopbackPort: XAI_LOOPBACK_PORT, + callbackPath: XAI_CALLBACK_PATH, + pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, + refreshLeadSeconds: XAI_REFRESH_LEAD_SECONDS, + userAgent: XAI_USER_AGENT, + codeChallengeMethod: "S256", +}; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 7ba73cfe..a74bc2ed 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -5,6 +5,7 @@ // Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime import "open-sse/index.js"; +import crypto from "crypto"; import { generatePKCE, generateState } from "./utils/pkce"; import { @@ -25,6 +26,11 @@ import { CODEBUDDY_CONFIG, getOAuthClientMetadata, } from "./constants/oauth"; +import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; +import { + decodeIdTokenEmail as decodeXaiIdTokenEmail, + discoverEndpoints as discoverXaiEndpoints, +} from "./services/xai"; const BASE64_BLOCK_SIZE = 4; @@ -186,6 +192,77 @@ const PROVIDERS = { }, }, + xai: { + config: XAI_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: XAI_CONFIG.loopbackPort, + callbackPath: XAI_CONFIG.callbackPath, + pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, + prepareConfig: async (config) => { + const endpoints = await discoverXaiEndpoints(); + return { + ...config, + authorizeUrl: endpoints.authorizeUrl, + tokenUrl: endpoints.tokenUrl, + }; + }, + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + // Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer + const nonce = crypto.randomBytes(16).toString("hex"); + 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, + plan: "generic", + referrer: "cli-proxy-api", + }; + const qs = Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + return `${config.authorizeUrl}?${qs}`; + }, + 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 await response.json(); + }, + mapTokens: (tokens) => { + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }; + const email = decodeXaiIdTokenEmail(tokens.id_token); + if (email) mapped.email = email; + if (tokens.id_token) { + mapped.providerSpecificData = { idToken: tokens.id_token }; + } + return mapped; + }, + }, + "gemini-cli": { config: GEMINI_CONFIG, flowType: "authorization_code", @@ -1183,18 +1260,21 @@ export function getProviderNames() { * Generate auth data for a provider * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) */ -export function generateAuthData(providerName, redirectUri, meta) { +export async function generateAuthData(providerName, redirectUri, meta) { const provider = getProvider(providerName); - const { codeVerifier, codeChallenge, state } = generatePKCE(); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; + const { codeVerifier, codeChallenge, state } = generatePKCE(provider.pkceVerifierBytes); let authUrl; if (provider.flowType === "device_code") { // Device code flow doesn't have auth URL upfront authUrl = null; } else if (provider.flowType === "authorization_code_pkce") { - authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge, meta || {}); + authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {}); } else { - authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, undefined, meta || {}); + authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {}); } return { @@ -1215,8 +1295,11 @@ export function generateAuthData(providerName, redirectUri, meta) { */ export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { const provider = getProvider(providerName); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; - const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state, meta || {}); + const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {}); let extra = null; if (provider.postExchange) { diff --git a/src/lib/oauth/services/xai.js b/src/lib/oauth/services/xai.js new file mode 100644 index 00000000..95c51856 --- /dev/null +++ b/src/lib/oauth/services/xai.js @@ -0,0 +1,238 @@ +import open from "open"; +import { OAuthService } from "./oauth.js"; +import crypto from "crypto"; +import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "../constants/xai.js"; +import { startLocalServer } from "../utils/server.js"; +import { generateCodeVerifier, generateCodeChallenge, generateState } from "../utils/pkce.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * xAI (Grok) OAuth Service + * + * Source of truth: router-for-me/CLIProxyAPI internal/auth/xai/xai.go + * + * Flow: + * 1. Discover endpoints from `${XAI_ISSUER}/.well-known/openid-configuration` + * 2. Bind loopback server on 127.0.0.1:56121, path /callback + * 3. PKCE S256 with 96-byte verifier + * 4. Exchange code with form-urlencoded body + * 5. id_token email decode (no signature verify, mirrors Go) + */ + +const BASE64_BLOCK_SIZE = 4; + +let cachedDiscovery = null; + +export function validateOAuthEndpoint(rawUrl, field) { + const value = String(rawUrl || "").trim(); + if (!value) throw new Error(`xai discovery ${field} is empty`); + + let parsed; + try { + parsed = new URL(value); + } catch (err) { + throw new Error(`xai discovery ${field} is invalid: ${err.message}`); + } + + if (parsed.protocol !== "https:") { + throw new Error(`xai discovery ${field} must use https: ${value}`); + } + + const host = parsed.hostname.toLowerCase().trim(); + if (host !== "x.ai" && !host.endsWith(".x.ai")) { + throw new Error(`xai discovery ${field} host ${host} is not on x.ai`); + } + + return value; +} + +/** + * Discover authorization + token endpoints. Cached process-wide. + */ +export async function discoverEndpoints() { + if (cachedDiscovery) return cachedDiscovery; + + try { + const res = await fetch(XAI_CONFIG.discoveryUrl, { + headers: { Accept: "application/json" }, + }); + if (res.ok) { + const data = await res.json(); + cachedDiscovery = { + authorizeUrl: validateOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"), + tokenUrl: validateOAuthEndpoint(data.token_endpoint, "token_endpoint"), + }; + return cachedDiscovery; + } + } catch { + // fall through to static fallback + } + + cachedDiscovery = { + authorizeUrl: XAI_CONFIG.authorizeUrl, + tokenUrl: XAI_CONFIG.tokenUrl, + }; + return cachedDiscovery; +} + +/** + * Decode the `email` claim from an id_token JWT. No signature verification — + * mirrors CLIProxyAPI Go behavior. Returns undefined if not parseable. + */ +export function decodeIdTokenEmail(idToken) { + if (!idToken || typeof idToken !== "string") return undefined; + const parts = idToken.split("."); + if (parts.length !== 3) return undefined; + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; + const json = Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8"); + const payload = JSON.parse(json); + return payload.email || payload.preferred_username || payload.sub || undefined; + } catch { + return undefined; + } +} + +export class XaiService extends OAuthService { + constructor() { + super(XAI_CONFIG); + } + + /** + * Build xAI authorization URL. Spaces in scope are encoded as %20. + */ + buildXaiAuthUrl(redirectUri, state, codeChallenge, authorizeUrl) { + const nonce = crypto.randomBytes(16).toString("hex"); + const params = { + response_type: "code", + client_id: XAI_CONFIG.clientId, + redirect_uri: redirectUri, + scope: XAI_CONFIG.scope, + code_challenge: codeChallenge, + code_challenge_method: XAI_CONFIG.codeChallengeMethod, + state, + nonce, + plan: "generic", + referrer: "cli-proxy-api", + }; + const qs = Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + return `${authorizeUrl}?${qs}`; + } + + /** + * Exchange authorization code for tokens. + * xAI is a public PKCE client — no client_secret. + */ + async exchangeXaiCode({ tokenUrl, code, redirectUri, codeVerifier }) { + const res = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: XAI_CONFIG.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`xAI token exchange failed: ${err}`); + } + return await res.json(); + } + + /** + * Refresh an access token using a refresh_token. + */ + async refreshAccessToken(refreshToken) { + const { tokenUrl } = await discoverEndpoints(); + const res = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: XAI_CONFIG.clientId, + refresh_token: refreshToken, + }), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`xAI token refresh failed: ${err}`); + } + return await res.json(); + } + + /** + * Complete xAI OAuth flow end-to-end (CLI entrypoint). + * Returns the raw token response plus extracted email. + */ + async connect() { + const spinner = createSpinner("Starting xAI OAuth...").start(); + try { + spinner.text = "Discovering xAI endpoints..."; + const { authorizeUrl, tokenUrl } = await discoverEndpoints(); + + spinner.text = `Starting local server on port ${XAI_CONFIG.loopbackPort}...`; + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }, XAI_CONFIG.loopbackPort); + const redirectUri = `http://127.0.0.1:${port}${XAI_CONFIG.callbackPath}`; + spinner.succeed(`Local server started on port ${port}`); + + const codeVerifier = generateCodeVerifier(XAI_PKCE_VERIFIER_BYTES); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + const authUrl = this.buildXaiAuthUrl(redirectUri, state, codeChallenge, authorizeUrl); + + console.log("\nOpening browser for xAI authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + await open(authUrl); + + spinner.start("Waiting for xAI authorization..."); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Authentication timeout (5 minutes)")), 300000); + const iv = setInterval(() => { + if (callbackParams) { + clearInterval(iv); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + if (!callbackParams.code) throw new Error("No authorization code received"); + if (callbackParams.state !== state) throw new Error("Invalid state parameter"); + + spinner.start("Exchanging code for tokens..."); + const tokens = await this.exchangeXaiCode({ + tokenUrl, + code: callbackParams.code, + redirectUri, + codeVerifier, + }); + + const email = decodeIdTokenEmail(tokens.id_token); + spinner.succeed("xAI connected successfully!"); + return { tokens, email }; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/utils/pkce.js b/src/lib/oauth/utils/pkce.js index 260096c7..ec651650 100644 --- a/src/lib/oauth/utils/pkce.js +++ b/src/lib/oauth/utils/pkce.js @@ -2,9 +2,11 @@ import crypto from "crypto"; /** * Generate PKCE code verifier (43-128 characters) + * + * @param {number} [bytes=32] number of random bytes (xAI uses 96) */ -export function generateCodeVerifier() { - return crypto.randomBytes(32).toString("base64url"); +export function generateCodeVerifier(bytes = 32) { + return crypto.randomBytes(bytes).toString("base64url"); } /** @@ -24,8 +26,8 @@ export function generateState() { /** * Generate complete PKCE pair */ -export function generatePKCE() { - const codeVerifier = generateCodeVerifier(); +export function generatePKCE(bytes = 32) { + const codeVerifier = generateCodeVerifier(bytes); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); @@ -35,4 +37,3 @@ export function generatePKCE() { state, }; } - diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index 862bf0b7..ff5400a9 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -274,3 +274,142 @@ export function stopCodexProxy() { } } +// ─────────────────────────────────────────────────────────────────────────── +// xAI fixed-port proxy on 127.0.0.1:56121 +// Same shape as the Codex proxy. Kept as a parallel implementation rather than +// generalizing the Codex one to keep the codex hot-path byte-equivalent. +// ─────────────────────────────────────────────────────────────────────────── + +let xaiProxyServer = null; +let xaiProxyTimeout = null; +const XAI_PROXY_TIMEOUT_MS = 300000; // 5 minutes +const XAI_PROXY_PORT = 56121; +const xaiPendingExchanges = new Map(); + +export function registerXaiSession({ state, codeVerifier, redirectUri }) { + if (!state || !codeVerifier || !redirectUri) return false; + xaiPendingExchanges.set(state, { + codeVerifier, + redirectUri, + status: "pending", + createdAt: Date.now(), + }); + return true; +} + +export function getXaiSessionStatus(state) { + return xaiPendingExchanges.get(state) || null; +} + +export function clearXaiSession(state) { + xaiPendingExchanges.delete(state); +} + +function renderXaiResultPage(success, message) { + return renderCodexResultPage(success, message); +} + +/** + * Start xAI proxy on fixed port 56121. + * Mode A (server-side): if any session was registered, proxy auto-exchanges + saves DB. + * Mode B (channel fallback): if no session, proxy 302 redirects to app port. + */ +export function startXaiProxy(appPort) { + return new Promise((resolve) => { + if (xaiProxyServer) { + resolve({ success: true }); + return; + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + const session = state ? xaiPendingExchanges.get(state) : null; + + // Mode A: server-side exchange + if (session) { + try { + if (errorParam) { + throw new Error(url.searchParams.get("error_description") || errorParam); + } + if (!code) throw new Error("No authorization code received"); + + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + + const tokenData = await exchangeTokens( + "xai", + code, + session.redirectUri, + session.codeVerifier, + state + ); + const connection = await createProviderConnection({ + provider: "xai", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXaiResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXaiResultPage(false, err.message)); + } finally { + stopXaiProxy(); + } + return; + } + + // Mode B: legacy fallback redirect + const redirectUrl = `http://localhost:${appPort}/callback${url.search}`; + res.writeHead(302, { Location: redirectUrl }); + res.end(); + stopXaiProxy(); + }); + + server.listen(XAI_PROXY_PORT, "127.0.0.1", () => { + xaiProxyServer = server; + xaiProxyTimeout = setTimeout(() => stopXaiProxy(), XAI_PROXY_TIMEOUT_MS); + resolve({ success: true }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + resolve({ success: false, reason: "port_busy" }); + } else { + resolve({ success: false, reason: err.message }); + } + }); + }); +} + +export function stopXaiProxy() { + if (xaiProxyTimeout) { + clearTimeout(xaiProxyTimeout); + xaiProxyTimeout = null; + } + if (xaiProxyServer) { + xaiProxyServer.close(); + xaiProxyServer = null; + } +} + diff --git a/src/lib/providerNormalization.js b/src/lib/providerNormalization.js index 2c2d9de6..8eb2a7e3 100644 --- a/src/lib/providerNormalization.js +++ b/src/lib/providerNormalization.js @@ -1,5 +1,14 @@ import { AI_PROVIDERS } from "../shared/constants/providers.js"; +/** + * Detect xAI Grok models by id pattern (grok-*, Grok_*, etc). + * @param {string} modelId + * @returns {boolean} + */ +export function isXaiModel(modelId) { + return typeof modelId === "string" && /^grok[-_]/i.test(modelId.trim()); +} + export function normalizeProviderId(provider) { if (typeof provider !== "string") return provider; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 87dcd64c..751bc065 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -66,6 +66,25 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } }, [authData, provider, onSuccess]); + const completeXaiManualCode = useCallback(async (code) => { + if (!authData?.state) return; + try { + const res = await fetch("/api/oauth/xai/manual-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, state: authData.state }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + + setStep("success"); + onSuccess?.(); + } catch (err) { + setError(err.message); + setStep("error"); + } + }, [authData, onSuccess]); + // Poll for device code token const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData) => { pollingAbortRef.current = false; @@ -175,6 +194,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, let redirectUri; if (provider === "codex") { redirectUri = "http://localhost:1455/auth/callback"; + } else if (provider === "xai") { + redirectUri = "http://127.0.0.1:56121/callback"; } else { redirectUri = `http://localhost:${appPort}/callback`; } @@ -208,7 +229,30 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } } - setAuthData({ ...data, redirectUri, codexServerSide }); + // xAI: same fixed-port server-side proxy pattern as codex (port 56121) + let xaiProxyActive = false; + let xaiServerSide = false; + if (provider === "xai") { + try { + const proxyUrl = new URL(`/api/oauth/xai/start-proxy`, window.location.origin); + proxyUrl.searchParams.set("app_port", appPort); + proxyUrl.searchParams.set("state", data.state); + proxyUrl.searchParams.set("code_verifier", data.codeVerifier); + proxyUrl.searchParams.set("redirect_uri", redirectUri); + const proxyRes = await fetch(proxyUrl.toString()); + const proxyData = await proxyRes.json(); + xaiProxyActive = proxyData.success; + xaiServerSide = !!proxyData.serverSide; + if (!xaiProxyActive && proxyData.reason === "port_busy") { + throw new Error("Port 56121 in use; close the conflicting process and retry"); + } + } catch (e) { + if (e?.message) throw e; + xaiProxyActive = false; + } + } + + setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide }); if (provider === "codex" && codexProxyActive) { // Proxy active: callback will be handled server-side (auto-exchange) or via channels (fallback) @@ -217,12 +261,18 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (!popupRef.current) { setStep("input"); } - } else if (!isLocalhost || provider === "codex") { + } else if (provider === "xai" && xaiProxyActive) { + setStep("waiting"); + popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); + if (!popupRef.current) { + setStep("input"); + } + } else if (!isLocalhost || provider === "codex" || provider === "xai") { // Non-localhost or proxy failed: manual input mode setStep("input"); window.open(data.authUrl, "_blank"); } else { - // Localhost (non-Codex): Open popup and wait for message + // Localhost (non-Codex/xAI): Open popup and wait for message setStep("waiting"); popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); if (!popupRef.current) { @@ -251,13 +301,16 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, pollingAbortRef.current = true; if (provider === "codex") { fetch("/api/oauth/codex/stop-proxy").catch(() => {}); + } else if (provider === "xai") { + fetch("/api/oauth/xai/stop-proxy").catch(() => {}); } } }, [isOpen, provider, startOAuthFlow]); - // Codex server-side mode: poll status (proxy auto-exchanges + saves DB) + // Fixed-port server-side mode: poll status (proxy auto-exchanges + saves DB) useEffect(() => { - if (!authData?.codexServerSide || !authData?.state) return; + const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null; + if (!pollProvider || !authData?.state) return; if (callbackProcessedRef.current) return; let cancelled = false; const POLL_INTERVAL_MS = 1500; @@ -268,7 +321,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (cancelled || callbackProcessedRef.current) return; attempts += 1; try { - const res = await fetch(`/api/oauth/codex/poll-status?state=${encodeURIComponent(authData.state)}`); + const res = await fetch(`/api/oauth/${pollProvider}/poll-status?state=${encodeURIComponent(authData.state)}`); const data = await res.json(); if (cancelled || callbackProcessedRef.current) return; if (data.status === "done") { @@ -392,6 +445,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return; } + if (provider === "xai" && input && !input.includes("://") && !input.includes("?") && !input.includes("code=")) { + await completeXaiManualCode(input); + return; + } + const url = new URL(input); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); @@ -402,7 +460,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } if (!code) { - throw new Error("No authorization code found in URL"); + throw new Error(provider === "xai" ? "Paste the callback URL or copied xAI code" : "No authorization code found in URL"); } await exchangeTokens(code, state); @@ -416,15 +474,22 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const handleClose = useCallback(() => { if (provider === "codex") { fetch("/api/oauth/codex/stop-proxy").catch(() => {}); + } else if (provider === "xai") { + fetch("/api/oauth/xai/stop-proxy").catch(() => {}); } onClose(); }, [onClose, provider]); if (!provider || !providerInfo) return null; + const isXaiProvider = provider === "xai"; const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || ""; + const modalTitle = isXaiProvider ? "Connect Grok Build OAuth" : `Connect ${providerInfo.name}`; + const manualPlaceholder = isXaiProvider + ? "http://127.0.0.1:56121/callback?code=... or copied code" + : placeholderUrl; return ( - +
{/* Waiting + Manual Input combined (non-device-code) */} {(step === "waiting" || step === "input") && !isDeviceCode && ( @@ -434,7 +499,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, progress_activity - Waiting for popup authorization… + + {isXaiProvider ? "Waiting for Grok Build OAuth…" : "Waiting for popup authorization…"} +
{/* Divider */} @@ -447,7 +514,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, {/* Option B: Manual paste */}
-

Step 1: Open this URL in your browser

+

+ Step 1: Open this {isXaiProvider ? "Grok Build OAuth URL" : "URL"} in your browser +

-

Step 2: Paste the callback URL here

+

+ Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : "callback URL"} here +

- After authorization, copy the full URL from your browser. + {provider === "xai" + ? "If xAI shows a code instead of redirecting, paste that code here." + : "After authorization, copy the full URL from your browser."}

setCallbackUrl(e.target.value)} - placeholder={placeholderUrl} + placeholder={manualPlaceholder} className="font-mono text-xs" />
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index ebd630fb..d8beadea 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -60,6 +60,7 @@ export const OAUTH_PROVIDERS = { codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", deprecated: true, deprecationNotice: RISK_NOTICE, thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } }, github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", deprecated: true, deprecationNotice: RISK_NOTICE, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } }, cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA", website: "https://cursor.com", notice: { signupUrl: "https://cursor.com" } }, + xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", notice: { apiKeyUrl: "https://console.x.ai", signupUrl: "https://x.ai" }, serviceKinds: ["llm", "imageToText", "webSearch", "image"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" }, authModes: ["oauth", "apikey"], hasOAuth: true }, // "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" }, kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC", website: "https://kilocode.ai", notice: { signupUrl: "https://kilocode.ai" } }, cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL", website: "https://cline.bot", notice: { signupUrl: "https://cline.bot" } }, @@ -86,7 +87,7 @@ export const APIKEY_PROVIDERS = { deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com", notice: { apiKeyUrl: "https://platform.deepseek.com/api_keys" } }, commandcode: { id: "commandcode", alias: "cmc", name: "Command Code", icon: "smart_toy", color: "#000000", textIcon: "CC", website: "https://commandcode.ai", notice: { text: "Use your CommandCode CLI API key (starts with user_...) from ~/.commandcode/auth.json or commandcode.ai/studio.", apiKeyUrl: "https://commandcode.ai/studio" } }, groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", notice: { apiKeyUrl: "https://console.groq.com/keys" }, serviceKinds: ["llm", "imageToText", "stt"], sttConfig: { baseUrl: "https://api.groq.com/openai/v1/audio/transcriptions", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "whisper-large-v3", name: "Whisper Large v3" }, { id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo" }, { id: "distil-whisper-large-v3-en", name: "Distil Whisper Large v3 EN" }] } }, - xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", notice: { apiKeyUrl: "https://console.x.ai" }, serviceKinds: ["llm", "imageToText", "webSearch"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" } }, + xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", notice: { apiKeyUrl: "https://console.x.ai" }, serviceKinds: ["llm", "imageToText", "webSearch", "image"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" }, authModes: ["oauth", "apikey"], hasOAuth: true }, mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", notice: { apiKeyUrl: "https://console.mistral.ai/api-keys" }, serviceKinds: ["llm", "imageToText", "embedding"], embeddingConfig: { baseUrl: "https://api.mistral.ai/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "mistral-embed", name: "Mistral Embed", dimensions: 1024 }] } }, perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", notice: { apiKeyUrl: "https://www.perplexity.ai/settings/api" }, serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.perplexity.ai/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } }, together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai", notice: { apiKeyUrl: "https://api.together.xyz/settings/api-keys" }, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://api.together.xyz/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "BAAI/bge-large-en-v1.5", name: "BGE Large EN v1.5", dimensions: 1024 }, { id: "togethercomputer/m2-bert-80M-8k-retrieval", name: "M2 BERT 80M 8K", dimensions: 768 }] } }, diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 6b6bbd76..ba3146c3 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -159,6 +159,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}); return { + authType: connection.authType, apiKey: connection.apiKey, accessToken: connection.accessToken, refreshToken: connection.refreshToken, diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index b6e88381..83bd7d7c 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -93,6 +93,13 @@ function toExpiresAt(expiresIn) { return new Date(Date.now() + expiresIn * 1000).toISOString(); } +function normalizeExpiresAt(expiresAt) { + if (!expiresAt) return null; + const date = new Date(expiresAt); + if (!Number.isFinite(date.getTime())) return null; + return date.toISOString(); +} + /** * Providers that carry a real Google project ID. * @param {string} provider @@ -154,6 +161,12 @@ export async function updateProviderCredentials(connectionId, newCredentials) { if (newCredentials.expiresIn) { updates.expiresAt = toExpiresAt(newCredentials.expiresIn); updates.expiresIn = newCredentials.expiresIn; + } else if (newCredentials.expiresAt) { + const expiresAt = normalizeExpiresAt(newCredentials.expiresAt); + if (expiresAt) { + updates.expiresAt = expiresAt; + updates.expiresIn = Math.max(1, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000)); + } } if (newCredentials.providerSpecificData) { updates.providerSpecificData = { @@ -224,7 +237,7 @@ export async function checkAndRefreshToken(provider, credentials) { : creds.providerSpecificData, expiresAt: newCreds.expiresIn ? toExpiresAt(newCreds.expiresIn) - : creds.expiresAt, + : normalizeExpiresAt(newCreds.expiresAt) || creds.expiresAt, }; // Non-blocking: refresh projectId with the new access token diff --git a/tests/unit/xai-oauth-service.test.js b/tests/unit/xai-oauth-service.test.js new file mode 100644 index 00000000..83f3b4d4 --- /dev/null +++ b/tests/unit/xai-oauth-service.test.js @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("xai/oauth service", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + }); + + it("validates discovered endpoints are https x.ai URLs", async () => { + const { validateOAuthEndpoint } = await import("../../src/lib/oauth/services/xai.js"); + + expect(validateOAuthEndpoint("https://auth.x.ai/oauth2/authorize", "authorization_endpoint")).toBe( + "https://auth.x.ai/oauth2/authorize" + ); + expect(() => validateOAuthEndpoint("http://auth.x.ai/oauth2/authorize", "authorization_endpoint")).toThrow( + /must use https/ + ); + expect(() => validateOAuthEndpoint("https://example.com/oauth2/authorize", "authorization_endpoint")).toThrow( + /is not on x\.ai/ + ); + }); + + it("discovers endpoints without custom user-agent headers", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + authorization_endpoint: "https://auth.x.ai/oauth2/authorize", + token_endpoint: "https://auth.x.ai/oauth2/token", + }), + }); + + const { discoverEndpoints } = await import("../../src/lib/oauth/services/xai.js"); + await expect(discoverEndpoints()).resolves.toEqual({ + authorizeUrl: "https://auth.x.ai/oauth2/authorize", + tokenUrl: "https://auth.x.ai/oauth2/token", + }); + expect(fetch).toHaveBeenCalledWith( + "https://auth.x.ai/.well-known/openid-configuration", + expect.objectContaining({ headers: { Accept: "application/json" } }) + ); + }); + + it("builds authorize URLs with CLIProxyAPI query extras", async () => { + const { XaiService } = await import("../../src/lib/oauth/services/xai.js"); + const authUrl = new XaiService().buildXaiAuthUrl( + "http://127.0.0.1:56121/callback", + "state-1", + "challenge-1", + "https://auth.x.ai/oauth2/authorize" + ); + const parsed = new URL(authUrl); + + expect(parsed.origin + parsed.pathname).toBe("https://auth.x.ai/oauth2/authorize"); + expect(parsed.searchParams.get("response_type")).toBe("code"); + expect(parsed.searchParams.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828"); + expect(parsed.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback"); + expect(parsed.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); + expect(parsed.searchParams.get("state")).toBe("state-1"); + expect(parsed.searchParams.get("nonce")).toMatch(/^[a-f0-9]{32}$/); + expect(parsed.searchParams.get("plan")).toBe("generic"); + expect(parsed.searchParams.get("referrer")).toBe("cli-proxy-api"); + }); + + it("generates dashboard auth data with CLIProxyAPI PKCE size and discovered endpoints", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + authorization_endpoint: "https://auth.x.ai/oauth2/authorize-from-discovery", + token_endpoint: "https://auth.x.ai/oauth2/token-from-discovery", + }), + }); + + const { generateAuthData } = await import("../../src/lib/oauth/providers.js"); + const data = await generateAuthData("xai", "http://127.0.0.1:56121/callback"); + const parsed = new URL(data.authUrl); + + expect(data.codeVerifier).toHaveLength(128); + expect(parsed.origin + parsed.pathname).toBe("https://auth.x.ai/oauth2/authorize-from-discovery"); + expect(parsed.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback"); + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); + expect(parsed.searchParams.get("plan")).toBe("generic"); + expect(parsed.searchParams.get("referrer")).toBe("cli-proxy-api"); + }); + + it("exchanges dashboard codes against the discovered xAI token endpoint", async () => { + const fetchMock = fetch; + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + authorization_endpoint: "https://auth.x.ai/oauth2/authorize", + token_endpoint: "https://auth.x.ai/oauth2/token-from-discovery", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }), + }); + + const { exchangeTokens } = await import("../../src/lib/oauth/providers.js"); + const tokens = await exchangeTokens( + "xai", + "auth-code", + "http://127.0.0.1:56121/callback", + "verifier-1", + "state-1" + ); + + expect(fetchMock.mock.calls[1][0]).toBe("https://auth.x.ai/oauth2/token-from-discovery"); + expect(fetchMock.mock.calls[1][1].body.get("grant_type")).toBe("authorization_code"); + expect(fetchMock.mock.calls[1][1].body.get("code")).toBe("auth-code"); + expect(fetchMock.mock.calls[1][1].body.get("code_verifier")).toBe("verifier-1"); + expect(tokens).toMatchObject({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresIn: 3600, + }); + }); +}); diff --git a/tests/unit/xai-tokenRefresh.test.js b/tests/unit/xai-tokenRefresh.test.js new file mode 100644 index 00000000..48cb2781 --- /dev/null +++ b/tests/unit/xai-tokenRefresh.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; + +// We can't easily import the open-sse switch logic without real PROVIDERS config, +// so verify the wrapper function shape directly via dynamic import. + +describe("xai/token-refresh wrapper", () => { + it("refreshXaiToken module loads without throwing", async () => { + // Just verify the file imports cleanly. The actual wrapper is internal. + const mod = await import("../../open-sse/services/tokenRefresh.js"); + expect(typeof mod.refreshTokenByProvider).toBe("function"); + expect(typeof mod.formatProviderCredentials).toBe("function"); + }); + + it("formatProviderCredentials returns Bearer-shape for xai", async () => { + const mod = await import("../../open-sse/services/tokenRefresh.js"); + const out = mod.formatProviderCredentials( + "xai", + { apiKey: "k", accessToken: "t", refreshToken: "r" }, + null + ); + expect(out).toEqual({ apiKey: "k", accessToken: "t" }); + }); + + it("refreshTokenByProvider returns null when refreshToken missing", async () => { + const mod = await import("../../open-sse/services/tokenRefresh.js"); + const out = await mod.refreshTokenByProvider("xai", { refreshToken: "" }, null); + expect(out).toBeNull(); + }); + + it("refreshTokenByProvider returns expiresIn for refreshed xai tokens", async () => { + vi.resetModules(); + vi.doMock("../../src/lib/oauth/services/xai.js", () => ({ + XaiService: class { + async refreshAccessToken(refreshToken) { + return { + access_token: "new-access", + refresh_token: `${refreshToken}-rotated`, + expires_in: 900, + id_token: "id-token", + }; + } + }, + })); + + const mod = await import("../../open-sse/services/tokenRefresh.js"); + const out = await mod.refreshTokenByProvider( + "xai", + { refreshToken: "old-refresh" }, + null + ); + + expect(out).toEqual({ + accessToken: "new-access", + refreshToken: "old-refresh-rotated", + expiresIn: 900, + idToken: "id-token", + }); + expect(out).not.toHaveProperty("expiresAt"); + + vi.doUnmock("../../src/lib/oauth/services/xai.js"); + vi.resetModules(); + }); +});