feat(oauth): zed/trae/windsurf providers + harden callback proxies

- zed live model discovery; codebuddy-intl handler; remove duplicate workbuddy
- split oauth providers.js into per-provider files (facade re-export)
- fold 5 standard refresh providers into config-driven generic
- hide trae/windsurf from registry (no tool calling support)
- fix login-CSRF + SSRF on trae/windsurf/zed local callback proxies
  via loopback-origin guard + strict state validation + apiOrigins allowlist
- move zed RSA private key transit to POST body; redact proxy logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
decolua
2026-07-25 17:25:19 +07:00
parent 783e271c16
commit 8e04fe1734
46 changed files with 4569 additions and 2203 deletions

View File

@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import {
getProvider,
generateAuthData,
exchangeTokens,
requestDeviceCode,
pollForToken
import {
getProvider,
generateAuthData,
exchangeTokens,
requestDeviceCode,
pollForToken
} from "@/lib/oauth/providers";
import { createProviderConnection } from "@/models";
import {
@@ -18,7 +18,24 @@ import {
registerXaiSession,
getXaiSessionStatus,
clearXaiSession,
startTraeProxy,
stopTraeProxy,
registerTraeSession,
getTraeSessionStatus,
clearTraeSession,
startWindsurfProxy,
stopWindsurfProxy,
registerWindsurfSession,
getWindsurfSessionStatus,
clearWindsurfSession,
startZedProxy,
stopZedProxy,
registerZedSession,
getZedSessionStatus,
clearZedSession,
} from "@/lib/oauth/utils/server";
import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect";
import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth";
async function completeXaiManualCode(code, state) {
const session = state ? getXaiSessionStatus(state) : null;
@@ -77,13 +94,34 @@ 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; });
// Zed: derive native_app_port from the local callback URL so the RSA keypair
// is bound to the port the proxy is actually listening on.
if (provider === "zed") {
try { const p = new URL(redirectUri).port; if (p) meta.nativeAppPort = p; } catch { /* ignore */ }
}
const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined);
return NextResponse.json(authData);
}
if (action === "start-proxy") {
// Trae/Windsurf/Zed use a dynamic-port local callback server (singleton session,
// state is registered separately via /register-session after /authorize).
if (provider === "trae") {
const result = await startTraeProxy();
return NextResponse.json(result);
}
if (provider === "windsurf") {
const result = await startWindsurfProxy();
return NextResponse.json(result);
}
if (provider === "zed") {
// Prefer ZED_HOSTED_CONFIG.defaultNativeAppPort (58443) so the browser redirect
// matches what Zed expects; falls back to a random port if it's busy.
const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort);
return NextResponse.json(result);
}
if (!["codex", "xai"].includes(provider)) {
return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 });
return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
}
const appPort = searchParams.get("app_port");
if (!appPort) {
@@ -105,18 +143,24 @@ export async function GET(request, { params }) {
}
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);
let session;
if (provider === "trae") session = getTraeSessionStatus(state);
else if (provider === "windsurf") session = getWindsurfSessionStatus(state);
else if (provider === "zed") session = getZedSessionStatus(state);
else if (provider === "xai") session = getXaiSessionStatus(state);
else if (provider === "codex") session = getCodexSessionStatus(state);
else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
if (!session) return NextResponse.json({ status: "unknown" });
if (session.status === "done" || session.status === "error") {
const payload = { ...session };
if (provider === "xai") clearXaiSession(state);
if (provider === "trae") clearTraeSession(state);
else if (provider === "windsurf") clearWindsurfSession(state);
else if (provider === "zed") clearZedSession(state);
else if (provider === "xai") clearXaiSession(state);
else clearCodexSession(state);
return NextResponse.json(payload);
}
@@ -124,14 +168,24 @@ export async function GET(request, { params }) {
}
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();
if (provider === "trae") stopTraeProxy();
else if (provider === "windsurf") stopWindsurfProxy();
else if (provider === "zed") stopZedProxy();
else if (provider === "xai") stopXaiProxy();
else if (provider === "codex") stopCodexProxy();
else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
return NextResponse.json({ success: true });
}
if (action === "ide-status") {
// Detect whether the IDE is installed locally (used by import-token UX).
if (provider !== "trae" && provider !== "windsurf") {
return NextResponse.json({ error: "ide-status only supported for trae/windsurf" }, { status: 400 });
}
const status = await detectIdeInstalled(provider);
return NextResponse.json(status);
}
if (action === "device-code") {
const providerData = getProvider(provider);
if (providerData.flowType !== "device_code") {
@@ -158,6 +212,7 @@ export async function GET(request, { params }) {
"kimi-coding",
"kilocode",
"codebuddy-cn",
"codebuddy-intl",
"qoder",
"grok-cli",
];
@@ -196,9 +251,54 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 });
}
if (action === "register-session") {
// Register proxy session out of URL query (state) + body (codeVerifier).
// Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs.
const state = searchParams.get("state") || body?.state;
if (!state) return NextResponse.json({ error: "Missing state" }, { status: 400 });
let ok = false;
if (provider === "trae") ok = registerTraeSession({ state });
else if (provider === "windsurf") ok = registerWindsurfSession({ state });
else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier });
else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 });
return NextResponse.json({ success: ok });
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state, meta } = body;
// Trae/Windsurf: code is either a raw callback URL or a pasted token.
// exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction.
if (provider === "trae" || provider === "windsurf") {
const token = typeof code === "string" ? code.trim() : "";
if (!token) {
return NextResponse.json({ error: "Missing token or callback URL" }, { status: 400 });
}
try {
const tokenData = await exchangeTokens(provider, token, null, null, state);
const connection = await createProviderConnection({
provider,
authType: provider === "windsurf" ? "api_key" : "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,
}
});
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// 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");
@@ -280,7 +380,7 @@ export async function POST(request, { params }) {
}
// Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn"];
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn", "codebuddy-intl"];
let result;
if (noPkceProviders.includes(provider)) {
// kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity)

View File

@@ -14,6 +14,7 @@ import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
import { resolveZedModels } from "open-sse/shared/zedAuth.js";
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
@@ -104,7 +105,23 @@ const LIVE_MODEL_RESOLVERS = {
providerSpecificData: conn.providerSpecificData || {},
}, { log: console });
return result?.models?.length ? { models: result.models } : null;
}
},
zed: async (conn) => {
const result = await resolveZedModels({
accessToken: conn.accessToken,
providerSpecificData: conn.providerSpecificData || {},
});
if (!result?.models?.length) return null;
return {
models: result.models
.filter((m) => !m.isDisabled)
.map((m) => ({
id: m.id,
name: m.name,
capabilities: m.supportsTools ? { tools: true } : undefined,
})),
};
},
};
const parseOpenAIStyleModels = (data) => {

View File

@@ -117,6 +117,9 @@ export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] };
// CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow)
export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] };
// CodeBuddy International — same shape as CN, .ai domain (mirror of codebuddy-cn).
export const CODEBUDDY_INTL_CONFIG = { ...PROVIDER_OAUTH["codebuddy-intl"] };
// Kimchi OAuth Configuration (Browser token callback flow)
export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
@@ -124,6 +127,77 @@ export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes
export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
// Trae (ByteDance marscode) OAuth — authorization_code flow with local callback.
// 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost}
// 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb}
// 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true
// 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt}
// 5) POST GetUserInfo (x-cloudide-token) → email/name
export const TRAE_CONFIG = {
clientId: "ono9krqynydwx5",
clientSecret: "-",
loginGuidanceUrls: [
"https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance",
"https://api.trae.ai/cloudide/api/v3/trae/GetLoginGuidance",
"https://www.trae.ai/cloudide/api/v3/trae/GetLoginGuidance",
],
apiOrigins: [
"https://api.marscode.com",
"https://api.trae.ai",
"https://www.trae.ai",
"https://www.marscode.com",
],
exchangeTokenPath: "/cloudide/api/v3/trae/oauth/ExchangeToken",
getUserInfoPath: "/cloudide/api/v3/trae/GetUserInfo",
authorizationPath: "/authorization",
callbackPath: "/callback",
minAppVersion: "3.5.54",
defaultAppVersion: "3.5.54",
defaultAppType: "stable",
defaultPluginVersion: "local",
// service machine id is derived at runtime; device_id "0" is the stable default
defaultDeviceId: "0",
userAgent: "Trae/1.0.0 antigravity-cockpit-tools",
webUrl: "https://www.trae.ai",
authScheme: "Cloud-IDE-JWT",
tokenLifetimeDays: 14,
oauthTimeoutMs: 600_000,
};
// Windsurf / Devin CLI OAuth — authorization_code (implicit) flow with local callback.
// 1) Browser opens windsurf.com/windsurf/signin?response_type=token&client_id=...&redirect_uri=${cb}
// 2) Redirect → ${cb}?access_token=${firebaseJWT}&state=...
// 3) POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name}
// 4) POST GetOneTimeAuthToken → GetCurrentUser (best-effort email/plan)
export const WINDSURF_CONFIG = {
clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u",
authBaseUrl: "https://www.windsurf.com",
signInPath: "/windsurf/signin",
registerApiBaseUrl: "https://register.windsurf.com",
registerPath: "/exa.seat_management_pb.SeatManagementService/RegisterUser",
oneTimeAuthPath: "/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken",
currentUserPath: "/exa.seat_management_pb.SeatManagementService/GetCurrentUser",
planStatusPath: "/exa.seat_management_pb.SeatManagementService/GetPlanStatus",
userStatusPath: "/exa.seat_management_pb.SeatManagementService/GetUserStatus",
defaultApiServerUrl: "https://server.codeium.com",
firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY",
callbackPath: "/windsurf-auth-callback",
userAgent: "antigravity-cockpit-tools",
oauthTimeoutMs: 600_000,
};
// Zed hosted LLM aggregator — RSA keypair native-app auth (NOT OAuth).
// Client generates ephemeral RSA-2048 keypair; user signs in at zed.dev/native_app_signin;
// Zed redirects to local callback with access_token RSA-encrypted against our public key.
// See open-sse/shared/zedAuth.js for the keypair/decrypt helpers.
export const ZED_HOSTED_CONFIG = {
webBaseUrl: "https://zed.dev",
cloudBaseUrl: "https://cloud.zed.dev",
llmBaseUrl: "https://cloud.zed.dev",
defaultNativeAppPort: 58443,
oauthTimeoutMs: 600_000,
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -147,6 +221,10 @@ export const PROVIDERS = {
CLINEPASS: "clinepass",
GITLAB: "gitlab",
CODEBUDDY: "codebuddy-cn",
CODEBUDDY_INTL: "codebuddy-intl",
KIMCHI: "kimchi",
GROK_CLI: "grok-cli",
TRAE: "trae",
WINDSURF: "windsurf",
ZED: "zed",
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
// Shared helpers used across provider entry files (currently trae + windsurf).
export function extractJsonPath(root, paths) {
for (const path of paths) {
let cur = root;
for (const key of path) {
if (cur == null || typeof cur !== "object") { cur = undefined; break; }
cur = cur[key];
}
if (typeof cur === "string" && cur.trim()) return cur.trim();
if (typeof cur === "number") return String(cur);
}
return null;
}

View File

@@ -0,0 +1,122 @@
import { ANTIGRAVITY_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js";
const antigravity = {
config: ANTIGRAVITY_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
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,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Numeric enums matching Antigravity binary ClientMetadata
const loadHeaders = {
"Authorization": `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
"x-request-source": "local",
};
const metadata = getOAuthClientMetadata();
// Fetch user info
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"x-request-source": "local",
},
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Load Code Assist to get project ID and tier
let projectId = "";
let tierId = "legacy-tier";
try {
const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
method: "POST",
headers: loadHeaders,
body: JSON.stringify({ metadata }),
});
if (loadRes.ok) {
const data = await loadRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
if (Array.isArray(data.allowedTiers)) {
for (const tier of data.allowedTiers) {
if (tier.isDefault && tier.id) {
tierId = tier.id.trim();
break;
}
}
}
}
} catch (e) {
console.log("Failed to load code assist:", e);
}
// Fire-and-forget onboarding — does not block DB save
if (projectId) {
const doOnboard = async () => {
for (let i = 0; i < 10; i++) {
try {
const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, {
method: "POST",
headers: loadHeaders,
body: JSON.stringify({ tierId, metadata }),
});
if (onboardRes.ok) {
const result = await onboardRes.json();
if (result.done === true) break;
}
} catch (e) {
break;
}
await new Promise(resolve => setTimeout(resolve, 5000));
}
};
doOnboard().catch(() => {});
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
};
export default antigravity;

View File

@@ -0,0 +1,60 @@
import { CLAUDE_CONFIG } from "../constants/oauth.js";
const claude = {
config: CLAUDE_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = new URLSearchParams({
code: "true",
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
state: state,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
// Parse code - may contain state after #
let authCode = code;
let codeState = "";
if (authCode.includes("#")) {
const parts = authCode.split("#");
authCode = parts[0];
codeState = parts[1] || "";
}
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code: authCode,
state: codeState || state,
grant_type: "authorization_code",
client_id: config.clientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
}),
};
export default claude;

View File

@@ -0,0 +1,62 @@
import { CLINE_CONFIG } from "../constants/oauth.js";
const cline = {
config: CLINE_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri) => {
const params = new URLSearchParams({
client_type: "extension",
callback_url: redirectUri,
redirect_uri: redirectUri,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
try {
// Cline encodes token data as base64 in the code param
let base64 = code;
const padding = 4 - (base64.length % 4);
if (padding !== 4) base64 += "=".repeat(padding);
const decoded = Buffer.from(base64, "base64").toString("utf-8");
const lastBrace = decoded.lastIndexOf("}");
if (lastBrace === -1) throw new Error("No JSON found in decoded code");
const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1));
return {
access_token: tokenData.accessToken,
refresh_token: tokenData.refreshToken,
email: tokenData.email,
firstName: tokenData.firstName,
lastName: tokenData.lastName,
expires_at: tokenData.expiresAt,
};
} catch (e) {
const response = await fetch(config.tokenExchangeUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cline token exchange failed: ${error}`);
}
const data = await response.json();
return {
access_token: data.data?.accessToken || data.accessToken,
refresh_token: data.data?.refreshToken || data.refreshToken,
email: data.data?.userInfo?.email || "",
expires_at: data.data?.expiresAt || data.expiresAt,
};
}
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_at
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
: 3600,
email: tokens.email,
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
}),
};
export default cline;

