Fix: keep the session when the auth server is briefly unreachable

Any transient failure used to end the session: the token route deleted
the refresh cookies on every non-OK answer from the OAuth endpoint,
refreshAccessToken logged out on any non-OK status or network error,
and the startup restore evicted the account and deleted its session
cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop
waking before the network is back all kicked the user out despite
"stay signed in".

Failures are now classified. Only a definitive rejection (400/401/403
from the OAuth endpoint, 401 from the token route) tears the session
down and deletes cookies, exactly as before. Network errors and 5xx
keep the session: the token refresh re-arms itself and retries every
~30 seconds until the server is back, and the startup restore keeps
the account, marked unreachable - the same treatment the rate-limit
carve-out (#104) already applies.

Token validity stays entirely server-enforced: the first definitive
401 after an outage still logs out as before.
This commit is contained in:
dealerweb
2026-07-06 15:52:05 +02:00
committed by Linus Rath
parent a4dc0b7b4e
commit 9110bc388f
3 changed files with 145 additions and 13 deletions
+63 -8
View File
@@ -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<AuthState, 'isRateLimited' | 'rateLimitUntil'> {
if (!client) {
return { isRateLimited: false, rateLimitUntil: null };
@@ -294,6 +320,10 @@ const clients = new Map<string, JMAPClient>();
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const refreshPromises = new Map<string, Promise<string | null>>();
// 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<string | null>, accountId?: string): void {
if (accountId) {
const existing = refreshTimers.get(accountId);
@@ -948,9 +978,18 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
});
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<AuthState>()(
}
} 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;
}