diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 369266b2a..ab7da8933 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -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 diff --git a/lib/web-push.ts b/lib/web-push.ts index 48e48ebd5..b2e5a7399 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -208,6 +208,15 @@ function urlBase64ToUint8Array(base64Url: string): Uint8Array { 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, }); }