From 47f6378d3fcafe51ec75c6a2166d14bdc635f37e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:15:45 +0200 Subject: [PATCH] fix: stop calling a reload without Remember me an expired session --- .../auth-store-reload-signed-out.test.ts | 74 +++++++++++++++++++ stores/auth-store.ts | 7 +- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 stores/__tests__/auth-store-reload-signed-out.test.ts diff --git a/stores/__tests__/auth-store-reload-signed-out.test.ts b/stores/__tests__/auth-store-reload-signed-out.test.ts new file mode 100644 index 000000000..c21edd6c6 --- /dev/null +++ b/stores/__tests__/auth-store-reload-signed-out.test.ts @@ -0,0 +1,74 @@ +import { beforeAll, beforeEach, expect, it, vi } from 'vitest'; +import { createConfig } from '@/lib/__tests__/fixtures/config'; + +// Without "Remember me" nothing is stored to resume from, so a reload signs +// the user out on purpose. The login page then said "Your session has +// expired", which reads like an error. +vi.mock('@/lib/jmap/client', () => { + class JMAPClient { + constructor(public serverUrl: string, public username: string, public password: string) {} + static withBearer() { throw new Error('not used'); } + async connect() {} + getSessionUsername() { return this.username; } + getUsername() { return this.username; } + async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; } + getAuthHeader() { return 'Basic eA=='; } + onConnectionChange() {} + onRateLimit() {} + getRateLimitRemainingMs() { return 0; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + disconnect() {} + getAccountId() { return 'acct'; } + } + class RateLimitError extends Error {} + class RequestTimeoutError extends Error {} + return { JMAPClient, RateLimitError, RequestTimeoutError }; +}); +vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null })); + +import * as nav from '@/lib/browser-navigation'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; + +const signedIn = { + id: 'bob@x.test@mail.x.test', label: 'Bob', serverUrl: 'https://mail.x.test', username: 'bob@x.test', authMode: 'basic' as const, + rememberMe: true, cookieSlot: 0, displayName: 'Bob', email: 'bob@x.test', isConnected: false, hasError: false, + lastLoginAt: 1, avatarColor: '#000000', isDefault: true, +}; +const redirect = vi.fn(); + +beforeAll(() => { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + const url = String(input); + if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 }); + return new Response('{}', { status: 401 }); + })); + vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(redirect); +}); + +beforeEach(() => { + redirect.mockClear(); + sessionStorage.clear(); +}); + +it('does not call a reload of a session without Remember me an expired session', async () => { + useAccountStore.setState({ accounts: [{ ...signedIn, rememberMe: false }] as never, activeAccountId: signedIn.id, defaultAccountId: signedIn.id }); + + await useAuthStore.getState().checkAuth(); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(sessionStorage.getItem('session_expired')).toBeNull(); +}); + +it('still reports a remembered session that could not be resumed', async () => { + useAccountStore.setState({ accounts: [signedIn] as never, activeAccountId: signedIn.id, defaultAccountId: signedIn.id }); + + await useAuthStore.getState().checkAuth(); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(sessionStorage.getItem('session_expired')).toBe('true'); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 7ee0bc23a..b2c5a8b79 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -2577,6 +2577,9 @@ export const useAuthStore = create()( // and sync the passthrough auth context. The context write never // throws and doesn't depend on the connect result, so its round trip // overlaps the connect instead of running after it. + // Accounts signed in without "Remember me": a reload ends them on + // purpose, so it is not reported as an expired session. + let forgottenAccounts = 0; const restoreAccount = async (account: (typeof accounts)[number]) => { if (clients.has(account.id)) return; // Already connected // Restored from the account list backup: listed until signed in. @@ -2589,6 +2592,7 @@ export const useAuthStore = create()( // way; a missing one surfaces as a 401 from fetchSlotSession below // and evicts too.) if (account.authMode !== 'oauth' && !account.rememberMe && !IS_LITE) { + forgottenAccounts += 1; evictAccount(account.id); accountStore.removeAccount(account.id); return; @@ -2791,7 +2795,8 @@ export const useAuthStore = create()( return; } - markSessionExpired(); + if (forgottenAccounts === accounts.length) saveRedirectAfterLogin(); + else markSessionExpired(); set({ isAuthenticated: false, isLoading: false,