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) => {