Fix OIDC auth cookie readiness

This commit is contained in:
Xenthys
2026-04-28 15:35:55 +00:00
parent 50b2f32d93
commit 5e52de33e1
6 changed files with 164 additions and 73 deletions
+61
View File
@@ -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;
+9
View File
@@ -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),
});
+8
View File
@@ -61,6 +61,14 @@ export interface ElectronAPI {
started: number;
errors: string[];
}>;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (name: string) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
+35 -36
View File
@@ -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);
@@ -5,7 +5,7 @@ import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
interface ElectronLoginFormProps {
serverUrl: string;
onAuthSuccess: () => void;
onAuthSuccess: (previousJwt: string | null) => void | Promise<void>;
onChangeServer: () => void;
}
@@ -27,13 +27,29 @@ export function ElectronLoginForm({
const isAuthenticatingRef = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(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<string | null | undefined>(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({
>
<iframe
ref={iframeRef}
src={serverUrl}
src={cookieSnapshotReady ? serverUrl : "about:blank"}
className="w-full h-full border-0"
title="Server Authentication"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
+32 -34
View File
@@ -577,43 +577,41 @@ export function Auth({
window.history.replaceState({}, document.title, window.location.pathname);
setTimeout(() => {
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
postAuthSuccessToWebView();
if (isReactNativeWebView()) {
postAuthSuccessToWebView();
setMobileAuthSuccess(true);
setOidcLoading(false);
return;
}
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setOidcLoading(false);
return;
}
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
setLoggedIn(true);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
});
setInternalLoggedIn(true);
})
.catch((err) => {
console.error("Failed to get user info after OIDC callback:", err);
setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
})
.finally(() => {
setOidcLoading(false);
setLoggedIn(true);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
});
}, 200);
setInternalLoggedIn(true);
})
.catch((err) => {
console.error("Failed to get user info after OIDC callback:", err);
setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
})
.finally(() => {
setOidcLoading(false);
});
}
}, []);