${closeScript}
`;
};
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("
404 Not Found
");
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,
};