From 6a92c334d4f904f96de5cebe332d690f8e3d7332 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Thu, 17 Sep 2026 19:54:34 +0300 Subject: [PATCH] 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 --- lib/__tests__/web-push.test.ts | 41 +++++++++++++++++++++++++++++++++- lib/web-push.ts | 30 +++++++++++++++++++------ 2 files changed, 63 insertions(+), 8 deletions(-) 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, }); }