mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
fix: keep local mail and the outbox when an automatic sign-out wasn't a real refusal of the login, for 30 days at most
This commit is contained in:
@@ -292,7 +292,10 @@ export async function PUT(request: NextRequest) {
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
cookieStore.delete(accessTokenCookieName(slot));
|
||||
clearIdToken(cookieStore, slot, request.nextUrl.basePath);
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
// Only invalid_grant says the login itself is over. invalid_client, or
|
||||
// a provider refusing an early refresh (#552), says nothing about it,
|
||||
// and the client keeps the account's local data for those.
|
||||
return NextResponse.json({ error: oauthErrorCode(errorText) === 'invalid_grant' ? 'invalid_grant' : 'Refresh failed' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
|
||||
}
|
||||
@@ -336,6 +339,15 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
function oauthErrorCode(body: string): string | null {
|
||||
try {
|
||||
const code = (JSON.parse(body) as { error?: unknown }).error;
|
||||
return typeof code === 'string' ? code : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a slot's refresh token and clear its token cookies. With
|
||||
* `endSession`, also returns the URL that ends the provider's session, or null
|
||||
|
||||
@@ -170,6 +170,21 @@ describe('oauth token route - access token cache (#552)', () => {
|
||||
expect(cookieStore.get('jmap_rt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('names a real invalid_grant, and only that, for the client to wipe on', async () => {
|
||||
const refuse = (status: number, body: string) => fetchMock.mockResolvedValueOnce(new Response(body, { status }));
|
||||
cookieStore.set('jmap_rt', 'refresh-token');
|
||||
refuse(400, JSON.stringify({ error: 'invalid_grant' }));
|
||||
expect(await callPut({ force: 'true' })).toMatchObject({ status: 401, body: { error: 'invalid_grant' } });
|
||||
|
||||
for (const body of [JSON.stringify({ error: 'invalid_client' }), JSON.stringify({ error: 'invalid_request', error_description: 'Token is not valid yet' }), '<html>Forbidden</html>']) {
|
||||
cookieStore.set('jmap_rt', 'refresh-token');
|
||||
refuse(403, body);
|
||||
const { status, body: answer } = await callPut({ force: 'true' });
|
||||
expect(status).toBe(401);
|
||||
expect(answer.error).not.toBe('invalid_grant');
|
||||
}
|
||||
});
|
||||
|
||||
it('answers 503 when the token endpoint is out of reach, which the client waits out', async () => {
|
||||
cookieStore.set('jmap_rt', 'refresh-token');
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* The server refused this account's refresh token for good. Nothing wipes on
|
||||
* this yet: a refused account is dropped, and its local data stays for the next
|
||||
* sign-in. HOOK: the wipe work (lib/store/wipe) registers here to act on a
|
||||
* permanent rejection. Free of the stores, so the auth store can call it.
|
||||
* The server really refused this login (`invalid_grant`), which the auth store
|
||||
* follows with a sign-out that wipes the account's local data. An outage or any
|
||||
* other refusal doesn't come here, and keeps the data. HOOK: lib/store/wipe
|
||||
* registers here for what else a permanent rejection should do. Free of the
|
||||
* stores, so the auth store can call it.
|
||||
*/
|
||||
|
||||
type Listener = (accountId: string) => void;
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type OutboxEntry,
|
||||
type OutboxError,
|
||||
} from './queue';
|
||||
import { useOfflineStore } from './state';
|
||||
import { visibleAccounts } from './state';
|
||||
import type { OfflineNotice } from './offline-mode';
|
||||
|
||||
export interface FlushDeps {
|
||||
@@ -152,7 +152,7 @@ export async function flushQueue(account: string, client: JMAPClient, deps: Flus
|
||||
|
||||
/** Marks sends of closed tabs uncertain (they may have gone out). */
|
||||
export async function settleStaleSends(account?: string, now = Date.now()): Promise<void> {
|
||||
const accounts = account ? [account] : useOfflineStore.getState().enabled;
|
||||
const accounts = account ? [account] : visibleAccounts();
|
||||
await Promise.all(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS })));
|
||||
}
|
||||
|
||||
|
||||
+43
-16
@@ -11,13 +11,15 @@
|
||||
* - Changes and sends made offline wait in the store's outbox and go out in
|
||||
* order once the server answers again and the organisation's policy was
|
||||
* fetched afresh (flush.ts).
|
||||
* - Everything is wiped at a full sign-out and the automatic sign-outs
|
||||
* (lib/sign-out-cleanup.ts deletes the store and its keys), for one account
|
||||
* when it signs out, is removed or switches offline mode off, and when the
|
||||
* organisation forbids it (checked on every policy fetch). An account the
|
||||
* app drops by itself (its session cookie ran out, say) keeps its data for
|
||||
* the next sign-in. Unsent messages a sign-out deletes without the user's
|
||||
* word are named (lost.ts).
|
||||
* - Everything is wiped at a full sign-out, at the session limits and when the
|
||||
* server really refuses the login (`invalid_grant`), by
|
||||
* lib/sign-out-cleanup.ts; for one account when it signs out, is removed or
|
||||
* switches offline mode off, and when the organisation forbids it (checked on
|
||||
* every policy fetch). An account the app drops by itself because it couldn't
|
||||
* confirm the login (an outage, an unknown refusal, a session cookie that ran
|
||||
* out) keeps its data for 30 days or the next sign-in, hidden from everyone
|
||||
* else. Unsent messages a sign-out deletes without the user's word are named
|
||||
* (lost.ts).
|
||||
*/
|
||||
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
@@ -52,7 +54,7 @@ import {
|
||||
type PolicyFingerprint,
|
||||
} from './queue';
|
||||
import { clearHandoffs } from '@/lib/launch-handoff';
|
||||
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
|
||||
import { clearEvicted, EVICTED_KEEP_MS, isEvicted, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
|
||||
import { flushQueue, settleStaleSends } from './flush';
|
||||
import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy';
|
||||
import { deleteShellCaches, tellServiceWorker } from './shell';
|
||||
@@ -470,7 +472,7 @@ export async function refreshQueueState(): Promise<void> {
|
||||
if (!offlineSupported()) return;
|
||||
// Nothing is kept for an account that hasn't it on; and opening the
|
||||
// store would create it (on the sign-in page, after a sign-out).
|
||||
if (useOfflineStore.getState().enabled.length === 0) {
|
||||
if (visibleAccounts().length === 0) {
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [] });
|
||||
return;
|
||||
}
|
||||
@@ -494,9 +496,9 @@ export async function refreshQueueState(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function refreshUsage(): Promise<void> {
|
||||
if (!offlineSupported() || useOfflineStore.getState().enabled.length === 0) return;
|
||||
if (!offlineSupported() || visibleAccounts().length === 0) return;
|
||||
try {
|
||||
const usage = await Promise.all(useOfflineStore.getState().enabled.map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage')));
|
||||
const usage = await Promise.all(visibleAccounts().map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage')));
|
||||
useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0) });
|
||||
} catch {
|
||||
/* deleted meanwhile */
|
||||
@@ -699,10 +701,13 @@ export async function wipeAccount(account: string): Promise<void> {
|
||||
const timer = syncTimers.get(account);
|
||||
if (timer) clearTimeout(timer);
|
||||
syncTimers.delete(account);
|
||||
// Waited for a sign-in that never came: whoever is here now mustn't read its subjects.
|
||||
if (isEvicted(account)) named.add(account);
|
||||
if (useOfflineStore.getState().enabled.includes(account) || useOfflineStore.getState().outbox.some((row) => row.account === account)) {
|
||||
await nameLost(account, useAuthStore.getState().isAuthenticated);
|
||||
}
|
||||
named.delete(account);
|
||||
clearEvicted(account);
|
||||
if (useOfflineStore.getState().enabled.includes(account)) {
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
}
|
||||
@@ -743,6 +748,8 @@ export async function wipeEverything(): Promise<void> {
|
||||
|
||||
/** Whether the policy of an account is known well enough to decide on it. */
|
||||
function policyDecided(account: string): boolean {
|
||||
// Dropped, with no groups to wait for: what applies to everyone reaches it.
|
||||
if (isEvicted(account)) return true;
|
||||
const auth = useAuthStore.getState();
|
||||
if (account === auth.activeAccountId || !auth.activeAccountId) return !usePolicyStore.getState().subjectPending;
|
||||
// Another signed-in account: its groups have to be known first (a group
|
||||
@@ -758,9 +765,11 @@ export function enforcePolicy(): void {
|
||||
for (const account of useOfflineStore.getState().enabled) {
|
||||
if (!policyDecided(account)) continue;
|
||||
if (!offlineAllowed(account)) {
|
||||
// Whoever is here now wasn't signed in to a dropped account.
|
||||
const someoneElses = isEvicted(account);
|
||||
clearHandoffs();
|
||||
void wipeAccount(account);
|
||||
notify({ kind: 'forbidden' });
|
||||
if (!someoneElses) notify({ kind: 'forbidden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -882,6 +891,7 @@ export function startOfflineMode(): () => void {
|
||||
cleanups.push(onAccountRemoved((id, reason) => {
|
||||
if (reason === 'explicit') void wipeAccount(id);
|
||||
}));
|
||||
void sweepAccountsGone();
|
||||
|
||||
// Session, then a sync, for every account signed in with offline mode on.
|
||||
const syncSignedIn = () => {
|
||||
@@ -923,23 +933,40 @@ export function startOfflineMode(): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Data no signed-in account vouches for. A dropped account's waits 30 days. One
|
||||
* that left the registry on purpose before this ran (a login without "remember
|
||||
* me" ends at a reload, before the host mounts) goes now, naming lost mail.
|
||||
*/
|
||||
async function sweepAccountsGone(now = Date.now()): Promise<void> {
|
||||
const evicted = readEvicted();
|
||||
const accounts = useAccountStore.getState().accounts;
|
||||
for (const account of useOfflineStore.getState().enabled) {
|
||||
const at = evicted[account];
|
||||
const gone = at !== undefined ? now - at > EVICTED_KEEP_MS : !accounts.some((entry) => entry.id === account);
|
||||
if (gone) await wipeAccount(account).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** How often the store catches up while the tab is open, on top of pushes. */
|
||||
export const SYNC_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
// A full sign-out deletes the store (lib/sign-out-cleanup.ts); forget what
|
||||
// is held in memory as well.
|
||||
onFullSignOut(() => {
|
||||
onFullSignOut(({ keepLocalStore }) => {
|
||||
for (const timer of syncTimers.values()) clearTimeout(timer);
|
||||
syncTimers.clear();
|
||||
getStore().stop();
|
||||
if (!keepLocalStore) getStore().stop();
|
||||
confirmed.clear();
|
||||
named.clear();
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false });
|
||||
useOfflineStore.setState((s) => ({ enabled: keepLocalStore ? s.enabled : [], outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false }));
|
||||
});
|
||||
|
||||
// Every sign-out the user didn't agree to lose mail at (the session limits,
|
||||
// a refused token renewal, a removed account) names the unsent messages.
|
||||
onBeforeSignOut(async (accountId) => {
|
||||
onBeforeSignOut(async (accountId, { keepLocalStore }) => {
|
||||
// Nothing is lost: it waits for the next sign-in.
|
||||
if (keepLocalStore) return;
|
||||
const stillSignedIn = accountId !== 'all' && useAccountStore.getState().accounts.some((a) => a.id !== accountId);
|
||||
await nameLost(accountId, stillSignedIn);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
|
||||
import { getStore } from '@/lib/store/client/api';
|
||||
import type { ConflictSummary, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem, StoredConflict, StoredOutboxEntry } from '@/lib/store/outbox/items';
|
||||
import { useOfflineStore } from './state';
|
||||
import { visibleAccounts } from './state';
|
||||
|
||||
export { LOCAL_BLOB_PREFIX, heldBlobIds, isLocalBlobId } from '@/lib/store/outbox/items';
|
||||
export type { EmailSendItem, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, RawSendItem, SendItem } from '@/lib/store/outbox/items';
|
||||
@@ -40,7 +40,7 @@ interface StoredQueue {
|
||||
|
||||
/** Everything waiting for one account (or every account with it on), oldest first. */
|
||||
export async function readQueue(account?: string): Promise<{ patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
|
||||
const accounts = account ? [account] : useOfflineStore.getState().enabled;
|
||||
const accounts = account ? [account] : visibleAccounts();
|
||||
const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') })));
|
||||
return {
|
||||
patches: read.reduce((sum, { queue }) => sum + queue.patches, 0),
|
||||
|
||||
@@ -74,6 +74,51 @@ export function writeEnabled(enabled: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** localStorage: accounts the app dropped by itself, and when. Their data waits for a new sign-in. */
|
||||
export const OFFLINE_EVICTED_KEY = 'bulwark-offline-evicted';
|
||||
/** How long that data waits. */
|
||||
export const EVICTED_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function readEvicted(): Record<string, number> {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(OFFLINE_EVICTED_KEY) ?? '{}') as unknown;
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, number> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeEvicted(evicted: Record<string, number>): void {
|
||||
try {
|
||||
if (Object.keys(evicted).length > 0) localStorage.setItem(OFFLINE_EVICTED_KEY, JSON.stringify(evicted));
|
||||
else localStorage.removeItem(OFFLINE_EVICTED_KEY);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** Only an account that keeps mail here has anything to wait for. */
|
||||
export function markEvicted(account: string, now = Date.now()): void {
|
||||
if (useOfflineStore.getState().enabled.includes(account)) writeEvicted({ ...readEvicted(), [account]: now });
|
||||
}
|
||||
|
||||
export function clearEvicted(account: string): void {
|
||||
const evicted = readEvicted();
|
||||
if (!(account in evicted)) return;
|
||||
delete evicted[account];
|
||||
writeEvicted(evicted);
|
||||
}
|
||||
|
||||
export function isEvicted(account: string): boolean {
|
||||
return account in readEvicted();
|
||||
}
|
||||
|
||||
/** The accounts whose data may show here: not those waiting for a sign-in, which may be someone else's. */
|
||||
export function visibleAccounts(): string[] {
|
||||
const evicted = readEvicted();
|
||||
return useOfflineStore.getState().enabled.filter((account) => !(account in evicted));
|
||||
}
|
||||
|
||||
export function serverKey(url: string): string {
|
||||
return url.replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
+30
-14
@@ -36,25 +36,38 @@ const SIGNED_OUT_STORAGE_KEYS = [
|
||||
// Offline mode's "Keep mail on this device" (lib/offline/state.ts): the
|
||||
// next person signing in here starts with it off.
|
||||
'bulwark-offline-accounts',
|
||||
// Which accounts' data waits for a new sign-in, and until when.
|
||||
'bulwark-offline-evicted',
|
||||
];
|
||||
|
||||
const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted']);
|
||||
|
||||
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
|
||||
|
||||
// Stores the auth store can't import (they import it) clear themselves here.
|
||||
const signedOutCleanups = new Set<() => void>();
|
||||
const signedOutCleanups = new Set<(options: SignOutCleanupOptions) => void>();
|
||||
|
||||
export interface SignOutCleanupOptions {
|
||||
/**
|
||||
* Keep the local store and its keys. For a sign-out the app decided on
|
||||
* because it couldn't confirm the login (an outage, an unknown refusal), or
|
||||
* when an account that was dropped that way still has data here.
|
||||
*/
|
||||
keepLocalStore?: boolean;
|
||||
}
|
||||
|
||||
/** Run `cleanup` when everyone signs out in this tab. */
|
||||
export function onFullSignOut(cleanup: () => void): void {
|
||||
export function onFullSignOut(cleanup: (options: SignOutCleanupOptions) => void): void {
|
||||
signedOutCleanups.add(cleanup);
|
||||
}
|
||||
|
||||
// Work that has to reach the server before an account signs out (changes
|
||||
// still queued for it). The auth store waits for it within its sign-out
|
||||
// budget, next to the plugins' onBeforeLogout.
|
||||
const beforeSignOutTasks = new Set<(accountId: string) => Promise<void>>();
|
||||
const beforeSignOutTasks = new Set<(accountId: string, options: SignOutCleanupOptions) => Promise<void>>();
|
||||
|
||||
/** Run `task` before an account signs out; `accountId` is 'all' when every account does. */
|
||||
export function onBeforeSignOut(task: (accountId: string) => Promise<void>): () => void {
|
||||
export function onBeforeSignOut(task: (accountId: string, options: SignOutCleanupOptions) => Promise<void>): () => void {
|
||||
beforeSignOutTasks.add(task);
|
||||
return () => beforeSignOutTasks.delete(task);
|
||||
}
|
||||
@@ -81,14 +94,15 @@ export async function passSignOutGates(accountId: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runBeforeSignOut(accountId: string): Promise<void> {
|
||||
await Promise.all([...beforeSignOutTasks].map((task) => task(accountId).catch(() => {})));
|
||||
export async function runBeforeSignOut(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> {
|
||||
await Promise.all([...beforeSignOutTasks].map((task) => task(accountId, options).catch(() => {})));
|
||||
}
|
||||
|
||||
/** Remove the accounts' leftovers from browser storage. */
|
||||
export function purgeSignedOutData(): void {
|
||||
export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
for (const key of SIGNED_OUT_STORAGE_KEYS) {
|
||||
if (options.keepLocalStore && LOCAL_STORE_KEYS.has(key)) continue;
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
@@ -111,18 +125,20 @@ export function purgeSignedOutData(): void {
|
||||
// Offline mode (#523): the local store with the mail and the outbox, the keys
|
||||
// it was encrypted with, and the app shell the service worker kept. Also the
|
||||
// database of older versions, in case it wasn't moved yet.
|
||||
void purgeStoreFiles();
|
||||
try {
|
||||
window.indexedDB?.deleteDatabase('bulwark-offline');
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
if (!options.keepLocalStore) {
|
||||
void purgeStoreFiles();
|
||||
try {
|
||||
window.indexedDB?.deleteDatabase('bulwark-offline');
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
forgetOfflineShell();
|
||||
}
|
||||
forgetOfflineShell();
|
||||
// Files and links on their way into a draft or an import.
|
||||
clearHandoffs();
|
||||
for (const cleanup of signedOutCleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
cleanup(options);
|
||||
} catch {
|
||||
/* one store failing must not keep the others' data */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// What an automatic sign-out does to the mail on this device. A real refusal of
|
||||
// the login wipes it, like a sign-out. An outage, or a refusal that says nothing
|
||||
// about the login, keeps it for the next sign-in.
|
||||
|
||||
const purge = vi.hoisted(() => ({ files: 0 }));
|
||||
vi.mock('@/lib/store/client/purge', () => ({ purgeStoreFiles: async () => { purge.files += 1; }, deleteStorePool: async () => {} }));
|
||||
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })) }));
|
||||
|
||||
let useAuthStore: typeof import('../auth-store').useAuthStore;
|
||||
let useAccountStore: typeof import('../account-store').useAccountStore;
|
||||
let state: typeof import('@/lib/offline/state');
|
||||
let offline: typeof import('@/lib/offline/offline-mode');
|
||||
let queue: typeof import('@/lib/offline/queue');
|
||||
let lost: typeof import('@/lib/offline/lost');
|
||||
let store: import('@/lib/store/__tests__/helpers/in-process-store').InProcessStore;
|
||||
|
||||
const ALICE = 'alice@mail.example.test';
|
||||
|
||||
function entry(id: string, cookieSlot: number) {
|
||||
return {
|
||||
id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode: 'oauth', rememberMe: true,
|
||||
displayName: id, email: id, lastLoginAt: Date.now(), isConnected: true, hasError: false, isDefault: cookieSlot === 0, cookieSlot, avatarColor: '#000',
|
||||
};
|
||||
}
|
||||
|
||||
function answer(status: number, body: unknown = {}): Response {
|
||||
return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
function tokenAnswers(next: () => Response): void {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => (String(input).includes('/api/auth/token') ? next() : answer(200))));
|
||||
}
|
||||
|
||||
/** `times`: as often as the retry timer would run it. */
|
||||
async function refresh(times = 1): Promise<void> {
|
||||
for (let i = 0; i < times; i += 1) await useAuthStore.getState().refreshAccessToken({ accountId: ALICE });
|
||||
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
purge.files = 0;
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
({ useAuthStore } = await import('../auth-store'));
|
||||
({ useAccountStore } = await import('../account-store'));
|
||||
state = await import('@/lib/offline/state');
|
||||
offline = await import('@/lib/offline/offline-mode');
|
||||
queue = await import('@/lib/offline/queue');
|
||||
lost = await import('@/lib/offline/lost');
|
||||
const browserNavigation = await import('@/lib/browser-navigation');
|
||||
vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
||||
const { InProcessStore } = await import('@/lib/store/__tests__/helpers/in-process-store');
|
||||
const { setStoreBackend } = await import('@/lib/store/client/api');
|
||||
store = new InProcessStore();
|
||||
setStoreBackend(store);
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
useAuthStore.setState({ isAuthenticated: true, activeAccountId: ALICE, authMode: 'oauth', serverUrl: 'https://mail.example.test', username: 'alice' });
|
||||
state.writeEnabled([ALICE]);
|
||||
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
|
||||
}, 60_000);
|
||||
|
||||
function expectKept(): void {
|
||||
expect(purge.files).toBe(0);
|
||||
expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]);
|
||||
expect(localStorage.getItem(state.OFFLINE_ACCOUNTS_KEY)).toContain(ALICE);
|
||||
expect(state.isEvicted(ALICE)).toBe(true);
|
||||
expect(store.removed).toEqual([]);
|
||||
expect(store.stopped).toBe(0);
|
||||
// Nothing was lost, so nothing is named at the sign-in page.
|
||||
expect(lost.consumeLost()).toEqual([]);
|
||||
}
|
||||
|
||||
describe('an automatic sign-out of the active account', () => {
|
||||
it('keeps the data through a run of 502s from a proxy', async () => {
|
||||
tokenAnswers(() => answer(502, '<html>Bad Gateway</html>'));
|
||||
await refresh(5);
|
||||
expectKept();
|
||||
});
|
||||
|
||||
it('keeps the data through a run of the route\'s own 500s', async () => {
|
||||
tokenAnswers(() => answer(500, { error: 'Internal server error' }));
|
||||
await refresh(5);
|
||||
expectKept();
|
||||
});
|
||||
|
||||
it('keeps the data on a refusal that says nothing about the login', async () => {
|
||||
tokenAnswers(() => answer(401, { error: 'Refresh failed' }));
|
||||
await refresh();
|
||||
expectKept();
|
||||
});
|
||||
|
||||
it('keeps the data on a refusal it can\'t read', async () => {
|
||||
tokenAnswers(() => answer(401, 'nope'));
|
||||
await refresh();
|
||||
expectKept();
|
||||
});
|
||||
|
||||
it('wipes on a real invalid_grant, and names the lost mail', async () => {
|
||||
tokenAnswers(() => answer(401, { error: 'invalid_grant' }));
|
||||
offline.startOfflineMode();
|
||||
await refresh();
|
||||
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
|
||||
expect(localStorage.getItem(state.OFFLINE_ACCOUNTS_KEY)).toBeNull();
|
||||
expect(lost.consumeLost()).toEqual(['Unsent']);
|
||||
});
|
||||
|
||||
it('wipes at the organisation\'s session limit', async () => {
|
||||
const { SESSION_TOO_OLD } = await import('@/lib/session-policy/constants');
|
||||
tokenAnswers(() => answer(401, { error: SESSION_TOO_OLD, maxHours: 8 }));
|
||||
await refresh();
|
||||
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a background account', () => {
|
||||
const BOB = 'bob@mail.example.test';
|
||||
const token = async () => useAuthStore.getState().refreshAccessToken({ accountId: BOB });
|
||||
|
||||
beforeEach(() => {
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
|
||||
state.writeEnabled([ALICE, BOB]);
|
||||
offline.startOfflineMode();
|
||||
});
|
||||
|
||||
it('keeps its data on an unknown refusal', async () => {
|
||||
tokenAnswers(() => answer(401, { error: 'Refresh failed' }));
|
||||
await token();
|
||||
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined());
|
||||
expect(state.isEvicted(BOB)).toBe(true);
|
||||
expect(store.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('wipes on a real invalid_grant', async () => {
|
||||
tokenAnswers(() => answer(401, { error: 'invalid_grant' }));
|
||||
await token();
|
||||
await vi.waitFor(() => expect(store.removed).toEqual([BOB]));
|
||||
expect(state.isEvicted(BOB)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data waiting for a sign-in', () => {
|
||||
const BOB = 'bob@mail.example.test';
|
||||
|
||||
beforeEach(async () => {
|
||||
state.writeEnabled([ALICE, BOB]);
|
||||
await queue.queueSend(BOB, { subject: 'Bob\'s', to: [] }, { kind: 'email', args: [] });
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
|
||||
useAccountStore.getState().removeAccount(BOB, 'evicted');
|
||||
});
|
||||
|
||||
it('is hidden from anyone else, in every list', async () => {
|
||||
await offline.refreshQueueState();
|
||||
expect(state.useOfflineStore.getState().outbox.map((row) => row.subject)).toEqual(['Unsent']);
|
||||
expect((await queue.readQueue()).outbox.map((row) => row.account)).toEqual([ALICE]);
|
||||
await offline.refreshUsage();
|
||||
expect(state.visibleAccounts()).toEqual([ALICE]);
|
||||
});
|
||||
|
||||
it('shows again when the account signs in', async () => {
|
||||
useAccountStore.getState().addAccount({ username: 'bob', serverUrl: 'https://mail.example.test', authMode: 'oauth', rememberMe: true, label: 'bob', displayName: 'bob', email: 'bob', lastLoginAt: 1, isConnected: true, hasError: false, isDefault: false } as never);
|
||||
expect(state.isEvicted(BOB)).toBe(false);
|
||||
await offline.refreshQueueState();
|
||||
expect(state.useOfflineStore.getState().outbox.map((row) => row.subject).sort()).toEqual(['Bob\'s', 'Unsent']);
|
||||
});
|
||||
|
||||
it('goes after 30 days', async () => {
|
||||
localStorage.setItem(state.OFFLINE_EVICTED_KEY, JSON.stringify({ [BOB]: Date.now() - state.EVICTED_KEEP_MS - 1000 }));
|
||||
offline.startOfflineMode();
|
||||
await vi.waitFor(() => expect(store.removed).toEqual([BOB]));
|
||||
expect(state.isEvicted(BOB)).toBe(false);
|
||||
expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]);
|
||||
});
|
||||
|
||||
it('stays before that', async () => {
|
||||
offline.startOfflineMode();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(store.removed).toEqual([]);
|
||||
expect(state.useOfflineStore.getState().enabled).toEqual([ALICE, BOB]);
|
||||
});
|
||||
|
||||
it('survives someone else\'s full sign-out, whose own data goes', async () => {
|
||||
offline.startOfflineMode();
|
||||
await useAuthStore.getState().logout({ endProviderSession: false });
|
||||
await vi.waitFor(() => expect(store.removed).toEqual([ALICE]));
|
||||
expect(purge.files).toBe(0);
|
||||
expect(state.useOfflineStore.getState().enabled).toEqual([BOB]);
|
||||
expect(state.isEvicted(BOB)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a login without "remember me"', () => {
|
||||
it('ends with its data at a reload, naming lost mail, even before the host runs', async () => {
|
||||
useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), rememberMe: false }] } as never);
|
||||
tokenAnswers(() => answer(200));
|
||||
await useAuthStore.getState().checkAuth();
|
||||
expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined();
|
||||
expect(state.isEvicted(ALICE)).toBe(false);
|
||||
offline.startOfflineMode();
|
||||
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
|
||||
expect(lost.consumeLost()).toEqual(['Unsent']);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
|
||||
import { authHooks } from '@/lib/plugin-hooks';
|
||||
import { clearEvicted, markEvicted } from '@/lib/offline/state';
|
||||
|
||||
export type AccountRemovalReason = 'explicit' | 'evicted';
|
||||
|
||||
@@ -111,6 +112,7 @@ export const useAccountStore = create<AccountState>()(
|
||||
const state = get();
|
||||
|
||||
const id = generateAccountId(entry.username, entry.serverUrl);
|
||||
clearEvicted(id);
|
||||
if (state.accounts.some((a) => a.id === id)) {
|
||||
// Already exists - update mutable fields and return existing id
|
||||
set((s) => ({
|
||||
@@ -163,6 +165,7 @@ export const useAccountStore = create<AccountState>()(
|
||||
|
||||
removeAccount: (accountId, reason = 'explicit') => {
|
||||
if (!get().accounts.some((a) => a.id === accountId)) return;
|
||||
if (reason === 'evicted') markEvicted(accountId);
|
||||
for (const listener of [...removalListeners]) listener(accountId, reason);
|
||||
set((s) => {
|
||||
const remaining = s.accounts.filter((a) => a.id !== accountId);
|
||||
|
||||
+47
-18
@@ -19,7 +19,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro
|
||||
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
|
||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
|
||||
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut, type SignOutCleanupOptions } from '@/lib/sign-out-cleanup';
|
||||
import { forgetPrincipals } from '@/lib/delegation/known-principals';
|
||||
import { useSearchHistoryStore } from './search-history-store';
|
||||
import { useCalendarNotificationStore } from './calendar-notification-store';
|
||||
@@ -31,7 +31,7 @@ import type { Identity } from '@/lib/jmap/types';
|
||||
import { authHooks } from '@/lib/plugin-hooks';
|
||||
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
|
||||
import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { readEvicted, useOfflineStore } from '@/lib/offline/state';
|
||||
import { toAsciiEmail } from '@/lib/idn';
|
||||
import { flushComposers } from '@/lib/compose-flush-registry';
|
||||
import { sortIdentities } from '@/lib/identity-order';
|
||||
@@ -154,6 +154,12 @@ export interface LogoutOptions {
|
||||
* provider, and signing in again should stay a single click.
|
||||
*/
|
||||
endProviderSession?: boolean;
|
||||
/**
|
||||
* The app, not the user, ends this login, and nothing says the login itself
|
||||
* is over (an outage, an unknown refusal): local mail and the outbox stay,
|
||||
* hidden, for the next sign-in or 30 days.
|
||||
*/
|
||||
keepLocalData?: boolean;
|
||||
}
|
||||
|
||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||
@@ -1163,6 +1169,20 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a refused renewal ends the login for real, which wipes the account's
|
||||
* local data: the server said invalid_grant, or the organisation's session limit
|
||||
* was reached. Anything else, including a body we can't read, keeps the data.
|
||||
*/
|
||||
async function loginReallyOver(res: Response): Promise<boolean> {
|
||||
try {
|
||||
const { error } = await res.clone().json() as { error?: unknown };
|
||||
return error === 'invalid_grant' || error === SESSION_TOO_OLD;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function notifySignInAgain(): void {
|
||||
void import('@/stores/toast-store').then(({ toast }) => {
|
||||
toast.error('Your session could not be renewed', 'Sign in again to continue.');
|
||||
@@ -1358,7 +1378,7 @@ async function startLocalCopyClient(accountId: string, serverUrl: string, userna
|
||||
// While device-only clients get replaced, restoring goes straight to the server.
|
||||
let skipLocalCopy = false;
|
||||
|
||||
function performFullLogout(set: (state: Partial<AuthState>) => void): void {
|
||||
function performFullLogout(set: (state: Partial<AuthState>) => void, options: SignOutCleanupOptions = {}): void {
|
||||
useSettingsStore.getState().disableSync();
|
||||
// With settings sync on, the local settings and templates are a copy of the
|
||||
// signed-out account's server file. Left in place, the next account to sign
|
||||
@@ -1397,7 +1417,9 @@ function performFullLogout(set: (state: Partial<AuthState>) => void): void {
|
||||
// doesn't re-write stale values.
|
||||
try { localStorage.removeItem('auth-storage'); } catch { /* noop */ }
|
||||
try { localStorage.removeItem('account-registry'); } catch { /* noop */ }
|
||||
purgeSignedOutData();
|
||||
// A dropped account's data outlives anyone else's sign-out. The account that
|
||||
// signed out takes its own along when it leaves the registry.
|
||||
purgeSignedOutData({ keepLocalStore: options.keepLocalStore || Object.keys(readEvicted()).length > 0 });
|
||||
// Other tabs still hold the accounts' mail in memory.
|
||||
broadcastSignOut();
|
||||
}
|
||||
@@ -1418,9 +1440,9 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin
|
||||
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
|
||||
}
|
||||
|
||||
function beforeLogoutHooks(accountId: string): Promise<void> {
|
||||
function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> {
|
||||
return Promise.race([
|
||||
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId)]).then(() => {}),
|
||||
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId, options)]).then(() => {}),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
|
||||
]);
|
||||
}
|
||||
@@ -2216,16 +2238,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
// maintenance windows and offline spells.
|
||||
if (res.status === 401) {
|
||||
resetRefreshBackoff(accountId ?? undefined);
|
||||
const over = await loginReallyOver(res);
|
||||
if (over && accountId) noteCredentialsRejected(accountId);
|
||||
// Only this login's refresh token was refused.
|
||||
if (accountId) noteCredentialsRejected(accountId);
|
||||
if (accountId && !isActive) {
|
||||
get().removeAccount(accountId, 'evicted');
|
||||
get().removeAccount(accountId, over ? 'explicit' : 'evicted');
|
||||
return null;
|
||||
}
|
||||
await saveDraftsBeforeExpiry(slot);
|
||||
notifyParent('sso:session-expired');
|
||||
markSessionExpired();
|
||||
get().logout({ endProviderSession: false });
|
||||
get().logout({ endProviderSession: false, keepLocalData: !over });
|
||||
return null;
|
||||
}
|
||||
// A Bulwark-side failure (500/502) that keeps repeating will not
|
||||
@@ -2240,7 +2263,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (accountId && accountId !== get().activeAccountId) get().removeAccount(accountId, 'evicted');
|
||||
else {
|
||||
await saveDraftsBeforeExpiry(slot);
|
||||
get().logout({ endProviderSession: false });
|
||||
// An outage on our side says nothing against the mail on this device.
|
||||
get().logout({ endProviderSession: false, keepLocalData: true });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2310,13 +2334,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
const slot = account?.cookieSlot ?? 0;
|
||||
// `options` can be a click event when the action is wired straight to onClick.
|
||||
const userInitiated = options?.endProviderSession !== false;
|
||||
const keepLocalData = options?.keepLocalData === true;
|
||||
const endProviderSession = userInitiated && !wasDemoMode && shouldEndProviderSession(account);
|
||||
|
||||
// Signing out on purpose may be stopped by the user (unsent mail in
|
||||
// the offline outbox would be lost); an automatic one may not.
|
||||
if (userInitiated && !(await passSignOutGates(accountId ?? 'all'))) return;
|
||||
|
||||
await beforeLogoutHooks(accountId ?? 'all');
|
||||
await beforeLogoutHooks(accountId ?? 'all', { keepLocalStore: keepLocalData });
|
||||
|
||||
// From here on nothing may renew this slot's session; the cleanup
|
||||
// below reopens it once the credentials are gone.
|
||||
@@ -2334,7 +2359,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (accountId) {
|
||||
clients.delete(accountId);
|
||||
evictAccount(accountId);
|
||||
accountStore.removeAccount(accountId);
|
||||
accountStore.removeAccount(accountId, keepLocalData ? 'evicted' : 'explicit');
|
||||
forgetPrincipals(accountId);
|
||||
if (!wasDemoMode) clearPluginStorageForAccount(accountId);
|
||||
}
|
||||
@@ -2428,7 +2453,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
clients.get(remaining.id)?.disconnect();
|
||||
clients.delete(remaining.id);
|
||||
evictAccount(remaining.id);
|
||||
accountStore.removeAccount(remaining.id);
|
||||
// Dropped by this sign-out, not signed out by anyone: their unsent
|
||||
// mail never passed a question, so it waits for their next sign-in.
|
||||
accountStore.removeAccount(remaining.id, 'evicted');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2442,7 +2469,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
...droppedAccounts.map((dropped) => clearSlotCredentials(dropped.cookieSlot ?? 0, dropped.authMode === 'oauth')),
|
||||
]);
|
||||
|
||||
performFullLogout(set);
|
||||
performFullLogout(set, { keepLocalStore: keepLocalData });
|
||||
|
||||
notifyParent('sso:logout');
|
||||
|
||||
@@ -2845,7 +2872,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (!account.rememberMe && !IS_LITE) {
|
||||
forgottenAccounts += 1;
|
||||
evictAccount(account.id);
|
||||
accountStore.removeAccount(account.id, 'evicted');
|
||||
// The user's choice on what may be a shared computer: its local data goes too.
|
||||
accountStore.removeAccount(account.id, 'explicit');
|
||||
if (account.authMode === 'oauth') void clearSlotCredentials(account.cookieSlot, true);
|
||||
return;
|
||||
}
|
||||
@@ -2870,7 +2898,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Repeated 500/502 (see recordPermanentRefreshFailure) falls
|
||||
// through here and evicts the account like a rejection. (#972)
|
||||
if (res.status >= 500) notifySignInAgain();
|
||||
throw new Error(`Token refresh failed: ${res.status}`);
|
||||
throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { loginOver: await loginReallyOver(res) });
|
||||
}
|
||||
} else {
|
||||
const res = await fetchSlotSession(account.cookieSlot);
|
||||
@@ -2927,9 +2955,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
// Remove unrestorable accounts so the user is prompted to log in
|
||||
// again rather than seeing a stale error entry forever. Its
|
||||
// refresh token goes with it: nothing would use or revoke it.
|
||||
// refresh token goes with it: nothing would use or revoke it. Its
|
||||
// local data waits, unless the server really ended the login.
|
||||
evictAccount(account.id);
|
||||
accountStore.removeAccount(account.id, 'evicted');
|
||||
accountStore.removeAccount(account.id, (err as { loginOver?: boolean }).loginOver ? 'explicit' : 'evicted');
|
||||
void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user