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,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;
}