diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index e734ba71..16768956 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) { if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText }); - cookieStore.delete(cookieName); - cookieStore.delete(refreshTokenServerCookieName(slot)); - return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + // Drop the refresh token only when the server definitively rejected it + // (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping + // the cookie lets the session resume once the server is back. + const status = tokenResponse.status; + if (status === 400 || status === 401 || status === 403) { + cookieStore.delete(cookieName); + cookieStore.delete(refreshTokenServerCookieName(slot)); + return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + } + return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); } const tokens = await tokenResponse.json(); diff --git a/stores/__tests__/auth-store-logout.test.ts b/stores/__tests__/auth-store-logout.test.ts index 813b6744..e8629c8a 100644 --- a/stores/__tests__/auth-store-logout.test.ts +++ b/stores/__tests__/auth-store-logout.test.ts @@ -55,7 +55,7 @@ describe('auth-store logout redirects', () => { expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true }); }); - it('marks session expiry, preserves the current path, and redirects to login on refresh failure', async () => { + it('marks session expiry, preserves the current path, and redirects to login when the refresh is rejected (401)', async () => { vi.useFakeTimers(); const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { @@ -63,7 +63,7 @@ describe('auth-store logout redirects', () => { const method = init?.method ?? 'GET'; if (url === '/api/auth/token?slot=0' && method === 'PUT') { - return { ok: false, json: async () => ({}) }; + return { ok: false, status: 401, json: async () => ({}) }; } if (url === '/api/auth/token?slot=0' && method === 'DELETE') { @@ -94,4 +94,74 @@ describe('auth-store logout redirects', () => { expect(sessionStorage.getItem('redirect_after_login')).toBe('/en/calendar?view=day'); expect(replaceSpy).toHaveBeenCalledWith('/en/login'); }); + + it('keeps the session and schedules a retry when the token endpoint is unavailable (5xx)', async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (url === '/api/auth/token?slot=0' && method === 'PUT') { + return { ok: false, status: 503, json: async () => ({}) }; + } + + throw new Error(`Unexpected fetch call: ${method} ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + + useAuthStore.setState({ + isAuthenticated: true, + authMode: 'oauth', + activeAccountId: null, + }); + + const token = await useAuthStore.getState().refreshAccessToken(); + + expect(token).toBeNull(); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(sessionStorage.getItem('session_expired')).toBeNull(); + expect(replaceSpy).not.toHaveBeenCalled(); + + // A retry is armed: advancing past the ~30 s window fires a second PUT. + await vi.advanceTimersByTimeAsync(31_000); + const refreshPuts = fetchMock.mock.calls.filter( + ([input, init]) => String(input) === '/api/auth/token?slot=0' && init?.method === 'PUT', + ); + expect(refreshPuts.length).toBe(2); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); + + it('keeps the session when the refresh request fails with a network error', async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (url === '/api/auth/token?slot=0' && method === 'PUT') { + throw new TypeError('Failed to fetch'); + } + + throw new Error(`Unexpected fetch call: ${method} ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + + useAuthStore.setState({ + isAuthenticated: true, + authMode: 'oauth', + activeAccountId: null, + }); + + const token = await useAuthStore.getState().refreshAccessToken(); + + expect(token).toBeNull(); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(sessionStorage.getItem('session_expired')).toBeNull(); + expect(replaceSpy).not.toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/stores/auth-store.ts b/stores/auth-store.ts index d61e4a1c..d5502594 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -75,6 +75,32 @@ function isRateLimitError(error: unknown): error is RateLimitError { return error instanceof RateLimitError; } +// An auth/session endpoint answered with a server-side error (5xx) - an +// outage, not a rejection of our credentials. +class TransientAuthError extends Error { + constructor(message: string, readonly status: number) { + super(`${message}: ${status}`); + } +} + +// True when a restore/refresh attempt failed because the server could not be +// reached (network error) or answered 5xx (restart, maintenance, proxy +// hiccup). Such failures must keep the account and its cookies - "stay signed +// in" has to survive downtime and offline spells. Only a definitive rejection +// (401/400) may evict. Mirrors the rate-limit carve-out (#104). +function isTransientAuthError(error: unknown): boolean { + if (error instanceof TransientAuthError) return true; + // fetch() rejects with TypeError when the network is unreachable. + if (error instanceof TypeError) return true; + // JMAPClient.connect()/refreshSession() embed the HTTP status in the + // message - a 5xx there is the server being down, not an auth failure. + if (error instanceof Error) { + const m = error.message.match(/(?:Failed to get session|Session refresh failed): (\d{3})/); + if (m) return m[1].startsWith('5'); + } + return false; +} + function getClientRateLimitState(client: IJMAPClient | null): Pick { if (!client) { return { isRateLimited: false, rateLimitUntil: null }; @@ -294,6 +320,10 @@ const clients = new Map(); const refreshTimers = new Map>(); const refreshPromises = new Map>(); +// Pseudo-expiry passed to scheduleRefresh when a refresh failed transiently: +// the "expiry - 60s" math below turns 90 into a retry in ~30 seconds. +const TOKEN_REFRESH_RETRY_SECONDS = 90; + function scheduleRefresh(expiresIn: number, refreshFn: () => Promise, accountId?: string): void { if (accountId) { const existing = refreshTimers.get(accountId); @@ -948,9 +978,18 @@ export const useAuthStore = create()( const res = await apiFetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' }); if (!res.ok) { - notifyParent('sso:session-expired'); - markSessionExpired(); - get().logout(); + // Only a definitive 401 ends the session. Anything else (5xx + // while the server restarts, proxy errors) is an outage - keep + // the session and retry shortly so "stay signed in" survives + // maintenance windows and offline spells. + if (res.status === 401) { + notifyParent('sso:session-expired'); + markSessionExpired(); + get().logout(); + return null; + } + debug.error(`Token refresh unavailable (${res.status}), retrying shortly`); + scheduleRefresh(TOKEN_REFRESH_RETRY_SECONDS, get().refreshAccessToken, accountId ?? undefined); return null; } @@ -975,10 +1014,10 @@ export const useAuthStore = create()( scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined); return access_token; } catch (error) { - debug.error('Token refresh failed:', error); - notifyParent('sso:session-expired'); - markSessionExpired(); - get().logout(); + // Network failure (offline, Wi-Fi switch, server unreachable) - + // not a rejection. Keep the session and retry shortly. + debug.error('Token refresh failed, retrying shortly:', error); + scheduleRefresh(TOKEN_REFRESH_RETRY_SECONDS, get().refreshAccessToken, accountId ?? undefined); return null; } finally { refreshPromise = null; @@ -1374,6 +1413,8 @@ export const useAuthStore = create()( scheduleRefresh(expires_in, get().refreshAccessToken, account.id); await syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot); accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); + } else if (res.status >= 500) { + throw new TransientAuthError('Token refresh failed', res.status); } else { throw new Error(`Token refresh failed: ${res.status}`); } @@ -1387,6 +1428,8 @@ export const useAuthStore = create()( clients.set(account.id, client); await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), account.cookieSlot); accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); + } else if (res.status >= 500) { + throw new TransientAuthError('Session restore failed', res.status); } else { throw new Error(`Session cookie missing: ${res.status}`); } @@ -1401,6 +1444,18 @@ export const useAuthStore = create()( }); continue; } + // Outage or offline - keep the account (and its cookies) so the + // session resumes once the server is reachable again. Same + // treatment as the rate-limit case above; only a definitive + // rejection below evicts. + if (isTransientAuthError(err)) { + accountStore.updateAccount(account.id, { + isConnected: false, + hasError: true, + errorMessage: 'Server unreachable', + }); + continue; + } // Remove unrestorable accounts so the user is prompted to log in // again rather than seeing a stale error entry forever. evictAccount(account.id); @@ -1627,7 +1682,7 @@ export const useAuthStore = create()( } } catch (error) { debug.error('Basic session restore failed:', error); - if (isRateLimitError(error)) { + if (isRateLimitError(error) || isTransientAuthError(error)) { set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null }); return; }