View File

@@ -0,0 +1,62 @@
import { CLINEPASS_CONFIG } from "../constants/oauth.js";
const clinepass = {
config: CLINEPASS_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri) => {
const params = new URLSearchParams({
client_type: "extension",
callback_url: redirectUri,
redirect_uri: redirectUri,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
try {
// Cline encodes token data as base64 in the code param
let base64 = code;
const padding = 4 - (base64.length % 4);
if (padding !== 4) base64 += "=".repeat(padding);
const decoded = Buffer.from(base64, "base64").toString("utf-8");
const lastBrace = decoded.lastIndexOf("}");
if (lastBrace === -1) throw new Error("No JSON found in decoded code");
const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1));
return {
access_token: tokenData.accessToken,
refresh_token: tokenData.refreshToken,
email: tokenData.email,
firstName: tokenData.firstName,
lastName: tokenData.lastName,
expires_at: tokenData.expiresAt,
};
} catch (e) {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`ClinePass token exchange failed: ${error}`);
}
const data = await response.json();
return {
access_token: data.data?.accessToken || data.accessToken,
refresh_token: data.data?.refreshToken || data.refreshToken,
email: data.data?.userInfo?.email || "",
expires_at: data.data?.expiresAt || data.expiresAt,
};
}
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_at
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
: 3600,
email: tokens.email,
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
}),
};
export default clinepass;

View File

@@ -0,0 +1,80 @@
import { CODEBUDDY_CONFIG } from "../constants/oauth.js";
// CodeBuddy (Tencent) - Browser OAuth Polling Flow
// 1. POST stateUrl → get { state, authUrl }
// 2. Open authUrl in browser
// 3. Poll tokenUrl with state until success (code 0) or timeout
const codebuddyCn = {
config: CODEBUDDY_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "copilot.tencent.com",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-Product": "SaaS",
},
body: "{}",
});
if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`);
const data = await response.json();
if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) {
throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`);
}
return {
device_code: data.data.state,
verification_uri: data.data.authUrl,
user_code: "",
interval: config.pollInterval / 1000,
_isCodeBuddy: true,
};
},
pollToken: async (config, deviceCode) => {
// CodeBuddy polls the token endpoint via GET with the state as a query
// param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=...
const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "copilot.tencent.com",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
"X-No-Department-Info": "true",
"X-Product": "SaaS",
},
});
if (!response.ok) return { ok: false, data: { error: "request_failed" } };
const data = await response.json();
// code 11217 = pending (RetryFetchToken), code 0 = success
if (data.code === 0 && data.data?.accessToken) {
return {
ok: true,
data: {
access_token: data.data.accessToken,
refresh_token: data.data.refreshToken || "",
token_type: data.data.tokenType || "Bearer",
expires_in: data.data.expiresIn,
},
};
}
if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } };
return { ok: false, data: { error: data.msg || "unknown_error" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in || 86400,
providerSpecificData: {},
}),
};
export default codebuddyCn;

View File

@@ -0,0 +1,74 @@
import { CODEBUDDY_INTL_CONFIG } from "../constants/oauth.js";
// CodeBuddy International — mirrors codebuddy-cn flow against the .ai domain.
const codebuddyIntl = {
config: CODEBUDDY_INTL_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "www.codebuddy.ai",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-Product": "SaaS",
},
body: "{}",
});
if (!response.ok) throw new Error(`CodeBuddy Intl state request failed: ${await response.text()}`);
const data = await response.json();
if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) {
throw new Error(`CodeBuddy Intl state error: ${data.msg || "missing state/authUrl"}`);
}
return {
device_code: data.data.state,
verification_uri: data.data.authUrl,
user_code: "",
interval: config.pollInterval / 1000,
_isCodeBuddy: true,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "www.codebuddy.ai",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
"X-No-Department-Info": "true",
"X-Product": "SaaS",
},
});
if (!response.ok) return { ok: false, data: { error: "request_failed" } };
const data = await response.json();
if (data.code === 0 && data.data?.accessToken) {
return {
ok: true,
data: {
access_token: data.data.accessToken,
refresh_token: data.data.refreshToken || "",
token_type: data.data.tokenType || "Bearer",
expires_in: data.data.expiresIn,
},
};
}
if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } };
return { ok: false, data: { error: data.msg || "unknown_error" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in || 86400,
providerSpecificData: {},
}),
};
export default codebuddyIntl;

View File

@@ -0,0 +1,69 @@
import { CODEX_CONFIG } from "../constants/oauth.js";
import { extractCodexAccountInfo, extractEmailFromAccessToken } from "../providerHelpers.js";
const codex = {
config: CODEX_CONFIG,
flowType: "authorization_code_pkce",
fixedPort: CODEX_CONFIG.fixedPort,
callbackPath: CODEX_CONFIG.callbackPath,
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = {
response_type: "code",
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
...config.extraParams,
state: state,
};
const queryString = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&");
return `${config.authorizeUrl}?${queryString}`;
},
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: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => {
const info = extractCodexAccountInfo(tokens.id_token);
const mapped = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
lastRefreshAt: new Date().toISOString(),
};
const email = info.email || extractEmailFromAccessToken(tokens.access_token);
if (email) mapped.email = email;
if (info.chatgptAccountId || info.chatgptPlanType) {
mapped.providerSpecificData = {
chatgptAccountId: info.chatgptAccountId,
chatgptPlanType: info.chatgptPlanType,
};
}
return mapped;
},
};
export default codex;

View File

@@ -0,0 +1,19 @@
import { CURSOR_CONFIG } from "../constants/oauth.js";
const cursor = {
config: CURSOR_CONFIG,
flowType: "import_token",
// Cursor uses import token flow - tokens are extracted from local SQLite database
// No OAuth flow needed, handled by /api/oauth/cursor/import route
mapTokens: (tokens) => ({
accessToken: tokens.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresIn: tokens.expiresIn || 86400,
providerSpecificData: {
machineId: tokens.machineId,
authMethod: "imported",
},
}),
};
export default cursor;

View File

