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:
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user