fix(logout): reduce unwanted API call during logout

- stop trying to refresh session
- display "successfully signed out" rather "your session is expired"
This commit is contained in:
Edouard Vanbelle
2026-08-09 13:47:47 +02:00
parent f4e923e381
commit bbef8afb7b
21 changed files with 191 additions and 106 deletions
+32 -12
View File
@@ -164,6 +164,21 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
const apiFetch: FetchFn = async (input, init) => {
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
// Session-teardown short-circuit. While a logout is in flight (or
// the caller has already navigated to /login post-logout without
// re-authenticating), the session is dead — any subscriber-fired
// refresh (`session.load()` in the layout, a store `$effect` re-
// fetching its slice, an idle poll) would hit /me → 401 → refresh
// → 401 → sessionExpiredHandler and clobber the friendly
// "logged out" landing with `?source=session_expired`. Fail these
// fast with an AbortError so callers unwrap cleanly via their
// existing `.catch` blocks and no server hop occurs. The auth
// primitives themselves (notably `/api/auth/logout`) are exempt so
// the logout POST that FLIPPED the gate can still complete.
const urlStrEarly = urlString(input as RequestInfo | URL);
if (logoutInProgress && !bypassesRetry(urlStrEarly)) {
throw new DOMException('Session terminated', 'AbortError');
}
const response = await dpopFetch(input, init);
// Server-status header piggyback — the server stamps
// `x-server-status` on every response while a maintenance
@@ -256,18 +271,23 @@ export function setSessionExpiredHandler(fn: () => void): void {
sessionExpiredHandler = fn;
}
// Logout-in-progress gate. Set to true by the logout endpoint wrapper
// (endpoints/auth.ts) for the duration of the POST /api/auth/logout
// call; reset in its `finally`. While set, `sessionExpiredHandler`
// is suppressed — an ambient 401 during the logout window is expected
// (the backend clears cookies and revokes the session as part of the
// logout response, so any in-flight fetch racing the logout will 401),
// and firing the handler would navigate to `/login?source=session_expired`
// mid-flight, cancelling the logout POST before we get its response
// body. Since the response body carries `post_logout_url` (the IdP's
// end_session_endpoint URL for OIDC-linked sessions), losing it means
// the browser never redirects to the IdP and the SSO session persists.
// See AppShell.svelte::onLogout for the caller-side counterpart.
// Session-teardown gate. Flipped ON by `AppShell::onLogout` immediately
// BEFORE it calls `logout()` and left ON across the redirect to /login
// (module state persists over SvelteKit soft nav — a hard reload wipes
// it back to `false`, which is the correct default for a fresh session).
// While set:
// 1. `apiFetch` short-circuits every non-auth-primitive request with
// an `AbortError` — no server hop, no 401, no audit noise. Callers
// unwrap through their existing `.catch` blocks.
// 2. On a 401 the `sessionExpiredHandler` divert is suppressed so it
// cannot clobber the friendly `/login?source=logged_out` landing
// with `?source=session_expired`.
// Rule (1) alone would defeat the logout POST itself, so the auth
// primitives (`/api/auth/logout`, `/api/auth/refresh`, …) are exempted
// via `bypassesRetry`. Rule (2) additionally covers the tail-end race
// where the logout response's `post_logout_url` matters for OIDC — an
// ambient 401 mid-flight cannot cancel the pending POST and swallow
// its body, which would leave the IdP session live.
let logoutInProgress = false;
export function setLogoutInProgress(value: boolean): void {
+7 -17
View File
@@ -3,7 +3,7 @@
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
* a genuine failure to the caller.
*/
import { ApiError, apiFetch, setLogoutInProgress } from '$lib/api/client';
import { ApiError, apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { AuthResponse, User } from '$lib/api/types';
@@ -564,16 +564,11 @@ export async function unlinkOidc(): Promise<void> {
}
export async function logout(): Promise<LogoutResult> {
// Gate the session-expired handler for the duration of this call.
// The backend revokes the session + clears cookies as part of the
// logout response, so any in-flight fetch racing us will 401. Without
// the gate, that ambient 401 would trigger a navigation to
// `/login?source=session_expired`, cancel the pending logout POST,
// and swallow the `post_logout_url` response body — leaving the SSO
// session live on the IdP because we never navigate to its
// end_session_endpoint. See client.ts `logoutInProgress` for details.
setLogoutInProgress(true);
try {
// The session-teardown gate (`setLogoutInProgress(true)`) is flipped
// by the CALLER (`AppShell::onLogout`) BEFORE this function runs, and
// left ON across the goto to /login. See `client.ts::logoutInProgress`
// for what the gate suppresses (short-circuits ambient fetches with
// AbortError + blocks the session-expired divert).
const res = await apiFetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
@@ -605,13 +600,8 @@ export async function logout(): Promise<LogoutResult> {
if (!res.ok) return {};
try {
const body = (await res.json()) as { post_logout_url?: unknown };
return typeof body?.post_logout_url === 'string'
? { postLogoutUrl: body.post_logout_url }
: {};
return typeof body?.post_logout_url === 'string' ? { postLogoutUrl: body.post_logout_url } : {};
} catch {
return {};
}
} finally {
setLogoutInProgress(false);
}
}
+16 -7
View File
@@ -4,6 +4,7 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { logout } from '$lib/api/endpoints/auth';
import { setLogoutInProgress } from '$lib/api/client';
import { searchResources } from '$lib/api/endpoints/search';
import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files';
import { deleteFolder } from '$lib/api/endpoints/folders';
@@ -494,6 +495,14 @@
}
async function onLogout() {
// Flip the session-teardown gate BEFORE the logout POST so every
// ambient/subscriber-fired fetch that fires between here and the
// /login mount short-circuits with AbortError instead of hitting
// the server (see `client.ts::logoutInProgress`). Left ON across
// the goto — module state persists over soft nav, so a stale
// reactive re-fetch during the transition still no-ops. A hard
// reload later (or the IdP round-trip below) wipes it naturally.
setLogoutInProgress(true);
let postLogoutUrl: string | undefined;
try {
({ postLogoutUrl } = await logout());
@@ -504,18 +513,18 @@
// Full-page navigation to the IdP end-session endpoint. Do NOT
// touch local session state first: `session.reset()` fires the
// layout $effect guard which races us with a competing
// `goto('/login?redirect=...')`, and any ambient in-flight
// fetch that 401s trips the sessionExpiredHandler with yet
// another navigation to `/login?source=session_expired`. Two
// or three concurrent navigations cancel each other and the
// browser stalls on the current page. The IdP round-trip lands
// us back on `/login` where the SPA reboots fresh from scratch —
// `goto('/login?redirect=...')`. The IdP round-trip lands us
// back on `/login` where the SPA reboots fresh from scratch —
// no local cleanup needed here.
window.location.replace(postLogoutUrl);
return;
}
session.reset();
await goto(resolve('/login'));
// `?source=logged_out` distinguishes the friendly explicit-logout
// 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.
await goto(resolve('/login?source=logged_out'));
}
</script>
+11
View File
@@ -119,6 +119,17 @@ class SessionStore {
this.user = null;
this.homeFolderId = null;
this.homeFolderName = null;
// Mark the store as `loaded` so any subsequent `session.load()` —
// notably the login page's existing-session probe and the root
// layout's post-nav mount — short-circuits to `null` instead of
// re-probing `/api/auth/me`. After an explicit logout we know for
// a fact the session is gone; a probe would 401, the interceptor
// would retry via /refresh (also 401), and `sessionExpiredHandler`
// would divert to `/login?source=session_expired` — clobbering the
// nice "logged out" landing. On a hard nav (natural expiry path)
// module state is fresh and this flag is `false` again, so the
// probe still runs there.
this.loaded = true;
}
}
+41 -2
View File
@@ -98,6 +98,16 @@
// immediately after so revisits / manual logouts don't re-show
// the stale message.
let sessionExpiredNotice = $state(false);
// One-shot "logged out" success banner, distinct from the
// session-expired one above. Triggered by AppShell::onLogout via
// `?source=logged_out`. Consumed on mount (URL stripped) so the
// notice never re-appears on reload.
let loggedOutNotice = $state(false);
// Also gates the existing-session probe below — after an explicit
// logout we know the session is dead; probing would 401 → refresh
// → 401 and clobber this landing with `?source=session_expired`
// via the interceptor.
let skipExistingSessionProbe = $state(false);
// One-shot notice populated from ?login_error=<key> on mount.
// Set by the OIDC callback's AutoLinkRefused redirect when the
// IdP-returned email matches an existing local account but the
@@ -345,8 +355,13 @@
// Strip it from the URL so the banner never re-appears on
// reloads / manual logout redirects. Uses history.replaceState
// (no navigation, no scroll jump).
if (page.url.searchParams.get('source') === 'session_expired') {
sessionExpiredNotice = true;
const sourceParam = page.url.searchParams.get('source');
if (sourceParam === 'session_expired' || sourceParam === 'logged_out') {
if (sourceParam === 'session_expired') sessionExpiredNotice = true;
else loggedOutNotice = true;
// Either flag means we KNOW there's no live session — skip
// the existing-session probe further down.
skipExistingSessionProbe = true;
const stripped = new URL(page.url);
stripped.searchParams.delete('source');
window.history.replaceState(
@@ -394,6 +409,11 @@
}
// 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) {
try {
const me = await fetchMe();
if (me) {
@@ -404,6 +424,7 @@
} catch {
/* probe failed — show the login page */
}
}
// 3) Bootstrap probe: a fresh install (no admin) must be set up first.
const [providers, status] = await Promise.all([getOidcProviders(), getAuthStatus()]);
@@ -527,6 +548,24 @@
</div>
{/if}
{#if loggedOutNotice}
<div
class="auth-success auth-error--dismissible"
style="display: flex"
role="status"
data-testid="login-logged-out-notice"
>
<span>{t('auth.logged_out', 'Successfully signed out.')}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-logged-out-dismiss-btn"
onclick={() => (loggedOutNotice = false)}>×</button
>
</div>
{/if}
{#if postRegisterNotice && mode === 'login'}
<div
class="auth-success auth-error--dismissible"
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "ليس لديك كلمة مرور؟ أدخل بريدك الإلكتروني وسنرسل لك رابط تسجيل دخول لمرة واحدة.",
"magic_unavailable": "تسجيل الدخول عبر البريد الإلكتروني غير متاح على هذا الخادم.",
"passwords_match": "Passwords match",
"sign_in": "تسجيل الدخول"
"sign_in": "تسجيل الدخول",
"logged_out": "تم تسجيل الخروج بنجاح."
},
"storage": {
"title": "التخزين",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "Kein Passwort? Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen einmaligen Anmeldelink.",
"magic_unavailable": "Die Anmeldung per E-Mail ist auf diesem Server nicht verfügbar.",
"passwords_match": "Passwörter stimmen überein",
"sign_in": "Anmelden"
"sign_in": "Anmelden",
"logged_out": "Erfolgreich abgemeldet."
},
"storage": {
"title": "Speicher",
+1
View File
@@ -724,6 +724,7 @@
"passwords_match": "Passwords match",
"register_error": "Registration failed",
"session_expired": "Your session expired. Please sign in again.",
"logged_out": "Successfully signed out.",
"sign_in": "Sign in",
"signing_in": "Signing in…",
"sending": "Sending…",
+2 -1
View File
@@ -569,7 +569,8 @@
"magic_hint": "¿Sin contraseña? Introduce tu correo electrónico y te enviaremos un enlace de inicio de sesión único.",
"magic_unavailable": "El inicio de sesión por correo electrónico no está disponible en este servidor.",
"passwords_match": "Las contraseñas coinciden",
"sign_in": "Iniciar sesión"
"sign_in": "Iniciar sesión",
"logged_out": "Sesión cerrada correctamente."
},
"storage": {
"title": "Almacenamiento",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "رمز عبور ندارید؟ ایمیل خود را وارد کنید تا یک پیوند ورود یک‌بار‌مصرف برایتان ارسال شود.",
"magic_unavailable": "ورود با ایمیل در این سرور در دسترس نیست.",
"passwords_match": "Passwords match",
"sign_in": "ورود"
"sign_in": "ورود",
"logged_out": "با موفقیت خارج شدید."
},
"storage": {
"title": "فضای ذخیره‌سازی",
+2 -1
View File
@@ -572,7 +572,8 @@
"login_error_already_linked_elsewhere": "Un compte local avec cette adresse e-mail existe déjà et est relié à une autre identité SSO. Contactez votre administrateur.",
"login_error_callback_denied": "Votre lien de connexion a expiré ou a déjà été utilisé. Veuillez réessayer.",
"login_error_callback_failed": "La connexion SSO n'a pas pu se terminer. Veuillez réessayer.",
"login_error_generic": "La connexion SSO a été refusée. Veuillez réessayer."
"login_error_generic": "La connexion SSO a été refusée. Veuillez réessayer.",
"logged_out": "Vous êtes déconnecté."
},
"storage": {
"title": "Stockage",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "पासवर्ड नहीं है? अपना ईमेल दर्ज करें और हम आपको एक बार उपयोग होने वाला साइन-इन लिंक भेज देंगे।",
"magic_unavailable": "इस सर्वर पर ईमेल द्वारा साइन-इन उपलब्ध नहीं है।",
"passwords_match": "Passwords match",
"sign_in": "साइन इन"
"sign_in": "साइन इन",
"logged_out": "सफलतापूर्वक साइन आउट हो गए।"
},
"storage": {
"title": "स्टोरेज",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "Niente password? Inserisci la tua email e ti invieremo un link di accesso monouso.",
"magic_unavailable": "L'accesso tramite email non è disponibile su questo server.",
"passwords_match": "Le password corrispondono",
"sign_in": "Accedi"
"sign_in": "Accedi",
"logged_out": "Disconnessione effettuata."
},
"storage": {
"title": "Archiviazione",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "パスワードをお持ちでない方は、メールアドレスを入力するとワンタイムサインインリンクをお送りします。",
"magic_unavailable": "このサーバーではメールでのサインインは利用できません。",
"passwords_match": "Passwords match",
"sign_in": "サインイン"
"sign_in": "サインイン",
"logged_out": "サインアウトしました。"
},
"storage": {
"title": "ストレージ",
+1
View File
@@ -687,6 +687,7 @@
"magic_sent": "해당 계정이 존재하면 로그인 링크가 전송되었습니다. 받은편지함을 확인하세요.",
"register_error": "가입 실패",
"session_expired": "세션이 만료되었습니다. 다시 로그인해 주세요.",
"logged_out": "로그아웃되었습니다.",
"signing_in": "로그인 중…",
"toggle_password": "비밀번호 표시"
},
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "Geen wachtwoord? Voer uw e-mailadres in en we sturen u een eenmalige aanmeldlink.",
"magic_unavailable": "Aanmelden per e-mail is niet beschikbaar op deze server.",
"passwords_match": "Passwords match",
"sign_in": "Inloggen"
"sign_in": "Inloggen",
"logged_out": "Succesvol afgemeld."
},
"storage": {
"title": "Opslag",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "Brak hasła? Wpisz swój adres e-mail, a wyślemy Ci jednorazowy link do logowania.",
"magic_unavailable": "Logowanie e-mailem nie jest dostępne na tym serwerze.",
"passwords_match": "Passwords match",
"sign_in": "Zaloguj się"
"sign_in": "Zaloguj się",
"logged_out": "Wylogowano pomyślnie."
},
"storage": {
"title": "Pamięć masowa",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "Sem senha? Digite seu e-mail e enviaremos um link de acesso único.",
"magic_unavailable": "O acesso por e-mail não está disponível neste servidor.",
"passwords_match": "As palavras-passe coincidem",
"sign_in": "Entrar"
"sign_in": "Entrar",
"logged_out": "Sessão terminada com sucesso."
},
"storage": {
"title": "Armazenamento",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "Нет пароля? Введите ваш email, и мы пришлём вам одноразовую ссылку для входа.",
"magic_unavailable": "Вход по электронной почте недоступен на этом сервере.",
"passwords_match": "Passwords match",
"sign_in": "Вход"
"sign_in": "Вход",
"logged_out": "Вы успешно вышли."
},
"storage": {
"title": "Хранилище",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "沒有密碼?輸入您的電子郵件,我們將向您發送一次性登入連結。",
"magic_unavailable": "此伺服器不支援電子郵件登入。",
"passwords_match": "Passwords match",
"sign_in": "登入"
"sign_in": "登入",
"logged_out": "已成功登出。"
},
"storage": {
"title": "儲存空間",
+2 -1
View File
@@ -564,7 +564,8 @@
"magic_hint": "没有密码?输入您的邮箱,我们将向您发送一次性登录链接。",
"magic_unavailable": "此服务器不支持邮箱登录。",
"passwords_match": "Passwords match",
"sign_in": "登录"
"sign_in": "登录",
"logged_out": "已成功退出。"
},
"storage": {
"title": "存储空间",