@@ -0,0 +1,85 @@
import { GEMINI_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js";
const geminiCli = {
config: GEMINI_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
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,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Fetch user info
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Fetch project ID
let projectId = "";
try {
const projectRes = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: getOAuthClientMetadata(),
mode: 1,
}),
}
);
if (projectRes.ok) {
const data = await projectRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
}
} catch (e) {
console.log("Failed to fetch project ID:", e);
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
};
export default geminiCli;

View File

@@ -0,0 +1,98 @@
import { GITHUB_CONFIG } from "../constants/oauth.js";
const github = {
config: GITHUB_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scopes,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
// Handle response properly - if not ok, try to get error as text first
let data;
try {
data = await response.json();
} catch (e) {
// If response is not JSON, get as text
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return {
ok: response.ok,
data: data,
};
},
postExchange: async (tokens) => {
// Get Copilot token using GitHub access token
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
// Get user info from GitHub
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const userInfo = userRes.ok ? await userRes.json() : {};
return { copilotToken, userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
name: extra?.userInfo?.login || extra?.userInfo?.name,
displayName: extra?.userInfo?.name || extra?.userInfo?.login,
email: extra?.userInfo?.email || null,
providerSpecificData: {
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
githubUserId: extra?.userInfo?.id,
githubLogin: extra?.userInfo?.login,
githubName: extra?.userInfo?.name,
githubEmail: extra?.userInfo?.email,
},
}),
};
export default github;

View File

@@ -0,0 +1,64 @@
import { GITLAB_CONFIG } from "../constants/oauth.js";
// GitLab Duo - Authorization Code Flow with PKCE
// Supports two login modes via loginMode metadata: "oauth" (default) or "pat"
const gitlab = {
config: GITLAB_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => {
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
const clientId = meta.clientId || "";
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
state,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
});
return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => {
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
const clientId = meta.clientId || "";
const clientSecret = meta.clientSecret || "";
const body = new URLSearchParams({
client_id: clientId,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
if (clientSecret) body.set("client_secret", clientSecret);
const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: body.toString(),
});
if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`);
const tokens = await response.json();
// Fetch user info
const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const user = userRes.ok ? await userRes.json() : {};
return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
providerSpecificData: {
username: tokens._user?.username || "",
email: tokens._user?.email || tokens._user?.public_email || "",
name: tokens._user?.name || "",
baseUrl: tokens._baseUrl,
clientId: tokens._clientId,
authKind: "oauth",
},
}),
};
export default gitlab;

View File

@@ -0,0 +1,130 @@
import { GROK_CLI_CONFIG } from "../constants/oauth.js";
import { decodeXaiIdTokenEmail, extractEmailFromAccessToken } from "../providerHelpers.js";
// Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com
const grokCli = {
config: GROK_CLI_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const body = new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
});
// Official CLI sends referrer=grok-build
if (config.referrer) body.set("referrer", config.referrer);
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
},
body,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Grok CLI device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: config.clientId,
}),
});
let data;
try {
data = await response.json();
} catch {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// Device flow: 400 + authorization_pending is expected while user authorizes
const pending =
data?.error === "authorization_pending" ||
data?.error === "slow_down";
return {
ok: response.ok || pending,
data,
};
},
postExchange: async (tokens) => {
// Best-effort user profile from cli-chat-proxy (non-fatal)
try {
const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-version": "0.2.93",
},
});
if (res.ok) return { user: await res.json() };
} catch {
/* ignore */
}
return { user: null };
},
mapTokens: (tokens, extra) => {
const email =
decodeXaiIdTokenEmail(tokens.id_token) ||
extractEmailFromAccessToken(tokens.access_token) ||
extra?.user?.email ||
null;
const userId =
extra?.user?.userId ||
extra?.user?.principalId ||
null;
const displayName = [extra?.user?.firstName, extra?.user?.lastName]
.filter(Boolean)
.join(" ")
.trim() || null;
const expiresAt = tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
: null;
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
// Surface an absolute expiry so the proactive refresh path
// (shouldRefreshCredentials / checkAndRefreshToken) can refresh the
// xAI token before it silently expires ~40-45 min after login.
// Without this, only the reactive 401 path in chatCore would refresh,
// causing intermittent "token expired" failures for Grok CLI.
expiresAt,
scope: tokens.scope,
// Top-level for dashboard connection cards
email: email || undefined,
displayName: displayName || undefined,
// Mirror identity into providerSpecificData so GrokCliExecutor can set
// x-email / x-userid without depending on top-level credential shape.
providerSpecificData: {
authMethod: "device_code",
idToken: tokens.id_token || null,
email: email || null,
userId,
hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null,
subscriptionTier: extra?.user?.subscriptionTier ?? null,
},
};
},
};
export default grokCli;

View File

@@ -0,0 +1,91 @@
import { IFLOW_CONFIG } from "../constants/oauth.js";
const iflow = {
config: IFLOW_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
loginMethod: config.extraParams.loginMethod,
type: config.extraParams.type,
redirect: redirectUri,
state: state,
client_id: config.clientId,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
// Create Basic Auth header
const basicAuth = Buffer.from(
`${config.clientId}:${config.clientSecret}`
).toString("base64");
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Fetch user info (MUST succeed to get API key)
const userInfoRes = await fetch(
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
{
headers: {
Accept: "application/json",
},
}
);
if (!userInfoRes.ok) {
const errorText = await userInfoRes.text();
throw new Error(`Failed to fetch user info: ${errorText}`);
}
const result = await userInfoRes.json();
if (!result.success) {
throw new Error(`User info request failed: ${result.message || 'Unknown error'}`);
}
const userInfo = result.data || {};
// Validate API key (critical for iFlow)
if (!userInfo.apiKey || userInfo.apiKey.trim() === "") {
throw new Error("Empty API key returned from iFlow");
}
// Validate email/phone
const email = userInfo.email?.trim() || userInfo.phone?.trim();
if (!email) {
throw new Error("Missing account email/phone in user info");
}
return { userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
apiKey: extra?.userInfo?.apiKey,
email: extra?.userInfo?.email || extra?.userInfo?.phone,
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
}),
};
export default iflow;

View File

@@ -0,0 +1,241 @@
// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime
import "open-sse/index.js";
import { generatePKCE } from "../utils/pkce.js";
import { extractCodexAccountInfo, fetchKiroProfileArn } from "../providerHelpers.js";
import claude from "./claude.js";
import codex from "./codex.js";
import xai from "./xai.js";
import grokCli from "./grok-cli.js";
import geminiCli from "./gemini-cli.js";
import antigravity from "./antigravity.js";
import iflow from "./iflow.js";
import qoder from "./qoder.js";
import qwen from "./qwen.js";
import github from "./github.js";
import kiro from "./kiro.js";
import cursor from "./cursor.js";
import kimi from "./kimi.js";
import kilocode from "./kilocode.js";
import cline from "./cline.js";
import clinepass from "./clinepass.js";
import gitlab from "./gitlab.js";
import codebuddyCn from "./codebuddy-cn.js";
import codebuddyIntl from "./codebuddy-intl.js";
import kimchi from "./kimchi.js";
import trae from "./trae.js";
import windsurf from "./windsurf.js";
import zed from "./zed.js";
// Provider configurations
const PROVIDERS = {
claude,
codex,
xai,
"grok-cli": grokCli,
"gemini-cli": geminiCli,
antigravity,
iflow,
qoder,
qwen,
github,
kiro,
cursor,
kimi,
kilocode,
cline,
clinepass,
gitlab,
"codebuddy-cn": codebuddyCn,
"codebuddy-intl": codebuddyIntl,
kimchi,
trae,
windsurf,
zed,
};
export { PROVIDERS };
// Re-export helpers that other files import from this path
export { extractCodexAccountInfo, fetchKiroProfileArn };
/**
* Get provider handler
*/
export function getProvider(name) {
// Legacy kimi-coding → kimi (dual-auth merge)
const key = name === "kimi-coding" ? "kimi" : name;
const provider = PROVIDERS[key];
if (!provider) {
throw new Error(`Unknown provider: ${name}`);
}
return provider;
}
/**
* Get all provider names
*/
export function getProviderNames() {
return Object.keys(PROVIDERS);
}
/**
* Generate auth data for a provider
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
*/
export async function generateAuthData(providerName, redirectUri, meta) {
const provider = getProvider(providerName);
const config = provider.prepareConfig
? await provider.prepareConfig(provider.config, meta || {})
: provider.config;
const { codeVerifier: pkceVerifier, codeChallenge, state: pkceState } = generatePKCE(provider.pkceVerifierBytes);
// Trae uses loginTraceID (set by prepareConfig) as the callback matcher, not PKCE state.
const state = config.loginTraceID || pkceState;
// Zed: codeVerifier carries the encoded RSA private key (from prepareConfig), not a PKCE verifier.
const codeVerifier = config.privateKeyVerifier || pkceVerifier;
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(config, redirectUri, state, codeChallenge, meta || {});
} else {
authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {});
}
return {
authUrl,
state,
codeVerifier,
codeChallenge,
redirectUri,
flowType: provider.flowType,
fixedPort: provider.fixedPort,
callbackPath: provider.callbackPath || "/callback",
};
}
/**
* Exchange code for tokens
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
*/
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(config, code, redirectUri, codeVerifier, state, meta || {});
let extra = null;
if (provider.postExchange) {
extra = await provider.postExchange(tokens);
}
return provider.mapTokens(tokens, extra);
}
/**
* Request device code (for device_code flow)
*/
export async function requestDeviceCode(providerName, codeChallenge, options) {
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
return await provider.requestDeviceCode(provider.config, codeChallenge, options || {});
}
/**
* Poll for token (for device_code flow)
* @param {string} providerName - Provider name
* @param {string} deviceCode - Device code from requestDeviceCode
* @param {string} codeVerifier - PKCE code verifier (optional for some providers)
* @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro)
*/
export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) {
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData);
if (result.ok) {
// For device code flows, success is only when we have an access token
if (result.data.access_token) {
// Call postExchange to get additional data (copilotToken, userInfo, etc.)
let extra = null;
if (provider.postExchange) {
extra = await provider.postExchange(result.data);
}
const tokens = provider.mapTokens(result.data, extra);
// Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403
if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) {
const profileArn = await fetchKiroProfileArn(tokens.accessToken);
if (profileArn) tokens.providerSpecificData.profileArn = profileArn;
}
return { success: true, tokens };
} else {
// Check if it's still pending authorization
if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') {
// This is not a failure, just still waiting
return {
success: false,
error: result.data.error,
errorDescription: result.data.error_description || result.data.message,
pending: result.data.error === 'authorization_pending'
};
} else {
// Actual error
return {
success: false,
error: result.data.error || 'no_access_token',
errorDescription: result.data.error_description || result.data.message || 'No access token received'
};
}
}
}
return { success: false, error: result.data.error, errorDescription: result.data.error_description };
}
// Run-once guard across the process lifetime
let codexBackfillDone = false;
// Backfill email + chatgpt account info for existing codex OAuth connections missing them
export async function backfillCodexEmails() {
if (codexBackfillDone) return;
codexBackfillDone = true;
try {
const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb");
const connections = await getProviderConnections();
const targets = connections.filter((c) => {
if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false;
const hasEmail = !!c.email;
const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId;
return !hasEmail || !hasAccountInfo;
});
for (const conn of targets) {
const info = extractCodexAccountInfo(conn.idToken);
if (!info.email && !info.chatgptAccountId) continue;
const patch = {};
if (!conn.email && info.email) patch.email = info.email;
if (info.chatgptAccountId || info.chatgptPlanType) {
patch.providerSpecificData = {
...(conn.providerSpecificData || {}),
chatgptAccountId: info.chatgptAccountId,
chatgptPlanType: info.chatgptPlanType,
};
}
if (Object.keys(patch).length) {
await updateProviderConnection(conn.id, patch);
}
}
} catch (err) {
codexBackfillDone = false;
console.log("backfillCodexEmails failed:", err?.message || err);
}
}

View File

@@ -0,0 +1,60 @@
import { KILOCODE_CONFIG } from "../constants/oauth.js";
const kilocode = {
config: KILOCODE_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.initiateUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
if (response.status === 429) {
throw new Error("Too many pending authorization requests. Please try again later.");
}
const error = await response.text();
throw new Error(`Device auth initiation failed: ${error}`);
}
const data = await response.json();
return {
device_code: data.code,
user_code: data.code,
verification_uri: data.verificationUrl,
verification_uri_complete: data.verificationUrl,
expires_in: data.expiresIn || 300,
interval: 3,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(`${config.pollUrlBase}/${deviceCode}`);
if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } };
if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } };
if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } };
if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } };
const data = await response.json();
if (data.status === "approved" && data.token) {
// Fetch profile to get orgId for X-Kilocode-OrganizationID header
let orgId = null;
try {
const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, {
headers: { "Authorization": `Bearer ${data.token}` }
});
if (profileRes.ok) {
const profile = await profileRes.json();
orgId = profile.organizations?.[0]?.id || null;
}
} catch {}
return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } };
}
return { ok: false, data: { error: "authorization_pending" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: null,
expiresIn: null,
email: tokens._userEmail,
...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}),
}),
};
export default kilocode;

View File

@@ -0,0 +1,75 @@
import { KIMCHI_CONFIG } from "../constants/oauth.js";
const kimchi = {
config: KIMCHI_CONFIG,
flowType: "browser_token",
buildAuthUrl: (config, redirectUri, state) => {
const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, "");
const params = new URLSearchParams({
callback: redirectUri,
state,
});
return `${baseUrl}/cli-auth?${params.toString()}`;
},
exchangeToken: async (config, token) => {
const accessToken = String(token || "").trim();
if (!accessToken) {
throw new Error("Missing Kimchi token");
}
const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers";
const validationRes = await fetch(validationUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!validationRes.ok) {
throw new Error(`Kimchi token validation failed: ${validationRes.status}`);
}
let userInfo = {};
if (config.userInfoUrl) {
try {
const userRes = await fetch(config.userInfoUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (userRes.ok) {
userInfo = await userRes.json();
}
} catch {
userInfo = {};
}
}
return {
access_token: accessToken,
token_type: "Bearer",
_kimchiUser: userInfo,
};
},
mapTokens: (tokens) => {
const user = tokens._kimchiUser || {};
const userId = user.id ? String(user.id) : "";
const username = user.username || "";
const email = user.email || (userId ? `kimchi-user-${userId}` : null);
return {
accessToken: tokens.access_token,
refreshToken: null,
email,
displayName: user.name || username || null,
providerSpecificData: {
authMethod: "browser_token",
userId,
username,
},
};
},
};
export default kimchi;

View File

@@ -0,0 +1,81 @@
import crypto from "crypto";
import { KIMI_CONFIG } from "../constants/oauth.js";
// Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`;
// `kimi-coding` remains an alias key so old UI/API routes still resolve.
const kimi = {
config: KIMI_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const { buildKimiHeaders } = await import("open-sse/config/appConstants.js");
const deviceId = crypto.randomUUID();
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
...buildKimiHeaders(deviceId),
};
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers,
body: new URLSearchParams({ client_id: config.clientId }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
const data = await response.json();
const authorizeDeviceUrl = config.authorizeDeviceUrl || "https://www.kimi.com/code/authorize_device";
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri || authorizeDeviceUrl,
verification_uri_complete:
data.verification_uri_complete ||
`${authorizeDeviceUrl}?user_code=${data.user_code}`,
expires_in: data.expires_in,
interval: data.interval || 5,
_kimiDeviceId: deviceId,
};
},
pollToken: async (config, deviceCode, _codeVerifier, extraData) => {
const { buildKimiHeaders } = await import("open-sse/config/appConstants.js");
const deviceId = extraData?._kimiDeviceId;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
...buildKimiHeaders(deviceId),
};
const response = await fetch(config.tokenUrl, {
method: "POST",
headers,
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
}),
});
let data;
try {
data = await response.json();
} catch {
data = { error: "invalid_response", error_description: "non-json token response" };
}
// CLIProxyAPI: Kimi returns 200 for pending states with error field
if (data.error === "authorization_pending" || data.error === "slow_down") {
return { ok: true, data };
}
if (data.access_token && deviceId) data._kimiDeviceId = deviceId;
return { ok: response.ok || !!data.access_token || !!data.error, data };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
authMethod: "device_code",
...(tokens._kimiDeviceId ? { deviceId: tokens._kimiDeviceId } : {}),
},
}),
};
export default kimi;

