Packages

Web Push notifications for Elixir: VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291/8188)

Current section

Files

Jump to
web_push README.md
Raw

README.md

# WebPush
Web Push notifications for Elixir. Payload encryption (RFC 8291 + RFC 8188 `aes128gcm`) and VAPID authentication (RFC 8292), sent over Finch.
Raw library: it starts no processes and holds no state. Your application supervises the Finch pool, stores the subscriptions, and decides what a notification looks like. WebPush handles the cryptography and the HTTP contract with the push service.
## Features
- **VAPID (RFC 8292)**: ES256 JWT signing with bare `:crypto`, no JWT dependency
- **Payload encryption (RFC 8291/8188)**: ECDH + HKDF + AES-128-GCM, verified byte-for-byte against the RFC 8291 ยง5 test vector
- **Zero state**: no application, no GenServers, no Ecto. Bring your own Finch pool and storage
- **Single dependency**: Finch (JSON encoding uses Elixir's native `JSON` module)
## Installation
```elixir
defp deps do
[
{:web_push, "~> 0.1.0"}
]
end
```
## Setup
Three moving parts: a Finch pool, VAPID keys, and stored subscriptions.
### 1. Supervise a Finch pool
```elixir
# lib/my_app/application.ex
children = [
{Finch, name: MyApp.WebPushFinch}
]
```
```elixir
# config/config.exs
config :web_push, finch: MyApp.WebPushFinch
```
### 2. Generate VAPID keys
```
mix web_push.gen.vapid
```
```elixir
# config/runtime.exs
config :web_push, :vapid,
public_key: System.fetch_env!("VAPID_PUBLIC_KEY"),
private_key: System.fetch_env!("VAPID_PRIVATE_KEY"),
subject: "mailto:you@example.com"
```
The public key is not secret: the browser needs it to subscribe (see below). The private key signs every push request.
### 3. Store subscriptions
Browsers hand you a JSON object per subscribed device:
```json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {"p256dh": "B...", "auth": "..."}
}
```
Persist it however you like (an Ecto table with a unique index on `endpoint` works well). When sending, map the row into a `WebPush.Subscription`:
```elixir
%WebPush.Subscription{endpoint: row.endpoint, p256dh: row.p256dh, auth: row.auth}
```
For request bodies straight from the browser, `WebPush.Subscription.from_map/1` parses the nested JSON shape.
## Sending
```elixir
alias WebPush.Subscription
sub = %Subscription{endpoint: endpoint, p256dh: p256dh, auth: auth}
case WebPush.send(sub, %{"title" => "new order", "url" => "/admin/orders/1"}, urgency: "high") do
:ok -> :sent
{:error, :gone} -> MyApp.Notifications.delete_subscription(sub.endpoint)
{:error, reason} -> Logger.warning("push failed: #{inspect(reason)}")
end
```
Options:
| option | default | meaning |
|---|---|---|
| `:ttl` | `86400` | seconds the push service may hold the message |
| `:urgency` | `"normal"` | `"very-low"`, `"low"`, `"normal"`, `"high"` |
| `:topic` | none | collapse key; pending messages with the same topic are replaced |
| `:finch` | configured | per-call Finch pool override |
Return values:
- `:ok`: the push service accepted the message (200/201/202/204)
- `{:error, :gone}`: 404 or 410, the subscription is dead. Delete it from storage
- `{:error, term}`: transport failure or unexpected status
## Client side
WebPush is server-side only. The browser half is two small files, adapted here as a starting point. Yours will differ in routes, CSRF handling, and copy.
### Service worker (`priv/static/sw.js`)
```js
self.addEventListener("push", (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title || "notification", {
body: data.body || "",
tag: data.tag,
data: { url: data.url || "/" },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(self.clients.openWindow(url));
});
```
### Subscribing
```js
// Your server exposes the VAPID public key (WebPush.Vapid.public_key/0)
// at some endpoint, e.g. GET /push/vapid-key.
async function subscribe() {
const reg = await navigator.serviceWorker.ready;
const { public_key } = await (await fetch("/push/vapid-key")).json();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
// POST sub.toJSON() to your server and persist it.
await fetch("/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sub.toJSON()),
});
}
function urlBase64ToUint8Array(b64) {
const padding = "=".repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + padding).replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
```
Note that push requires a service worker registration and a user gesture for the permission prompt on most browsers.
## How it fits together
```
payload map
-> JSON.encode!/1 (WebPush)
-> RFC 8291 encryption (WebPush.Encryption, pure :crypto)
-> VAPID Authorization header (WebPush.Vapid, pure :crypto)
-> POST to subscription endpoint (Finch, your pool)
```
`WebPush.Encryption` and `WebPush.Vapid` are pure functions and can be used on their own if you need a different transport.
## License
MIT. See [LICENSE](LICENSE).