fix: end a login's push registration when it signs out, is removed or wiped

This commit is contained in:
Linus Rath
2026-11-04 03:13:57 +01:00
parent 8b4fb72357
commit a6ef818149
5 changed files with 137 additions and 5 deletions
+52
View File
@@ -7,6 +7,7 @@ import {
buildEmailPushConfig,
disableWebPush,
enableWebPush,
endWebPushForSignOut,
isWebPushEnabled,
listPushDevices,
mailAccountsWithoutPush,
@@ -1724,6 +1725,57 @@ describe('disableWebPush', () => {
});
});
describe('endWebPushForSignOut', () => {
it('takes everything down for a login with push on, using the relay it registered with', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
const calls = installFetch({});
await endWebPushForSignOut(client);
expect(client.destroyed).toEqual(['push-mine']);
expect(calls).toEqual([{ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }]);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('does nothing and calls nobody for a login without push', async () => {
const client = makeClient([]);
const calls = installFetch({});
await endWebPushForSignOut(client);
expect(calls).toEqual([]);
expect(client.listPushSubscriptions).not.toHaveBeenCalled();
});
it('never throws: signing out goes on', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); }));
(client.destroyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
await expect(endWebPushForSignOut(client)).resolves.toBeUndefined();
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('forgets the key material with nobody left', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const { browserSub } = installPushBrowser();
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
installFetch({});
await endWebPushForSignOut(client);
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('listPushDevices', () => {
it('flags this device and reports the relay status of each registration', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+1
View File
@@ -265,6 +265,7 @@ async function endWebPushIfNobodyLeft(): Promise<void> {
const registration = await pushRegistration();
const subscription = await registration?.pushManager?.getSubscription();
await subscription?.unsubscribe();
removeLocalKeys([], ['bulwark.push.']);
}
/** With nobody left after this there's nothing to lock for, and a next person shouldn't meet it. */
+17
View File
@@ -1031,6 +1031,23 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise<v
}
}
/**
* A login is signing out or being removed: nothing for it should wake this
* browser any more, and it won't be there to refresh anything. Takes no relay
* from the settings: what counts is the one it registered with.
*/
export async function endWebPushForSignOut(client: IJMAPClient): Promise<void> {
let accountId: string;
try {
accountId = client.getAccountId();
} catch {
return;
}
if (!accountId || typeof localStorage === 'undefined' || !localStorage.getItem(subscriptionIdKey(accountId))) return;
const relayBaseUrl = localStorage.getItem(relayBaseKey(accountId)) ?? undefined;
await disableWebPush({ client, relayBaseUrl }).catch(() => undefined);
}
export interface PushDevice {
// The JMAP PushSubscription id - what you destroy to revoke it.
id: string;
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const endWebPushForSignOut = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('@/lib/web-push', async (original) => ({ ...(await original<typeof import('@/lib/web-push')>()), endWebPushForSignOut }));
import { useAuthStore } from '../auth-store';
function fakeClient() {
return { disconnect: vi.fn(), getAccountId: () => 'jmap-a' } as unknown as NonNullable<ReturnType<typeof useAuthStore.getState>['client']>;
}
describe('signing out ends the login\'s push registration', () => {
beforeEach(() => {
endWebPushForSignOut.mockClear();
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
});
it('when the user signs out', async () => {
const client = fakeClient();
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false });
await useAuthStore.getState().logout();
expect(endWebPushForSignOut).toHaveBeenCalledWith(client);
});
it('when a wipe signs the login out', async () => {
const client = fakeClient();
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false });
await useAuthStore.getState().logout({ endProviderSession: false });
expect(endWebPushForSignOut).toHaveBeenCalledWith(client);
});
it('not when the session only ran out: it comes back with the next sign-in', async () => {
const client = fakeClient();
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false });
await useAuthStore.getState().logout({ endProviderSession: false, keepLocalData: true });
expect(endWebPushForSignOut).not.toHaveBeenCalled();
});
it('not in demo mode', async () => {
const client = fakeClient();
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true });
await useAuthStore.getState().logout();
expect(endWebPushForSignOut).not.toHaveBeenCalled();
useAuthStore.setState({ isDemoMode: false });
});
});
+21 -5
View File
@@ -1625,6 +1625,17 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
}
/** Within the sign-out's budget: an unreachable server doesn't hold it up. */
function endPushForSignOut(signingOut: Iterable<IJMAPClient>): Promise<void> {
return Promise.race([
(async () => {
const { endWebPushForSignOut } = await import('@/lib/web-push');
for (const client of signingOut) await endWebPushForSignOut(client);
})(),
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
]);
}
function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> {
return Promise.race([
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId, options)]).then(() => {}),
@@ -2541,6 +2552,8 @@ export const useAuthStore = create<AuthState>()(
if (userInitiated && !(await passSignOutGates(accountId ?? 'all'))) return;
await beforeLogoutHooks(accountId ?? 'all', { keepLocalStore: keepLocalData });
// A session that only ran out comes back with the next sign-in, push included.
if (state.client && !wasDemoMode && !keepLocalData) await endPushForSignOut([state.client]);
// From here on nothing may renew this slot's session; the cleanup
// below reopens it once the credentials are gone.
@@ -2690,12 +2703,14 @@ export const useAuthStore = create<AuthState>()(
if (!account) return;
const slot = account.cookieSlot ?? 0;
const wasOAuth = account.authMode === 'oauth';
// Started first: the cleanup marks the slot closing right away, so a
// renewal in flight cannot bring the session back.
void clearSlotCredentials(slot, wasOAuth);
clearRefreshTimer(accountId);
const client = clients.get(accountId);
// The login's push goes first, while its session still works.
const pushEnded = client && reason === 'explicit' ? endPushForSignOut([client]) : Promise.resolve();
// The cleanup marks the slot closing right away, so a renewal in flight
// cannot bring the session back.
void pushEnded.finally(() => clearSlotCredentials(slot, wasOAuth));
clearRefreshTimer(accountId);
if (client) {
forgetCalendarSubscriptions(client);
try { client.disconnect(); } catch { /* noop */ }
@@ -2717,6 +2732,7 @@ export const useAuthStore = create<AuthState>()(
for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session');
}
await beforeLogoutHooks('all', { keepLocalStore: keepLocalData });
if (!get().isDemoMode && !keepLocalData) await endPushForSignOut(clients.values());
const accountStore = useAccountStore.getState();
const allAccounts = [...accountStore.accounts];