View File

@@ -0,0 +1,151 @@
import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth.js";
import { extractEmailFromAccessToken } from "../providerHelpers.js";
const kiro = {
config: KIRO_CONFIG,
flowType: "device_code",
// Kiro uses AWS SSO OIDC - requires client registration first
requestDeviceCode: async (config, codeChallenge, options = {}) => {
const trimmedRegion = typeof options.region === "string" ? options.region.trim() : "";
const region = trimmedRegion || "us-east-1";
assertValidAwsRegion(region);
const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : "";
const startUrl = trimmedStartUrl || config.startUrl;
const authMethod = options.authMethod === "idc" ? "idc" : "builder-id";
const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`;
const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`;
// Step 1: Register client with AWS SSO OIDC
const registerRes = await fetch(registerClientUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
issuerUrl: config.issuerUrl,
}),
});
if (!registerRes.ok) {
const error = await registerRes.text();
throw new Error(`Client registration failed: ${error}`);
}
const clientInfo = await registerRes.json();
// Step 2: Request device authorization
const deviceRes = await fetch(deviceAuthUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
startUrl,
}),
});
if (!deviceRes.ok) {
const error = await deviceRes.text();
throw new Error(`Device authorization failed: ${error}`);
}
const deviceData = await deviceRes.json();
// Return combined data for polling
return {
device_code: deviceData.deviceCode,
user_code: deviceData.userCode,
verification_uri: deviceData.verificationUri,
verification_uri_complete: deviceData.verificationUriComplete,
expires_in: deviceData.expiresIn,
interval: deviceData.interval || 5,
// Store client credentials for token exchange
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
_region: region,
_authMethod: authMethod,
_startUrl: startUrl,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const region = extraData?._region || "us-east-1";
assertValidAwsRegion(region);
const tokenUrl = `https://oidc.${region}.amazonaws.com/token`;
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: extraData?._clientId,
clientSecret: extraData?._clientSecret,
deviceCode: deviceCode,
grantType: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// AWS SSO OIDC returns camelCase
if (data.accessToken) {
return {
ok: true,
data: {
access_token: data.accessToken,
refresh_token: data.refreshToken,
expires_in: data.expiresIn,
profile_arn: data?.profileArn || null,
// Store client credentials for refresh
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
_region: extraData?._region,
_authMethod: extraData?._authMethod,
_startUrl: extraData?._startUrl,
},
};
}
return {
ok: false,
data: {
error: data.error || "authorization_pending",
error_description: data.error_description || data.message,
},
};
},
mapTokens: (tokens) => {
const email = extractEmailFromAccessToken(tokens.access_token);
const mapped = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
email,
providerSpecificData: {
profileArn: tokens?.profile_arn || null,
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
region: tokens._region || "us-east-1",
authMethod: tokens._authMethod || "builder-id",
startUrl: tokens._startUrl || KIRO_CONFIG.startUrl,
},
};
return mapped;
},
};
export default kiro;

View File

