0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ログイン不要のWeb Pushを匿名 device_token で届ける(Cloudflare Workers)

0
Posted at

「アカウント登録なし・メールなし」で Web Push を届けたい、という要件は、実装の最初の一歩でつまずきます。通知を送る前に「誰に送るか」を何かで持つ必要があるのに、ログインが無いと user_id もメールもありません。

価格ウォッチャーを個人で作ったとき、ここを匿名の device_token で解きました。この記事は Cloudflare Workers + Hono を前提に、ログイン不要の Web Push を成立させる最小構成をまとめます。宛先の持ち方さえ決まれば、あとは仕様どおりの配線です。

宛先は匿名 device_token(UUID) にする

ログインが無いなら、識別子は端末ごとに発行する匿名トークンにするのが素直です。初回訪問で UUID を 1 つ発行し、httpOnly cookie に保存します。

// Hono middleware: cookie が無ければ発行するだけ
export function deviceTokenMiddleware() {
  return async (c, next) => {
    let token = getCookie(c, 'ysg_device');
    if (!token) {
      token = crypto.randomUUID();          // 匿名 UUID v4
      setCookie(c, 'ysg_device', token, {
        path: '/', httpOnly: true, secure: true,
        sameSite: 'Lax', maxAge: 60 * 60 * 24 * 365,
      });
    }
    c.set('deviceToken', token);
    await next();
  };
}

crypto.randomUUID() は Workers でも Node でも同じ API です。メールを取らないので個人情報を持たずに済みます。宛先は「この人」ではなく「この端末」、と割り切るのがポイントです。

device_token で user を UPSERT し、購読をぶら下げる

匿名トークンが決まれば、あとは普通のリレーションです。device_token をユニークキーにした users を UPSERT し、Web Push の購読を user に紐づけます(D1 / SQLite の例)。

await db.prepare(
  `INSERT INTO users (device_token, created_at, updated_at)
   VALUES (?, ?, ?)
   ON CONFLICT(device_token) DO UPDATE SET updated_at = excluded.updated_at`
).bind(deviceToken, ts, ts).run();

購読は 1 ユーザーに複数行ぶら下げます。ブラウザの PushManager.subscribe() が返すオブジェクトから endpoint / p256dh / auth を保存すれば、PC とスマホの両方に届きます。endpointUNIQUE にしておくと、再購読で行が増えません。

CREATE TABLE push_subscriptions (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  endpoint   TEXT UNIQUE NOT NULL,
  p256dh     TEXT NOT NULL,
  auth       TEXT NOT NULL,
  revoked_at TEXT               -- 404/410 で立てる。DELETE しない
);

失効した購読は DELETE せず revoked_at を立てる論理失効にしておくと、有効購読は WHERE revoked_at IS NULL で引けます。

送信は Workers + VAPID を webcrypto で

Node の web-push は Workers では動かないので、webcrypto で動くライブラリ(@block65/webcrypto-web-push)を使います。やることは、有効な購読を引く・ペイロードを暗号化してヘッダを組む・fetch する、の 3 つです。

const req = await buildPushPayload(
  { data: { title, body, data: { url }, tag } },  // SW が受け取る JSON
  { endpoint, expirationTime: null, keys: { p256dh, auth } },
  { subject, publicKey, privateKey },             // VAPID 鍵(env)
);
const res = await fetch(req.endpoint, req.init);

ペイロードは購読ごとの鍵で aes128gcm 暗号化されます。VAPID 鍵は env のシークレットから組み、subject は mailto: かアプリ URL にします。

実機で詰まる2つ: FCM のヘッダと iOS の standalone

ローカルで通ったコードが Chrome と iPhone で無言で失敗する、というのが Web Push の洗礼でした。

FCM が VAPID の Authorization ヘッダを拒否する。 ライブラリが出す古いドラフト(draft-01)の Authorization: WebPush <jwt> を、FCM は invalid JWT provided(403)で弾きます。新しい draft-02 の vapid t=<jwt>, k=<publicKey> 形式に組み替えると 201 で受理されました。Apple や autopush も draft-02 を受けます。

const m = headers[authKey].match(/^WebPush\s+(.+)$/i);
if (m) headers[authKey] = `vapid t=${m[1].trim()}, k=${publicKey}`;

iOS はホーム画面に追加しないと購読できない。 iOS/iPadOS の Safari は、PWA をホーム画面に追加して standalone 起動したときだけ購読できます。通常タブでは subscribe() が失敗するので、iOS かつ standalone でない端末は購読を試みず、ホーム画面追加のガイドを出します。

if (isIOS() && !isStandalone()) {
  showHomeScreenGuide();   // 購読は試みない
  return;
}
// standalone 判定は matchMedia('(display-mode: standalone)') と navigator.standalone の両方を見る

ここを分岐しないと、iPhone ユーザーは許可ボタンを押しても何も起きない、という一番わかりにくい失敗をします。

まとめ

ログイン無しの Web Push は、宛先を匿名 device_token にした瞬間にほとんど解けます。あとは購読を user にぶら下げ、Workers から VAPID で送るだけ。実機で効くのは FCM のヘッダ形式と iOS の standalone 制約の 2 つです。

失効購読を DELETE せず到達率 KPI として残す設計、ログイン無しでの CSRF(device_token 由来の HMAC)、通知を冪等に届ける outbox など、実運用で見えた設計は Aulvem 本家にまとめました → Aulvem|ログイン不要でWeb Push を届ける。動くものはヤスゴロで、ログイン不要のまま試せます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?