Ctrl K
ComunidadGuías…

utils/xaiOAuth.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Pure-stdlib (fetch) xAI/Grok OAuth: device-code login + refresh.
 *
 * Lives here (not in a CLI module) so the Next.js server side can import it
 * via pullfrog/internal without dragging in node:child_process. Used by:
 *   - action/commands/auth.ts   (`pullfrog auth grok` device-code login)
 *   - action/utils/codexHome.ts (materialize into opencode's auth.json)
 *   - utils/xaiSecretRotation.ts (server-side rotation at run-context)
 *
 * We talk to xAI's OAuth endpoints directly rather than shelling out to the
 * Grok CLI the way `auth codex` shells out to `codex login`. The Grok CLI is
 * a curl|bash install that nothing else in the flow needs, and the device
 * grant is ~60 lines of fetch — requiring a second CLI to obtain a
 * credential opencode consumes natively would be gratuitous.
 *
 * See wiki/grok-auth.md.
 */

import { OAuthInvalidGrantError, parseOAuthErrorBody } from "./oauthShared.ts";

export interface XaiAuthBody {
  auth_mode: "grok";
  tokens: {
    access_token: string;
    refresh_token: string;
  };
  last_refresh?: string;
  /**
   * ISO timestamp of an `invalid_grant` rejection. xAI rotates the refresh
   * token on every use, so a rejection is PERMANENT until the user re-runs
   * `pullfrog auth grok`. Without the latch the server re-issues the same
   * doomed refresh on every run, each holding a Postgres row lock across an
   * external call — the failure mode measured for Codex in
   * [#1101](https://github.com/pullfrog/app/issues/1101). Cleared implicitly:
   * `pullfrog auth grok` and `PUT /api/runtime/secret` write a fresh blob
   * without it, which re-arms rotation.
   */
  refresh_rejected_at?: string;
}

/** Public Grok-CLI OAuth client. Identical to the `oidc_client_id` the Grok
 * CLI writes into its own `~/.grok/auth.json` and to opencode's XaiAuthPlugin
 * CLIENT_ID — one chain, so a token minted here refreshes anywhere. */
export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
export const XAI_OAUTH_TOKEN_URL = "https://auth.x.ai/oauth2/token";
export const XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
export const XAI_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
/** `api:access` is the scope that lets the subscription token authorize
 * api.x.ai — without it the credential logs in but cannot infer. */
export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";

interface OAuthTokenResponse {
  access_token: string;
  refresh_token: string;
  expires_in?: number;
}

/** xAI speaks RFC 6749 here: a spent or revoked refresh token answers
 * `400 {"error":"invalid_grant"}`. Measured against auth.x.ai with negative
 * controls — a bad `grant_type` answers `unsupported_grant_type` and an
 * unknown client answers `401 invalid_client`, neither of which says anything
 * about the token itself. */
function xaiChainIsDead(body: string): boolean {
  return parseOAuthErrorBody(body)?.error === "invalid_grant";
}

/** force one refresh round-trip against xAI. returns the rotated blob; does
 * NOT persist — the caller writes it back wherever the token lives.
 *
 * The 10s timeout matters server-side: `maybeRotateXaiSecret` holds a DB row
 * lock across this call, so the cap keeps us inside the enclosing transaction
 * budget and guarantees queued callers get a turn. Real latency is sub-second. */
export async function refreshXaiAuthBody(body: XaiAuthBody): Promise<XaiAuthBody> {
  const response = await fetch(XAI_OAUTH_TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: body.tokens.refresh_token,
      client_id: XAI_OAUTH_CLIENT_ID,
    }).toString(),
    signal: AbortSignal.timeout(10_000),
  });
  if (!response.ok) {
    const text = await response.text().catch(() => "");
    if (response.status >= 400 && response.status < 500) {
      throw new OAuthInvalidGrantError("Grok", response.status, text, xaiChainIsDead(text));
    }
    throw new Error(`Grok token refresh failed: ${response.status} ${text}`);
  }
  const tokens = (await response.json()) as OAuthTokenResponse;
  return {
    auth_mode: "grok",
    tokens: {
      access_token: tokens.access_token,
      // xAI rotates on every use, but tolerate a server that echoes nothing
      // rather than writing an empty refresh token that bricks the chain.
      refresh_token: tokens.refresh_token || body.tokens.refresh_token,
    },
    last_refresh: new Date().toISOString(),
  };
}

