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:
Shuki Vaknin
2026-09-17 19:54:34 +03:00
parent 2df64024bd
commit 6a92c334d4
2 changed files with 63 additions and 8 deletions
+40 -1
View File
@@ -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
View File
@@ -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,
});
}