@@ -0,0 +1,102 @@
import { QODER_CONFIG } from "../constants/oauth.js";
const qoder = {
config: QODER_CONFIG,
flowType: "device_code",
// Qoder uses a custom device flow: PKCE + nonce + machine_id are generated
// locally, the user lands on qoder.com/device/selectAccounts in the
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears.
requestDeviceCode: async (config) => {
const { QoderService } = await import("@/lib/oauth/services/qoder");
const flow = new QoderService().initiateDeviceFlow();
// Match the device_code shape the rest of the OAuthModal expects
// (device_code, user_code, verification_uri[_complete], interval).
// The poll endpoint identifies us by nonce+verifier, not by a
// server-issued device_code, so we plumb our own values through:
// device_code = nonce (modal forwards as deviceCode on poll)
// codeVerifier = our PKCE verifier (route forwards as codeVerifier)
return {
device_code: flow.nonce,
user_code: flow.nonce.slice(0, 8).toUpperCase(),
verification_uri: config.loginUrl,
verification_uri_complete: flow.verificationUriComplete,
expires_in: 300,
interval: 2,
codeVerifier: flow.codeVerifier,
_qoderNonce: flow.nonce,
_qoderMachineId: flow.machineId,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const { QoderService } = await import("@/lib/oauth/services/qoder");
const svc = new QoderService();
const nonce = deviceCode || extraData?._qoderNonce;
const verifier = codeVerifier || extraData?._qoderVerifier;
if (!nonce || !verifier) {
return {
ok: false,
data: { error: "invalid_request", error_description: "Missing nonce/verifier" },
};
}
let result;
try {
result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier });
} catch (err) {
return {
ok: false,
data: { error: "poll_failed", error_description: err.message },
};
}
if (result.status === "pending") {
return { ok: false, data: { error: "authorization_pending" } };
}
// Best-effort profile lookup so we have a name/email to display.
const userInfo = await svc.fetchUserInfo(result.accessToken);
// expireTime is a Unix-ms timestamp from QoderService.parseExpiry,
// which already falls back to "now + 30 days" when the upstream
// omits expiry. Floor to a sane minimum (1 day) so a stale or
// skewed upstream timestamp doesn't truncate the stored token below
// something useful.
const minSeconds = 24 * 60 * 60;
const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000);
const expiresIn = Math.max(minSeconds, remainingSeconds);
return {
ok: true,
data: {
access_token: result.accessToken,
refresh_token: result.refreshToken,
expires_in: expiresIn,
_qoderUserId: result.userId,
_qoderMachineId: extraData?._qoderMachineId || "",
_qoderName: userInfo.name,
_qoderEmail: userInfo.email,
_qoderOrganizationId: userInfo.organizationId,
},
};
},
mapTokens: (tokens) => {
const rawEmail = (tokens._qoderEmail || "").trim();
const displayName = (tokens._qoderName || "").trim() || null;
const userId = tokens._qoderUserId || "";
// Dedup in createProviderConnection requires a non-empty email. When
// fetchUserInfo silently fails (returns ""), fall back to a stable
// synthetic identifier derived from userId so re-logins update the
// existing row instead of accumulating "Account N" duplicates.
const email = rawEmail || (userId ? `qoder-user-${userId}` : null);
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
email,
displayName,
providerSpecificData: {
authMethod: "device",
userId,
machineId: tokens._qoderMachineId || "",
organizationId: tokens._qoderOrganizationId || "",
},
};
},
};
export default qoder;

View File

@@ -0,0 +1,56 @@
import { QWEN_CONFIG } from "../constants/oauth.js";
const qwen = {
config: QWEN_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config, codeChallenge) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode, 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: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
};
export default qwen;

View File

@@ -0,0 +1,263 @@
import crypto from "crypto";
import { TRAE_CONFIG } from "../constants/oauth.js";
import { extractJsonPath } from "./_shared.js";
// ───────────────────────────────────────────────────────────────────────────
// Trae (ByteDance marscode) OAuth helpers
// ───────────────────────────────────────────────────────────────────────────
// Per-login device context. No IDE access in 9router, so use stable defaults.
function buildTraeDeviceContext() {
return {
plugin_version: TRAE_CONFIG.defaultPluginVersion,
machine_id: crypto.randomUUID(),
device_id: TRAE_CONFIG.defaultDeviceId,
x_device_brand: "unknown",
x_device_type: "unknown",
x_os_version: "unknown",
x_env: "",
x_app_version: TRAE_CONFIG.defaultAppVersion,
x_app_type: TRAE_CONFIG.defaultAppType,
};
}
// POST GetLoginGuidance → { Result: { LoginHost } }
async function fetchTraeLoginGuidance(loginTraceId) {
const body = JSON.stringify({ loginTraceID: loginTraceId, login_trace_id: loginTraceId });
let lastErr = "no successful response";
for (const url of TRAE_CONFIG.loginGuidanceUrls) {
try {
const res = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": TRAE_CONFIG.userAgent,
},
body,
});
if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; }
const data = await res.json();
const loginHost = extractJsonPath(data, [
["Result", "LoginHost"], ["Result", "loginHost"], ["Result", "LoginURL"],
["result", "loginHost"], ["data", "Result", "LoginHost"], ["data", "loginHost"],
["LoginHost"], ["loginHost"],
]);
if (loginHost) return loginHost;
lastErr = `${url} missing LoginHost`;
} catch (e) { lastErr = `${url} ${e.message}`; }
}
throw new Error(`Trae GetLoginGuidance failed: ${lastErr}`);
}
// Build the browser verification URL the user opens to sign in.
function buildTraeVerificationUrl(loginHost, loginTraceId, callbackUrl, ctx) {
const url = new URL(loginHost.startsWith("http") ? loginHost : `https://${loginHost.replace(/^\/+/, "")}`);
url.pathname = TRAE_CONFIG.authorizationPath;
const p = new URLSearchParams();
p.set("login_version", "1");
p.set("auth_from", "trae");
p.set("login_channel", "native_ide");
p.set("plugin_version", ctx.plugin_version);
p.set("auth_type", "local");
p.set("client_id", TRAE_CONFIG.clientId);
p.set("redirect", "0");
p.set("login_trace_id", loginTraceId);
p.set("auth_callback_url", callbackUrl);
p.set("machine_id", ctx.machine_id);
p.set("device_id", ctx.device_id);
p.set("x_device_id", ctx.device_id);
p.set("x_machine_id", ctx.machine_id);
p.set("x_device_brand", ctx.x_device_brand);
p.set("x_device_type", ctx.x_device_type);
p.set("x_os_version", ctx.x_os_version);
p.set("x_env", ctx.x_env);
p.set("x_app_version", ctx.x_app_version);
p.set("x_app_type", ctx.x_app_type);
url.search = p.toString();
return url.toString();
}
// Parse the Trae OAuth callback (query string or full URL).
// Expected: ?isRedirect=true&refreshToken=...&loginHost=...[&x-cloudide-token=...]
function parseTraeCallback(raw) {
const text = String(raw || "").trim();
let queryStr = text;
if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1);
if (text.startsWith("#")) queryStr = text.slice(1);
const params = Object.fromEntries(new URLSearchParams(queryStr));
const pick = (keys) => {
for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); }
return null;
};
const err = pick(["error", "error_code", "errorCode"]);
if (err) {
const desc = pick(["error_description", "error_desc", "message"]);
throw new Error(desc ? `Trae auth failed: ${err} (${desc})` : `Trae auth failed: ${err}`);
}
const refreshToken = pick(["refreshToken", "refresh_token", "RefreshToken"]);
if (!refreshToken) throw new Error("Trae callback missing refreshToken");
const loginHost = pick(["loginHost", "login_host", "LoginHost", "host", "consoleHost"]);
if (!loginHost) throw new Error("Trae callback missing loginHost");
const cloudideToken = pick(["x-cloudide-token", "xCloudideToken", "accessToken", "access_token", "token"]);
return { refreshToken, loginHost, cloudideToken };
}
// Allowed API origins for ExchangeToken/GetUserInfo — hardcoded HTTPS allowlist only.
// loginHost from the callback is intentionally NOT honored (SSRF guard: a callback
// attacker could otherwise point this at internal hosts/cloud metadata).
function traeApiOrigins() {
return [...TRAE_CONFIG.apiOrigins];
}
// POST ExchangeToken {ClientID, RefreshToken, ClientSecret, UserID} → {Result:{AccessToken,RefreshToken,ExpiresAt}}
async function fetchTraeExchangeToken(refreshToken, cloudideToken) {
const body = JSON.stringify({
ClientID: TRAE_CONFIG.clientId,
RefreshToken: refreshToken,
ClientSecret: TRAE_CONFIG.clientSecret,
UserID: "",
});
let lastErr = "no successful response";
for (const origin of traeApiOrigins()) {
const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.exchangeTokenPath}`;
try {
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": TRAE_CONFIG.userAgent,
};
if (cloudideToken) headers["x-cloudide-token"] = cloudideToken;
const res = await fetch(url, { method: "POST", headers, body });
const text = await res.text();
if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; }
let data; try { data = JSON.parse(text); } catch { lastErr = `${url} invalid JSON`; continue; }
const accessToken = extractJsonPath(data, [
["Result", "AccessToken"], ["Result", "accessToken"], ["result", "access_token"], ["accessToken"],
]);
if (!accessToken) {
const msg = extractJsonPath(data, [["message"], ["msg"], ["error"], ["Result", "Message"]]) || "missing AccessToken";
lastErr = `${url} ${msg}`;
continue;
}
return {
accessToken,
refreshToken: extractJsonPath(data, [["Result", "RefreshToken"], ["result", "refresh_token"], ["refreshToken"]]) || refreshToken,
expiresIn: null, // ExchangeToken returns ExpiresAt (absolute), converted below
expiresAt: extractJsonPath(data, [["Result", "ExpiresAt"], ["Result", "expiresAt"], ["result", "expires_at"], ["expiresAt"]]),
};
} catch (e) { lastErr = `${url} ${e.message}`; }
}
throw new Error(`Trae ExchangeToken failed: ${lastErr}`);
}
// POST GetUserInfo with x-cloudide-token → identity fields used by SOLO common_params.
async function fetchTraeUserInfo(accessToken) {
for (const origin of traeApiOrigins()) {
const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.getUserInfoPath}`;
try {
const res = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": TRAE_CONFIG.userAgent,
"x-cloudide-token": accessToken,
},
body: JSON.stringify({}),
});
if (!res.ok) continue;
const data = await res.json();
return {
email: extractJsonPath(data, [
["Result", "NonPlainTextEmail"], ["Result", "Email"], ["Result", "email"],
["email"], ["data", "email"],
]),
name: extractJsonPath(data, [
["Result", "ScreenName"], ["Result", "Nickname"], ["Result", "Name"],
["result", "nickname"], ["nickname"], ["name"],
]),
aiRegion: extractJsonPath(data, [["Result", "AIRegion"], ["Result", "aiRegion"], ["aiRegion"]]),
region: extractJsonPath(data, [["Result", "Region"], ["Result", "region"], ["region"]]),
tenant: extractJsonPath(data, [["Result", "TenantID"], ["Result", "tenantId"], ["tenantId"]]),
userId: extractJsonPath(data, [["Result", "UserID"], ["Result", "userId"], ["userId"]]),
};
} catch { /* try next origin */ }
}
return { email: null, name: null };
}
// Map AIRegion (e.g. "SG", "US") → SOLO scope used in common_params.
function traeScopeForRegion(aiRegion) {
const r = (aiRegion || "").toLowerCase();
if (r === "sg" || r.includes("singapore")) return "marscode-sg";
if (r === "cn" || r.includes("cn") || r.includes("china")) return "marscode-cn";
return "marscode-us";
}
// Trae — browser OAuth: GetLoginGuidance → verification URL
// → local callback (refreshToken+loginHost) → ExchangeToken → GetUserInfo.
// state === config.loginTraceID so the proxy can match the callback.
const trae = {
config: TRAE_CONFIG,
flowType: "authorization_code",
callbackPath: TRAE_CONFIG.callbackPath,
prepareConfig: async (config) => {
const loginTraceID = crypto.randomUUID();
const loginHost = await fetchTraeLoginGuidance(loginTraceID);
return { ...config, loginTraceID, loginHost };
},
buildAuthUrl: (config, redirectUri, state) => {
const ctx = buildTraeDeviceContext();
const traceId = config.loginTraceID || state;
return buildTraeVerificationUrl(config.loginHost, traceId, redirectUri, ctx);
},
exchangeToken: async (config, code) => {
const trimmed = String(code || "").trim();
// Paste-token mode: raw Cloud-IDE-JWT (no refresh exchange)
const looksCallback = /[?=&]/.test(trimmed) && (trimmed.includes("refreshToken") || trimmed.includes("refresh_token"));
if (!looksCallback) {
// Strip "Cloud-IDE-JWT " / "Bearer " prefix users paste from the Authorization header
const clean = trimmed.replace(/^(Cloud-IDE-JWT|Bearer)\s+/i, "");
return { accessToken: clean, refreshToken: null, expiresIn: TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60, _authMethod: "imported" };
}
const { refreshToken, cloudideToken } = parseTraeCallback(trimmed);
return { ...(await fetchTraeExchangeToken(refreshToken, cloudideToken)), _authMethod: "oauth" };
},
postExchange: async (tokens) => {
const userInfo = await fetchTraeUserInfo(tokens.accessToken);
return { userInfo };
},
mapTokens: (tokens, extra) => {
const expiresIn = tokens.expiresIn
|| (tokens.expiresAt ? Math.max(60, Number(tokens.expiresAt) - Math.floor(Date.now() / 1000)) : TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60);
const ui = extra?.userInfo || {};
const aiRegion = ui.aiRegion || "US-East";
// SOLO common_params defaults — identity fields web_id/biz_user_id are not
// exposed by GetUserInfo; empty strings are accepted upstream (verified).
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn,
email: ui.email || undefined,
displayName: ui.name || undefined,
providerSpecificData: {
authMethod: tokens._authMethod || "oauth",
aiRegion,
region: ui.region || aiRegion,
tenant: ui.tenant || "marscode",
userId: ui.userId || "",
scope: traeScopeForRegion(aiRegion),
webId: "",
bizUserId: "",
userUniqueId: "",
appLanguage: "en",
appVersion: TRAE_CONFIG.defaultAppVersion,
userRegion: aiRegion === "SG" ? "SG" : "US",
userIdentity: "Free",
},
};
},
};
export default trae;

