Skip to main content

AuthWebPushChannel

WIP

Web Push protocol with VAPID. Deliver magic-link prompts to a user's existing browser tab without email.

Install

bun add web-push

Mint VAPID keys

Run once and persist:

bunx web-push generate-vapid-keys

Output:

Public Key: BAa8WnAwS...
Private Key: 9pUVjK7...

Store both in your .env. The public key also goes to the browser client (it's safe to expose); the private key stays server-side.

Setup

import { AuthWebPushChannel } from '@gentleduck/auth/channels/webpush'

const channel = new AuthWebPushChannel({
  vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,
  vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,
  vapidSubject: 'mailto:admin@example.com',
  kind: 'webpush',
  id: 'webpush',
})

vapidSubject is a contact URL (per the Web Push spec). Use a mailto: URL or an HTTPS URL pointing at a status page.

Browser subscription

The browser must subscribe before duck-auth can push:

// Service worker (registered separately):
self.addEventListener('push', (event) => {
  const data = event.data.json()
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      data: { url: data.url },
    }),
  )
})

self.addEventListener('notificationclick', (event) => {
  event.waitUntil(clients.openWindow(event.notification.data.url))
})

// Client-side:
const registration = await navigator.serviceWorker.register('/sw.js')
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY),
})

// Persist subscription on the server
await fetch('/api/webpush/subscribe', {
  method: 'POST',
  body: JSON.stringify(subscription),
})

Store the subscription against the identity's profile or a side table:

await auth.identities.update(identityId, {
  profile: {
    ...existingProfile,
    webPushSubscriptions: [...existingProfile.webPushSubscriptions, subscription],
  },
})
import { magicLink } from '@gentleduck/auth/providers/magic-link'

auth.providers.use(
  magicLink({
    channels: { webpush: channel },
    findIdentityByEmail: (email) => auth.identities.findByEmail(email),
  }),
)

// Client:
await client.beginProvider('magic-link', { email, channel: 'webpush' })

The channel pushes a notification with a clickable URL; the user taps the notification and lands on your magic-link callback.

This is lower assurance than email - anyone with access to the device can accept the push. Pair with MFA for sensitive accounts.

Subscription expiration

Web Push subscriptions can expire (typically when the user clears browser data or the browser revokes the key). Handle 410 Gone responses by removing the stale subscription:

try {
  await channel.send({ ... })
} catch (err) {
  if (err.code === 410) {
    await removeStaleSubscription(identityId, subscription)
  }
}

duck-auth's shipped AuthWebPushChannel already removes 410-returning subscriptions automatically - the manual handling is only needed if you subclass and override send().