mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: end a login's push registration when it signs out, is removed or wiped
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user