9router/src/app/api/oauth/[provider]/[action]/route.js
Muhammad Mugni Hadi d976f4cc87 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 <cursoragent@cursor.com>
2026-05-21 11:33:18 +07:00

333 lines
12 KiB
JavaScript

import { NextResponse } from "next/server";
import {
getProvider,
generateAuthData,
exchangeTokens,
requestDeviceCode,
pollForToken
} from "@/lib/oauth/providers";
import { createProviderConnection } from "@/models";
import {
startCodexProxy,
stopCodexProxy,
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
*/
// GET /api/oauth/[provider]/authorize - Generate auth URL
// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow)
export async function GET(request, { params }) {
try {
const { provider, action } = await params;
const { searchParams } = new URL(request.url);
if (action === "authorize") {
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
// Collect provider-specific meta params (e.g. gitlab passes baseUrl, clientId, clientSecret)
const reservedParams = new Set(["redirect_uri"]);
const meta = {};
searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; });
const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined);
return NextResponse.json(authData);
}
if (action === "start-proxy") {
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 });
}
const state = searchParams.get("state");
const codeVerifier = searchParams.get("code_verifier");
const redirectUri = searchParams.get("redirect_uri");
const result = provider === "xai"
? await startXaiProxy(Number(appPort))
: await startCodexProxy(Number(appPort));
let serverSide = false;
if (result.success && state && codeVerifier && redirectUri) {
serverSide = provider === "xai"
? registerXaiSession({ state, codeVerifier, redirectUri })
: registerCodexSession({ state, codeVerifier, redirectUri });
}
return NextResponse.json({ ...result, serverSide });
}
if (action === "poll-status") {
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 = provider === "xai" ? getXaiSessionStatus(state) : getCodexSessionStatus(state);
if (!session) return NextResponse.json({ status: "unknown" });
if (session.status === "done" || session.status === "error") {
const payload = { ...session };
if (provider === "xai") clearXaiSession(state);
else clearCodexSession(state);
return NextResponse.json(payload);
}
return NextResponse.json({ status: session.status });
}
if (action === "stop-proxy") {
if (!["codex", "xai"].includes(provider)) {
return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 });
}
if (provider === "xai") stopXaiProxy();
else stopCodexProxy();
return NextResponse.json({ success: true });
}
if (action === "device-code") {
const providerData = getProvider(provider);
if (providerData.flowType !== "device_code") {
return NextResponse.json({ error: "Provider does not support device code flow" }, { status: 400 });
}
const authData = await generateAuthData(provider, null);
const startUrl = searchParams.get("start_url");
const region = searchParams.get("region");
const authMethod = searchParams.get("auth_method");
const deviceOptions = provider === "kiro"
? {
...(startUrl ? { startUrl } : {}),
...(region ? { region } : {}),
...(authMethod ? { authMethod } : {}),
}
: undefined;
// Providers that don't use PKCE for device code
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy"];
let deviceData;
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
} else {
// Qwen and other PKCE providers
deviceData = await requestDeviceCode(provider, authData.codeChallenge, deviceOptions);
}
return NextResponse.json({
...deviceData,
codeVerifier: authData.codeVerifier,
});
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
console.log("OAuth GET error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
// POST /api/oauth/[provider]/exchange - Exchange code for tokens and save
// POST /api/oauth/[provider]/poll - Poll for token (device_code flow)
export async function POST(request, { params }) {
try {
const { provider, action } = await params;
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 });
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state, meta } = body;
// Detect if "code" is actually a raw JWT access token (starts with eyJ)
if (code && code.startsWith("eyJ") && code.includes(".")) {
const { extractCodexAccountInfo } = await import("@/lib/oauth/providers");
const info = extractCodexAccountInfo(code);
// Also decode JWT directly for ChatGPT website tokens which use
// top-level account_id/plan_type instead of nested openai auth claims
let directPayload = {};
try {
const b64 = code.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
directPayload = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
} catch {}
const accountId = info.chatgptAccountId || directPayload.account_id;
const planType = info.chatgptPlanType || directPayload.plan_type;
const email = info.email || directPayload.email;
const providerSpecificData = { authMethod: "access_token" };
if (accountId) providerSpecificData.chatgptAccountId = accountId;
if (planType) providerSpecificData.chatgptPlanType = planType;
const connection = await createProviderConnection({
provider,
authType: "access_token",
accessToken: code,
email: email || null,
providerSpecificData,
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
}
});
}
// Cline uses authorization_code without PKCE
const noPkceExchangeProviders = ["cline"];
if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl)
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, meta);
// Save to database
const connection = await createProviderConnection({
provider,
authType: "oauth",
...tokenData,
expiresAt: tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
: null,
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
}
});
}
if (action === "poll") {
const { deviceCode, codeVerifier, extraData } = body;
if (!deviceCode) {
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
}
// Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy"];
let result;
if (noPkceProviders.includes(provider)) {
result = await pollForToken(provider, deviceCode);
} else if (provider === "kiro") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await pollForToken(provider, deviceCode, null, extraData);
} else {
// Qwen and other PKCE providers
if (!codeVerifier) {
return NextResponse.json({ error: "Missing code verifier" }, { status: 400 });
}
result = await pollForToken(provider, deviceCode, codeVerifier);
}
if (result.success) {
// Save to database
const connection = await createProviderConnection({
provider,
authType: "oauth",
...result.tokens,
expiresAt: result.tokens.expiresIn
? new Date(Date.now() + result.tokens.expiresIn * 1000).toISOString()
: null,
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
}
});
}
// Still pending or error - don't create connection for pending states
const isPending = result.pending || result.error === "authorization_pending" || result.error === "slow_down";
return NextResponse.json({
success: false,
error: result.error,
errorDescription: result.errorDescription,
pending: isPending,
});
}
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);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}