View File

@@ -0,0 +1,132 @@
import { WINDSURF_CONFIG } from "../constants/oauth.js";
import { extractJsonPath } from "./_shared.js";
// ───────────────────────────────────────────────────────────────────────────
// Windsurf OAuth helpers
// ───────────────────────────────────────────────────────────────────────────
async function windsurfSeatRequest(baseUrl, path, body) {
const url = `${baseUrl.replace(/\/$/, "")}${path}`;
const res = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": WINDSURF_CONFIG.userAgent,
},
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) throw new Error(`Windsurf ${path} HTTP ${res.status}: ${text.slice(0, 200)}`);
try { return JSON.parse(text); } catch { throw new Error(`Windsurf ${path} invalid JSON`); }
}
// Parse Windsurf callback (query string or full URL): ?access_token=...&state=...
function parseWindsurfCallback(raw, expectedState) {
const text = String(raw || "").trim();
let queryStr = text;
if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1);
if (text.startsWith("#")) queryStr = text.slice(1);
const params = Object.fromEntries(new URLSearchParams(queryStr));
const pick = (keys) => {
for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); }
return null;
};
const err = pick(["error"]);
if (err) {
const desc = pick(["error_description"]);
throw new Error(desc ? `Windsurf auth failed: ${err} (${desc})` : `Windsurf auth failed: ${err}`);
}
const accessToken = pick(["access_token", "token"]);
if (!accessToken) throw new Error("Windsurf callback missing access_token");
const state = pick(["state"]);
if (expectedState && state && state !== expectedState) {
throw new Error("Windsurf callback state mismatch");
}
return { firebaseIdToken: accessToken };
}
// POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name}
async function fetchWindsurfRegisterUser(firebaseIdToken) {
const data = await windsurfSeatRequest(WINDSURF_CONFIG.registerApiBaseUrl, WINDSURF_CONFIG.registerPath, {
firebase_id_token: firebaseIdToken,
});
const apiKey = extractJsonPath(data, [["apiKey"], ["api_key"]]);
if (!apiKey) throw new Error("Windsurf RegisterUser missing apiKey");
const apiServerUrl = extractJsonPath(data, [["apiServerUrl"], ["api_server_url"]]) || WINDSURF_CONFIG.defaultApiServerUrl;
const name = extractJsonPath(data, [["name"]]);
return { apiKey, apiServerUrl, name };
}
// Best-effort: GetOneTimeAuthToken → GetCurrentUser → email/name.
async function fetchWindsurfUserInfo(apiServerUrl, firebaseIdToken) {
try {
const authRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.oneTimeAuthPath, { firebaseIdToken });
const authToken = extractJsonPath(authRes, [["authToken"], ["auth_token"]]);
if (!authToken) return { email: null, name: null };
const userRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.currentUserPath, {
authToken,
includeSubscription: true,
});
const user = userRes.user || userRes;
return {
email: extractJsonPath(user, [["email"]]),
name: extractJsonPath(user, [["name"]]),
};
} catch { return { email: null, name: null }; }
}
// Windsurf — browser OAuth: windsurf.com/signin →
// local callback (firebase JWT) → RegisterUser → apiKey (used as credential).
const windsurf = {
config: WINDSURF_CONFIG,
flowType: "authorization_code",
callbackPath: WINDSURF_CONFIG.callbackPath,
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
response_type: "token",
client_id: config.clientId,
redirect_uri: redirectUri,
state,
prompt: "login",
redirect_parameters_type: "query",
workflow: "onboarding",
});
return `${config.authBaseUrl}${config.signInPath}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
const trimmed = String(code || "").trim();
const looksCallback = trimmed.includes("?") || trimmed.includes("access_token=");
if (!looksCallback) {
// Paste-token mode: sk-ws-... apiKey OR firebase JWT (eyJ...). Strip "Bearer " if pasted.
const clean = trimmed.replace(/^Bearer\s+/i, "");
if (clean.startsWith("sk-ws-")) {
return { accessToken: clean, refreshToken: null, expiresIn: null, apiServerUrl: config.defaultApiServerUrl, firebaseIdToken: null, _authMethod: "imported" };
}
const reg = await fetchWindsurfRegisterUser(clean);
return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken: clean, _authMethod: "imported" };
}
const { firebaseIdToken } = parseWindsurfCallback(trimmed, state);
const reg = await fetchWindsurfRegisterUser(firebaseIdToken);
return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken, _authMethod: "oauth" };
},
postExchange: async (tokens) => {
if (!tokens.firebaseIdToken) return { userInfo: { email: null, name: null } };
const info = await fetchWindsurfUserInfo(tokens.apiServerUrl, tokens.firebaseIdToken);
return { userInfo: info };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.accessToken,
refreshToken: null,
expiresIn: null,
email: extra?.userInfo?.email || undefined,
displayName: extra?.userInfo?.name || undefined,
providerSpecificData: {
authMethod: tokens._authMethod || "oauth",
apiServerUrl: tokens.apiServerUrl,
firebaseIdToken: tokens.firebaseIdToken,
},
}),
};
export default windsurf;

View File

@@ -0,0 +1,96 @@
import crypto from "crypto";
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "../constants/xai.js";
import { validateXaiOAuthEndpoint, decodeXaiIdTokenEmail } from "../providerHelpers.js";
// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package
let cachedXaiDiscovery = null;
async function discoverXaiEndpoints() {
if (cachedXaiDiscovery) return cachedXaiDiscovery;
try {
const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } });
if (res.ok) {
const data = await res.json();
cachedXaiDiscovery = {
authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"),
tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"),
};
return cachedXaiDiscovery;
}
} catch { /* fall through to static fallback */ }
cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl };
return cachedXaiDiscovery;
}
const 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;
},
};
export default xai;

View File

@@ -0,0 +1,62 @@
import { ZED_HOSTED_CONFIG } from "../constants/oauth.js";
import {
createZedNativeAuthData,
parseZedCallbackPayload,
decryptZedAccessToken,
fetchZedAuthenticatedUser,
resolveZedOrganizationId,
} from "open-sse/shared/zedAuth.js";
// Zed — RSA keypair native-app flow (NOT OAuth). prepareConfig generates a fresh
// keypair; buildAuthUrl returns the native_app_signin URL; exchangeToken decrypts
// the RSA-encrypted access token from the local callback.
const zed = {
config: ZED_HOSTED_CONFIG,
flowType: "authorization_code",
callbackPath: "/",
prepareConfig: async (config, meta) => {
// native_app_port is the local callback port (passed via meta from start-proxy).
const nativeAppPort = Number(meta?.nativeAppPort) || ZED_HOSTED_CONFIG.defaultNativeAppPort;
const auth = createZedNativeAuthData(config, { nativeAppPort });
return { ...config, ...auth };
},
buildAuthUrl: (config, redirectUri, state) => config.authUrl,
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
// code = raw callback URL/query; codeVerifier = encoded private key verifier.
const { userId, encryptedAccessToken } = parseZedCallbackPayload(code);
const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier);
return { accessToken, userId, systemId: config.systemId };
},
postExchange: async (tokens) => {
const credentials = {
accessToken: tokens.accessToken,
providerSpecificData: { userId: tokens.userId, systemId: tokens.systemId },
};
let userInfo = null;
try {
userInfo = await fetchZedAuthenticatedUser(credentials, { config: ZED_HOSTED_CONFIG });
} catch { /* best-effort */ }
const organizationId = resolveZedOrganizationId(credentials, userInfo);
return {
userInfo,
organizationId,
email: userInfo?.email || null,
name: userInfo?.name || userInfo?.display_name || null,
};
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.accessToken,
refreshToken: null,
expiresIn: null,
email: extra?.email || undefined,
displayName: extra?.name || undefined,
providerSpecificData: {
authMethod: "oauth",
userId: tokens.userId,
systemId: tokens.systemId,
organizationId: extra?.organizationId || "",
},
}),
};
export default zed;

View File

@@ -0,0 +1,57 @@
import fs from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import os from "os";
const execAsync = promisify(exec);
// Install paths per provider per platform — Trae and standard Windsurf IDE locations.
const IDE_PATHS = {
trae: {
darwin: ["/Applications/Trae.app"],
win32: [
path.join(process.env.LOCALAPPDATA || "", "Programs", "Trae", "Trae.exe"),
path.join(process.env.ProgramFiles || "", "Trae", "Trae.exe"),
],
linux: ["/usr/bin/trae", "/usr/local/bin/trae", "/opt/trae", "/opt/Trae"],
},
windsurf: {
darwin: ["/Applications/Windsurf.app"],
win32: [
path.join(process.env.LOCALAPPDATA || "", "Programs", "Windsurf", "Windsurf.exe"),
path.join(process.env.ProgramFiles || "", "Windsurf", "Windsurf.exe"),
],
linux: ["/usr/bin/windsurf", "/usr/local/bin/windsurf", "/opt/windsurf", "/opt/Windsurf"],
},
};
const IDE_BINARIES = {
trae: "trae",
windsurf: "windsurf",
};
async function pathExists(p) {
try { await fs.access(p); return true; } catch { return false; }
}
async function checkBinary(bin) {
try {
const cmd = os.platform() === "win32" ? `where ${bin}` : `which ${bin}`;
await execAsync(cmd, { windowsHide: true });
return true;
} catch { return false; }
}
// Returns { installed: boolean, path: string|null } for the given provider's IDE.
export async function detectIdeInstalled(providerId) {
const platform = os.platform();
const paths = IDE_PATHS[providerId];
if (!paths) return { installed: false, path: null };
for (const p of paths[platform] || []) {
if (p && await pathExists(p)) return { installed: true, path: p };
}
const bin = IDE_BINARIES[providerId];
if (bin && await checkBinary(bin)) return { installed: true, path: bin };
return { installed: false, path: null };
}

View File

@@ -1,6 +1,16 @@
import http from "http";
import { URL } from "url";
import { CODEX_CONFIG } from "../constants/oauth.js";
import { CODEX_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, ZED_HOSTED_CONFIG } from "../constants/oauth.js";
// Loopback origin guard for local callback proxies.
// Legit OAuth redirects are top-level navigations (no `Origin` header); a cross-site
// page issuing `fetch(..., {mode:"no-cors"})` to scan + hit 127.0.0.1 always sends
// `Origin: https://attacker`. Reject any non-loopback Origin to block login-CSRF.
function isLoopbackOrigin(origin) {
if (!origin) return true; // navigation redirect — allow
return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
}
/**
* Start a local HTTP server to receive OAuth callback
@@ -424,3 +434,324 @@ export function stopXaiProxy() {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Trae dynamic-port proxy. Singleton session (one connect at a time per provider).
// Callback path = /callback with params refreshToken + loginHost.
// ───────────────────────────────────────────────────────────────────────────
let traeProxyServer = null;
let traeProxyTimeout = null;
let traeProxyPort = null;
let traeSession = null;
export function registerTraeSession({ state }) {
if (!state) return false;
traeSession = { state, status: "pending", createdAt: Date.now() };
return true;
}
export function getTraeSessionStatus(state) {
if (!traeSession) return null;
if (state && traeSession.state !== state) return null;
return traeSession;
}
export function clearTraeSession(state) {
if (!state || (traeSession && traeSession.state === state)) traeSession = null;
}
export function startTraeProxy() {
return new Promise((resolve) => {
if (traeProxyServer) {
resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` });
return;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== TRAE_CONFIG.callbackPath && url.pathname !== "/auth/callback") {
res.writeHead(404);
res.end("Not found");
return;
}
const session = traeSession;
if (!session) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "No active Trae login session"));
return;
}
// Anti-CSRF: reject cross-origin fetches (legit redirects send no Origin),
// and reject state mismatch when state is present.
if (!isLoopbackOrigin(req.headers.origin)) {
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "Cross-origin callback rejected"));
return;
}
const cbState = url.searchParams.get("state");
if (cbState && session.state && cbState !== session.state) {
session.status = "error";
session.error = "Trae callback state mismatch";
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, session.error));
stopTraeProxy();
return;
}
// Pass the raw callback query to exchangeTokens → parseTraeCallback
const rawCallback = `${url.pathname}?${url.searchParams.toString()}`;
try {
const { exchangeTokens } = await import("../providers.js");
const { createProviderConnection } = await import("@/models");
const tokenData = await exchangeTokens("trae", rawCallback);
const connection = await createProviderConnection({
provider: "trae",
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(renderCodexResultPage(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(renderCodexResultPage(false, err.message));
} finally {
stopTraeProxy();
}
});
server.listen(0, "127.0.0.1", () => {
traeProxyServer = server;
traeProxyPort = server.address().port;
traeProxyTimeout = setTimeout(() => stopTraeProxy(), TRAE_CONFIG.oauthTimeoutMs);
resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` });
});
server.on("error", (err) => resolve({ success: false, reason: err.message }));
});
}
export function stopTraeProxy() {
if (traeProxyTimeout) { clearTimeout(traeProxyTimeout); traeProxyTimeout = null; }
if (traeProxyServer) { traeProxyServer.close(); traeProxyServer = null; }
traeProxyPort = null;
}
// ───────────────────────────────────────────────────────────────────────────
// Windsurf dynamic-port proxy. Singleton session.
// Callback path = /windsurf-auth-callback with params access_token (firebase JWT) + state.
// ───────────────────────────────────────────────────────────────────────────
let windsurfProxyServer = null;
let windsurfProxyTimeout = null;
let windsurfProxyPort = null;
let windsurfSession = null;
export function registerWindsurfSession({ state }) {
if (!state) return false;
windsurfSession = { state, status: "pending", createdAt: Date.now() };
return true;
}
export function getWindsurfSessionStatus(state) {
if (!windsurfSession) return null;
if (state && windsurfSession.state !== state) return null;
return windsurfSession;
}
export function clearWindsurfSession(state) {
if (!state || (windsurfSession && windsurfSession.state === state)) windsurfSession = null;
}
export function startWindsurfProxy() {
return new Promise((resolve) => {
if (windsurfProxyServer) {
resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` });
return;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== WINDSURF_CONFIG.callbackPath) {
res.writeHead(404);
res.end("Not found");
return;
}
const session = windsurfSession;
if (!session) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "No active Windsurf login session"));
return;
}
// Anti-CSRF: reject cross-origin fetches, and require state present + matching.
if (!isLoopbackOrigin(req.headers.origin)) {
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "Cross-origin callback rejected"));
return;
}
const cbState = url.searchParams.get("state");
if (!cbState || !session.state || cbState !== session.state) {
session.status = "error";
session.error = "Windsurf callback state mismatch";
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, session.error));
stopWindsurfProxy();
return;
}
const rawCallback = `${url.pathname}?${url.searchParams.toString()}`;
try {
const { exchangeTokens } = await import("../providers.js");
const { createProviderConnection } = await import("@/models");
const tokenData = await exchangeTokens("windsurf", rawCallback, null, null, session.state);
const connection = await createProviderConnection({
provider: "windsurf",
authType: "api_key",
...tokenData,
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(renderCodexResultPage(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(renderCodexResultPage(false, err.message));
} finally {
stopWindsurfProxy();
}
});
server.listen(0, "127.0.0.1", () => {
windsurfProxyServer = server;
windsurfProxyPort = server.address().port;
windsurfProxyTimeout = setTimeout(() => stopWindsurfProxy(), WINDSURF_CONFIG.oauthTimeoutMs);
resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` });
});
server.on("error", (err) => resolve({ success: false, reason: err.message }));
});
}
export function stopWindsurfProxy() {
if (windsurfProxyTimeout) { clearTimeout(windsurfProxyTimeout); windsurfProxyTimeout = null; }
if (windsurfProxyServer) { windsurfProxyServer.close(); windsurfProxyServer = null; }
windsurfProxyPort = null;
}
// ───────────────────────────────────────────────────────────────────────────
// Zed RSA native-app proxy. Singleton session.
// Callback: GET http://127.0.0.1:<port>/?user_id=...&access_token=<RSA-encrypted>
// The proxy decrypts the access token using the private key stored in session.codeVerifier.
// ───────────────────────────────────────────────────────────────────────────
let zedProxyServer = null;
let zedProxyTimeout = null;
let zedProxyPort = null;
let zedSession = null;
export function registerZedSession({ state, codeVerifier }) {
if (!state || !codeVerifier) return false;
zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() };
return true;
}
export function getZedSessionStatus(state) {
if (!zedSession) return null;
if (state && zedSession.state !== state) return null;
return zedSession;
}
export function clearZedSession(state) {
if (!state || (zedSession && zedSession.state === state)) zedSession = null;
}
export function startZedProxy(preferredPort = 0) {
return new Promise((resolve) => {
if (zedProxyServer) {
resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` });
return;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
// Log path + redacted params (access_token is the RSA-encrypted credential).
const redacted = Object.fromEntries(url.searchParams);
for (const k of ["access_token", "user_id", "code_verifier", "state"]) {
if (redacted[k]) redacted[k] = "<redacted>";
}
console.log("[Zed proxy]", req.method, url.pathname, JSON.stringify(redacted));
if (url.pathname !== "/" && url.pathname !== "/callback") {
res.writeHead(404);
res.end("Not found");
return;
}
const session = zedSession;
if (!session) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "No active Zed login session"));
return;
}
// Anti-CSRF: Zed tokens are RSA-encrypted to our keypair so they can't be
// forged cross-site, but still reject cross-origin fetches for defense-in-depth.
if (!isLoopbackOrigin(req.headers.origin)) {
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "Cross-origin callback rejected"));
return;
}
// Pass raw callback path+query to exchangeTokens → parseZedCallbackPayload.
// codeVerifier carries the encoded RSA private key for decryption.
const rawCallback = url.search ? `${url.pathname}?${url.searchParams.toString()}` : url.pathname;
try {
const { exchangeTokens } = await import("../providers.js");
const { createProviderConnection } = await import("@/models");
const tokenData = await exchangeTokens("zed", rawCallback, null, session.codeVerifier, session.state);
const connection = await createProviderConnection({
provider: "zed",
authType: "oauth",
...tokenData,
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(renderCodexResultPage(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(renderCodexResultPage(false, err.message));
} finally {
stopZedProxy();
}
});
const tryPort = Number(preferredPort) || 0;
server.on("error", (err) => {
// If the preferred port (e.g. 58443) is busy, fall back to a random port.
if (err.code === "EADDRINUSE" && tryPort !== 0) {
console.log(`[Zed proxy] port ${tryPort} busy, falling back to random`);
server.listen(0, "127.0.0.1", () => {
zedProxyServer = server;
zedProxyPort = server.address().port;
zedProxyTimeout = setTimeout(() => stopZedProxy(), ZED_HOSTED_CONFIG.oauthTimeoutMs);
console.log(`[Zed proxy] listening on random port ${zedProxyPort}`);
resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` });
});
} else {
console.log(`[Zed proxy] listen error: ${err.message}`);
resolve({ success: false, reason: err.message });
}
});
server.listen(tryPort, "127.0.0.1", () => {
zedProxyServer = server;
zedProxyPort = server.address().port;
zedProxyTimeout = setTimeout(() => { console.log("[Zed proxy] timeout, stopping"); stopZedProxy(); }, ZED_HOSTED_CONFIG.oauthTimeoutMs);
console.log(`[Zed proxy] listening on port ${zedProxyPort}`);
resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` });
});
});
}
export function stopZedProxy() {
console.log(`[Zed proxy] stopping (port ${zedProxyPort || "-"})`);
if (zedProxyTimeout) { clearTimeout(zedProxyTimeout); zedProxyTimeout = null; }
if (zedProxyServer) { zedProxyServer.close(); zedProxyServer = null; }
zedProxyPort = null;
}

View File

@@ -5,6 +5,31 @@ import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
// Providers using the dynamic-port local callback proxy.
// Browser OAuth: popup → auto callback → auto exchange → poll-status.
const PROXY_OAUTH_PROVIDERS = new Set(["trae", "windsurf", "zed"]);
// Providers offering a paste-token fallback (import-token flow).
// UX warns if the IDE (which issues the token) is not installed.
const PASTE_TOKEN_PROVIDERS = {
trae: {
label: "Cloud-IDE-JWT",
instructions:
"Sign in at trae.ai (or solo.trae.ai), open DevTools → Network, copy the Cloud-IDE-JWT token from any request's Authorization header (~14-day lifetime).",
placeholder: "Paste Cloud-IDE-JWT here...",
ideName: "Trae",
ideOptional: true, // token can be grabbed from DevTools without the IDE
},
windsurf: {
label: "Windsurf API key",
instructions:
"In the Windsurf/VS Code IDE, run the \"Windsurf: Provide Auth Token\" command, then copy the displayed sk-ws-... key.",
placeholder: "Paste sk-ws-... key here...",
ideName: "Windsurf",
ideOptional: false,
},
};
/**
* OAuth Modal Component
* - Localhost: Auto callback via popup message
@@ -18,6 +43,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const [isDeviceCode, setIsDeviceCode] = useState(false);
const [deviceData, setDeviceData] = useState(null);
const [polling, setPolling] = useState(false);
// trae/windsurf: choose between browser OAuth (proxy) and paste-token (import)
const [authMode, setAuthMode] = useState("browser"); // "browser" | "paste-token"
const [pasteToken, setPasteToken] = useState("");
const [ideStatus, setIdeStatus] = useState(null);
const popupRef = useRef(null);
const pollingAbortRef = useRef(false);
const openedRef = useRef(false);
@@ -150,12 +179,50 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setPolling(false);
}, [provider, onSuccess]);
// Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange.
const startProxyFlow = useCallback(async (providerId) => {
// 1. Start the local callback server (returns a dynamic port + callback URL).
const startRes = await fetch(`/api/oauth/${providerId}/start-proxy`);
const startData = await startRes.json();
if (!startRes.ok || !startData.success || !startData.callbackUrl) {
throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`);
}
// 2. Build the authorize URL with redirect_uri = proxy callback URL.
const authorizeUrl = new URL(`/api/oauth/${providerId}/authorize`, window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl);
const authRes = await fetch(authorizeUrl);
const authData = await authRes.json();
if (!authRes.ok) throw new Error(authData.error);
// 3. Register the session so the proxy can match the incoming callback.
// Zed also passes code_verifier (encodes the RSA private key for decrypt);
// sent via POST body so the private key never lands in URL/query logs.
const regBody = { state: authData.state };
if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier;
await fetch(`/api/oauth/${providerId}/register-session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(regBody),
});
// 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status.
setAuthData({ ...authData, proxyProvider: providerId });
setStep("waiting");
popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700");
if (!popupRef.current) setStep("input"); // popup blocked → fall back to manual paste
}, []);
// Start OAuth flow
const startOAuthFlow = useCallback(async () => {
if (!provider) return;
try {
setError(null);
// Trae/Windsurf: proxy OAuth (browser mode) — handled by dedicated flow.
// Paste-token mode is handled by handleManualSubmit (no /authorize call).
if (PROXY_OAUTH_PROVIDERS.has(provider) && authMode === "browser") {
await startProxyFlow(provider);
return;
}
// Device code flow providers (must match oauth providers with flowType: "device_code")
const deviceCodeProviders = [
"github",
@@ -165,6 +232,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
"kimi-coding",
"kilocode",
"codebuddy-cn",
"codebuddy-intl",
"qoder",
"grok-cli",
];
@@ -329,7 +397,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError(err.message);
setStep("error");
}
}, [provider, isLocalhost, startPolling, oauthMeta, idcConfig]);
}, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]);
// Reset state and start OAuth when modal opens
useEffect(() => {
@@ -343,7 +411,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
setAuthMode("browser");
setPasteToken("");
setIdeStatus(null);
pollingAbortRef.current = false;
// Best-effort IDE detection for paste-token providers (Trae/Windsurf)
if (PASTE_TOKEN_PROVIDERS[provider]) {
fetch(`/api/oauth/${provider}/ide-status`)
.then((r) => r.json())
.then((data) => setIdeStatus(data))
.catch(() => setIdeStatus({ installed: false, path: null }));
}
startOAuthFlow();
} else if (!isOpen) {
// Abort polling and cleanup proxy when modal closes
@@ -353,13 +431,26 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
}
}, [isOpen, provider, startOAuthFlow]);
// Fixed-port server-side mode: poll status (proxy auto-exchanges + saves DB)
// Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port):
// poll status until the proxy auto-exchanges and saves the connection.
useEffect(() => {
const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null;
const pollProvider = authData?.codexServerSide
? "codex"
: authData?.xaiServerSide
? "xai"
: authData?.proxyProvider
? authData.proxyProvider
: null;
if (!pollProvider || !authData?.state) return;
if (callbackProcessedRef.current) return;
let cancelled = false;
@@ -487,8 +578,38 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
try {
setError(null);
// Paste-token mode (Trae/Windsurf): token goes straight to /exchange
if (authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider]) {
const token = pasteToken.trim();
if (!token) throw new Error("Missing token");
const res = await fetch(`/api/oauth/${provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: token }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
return;
}
const input = callbackUrl.trim();
// Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL
if (PROXY_OAUTH_PROVIDERS.has(provider) && input) {
const res = await fetch(`/api/oauth/${provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: input, state: authData?.state }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
return;
}
// Detect raw JWT access token (starts with eyJ) — skip URL parsing
if (input.startsWith("eyJ") && input.includes(".")) {
await exchangeTokens(input, null);
@@ -538,6 +659,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
onClose();
}, [onClose, provider]);
@@ -556,8 +683,82 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return (
<Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{/* Waiting + Manual Input combined (non-device-code) */}
{(step === "waiting" || step === "input") && !isDeviceCode && (
{/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */}
{PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && (
<>
<div className="flex gap-2">
<button
type="button"
onClick={() => { setAuthMode("browser"); setError(null); setStep("waiting"); startOAuthFlow(); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "browser" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🌐 Sign in with browser
</button>
<button
type="button"
onClick={() => { setAuthMode("paste-token"); setError(null); setStep("input"); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "paste-token" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🔑 Paste token
</button>
</div>
{authMode === "browser" && (
<>
{step === "waiting" && (
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">
<span className="material-symbols-outlined text-base text-primary animate-spin">progress_activity</span>
<span className="text-sm">Waiting for browser authorization</span>
</div>
)}
{step === "input" && (
<div className="space-y-3">
<p className="text-sm text-text-muted">
Popup was blocked. After authorizing in the browser, paste the full callback URL here:
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="http://127.0.0.1:.../callback?..."
className="font-mono text-xs"
/>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>Connect</Button>
<Button onClick={handleClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
)}
</>
)}
{authMode === "paste-token" && (
<div className="space-y-3">
{ideStatus && !ideStatus.installed && (
<div className={`px-3 py-2 rounded-lg text-sm ${PASTE_TOKEN_PROVIDERS[provider].ideOptional ? "bg-blue-500/10 text-blue-700 dark:text-blue-300" : "bg-yellow-500/10 text-yellow-700 dark:text-yellow-300"}`}>
{PASTE_TOKEN_PROVIDERS[provider].ideName} IDE not detected.
{PASTE_TOKEN_PROVIDERS[provider].ideOptional
? " You can still grab the token from DevTools."
: ` Install ${PASTE_TOKEN_PROVIDERS[provider].ideName} IDE to get the token, or use "Sign in with browser".`}
</div>
)}
<p className="text-sm text-text-muted">{PASTE_TOKEN_PROVIDERS[provider].instructions}</p>
<Input
value={pasteToken}
onChange={(e) => setPasteToken(e.target.value)}
placeholder={PASTE_TOKEN_PROVIDERS[provider].placeholder}
className="font-mono text-xs"
/>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!pasteToken}>Connect</Button>
<Button onClick={handleClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
)}
</>
)}
{/* Waiting + Manual Input combined (non-device-code, non-proxy) */}
{(step === "waiting" || step === "input") && !isDeviceCode && !PROXY_OAUTH_PROVIDERS.has(provider) && (
<>
{/* Option A: Auto via popup */}
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">