mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
refactor(cloud-sync): simplify OAuth callback flow
This commit is contained in:
1 parent
c721591466
commit
78fc4628b9
7 files changed
+389
-112
No files matched your search
@@ -146,6 +146,7 @@ const getSnapshot = (): SyncManagerState => {
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const activeOAuthSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
@@ -262,7 +263,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
if (result.type !== 'device_code') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
return result.data as DeviceFlowState;
|
||||
return result.data;
|
||||
}, []);
|
||||
|
||||
const completeGitHubAuth = useCallback(async (
|
||||
@@ -279,30 +280,29 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const prepare = bridge?.prepareOAuthCallback;
|
||||
const awaitCallback = bridge?.awaitOAuthCallback;
|
||||
if (!prepare || !awaitCallback) {
|
||||
const openExternal = bridge?.openExternal;
|
||||
if (!prepare || !awaitCallback || !openExternal) {
|
||||
throw new Error('OAuth bridge is unavailable');
|
||||
}
|
||||
|
||||
// Bind the loopback callback server first so we know which port to put
|
||||
// in the provider's redirect_uri (#823: 45678 may be in use).
|
||||
const prepared = await prepare();
|
||||
const redirectUri = prepared.redirectUri;
|
||||
const { redirectUri, sessionId } = await prepare();
|
||||
activeOAuthSessionIdRef.current = sessionId;
|
||||
|
||||
let authStarted = false;
|
||||
try {
|
||||
const result = await manager.startProviderAuth(provider, redirectUri);
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
authStarted = true;
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
const data = result.data;
|
||||
|
||||
const adapter = manager.getAdapter(provider) as
|
||||
| { getPKCEState?: () => string | null }
|
||||
| undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
const callbackPromise = awaitCallback(expectedState);
|
||||
const callbackPromise = awaitCallback(expectedState, sessionId);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563).
|
||||
// Race: if browser launch fails, surface the error immediately.
|
||||
@@ -310,9 +310,9 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
await openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
bridge?.cancelOAuthCallback?.(sessionId);
|
||||
reject(
|
||||
err instanceof Error
|
||||
? err
|
||||
@@ -331,16 +331,20 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
return data.url;
|
||||
} catch (err) {
|
||||
// If we bound a server but never reached awaitCallback (or it failed
|
||||
// before resolving), release the port so the next attempt can bind.
|
||||
if (!authStarted) {
|
||||
try {
|
||||
await bridge?.cancelOAuthCallback?.();
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
const ownsActiveSession = activeOAuthSessionIdRef.current === sessionId;
|
||||
try {
|
||||
await bridge?.cancelOAuthCallback?.(sessionId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
if (ownsActiveSession) {
|
||||
manager.resetProviderStatus(provider);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (activeOAuthSessionIdRef.current === sessionId) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
@@ -380,7 +384,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
bridge?.cancelOAuthCallback?.(activeOAuthSessionIdRef.current ?? undefined);
|
||||
}, []);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
@@ -1152,6 +1152,40 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} | null>(null);
|
||||
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [pendingConnectProvider, setPendingConnectProvider] = useState<CloudProvider | null>(null);
|
||||
const pendingConnectProviderRef = useRef<CloudProvider | null>(null);
|
||||
|
||||
const hasConnectingProvider = (Object.values(sync.providers) as Array<{ status: string }>).some(
|
||||
(provider) => provider.status === 'connecting'
|
||||
);
|
||||
|
||||
const isConnectDisabled = (provider: CloudProvider): boolean => {
|
||||
if (pendingConnectProvider && pendingConnectProvider !== provider) {
|
||||
return true;
|
||||
}
|
||||
if (pendingConnectProvider === provider) {
|
||||
return true;
|
||||
}
|
||||
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
|
||||
return true;
|
||||
}
|
||||
return sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers[provider]);
|
||||
};
|
||||
|
||||
const beginPendingConnect = (provider: CloudProvider): boolean => {
|
||||
if (pendingConnectProviderRef.current) {
|
||||
return false;
|
||||
}
|
||||
pendingConnectProviderRef.current = provider;
|
||||
setPendingConnectProvider(provider);
|
||||
return true;
|
||||
};
|
||||
|
||||
const endPendingConnect = (provider: CloudProvider) => {
|
||||
if (pendingConnectProviderRef.current !== provider) return;
|
||||
pendingConnectProviderRef.current = null;
|
||||
setPendingConnectProvider((current) => (current === provider ? null : current));
|
||||
};
|
||||
|
||||
// Change master key dialog
|
||||
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
|
||||
@@ -1275,6 +1309,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
// Connect GitHub (disconnect others first - single provider only)
|
||||
const handleConnectGitHub = async () => {
|
||||
if (!beginPendingConnect('github')) return;
|
||||
try {
|
||||
await disconnectOtherProviders('github');
|
||||
const deviceFlow = await sync.connectGitHub();
|
||||
@@ -1300,42 +1335,46 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
sync.resetProviderStatus('github');
|
||||
const message = getNetworkErrorMessage(error, t('common.unknownError'));
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
} finally {
|
||||
endPendingConnect('github');
|
||||
}
|
||||
};
|
||||
|
||||
// Connect Google (disconnect others first - single provider only)
|
||||
const handleConnectGoogle = async () => {
|
||||
if (!beginPendingConnect('google')) return;
|
||||
try {
|
||||
await disconnectOtherProviders('google');
|
||||
await sync.connectGoogle();
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('google');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
|
||||
}
|
||||
} finally {
|
||||
endPendingConnect('google');
|
||||
}
|
||||
};
|
||||
|
||||
// Connect OneDrive (disconnect others first - single provider only)
|
||||
const handleConnectOneDrive = async () => {
|
||||
if (!beginPendingConnect('onedrive')) return;
|
||||
try {
|
||||
await disconnectOtherProviders('onedrive');
|
||||
await sync.connectOneDrive();
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('onedrive');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
|
||||
}
|
||||
} finally {
|
||||
endPendingConnect('onedrive');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1673,7 +1712,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
|
||||
disabled={isConnectDisabled('github')}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1697,7 +1736,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
disabled={isConnectDisabled('google')}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
@@ -1714,7 +1753,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
disabled={isConnectDisabled('onedrive')}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
@@ -1731,7 +1770,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
|
||||
disabled={isConnectDisabled('webdav')}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1748,7 +1787,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
|
||||
disabled={isConnectDisabled('s3')}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
|
||||
@@ -8,17 +8,11 @@
|
||||
*/
|
||||
|
||||
const http = require("node:http");
|
||||
const url = require("node:url");
|
||||
|
||||
let server = null;
|
||||
let activePort = null;
|
||||
let requestHandler = null;
|
||||
let pendingResolve = null;
|
||||
let pendingReject = null;
|
||||
let pendingExpectedState = null;
|
||||
let serverTimeout = null;
|
||||
|
||||
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) =>
|
||||
@@ -192,30 +186,50 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
|
||||
</html>`;
|
||||
};
|
||||
|
||||
function handleOAuthRequest(req, res) {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
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") {
|
||||
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, state, error, error_description } = parsedUrl.query;
|
||||
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: error_description || error || "Unknown error",
|
||||
detail: errorDescription || error || "Unknown error",
|
||||
status: "error",
|
||||
})
|
||||
);
|
||||
rejectPending(new Error(error_description || error || "Authorization failed"));
|
||||
finishSessionWithError(
|
||||
session,
|
||||
new Error(errorDescription || error || "Authorization failed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,11 +241,11 @@ function handleOAuthRequest(req, res) {
|
||||
status: "error",
|
||||
})
|
||||
);
|
||||
rejectPending(new Error("Missing authorization code"));
|
||||
finishSessionWithError(session, new Error("Missing authorization code"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingExpectedState && state !== pendingExpectedState) {
|
||||
if (session.expectedState && state !== session.expectedState) {
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
title: "Security Check Failed",
|
||||
@@ -239,7 +253,7 @@ function handleOAuthRequest(req, res) {
|
||||
status: "error",
|
||||
})
|
||||
);
|
||||
rejectPending(new Error("State mismatch - possible CSRF attack"));
|
||||
finishSessionWithError(session, new Error("State mismatch - possible CSRF attack"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,21 +265,78 @@ function handleOAuthRequest(req, res) {
|
||||
})
|
||||
);
|
||||
|
||||
const resolve = pendingResolve;
|
||||
cleanup();
|
||||
resolveSession(session, { code, state });
|
||||
}
|
||||
|
||||
function resolveSession(session, payload) {
|
||||
if (!session) return;
|
||||
const resolve = session.resolve;
|
||||
disposeSession(session);
|
||||
if (resolve) {
|
||||
resolve({ code, state });
|
||||
resolve(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectPending(err) {
|
||||
const reject = pendingReject;
|
||||
cleanup();
|
||||
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
|
||||
@@ -274,14 +345,14 @@ function rejectPending(err) {
|
||||
*
|
||||
* @returns {Promise<{server: http.Server, port: number}>}
|
||||
*/
|
||||
function bindLoopbackServer() {
|
||||
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) => {
|
||||
if (requestHandler) requestHandler(req, res);
|
||||
handleOAuthRequest(session, req, res);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
const onError = (err) => {
|
||||
@@ -319,28 +390,54 @@ function bindLoopbackServer() {
|
||||
* @returns {Promise<{port: number, redirectUri: string}>}
|
||||
*/
|
||||
async function prepareOAuthCallback() {
|
||||
// Close any previously-prepared server (e.g. from an abandoned flow).
|
||||
closeServer();
|
||||
// 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 bound = await bindLoopbackServer();
|
||||
server = bound.server;
|
||||
activePort = bound.port;
|
||||
requestHandler = handleOAuthRequest;
|
||||
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 server.unref === "function") server.unref();
|
||||
if (typeof session.server.unref === "function") session.server.unref();
|
||||
|
||||
server.on("error", (err) => {
|
||||
session.server.on("error", (err) => {
|
||||
console.error("OAuth server error:", err);
|
||||
rejectPending(err);
|
||||
rejectSession(session, err);
|
||||
});
|
||||
|
||||
const redirectUri = `http://127.0.0.1:${activePort}/oauth/callback`;
|
||||
const redirectUri = `http://127.0.0.1:${session.port}${OAUTH_CALLBACK_PATH}`;
|
||||
console.log(`OAuth callback server listening on ${redirectUri}`);
|
||||
return { port: activePort, redirectUri };
|
||||
return { sessionId: session.id, port: session.port, redirectUri };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,28 +445,43 @@ async function prepareOAuthCallback() {
|
||||
* 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) {
|
||||
function awaitOAuthCallback(expectedState, sessionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!server) {
|
||||
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 (pendingResolve || pendingReject) {
|
||||
if (session.resolve || session.reject) {
|
||||
reject(new Error("An OAuth callback is already in progress"));
|
||||
return;
|
||||
}
|
||||
pendingResolve = resolve;
|
||||
pendingReject = reject;
|
||||
pendingExpectedState = expectedState || null;
|
||||
|
||||
serverTimeout = setTimeout(() => {
|
||||
rejectPending(
|
||||
new Error("OAuth timeout - user did not complete authorization in time")
|
||||
);
|
||||
}, OAUTH_TIMEOUT);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -380,17 +492,20 @@ function awaitOAuthCallback(expectedState) {
|
||||
* popup fallback.
|
||||
*/
|
||||
function getActiveOAuthPort() {
|
||||
return activePort;
|
||||
return activeSession?.port ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending OAuth flow
|
||||
*/
|
||||
function cancelOAuthCallback() {
|
||||
rejectPending(new Error("OAuth flow cancelled"));
|
||||
function cancelOAuthCallback(sessionId) {
|
||||
if (!activeSession) return;
|
||||
if (sessionId && activeSession.id !== sessionId) return;
|
||||
finishSessionWithError(activeSession, new Error("OAuth flow cancelled"));
|
||||
}
|
||||
|
||||
function closeServer() {
|
||||
function closeServer(session) {
|
||||
const server = session?.server;
|
||||
if (server) {
|
||||
try {
|
||||
// closeAllConnections (Node 18.2+) forces any keep-alive sockets shut
|
||||
@@ -407,25 +522,11 @@ function closeServer() {
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
activePort = null;
|
||||
requestHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up pending state and close the server. Called after the code is
|
||||
* delivered, on error, and on cancellation.
|
||||
*/
|
||||
function cleanup() {
|
||||
if (serverTimeout) {
|
||||
clearTimeout(serverTimeout);
|
||||
serverTimeout = null;
|
||||
if (session) {
|
||||
session.server = null;
|
||||
session.port = null;
|
||||
}
|
||||
pendingResolve = null;
|
||||
pendingReject = null;
|
||||
pendingExpectedState = null;
|
||||
closeServer();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,12 +538,12 @@ function setupOAuthBridge(ipcMain) {
|
||||
return prepareOAuthCallback();
|
||||
});
|
||||
|
||||
ipcMain.handle("oauth:awaitCallback", async (_event, expectedState) => {
|
||||
return awaitOAuthCallback(expectedState);
|
||||
ipcMain.handle("oauth:awaitCallback", async (_event, expectedState, sessionId) => {
|
||||
return awaitOAuthCallback(expectedState, sessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle("oauth:cancelCallback", async () => {
|
||||
cancelOAuthCallback();
|
||||
ipcMain.handle("oauth:cancelCallback", async (_event, sessionId) => {
|
||||
cancelOAuthCallback(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,137 @@ test("cancelOAuthCallback releases the bound port", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("callbacks that arrive before awaitOAuthCallback do not consume the session", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
const { port } = await bridge.prepareOAuthCallback();
|
||||
|
||||
const earlyStatus = await fetchCallback(port, { code: "stale-code", state: "stale-state" });
|
||||
assert.equal(earlyStatus, 200);
|
||||
assert.equal(bridge.getActiveOAuthPort(), port);
|
||||
|
||||
const pending = bridge.awaitOAuthCallback("expected-state");
|
||||
const status = await fetchCallback(port, {
|
||||
code: "fresh-code",
|
||||
state: "expected-state",
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result.code, "fresh-code");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelOAuthCallback before awaitOAuthCallback is replayed as a cancellation", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
const { sessionId } = await bridge.prepareOAuthCallback();
|
||||
bridge.cancelOAuthCallback(sessionId);
|
||||
await assert.rejects(bridge.awaitOAuthCallback(undefined, sessionId), /cancelled/);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelOAuthCallback during prepare rejects the pending prepare", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
const pendingPrepare = bridge.prepareOAuthCallback();
|
||||
bridge.cancelOAuthCallback();
|
||||
|
||||
await assert.rejects(pendingPrepare, /cancelled/);
|
||||
assert.equal(bridge.getActiveOAuthPort(), null);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelOAuthCallback ignores stale session ids", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
const stale = await bridge.prepareOAuthCallback();
|
||||
const fresh = await bridge.prepareOAuthCallback();
|
||||
|
||||
bridge.cancelOAuthCallback(stale.sessionId);
|
||||
assert.equal(bridge.getActiveOAuthPort(), fresh.port);
|
||||
|
||||
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
||||
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
||||
assert.equal(status, 200);
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result.code, "fresh-code");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("awaitOAuthCallback rejects stale session ids instead of attaching to the new flow", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
const stale = await bridge.prepareOAuthCallback();
|
||||
const fresh = await bridge.prepareOAuthCallback();
|
||||
|
||||
await assert.rejects(
|
||||
bridge.awaitOAuthCallback(undefined, stale.sessionId),
|
||||
/cancelled/
|
||||
);
|
||||
|
||||
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
||||
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
||||
assert.equal(status, 200);
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result.code, "fresh-code");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent prepareOAuthCallback calls cancel the superseded attempt", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
const stalePrepare = bridge.prepareOAuthCallback();
|
||||
const staleRejects = assert.rejects(stalePrepare, /cancelled/);
|
||||
|
||||
const freshPrepare = bridge.prepareOAuthCallback();
|
||||
const fresh = await freshPrepare;
|
||||
await staleRejects;
|
||||
|
||||
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
||||
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
||||
assert.equal(status, 200);
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result.code, "fresh-code");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("prepareOAuthCallback replaces an in-flight await cleanly", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
const stale = await bridge.prepareOAuthCallback();
|
||||
const stalePending = bridge.awaitOAuthCallback(undefined, stale.sessionId);
|
||||
const staleRejects = assert.rejects(stalePending, /cancelled/);
|
||||
|
||||
const fresh = await bridge.prepareOAuthCallback();
|
||||
await staleRejects;
|
||||
|
||||
const nextPending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
||||
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
||||
assert.equal(status, 200);
|
||||
|
||||
const result = await nextPending;
|
||||
assert.equal(result.code, "fresh-code");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("getActiveOAuthPort is null before prepare and after cleanup", async () => {
|
||||
const { bridge, cleanup } = await freshModule();
|
||||
try {
|
||||
|
||||
@@ -963,8 +963,9 @@ const api = {
|
||||
// port (which may differ from the preferred 45678 if it was in use) and
|
||||
// embed it into the provider's redirect_uri before opening the browser.
|
||||
prepareOAuthCallback: () => ipcRenderer.invoke("oauth:prepareCallback"),
|
||||
awaitOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:awaitCallback", expectedState),
|
||||
cancelOAuthCallback: () => ipcRenderer.invoke("oauth:cancelCallback"),
|
||||
awaitOAuthCallback: (expectedState, sessionId) =>
|
||||
ipcRenderer.invoke("oauth:awaitCallback", expectedState, sessionId),
|
||||
cancelOAuthCallback: (sessionId) => ipcRenderer.invoke("oauth:cancelCallback", sessionId),
|
||||
|
||||
// GitHub Device Flow (proxied via main process to avoid CORS)
|
||||
githubStartDeviceFlow: (options) => ipcRenderer.invoke("netcatty:github:deviceFlow:start", options),
|
||||
|
||||
Vendored
+3
-3
@@ -607,9 +607,9 @@ declare global {
|
||||
// back to an OS-assigned free port if busy). The caller then builds the
|
||||
// OAuth URL against `redirectUri`, opens the browser, and finally awaits
|
||||
// the code via `awaitOAuthCallback`.
|
||||
prepareOAuthCallback?(): Promise<{ port: number; redirectUri: string }>;
|
||||
awaitOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
prepareOAuthCallback?(): Promise<{ sessionId: string; port: number; redirectUri: string }>;
|
||||
awaitOAuthCallback?(expectedState?: string, sessionId?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(sessionId?: string): Promise<void>;
|
||||
|
||||
// GitHub Device Flow (cloud sync)
|
||||
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
|
||||
|
||||
@@ -37,7 +37,7 @@ import packageJson from '../../package.json';
|
||||
import { EncryptionService } from './EncryptionService';
|
||||
import { createAdapter, type CloudAdapter } from './adapters';
|
||||
import { localStorageAdapter } from '../persistence/localStorageAdapter';
|
||||
import type { GitHubAdapter } from './adapters/GitHubAdapter';
|
||||
import type { DeviceFlowState, GitHubAdapter } from './adapters/GitHubAdapter';
|
||||
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
|
||||
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
|
||||
import {
|
||||
@@ -97,6 +97,10 @@ interface ProviderSyncAnchor {
|
||||
observedAt: number;
|
||||
}
|
||||
|
||||
export type StartProviderAuthResult =
|
||||
| { type: 'device_code'; data: DeviceFlowState }
|
||||
| { type: 'url'; data: { url: string; redirectUri: string } };
|
||||
|
||||
// ============================================================================
|
||||
// CloudSyncManager Class
|
||||
// ============================================================================
|
||||
@@ -704,10 +708,7 @@ export class CloudSyncManager {
|
||||
async startProviderAuth(
|
||||
provider: CloudProvider,
|
||||
redirectUri?: string
|
||||
): Promise<{
|
||||
type: 'device_code' | 'url';
|
||||
data: unknown;
|
||||
}> {
|
||||
): Promise<StartProviderAuthResult> {
|
||||
if (provider === 'webdav' || provider === 's3') {
|
||||
throw new Error('Provider requires manual configuration');
|
||||
}
|
||||
@@ -730,7 +731,7 @@ export class CloudSyncManager {
|
||||
if (!redirectUri) {
|
||||
throw new Error(
|
||||
`startProviderAuth('${provider}') requires a redirectUri — ` +
|
||||
'call preparePKCECallback on the bridge first and pass its redirectUri through.'
|
||||
'call prepareOAuthCallback on the bridge first and pass its redirectUri through.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user