Files
NetMesh/electron/bridges/oauthBridge.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

557 lines
16 KiB
JavaScript

/**
* OAuth Callback Bridge
*
* Handles OAuth loopback redirects for Google Drive and OneDrive.
* Prepares a temporary HTTP server on 127.0.0.1 (preferred port 45678; falls
* back to an OS-assigned free port if 45678 is already in use — issue #823)
* and waits on it for the authorization code.
*/
const http = require("node:http");
let activeSession = null;
let nextSessionId = 0;
const PREFERRED_OAUTH_PORT = 45678;
const OAUTH_CALLBACK_PATH = "/oauth/callback";
const OAUTH_TIMEOUT = 5 * 60 * 1000; // 5 minutes
const escapeHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
const accent =
status === "success" ? "200 100% 61%" : status === "error" ? "0 70% 50%" : "38 92% 50%";
const safeTitle = escapeHtml(title);
const safeMessage = escapeHtml(message);
const safeDetail = detail ? escapeHtml(detail) : "";
const titleIcon =
status === "success"
? `<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>`
: status === "error"
? `<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>`
: `<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v20"/><path d="M5 9h14"/><path d="M7 3h10"/><path d="M7 21h10"/></svg>`;
const detailBlock = safeDetail
? `<div class="detail">${safeDetail}</div>`
: "";
const closeScript = autoClose
? "<script>setTimeout(() => window.close(), 1400);</script>"
: "";
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${safeTitle}</title>
<style>
:root {
color-scheme: dark;
--background: 220 28% 8%;
--foreground: 210 40% 95%;
--card: 220 22% 12%;
--muted: 220 10% 70%;
--border: 220 22% 18%;
--accent: ${accent};
--radius: 0.65rem;
--ring: 200 100% 61%;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--background: 216 33% 96%;
--foreground: 222 47% 12%;
--card: 0 0% 100%;
--muted: 220 10% 45%;
--border: 220 16% 82%;
--ring: 208 100% 50%;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: "Space Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif;
background:
radial-gradient(900px circle at 15% 0%, hsl(var(--accent) / 0.10), transparent 38%),
radial-gradient(1200px circle at 85% 10%, hsl(var(--ring) / 0.16), transparent 40%),
hsl(var(--background));
color: hsl(var(--foreground));
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.shell {
width: min(560px, 92vw);
border-radius: calc(var(--radius) + 10px);
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
box-shadow: 0 18px 40px hsl(var(--foreground) / 0.12);
padding: 28px;
animation: fadeUp 220ms ease-out;
}
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 36px;
height: 36px;
}
.brand {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.02em;
color: hsl(var(--foreground));
}
h1 {
margin: 0 0 8px;
font-size: 28px;
letter-spacing: -0.02em;
}
.title-row {
display: inline-flex;
align-items: center;
gap: 10px;
}
.title-icon {
width: 22px;
height: 22px;
color: hsl(var(--accent));
}
.subtitle {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: hsl(var(--muted));
}
.detail {
margin-top: 16px;
padding: 12px 14px;
background: hsl(var(--background) / 0.6);
border-radius: calc(var(--radius) - 4px);
border: 1px dashed hsl(var(--border));
font-size: 13px;
line-height: 1.5;
word-break: break-word;
}
.footer {
margin-top: 22px;
font-size: 13px;
color: hsl(var(--muted));
}
.accent {
color: hsl(var(--accent));
font-weight: 600;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="shell">
<div class="header">
<svg class="logo" viewBox="0 0 56 56" aria-hidden="true">
<rect x="0" y="0" width="56" height="56" rx="12" fill="#2F7BFF"/>
<rect x="10" y="13" width="36" height="24" rx="4" fill="#FFFFFF" stroke="#1D4FCF" stroke-opacity="0.12"/>
<rect x="10" y="13" width="36" height="5" rx="4" fill="#E6EEFF"/>
<circle cx="14" cy="15.5" r="1" fill="#1E4FD1"/>
<circle cx="18" cy="15.5" r="1" fill="#1E4FD1" opacity="0.7"/>
<circle cx="22" cy="15.5" r="1" fill="#1E4FD1" opacity="0.5"/>
<path d="M16 28 L20 26 L16 24" stroke="#1E4FD1" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 30 H30" stroke="#1E4FD1" stroke-width="1.6" stroke-linecap="round"/>
<path d="M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47" stroke="white" fill="none" stroke-width="3.2" stroke-linecap="round"/>
<rect x="34" y="44" width="6" height="5" rx="1" fill="white" stroke="#1E4FD1"/>
</svg>
<div>
<div class="brand">Netcatty</div>
</div>
</div>
<h1 class="title-row"><span>${safeTitle}</span>${titleIcon}</h1>
<p class="subtitle">${safeMessage}</p>
${detailBlock}
<div class="footer">You can close this window and return to <span class="accent">Netcatty</span>.</div>
</div>
${closeScript}
</body>
</html>`;
};
function handleOAuthRequest(session, req, res) {
const parsedUrl = new URL(req.url, "http://127.0.0.1");
// Only handle the callback path
if (parsedUrl.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>404 Not Found</h1>");
return;
}
const code = parsedUrl.searchParams.get("code");
const state = parsedUrl.searchParams.get("state");
const error = parsedUrl.searchParams.get("error");
const errorDescription = parsedUrl.searchParams.get("error_description");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
// We deliberately ignore callbacks until awaitOAuthCallback() has armed the
// session. In the real flow the browser only opens after that point, so any
// earlier localhost hit is stale/noise and must not consume the active retry.
if (!session.resolve || !session.reject) {
res.end(
renderOAuthPage({
title: "Authorization Not Ready",
message: "This sign-in request is not active yet. Return to Netcatty and try again.",
status: "warning",
})
);
return;
}
if (error) {
res.end(
renderOAuthPage({
title: "Authorization Failed",
message: "We could not complete the sign-in flow.",
detail: errorDescription || error || "Unknown error",
status: "error",
})
);
finishSessionWithError(
session,
new Error(errorDescription || error || "Authorization failed")
);
return;
}
if (!code) {
res.end(
renderOAuthPage({
title: "Missing Authorization Code",
message: "The authorization response did not include a code.",
status: "error",
})
);
finishSessionWithError(session, new Error("Missing authorization code"));
return;
}
if (session.expectedState && state !== session.expectedState) {
res.end(
renderOAuthPage({
title: "Security Check Failed",
message: "State parameter mismatch. This may indicate a CSRF attack.",
status: "error",
})
);
finishSessionWithError(session, new Error("State mismatch - possible CSRF attack"));
return;
}
res.end(
renderOAuthPage({
title: "Authorization Complete",
message: "You are signed in and ready to sync. You can close this tab now.",
status: "success",
})
);
resolveSession(session, { code, state });
}
function resolveSession(session, payload) {
if (!session) return;
const resolve = session.resolve;
disposeSession(session);
if (resolve) {
resolve(payload);
}
}
function rejectSession(session, err) {
if (!session) return;
const reject = session.reject;
disposeSession(session);
if (reject) {
reject(err);
}
}
function finishSessionWithError(session, err) {
if (!session) return;
if (session.reject) {
rejectSession(session, err);
return;
}
storeSessionError(session, err);
}
function disposeSession(session) {
if (!session) return;
if (session.timeout) {
clearTimeout(session.timeout);
session.timeout = null;
}
session.resolve = null;
session.reject = null;
session.expectedState = null;
session.error = null;
closeServer(session);
if (activeSession === session) {
activeSession = null;
}
}
function storeSessionError(session, err) {
if (!session) return;
if (session.timeout) {
clearTimeout(session.timeout);
session.timeout = null;
}
session.resolve = null;
session.reject = null;
session.expectedState = null;
session.error = err;
closeServer(session);
}
function armSessionTimeout(session) {
session.timeout = setTimeout(() => {
rejectSession(
session,
new Error("OAuth timeout - user did not complete authorization in time")
);
}, OAUTH_TIMEOUT);
}
/**
* Try to bind an HTTP server on the loopback interface. Prefers
* PREFERRED_OAUTH_PORT; if the port is already in use (EADDRINUSE) or the OS
* otherwise refuses it (EACCES), falls back to letting the OS pick a free
* ephemeral port.
*
* @returns {Promise<{server: http.Server, port: number}>}
*/
function bindLoopbackServer(session) {
const ports = [PREFERRED_OAUTH_PORT, 0];
return (async () => {
let lastErr;
for (const port of ports) {
try {
const s = http.createServer((req, res) => {
handleOAuthRequest(session, req, res);
});
await new Promise((resolve, reject) => {
const onError = (err) => {
s.removeListener("listening", onListening);
reject(err);
};
const onListening = () => {
s.removeListener("error", onError);
resolve();
};
s.once("error", onError);
s.once("listening", onListening);
s.listen(port, "127.0.0.1");
});
const bound = s.address();
return { server: s, port: bound && typeof bound === "object" ? bound.port : port };
} catch (err) {
lastErr = err;
if (err && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
// Try the next candidate (OS-assigned)
continue;
}
throw err;
}
}
throw lastErr || new Error("Failed to bind OAuth loopback server");
})();
}
/**
* Bind a loopback HTTP server for the OAuth callback. Returns the chosen
* port and fully-qualified redirect URI so the caller can build the
* provider's authorization URL against it.
*
* @returns {Promise<{port: number, redirectUri: string}>}
*/
async function prepareOAuthCallback() {
// Replace any previously-prepared flow so it cannot leak a bound port or
// leave an await Promise hanging forever.
rejectSession(activeSession, new Error("OAuth flow cancelled"));
const session = {
id: `oauth-${++nextSessionId}`,
server: null,
port: null,
resolve: null,
reject: null,
expectedState: null,
timeout: null,
error: null,
};
activeSession = session;
armSessionTimeout(session);
let bound;
try {
bound = await bindLoopbackServer(session);
} catch (err) {
disposeSession(session);
throw err;
}
session.server = bound.server;
session.port = bound.port;
if (activeSession !== session || session.error) {
const err = session.error || new Error("OAuth flow cancelled");
disposeSession(session);
throw err;
}
// Don't let the callback server itself keep the process alive — the
// awaitOAuthCallback Promise is what should pin the event loop. In Electron
// main this is a no-op (the UI keeps things alive), but in tests and CLI
// contexts it means the process can exit cleanly between runs.
if (typeof session.server.unref === "function") session.server.unref();
session.server.on("error", (err) => {
console.error("OAuth server error:", err);
rejectSession(session, err);
});
const redirectUri = `http://127.0.0.1:${session.port}${OAUTH_CALLBACK_PATH}`;
console.log(`OAuth callback server listening on ${redirectUri}`);
return { sessionId: session.id, port: session.port, redirectUri };
}
/**
* Wait for the authorization code to arrive at the prepared callback
* server. Must be called after prepareOAuthCallback().
*
* @param {string} [expectedState] - State parameter to validate
* @param {string} [sessionId] - Session returned by prepareOAuthCallback
* @returns {Promise<{code: string, state?: string}>}
*/
function awaitOAuthCallback(expectedState, sessionId) {
return new Promise((resolve, reject) => {
const session = activeSession;
if (!session) {
reject(new Error("OAuth callback server not prepared"));
return;
}
if (sessionId && session.id !== sessionId) {
reject(new Error("OAuth flow cancelled"));
return;
}
// Only one await may be outstanding at a time.
if (session.resolve || session.reject) {
reject(new Error("An OAuth callback is already in progress"));
return;
}
if (session.error) {
const err = session.error;
disposeSession(session);
reject(err);
return;
}
if (!session.server) {
reject(new Error("OAuth callback server not prepared"));
return;
}
session.resolve = resolve;
session.reject = reject;
session.expectedState = expectedState || null;
});
}
/**
* Return the port the OAuth callback server is currently listening on, or
* null when no callback is in flight. Used by windowManager to decide
* whether a loopback `/oauth/callback` URL is trustworthy for the in-app
* popup fallback.
*/
function getActiveOAuthPort() {
return activeSession?.port ?? null;
}
/**
* Cancel pending OAuth flow
*/
function cancelOAuthCallback(sessionId) {
if (!activeSession) return;
if (sessionId && activeSession.id !== sessionId) return;
finishSessionWithError(activeSession, new Error("OAuth flow cancelled"));
}
function closeServer(session) {
const server = session?.server;
if (server) {
try {
// closeAllConnections (Node 18.2+) forces any keep-alive sockets shut
// so the port is immediately reusable — otherwise server.close() would
// wait on idle keep-alive connections before fully releasing.
if (typeof server.closeAllConnections === "function") {
try {
server.closeAllConnections();
} catch {
// ignore
}
}
server.close();
} catch {
// Ignore
}
}
if (session) {
session.server = null;
session.port = null;
}
}
/**
* Setup IPC handlers
* @param {Electron.IpcMain} ipcMain
*/
function setupOAuthBridge(ipcMain) {
ipcMain.handle("oauth:prepareCallback", async () => {
return prepareOAuthCallback();
});
ipcMain.handle("oauth:awaitCallback", async (_event, expectedState, sessionId) => {
return awaitOAuthCallback(expectedState, sessionId);
});
ipcMain.handle("oauth:cancelCallback", async (_event, sessionId) => {
cancelOAuthCallback(sessionId);
});
}
module.exports = {
setupOAuthBridge,
prepareOAuthCallback,
awaitOAuthCallback,
cancelOAuthCallback,
getActiveOAuthPort,
};