Add Kimchi as a browser-token OAuth provider routed through its OpenAI-compatible gateway. Discover live models for /v1/models and provider models, normalize Claude-compatible requests, and wire up provider connection tests. Co-authored-by: Cursor <cursoragent@cursor.com>
343 lines
12 KiB
JavaScript
343 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-cn", "qoder"];
|
|
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,
|
|
// Prefer the verifier the provider's requestDeviceCode generated for
|
|
// itself (qoder rolls its own PKCE pair); fall back to the generic one.
|
|
codeVerifier: 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. Kimchi returns a browser token.
|
|
const noPkceExchangeProviders = ["cline", "kimchi"];
|
|
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-cn"];
|
|
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 if (provider === "qoder") {
|
|
// Qoder needs both the PKCE verifier (codeVerifier) and the machineId
|
|
// captured at device-code time (extraData._qoderMachineId) so
|
|
// mapTokens can persist it for COSY signing.
|
|
if (!codeVerifier) {
|
|
return NextResponse.json({ error: "Missing code verifier" }, { status: 400 });
|
|
}
|
|
result = await pollForToken(provider, deviceCode, codeVerifier, 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 });
|
|
}
|
|
}
|