/** parse + validate a stored Grok blob. returns null on any shape mismatch —
 * caller treats null as "no grok auth". */
export function parseXaiAuthBody(raw: string): XaiAuthBody | null {
  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return null;
  }
  if (!parsed || typeof parsed !== "object") return null;
  const v = parsed as Record<string, unknown>;
  if (v.auth_mode !== "grok") return null;
  const tokens = v.tokens;
  if (!tokens || typeof tokens !== "object") return null;
  const t = tokens as Record<string, unknown>;
  if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
  if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
  return {
    auth_mode: "grok",
    ...(typeof v.refresh_rejected_at === "string"
      ? { refresh_rejected_at: v.refresh_rejected_at }
      : {}),
    tokens: { access_token: t.access_token, refresh_token: t.refresh_token },
    ...(typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}),
  };
}

/** serialize to the canonical stored form. */
export function stringifyXaiAuthBody(body: XaiAuthBody): string {
  return `${JSON.stringify(body, null, 2)}\n`;
}

export interface XaiDeviceCode {
  deviceCode: string;
  userCode: string;
  /** pre-filled URL when xAI supplies one, else the bare verification URL. */
  verificationUrl: string;
  intervalMs: number;
  expiresAtMs: number;
}

/** RFC 8628 step 1: ask xAI for a device code. The user approves in a browser
 * on any device — no loopback callback server, so this works unchanged from a
 * container, an SSH session, or a locked-down workstation. */
export async function startXaiDeviceAuth(): Promise<XaiDeviceCode> {
  const response = await fetch(XAI_DEVICE_CODE_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: XAI_OAUTH_CLIENT_ID,
      scope: XAI_OAUTH_SCOPE,
    }).toString(),
    signal: AbortSignal.timeout(15_000),
  });
  if (!response.ok) {
    const text = await response.text().catch(() => "");
    throw new Error(`Grok device authorization failed: ${response.status} ${text}`);
  }
  const body = (await response.json()) as {
    device_code: string;
    user_code: string;
    verification_uri?: string;
    verification_uri_complete?: string;
    interval?: number;
    expires_in?: number;
  };
  return {
    deviceCode: body.device_code,
    userCode: body.user_code,
    verificationUrl:
      body.verification_uri_complete || body.verification_uri || "https://x.ai/device",
    intervalMs: Math.max((body.interval ?? 5) * 1000, 1_000),
    expiresAtMs: Date.now() + (body.expires_in ?? 300) * 1000,
  };
}

/** RFC 8628 step 2: long-poll the token endpoint until the user approves.
 * Honors the spec's `authorization_pending` / `slow_down` back-off. Resolves
 * with the minted chain, or throws on denial / expiry. */
export async function pollXaiDeviceAuth(device: XaiDeviceCode): Promise<XaiAuthBody> {
  let intervalMs = device.intervalMs;
  while (Date.now() < device.expiresAtMs) {
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
    const response = await fetch(XAI_OAUTH_TOKEN_URL, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: XAI_DEVICE_CODE_GRANT_TYPE,
        device_code: device.deviceCode,
        client_id: XAI_OAUTH_CLIENT_ID,
      }).toString(),
      signal: AbortSignal.timeout(15_000),
    });
    if (response.ok) {
      const tokens = (await response.json()) as OAuthTokenResponse;
      return {
        auth_mode: "grok",
        tokens: {
          access_token: tokens.access_token,
          refresh_token: tokens.refresh_token,
        },
        last_refresh: new Date().toISOString(),
      };
    }
    const body = (await response.json().catch(() => ({}))) as { error?: string };
    if (body.error === "authorization_pending") continue;
    if (body.error === "slow_down") {
      intervalMs += 5_000;
      continue;
    }
    if (body.error === "access_denied" || body.error === "authorization_denied") {
      throw new Error("Grok sign-in was denied.");
    }
    if (body.error === "expired_token") break;
    throw new Error(`Grok device authorization failed: ${response.status} ${body.error ?? ""}`);
  }
  throw new Error("Grok sign-in timed out — re-run the command and approve the code sooner.");
}