diff --git a/frontend/src/lib/api/csrf.ts b/frontend/src/lib/api/csrf.ts index 1d1549b9..e791745d 100644 --- a/frontend/src/lib/api/csrf.ts +++ b/frontend/src/lib/api/csrf.ts @@ -17,3 +17,20 @@ export function getCsrfHeaders(): Record { const token = getCsrfToken(); return token ? { 'X-CSRF-Token': token } : {}; } + +/** + * Best-effort "does the browser think it has a session?" hint. The server + * sets `oxicloud_csrf` (non-HttpOnly, JS-visible) alongside the session + * cookies on every login and clears it on logout, so its ABSENCE is a + * reliable proof of "no session" — cheaper than a network probe that + * would 401 → refresh 401 → 401 on first landing with no cookies. + * + * Its PRESENCE is only a hint: the session cookies (HttpOnly) may have + * been revoked server-side while the CSRF cookie lingers. Callers that + * see `true` must still probe /api/auth/me — this helper just lets a + * fresh no-cookie bootstrap skip the doomed 2× /me + /refresh burst. + */ +export function hasSessionHint(): boolean { + if (typeof document === 'undefined') return false; + return document.cookie.split('; ').some((row) => row.startsWith('oxicloud_csrf=')); +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 12047e12..6200c1ec 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -524,11 +524,7 @@ // landing from `?source=session_expired` (auto-divert on 401 → // refresh 401). The login page reads the flag, shows the success // notice, and skips its existing-session probe. - // `resolve()` from `$app/paths` normalises a route-id (adds base - // path prefix); it does NOT accept a query string. Concatenate - // the `?source=logged_out` marker AFTER resolve so the login - // page's onMount branch actually sees it. - await goto(resolve('/login') + '?source=logged_out'); + await goto(resolve('/login?source=logged_out')); } diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 82046670..6b523301 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -8,6 +8,7 @@ */ import { bindDpopIfPossible, fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; import { setLogoutInProgress } from '$lib/api/client'; +import { hasSessionHint } from '$lib/api/csrf'; import { drives } from '$lib/stores/drives.svelte'; import type { User } from '$lib/api/types'; import { ensureActiveUser } from '$lib/utils/localStoragePrefs'; @@ -41,6 +42,16 @@ class SessionStore { */ async load(): Promise { if (this.loaded) return this.user; + // No JS-visible session hint ⇒ nothing to probe. The server sets + // `oxicloud_csrf` alongside the HttpOnly session cookies and clears + // it on logout, so a missing hint means no session. Skips the + // doomed 2× /me + /refresh burst that would otherwise fire on + // every first landing / post-logout re-mount with no cookies. + if (!hasSessionHint()) { + this.user = null; + this.loaded = true; + return null; + } try { let me = await fetchMe(); if (!me && (await tryRefresh())) { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 52feb865..3bbdb33f 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -22,6 +22,7 @@ } from '$lib/api/endpoints/auth'; import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import { session } from '$lib/stores/session.svelte'; + import { hasSessionHint } from '$lib/api/csrf'; type Mode = 'login' | 'register' | 'setup'; let mode = $state('login'); @@ -409,11 +410,13 @@ } // 2) Existing-session probe: if already authenticated, skip the form. - // Skipped when we KNOW the session is gone (explicit logout or - // interceptor-detected expiry) — probing would 401, the - // interceptor would retry via /refresh (also 401), and the - // sessionExpiredHandler would clobber this landing. - if (!skipExistingSessionProbe) { + // Skipped when we KNOW the session is gone: explicit logout / + // interceptor-detected expiry (both set `skipExistingSessionProbe`), + // OR the CSRF hint cookie is absent (fresh browser, no cookies at + // all — a probe would just 401). Probing anyway would trip the + // apiFetch → 401 → refresh → 401 → sessionExpiredHandler chain + // that clobbers whatever notice we're about to paint. + if (!skipExistingSessionProbe && hasSessionHint()) { try { const me = await fetchMe(); if (me) {