diff --git a/electron/main.cjs b/electron/main.cjs index 354fd897..adb364ba 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1857,6 +1857,67 @@ ipcMain.handle("get-session-cookie", async (_event, name) => { } }); +function cookieMatchesUrl(cookie, targetUrl) { + if (!targetUrl) return true; + + try { + const targetHost = new URL(targetUrl).hostname; + const cookieDomain = (cookie.domain || "").replace(/^\./, ""); + + return ( + cookieDomain === targetHost || + targetHost.endsWith(`.${cookieDomain}`) || + (!cookieDomain && targetHost === "localhost") + ); + } catch { + return true; + } +} + +ipcMain.handle( + "wait-session-cookie", + async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => { + const ses = mainWindow?.webContents?.session; + if (!ses) return { success: false, error: "No Electron session" }; + + const existingCookies = await ses.cookies.get({ + name, + ...(targetUrl ? { url: targetUrl } : {}), + }); + const existingCookie = existingCookies.find((cookie) => + cookieMatchesUrl(cookie, targetUrl), + ); + if (existingCookie?.value && existingCookie.value !== previousValue) { + return { success: true, value: existingCookie.value }; + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + ses.cookies.off("changed", onCookieChanged); + resolve({ success: false, error: "Timed out waiting for cookie" }); + }, timeoutMs); + + function onCookieChanged(_event, cookie, _cause, removed) { + if ( + removed || + cookie.name !== name || + !cookie.value || + cookie.value === previousValue || + !cookieMatchesUrl(cookie, targetUrl) + ) { + return; + } + + clearTimeout(timeout); + ses.cookies.off("changed", onCookieChanged); + resolve({ success: true, value: cookie.value }); + } + + ses.cookies.on("changed", onCookieChanged); + }); + }, +); + ipcMain.handle("clear-session-cookies", async () => { try { const ses = mainWindow?.webContents?.session; diff --git a/electron/preload.js b/electron/preload.js index 0ea54cd6..a236c5c6 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -33,6 +33,15 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("start-c2s-autostart-tunnels"), clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), + getSessionCookie: (name) => ipcRenderer.invoke("get-session-cookie", name), + waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) => + ipcRenderer.invoke( + "wait-session-cookie", + name, + targetUrl, + previousValue, + timeoutMs, + ), invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 66f776ba..1fc26a2d 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -61,6 +61,14 @@ export interface ElectronAPI { started: number; errors: string[]; }>; + clearSessionCookies: () => Promise; + getSessionCookie: (name: string) => Promise; + waitForSessionCookie: ( + name: string, + targetUrl?: string, + previousValue?: string | null, + timeoutMs?: number, + ) => Promise<{ success: boolean; value?: string; error?: string }>; showSaveDialog: (options: DialogOptions) => Promise; showOpenDialog: (options: DialogOptions) => Promise; diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 3acccd88..ae1d09a0 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -139,25 +139,18 @@ export function Auth({ const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState(false); - const handleElectronAuthSuccess = useCallback(async () => { + const handleElectronAuthSuccess = useCallback(async (previousJwt: string | null) => { try { - let retries = 5; - let meRes = null; - while (retries-- > 0) { - try { - meRes = await getUserInfo(); - break; - } catch (err: any) { - const isNoServer = - err?.code === "NO_SERVER_CONFIGURED" || - err?.message?.includes("no-server-configured"); - if (isNoServer && retries > 0) { - await new Promise((r) => setTimeout(r, 500)); - } else { - throw err; - } - } + const cookieReady = await window.electronAPI?.waitForSessionCookie?.( + "jwt", + currentServerUrl, + previousJwt, + 5000, + ); + if (cookieReady && !cookieReady.success) { + throw new Error(cookieReady.error || "Authentication cookie not ready"); } + const meRes = await getUserInfo(); if (!meRes) throw new Error("Failed to get user info"); setInternalLoggedIn(true); setLoggedIn(true); @@ -181,6 +174,7 @@ export function Auth({ setUserId, t, setInternalLoggedIn, + currentServerUrl, ]); useEffect(() => { @@ -655,27 +649,32 @@ export function Auth({ if (success) { setOidcLoading(true); + if (isInElectronWebView()) { + try { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + source: "oidc_callback", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + setOidcLoading(false); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + return; + } catch (e) { + console.error("Error posting auth success message:", e); + } + } + getUserInfo() .then((meRes) => { - if (isInElectronWebView()) { - try { - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - source: "oidc_callback", - platform: "desktop", - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - setOidcLoading(false); - return; - } catch (e) { - console.error("Error posting auth success message:", e); - } - } - setInternalLoggedIn(true); setLoggedIn(true); setIsAdmin(!!meRes.is_admin); diff --git a/src/ui/desktop/authentication/ElectronLoginForm.tsx b/src/ui/desktop/authentication/ElectronLoginForm.tsx index f8b34fc7..dbd32f6f 100644 --- a/src/ui/desktop/authentication/ElectronLoginForm.tsx +++ b/src/ui/desktop/authentication/ElectronLoginForm.tsx @@ -5,7 +5,7 @@ import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react"; interface ElectronLoginFormProps { serverUrl: string; - onAuthSuccess: () => void; + onAuthSuccess: (previousJwt: string | null) => void | Promise; onChangeServer: () => void; } @@ -27,13 +27,29 @@ export function ElectronLoginForm({ const isAuthenticatingRef = useRef(false); const iframeRef = useRef(null); const hasAuthenticatedRef = useRef(false); + const [cookieSnapshotReady, setCookieSnapshotReady] = useState(false); const [currentUrl, setCurrentUrl] = useState(serverUrl); const hasLoadedOnce = useRef(false); const onAuthSuccessRef = useRef(onAuthSuccess); + const initialJwtRef = useRef(undefined); useEffect(() => { onAuthSuccessRef.current = onAuthSuccess; }, [onAuthSuccess]); + useEffect(() => { + window.electronAPI + ?.getSessionCookie?.("jwt") + .then((value) => { + initialJwtRef.current = value; + }) + .catch(() => { + initialJwtRef.current = null; + }) + .finally(() => { + setCookieSnapshotReady(true); + }); + }, []); + const handleAuthSuccess = useCallback(async () => { if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return; hasAuthenticatedRef.current = true; @@ -41,7 +57,7 @@ export function ElectronLoginForm({ setIsAuthenticating(true); try { - onAuthSuccessRef.current(); + await onAuthSuccessRef.current(initialJwtRef.current ?? null); } catch (_err) { setError(t("errors.authTokenSaveFailed")); isAuthenticatingRef.current = false; @@ -186,7 +202,7 @@ export function ElectronLoginForm({ >