mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
fix(push): resubscribe when the browser subscription uses another VAPID key
enableWebPush reused any existing browser PushSubscription that exposed an applicationServerKey, without comparing that key to the relay's. A push subscription is bound to the key it was created with. So a browser that subscribed against another relay, or before this relay rotated its keys, kept a subscription the push service rejects on every send. Mozilla answers 401 "VAPID public key mismatch". isWebPushEnabled still reports push as on, so nothing prompts the user to fix it. Compare the key bytes and resubscribe on a mismatch. Because resyncWebPush runs enableWebPush on app start, affected browsers repair themselves the next time the app opens. Claude-Session: https://claude.ai/code/session_019KwXZEwxijHRbfobfXmcK2
This commit is contained in:
@@ -103,7 +103,9 @@ function makeClient(
|
||||
function installPushBrowser() {
|
||||
const browserSub = {
|
||||
endpoint: 'https://fcm.example/endpoint',
|
||||
options: { applicationServerKey: new ArrayBuffer(8) },
|
||||
// The key installFetch's relay advertises ('QUJD' = "ABC"), so the
|
||||
// browser subscription belongs to that relay unless a test says otherwise.
|
||||
options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer as ArrayBuffer | null },
|
||||
getKey: () => new Uint8Array([1, 2, 3]).buffer,
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
};
|
||||
@@ -202,6 +204,43 @@ describe('enableWebPush', () => {
|
||||
|
||||
expect(client.destroyed).not.toContain('push-other');
|
||||
});
|
||||
|
||||
it('reuses a browser subscription made with this relay key', async () => {
|
||||
const { browserSub, registration } = installPushBrowser();
|
||||
installFetch({});
|
||||
|
||||
await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY });
|
||||
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(registration.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces a browser subscription made with another relay key', async () => {
|
||||
const { browserSub, registration } = installPushBrowser();
|
||||
browserSub.options.applicationServerKey = new Uint8Array([9, 9, 9]).buffer;
|
||||
const calls = installFetch({});
|
||||
|
||||
await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY });
|
||||
|
||||
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
const [{ applicationServerKey }] = registration.pushManager.subscribe.mock.calls[0] as unknown as [
|
||||
{ applicationServerKey: Uint8Array },
|
||||
];
|
||||
expect(Array.from(applicationServerKey)).toEqual([65, 66, 67]);
|
||||
expect(calls.some((c) => c.url.endsWith('/api/push/register/web'))).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces a browser subscription whose key the browser does not expose', async () => {
|
||||
const { browserSub, registration } = installPushBrowser();
|
||||
browserSub.options.applicationServerKey = null;
|
||||
installFetch({});
|
||||
|
||||
await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY });
|
||||
|
||||
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Registrations made before the client learned about the delivery filter are
|
||||
|
||||
+23
-7
@@ -208,6 +208,15 @@ function urlBase64ToUint8Array(base64Url: string): Uint8Array<ArrayBuffer> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// A browser that doesn't expose the key it subscribed with counts as a
|
||||
// mismatch: we can't prove the subscription belongs to this relay.
|
||||
function sameKey(existing: ArrayBuffer | null | undefined, expected: Uint8Array): boolean {
|
||||
if (!existing) return false;
|
||||
const bytes = new Uint8Array(existing);
|
||||
if (bytes.length !== expected.length) return false;
|
||||
return bytes.every((byte, i) => byte === expected[i]);
|
||||
}
|
||||
|
||||
function readPushKey(
|
||||
sub: PushSubscription,
|
||||
name: 'p256dh' | 'auth',
|
||||
@@ -408,18 +417,25 @@ export async function enableWebPush(
|
||||
// Reuse an existing browser PushSubscription when possible - resubscribing
|
||||
// with the same VAPID key produces the same endpoint, but the call still
|
||||
// costs a network round-trip the user can feel.
|
||||
//
|
||||
// Only reuse it if it was made with THIS relay's key. A subscription is
|
||||
// bound to the VAPID key it was created with, so one left over from another
|
||||
// relay (or from before this relay rotated its keys) is rejected by the push
|
||||
// service on every send - Mozilla answers 401 "VAPID public key mismatch" -
|
||||
// while isWebPushEnabled keeps reporting push as on.
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||
let pushSubscription = await registration.pushManager.getSubscription();
|
||||
if (pushSubscription) {
|
||||
const keyMatches = pushSubscription.options?.applicationServerKey;
|
||||
if (!keyMatches) {
|
||||
await pushSubscription.unsubscribe();
|
||||
pushSubscription = null;
|
||||
}
|
||||
if (
|
||||
pushSubscription
|
||||
&& !sameKey(pushSubscription.options?.applicationServerKey, applicationServerKey)
|
||||
) {
|
||||
await pushSubscription.unsubscribe();
|
||||
pushSubscription = null;
|
||||
}
|
||||
if (!pushSubscription) {
|
||||
pushSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
applicationServerKey,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user