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,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user