9router / src /lib /oauth /services /xai.js
2api
feat: configurable stream stall timeout + per-provider UI
88c4c60
Raw
History Blame Contribute Delete
7.6 kB
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;
}
}
}