📬

iOS(16.4+)を含むブラウザでWeb Push機能を実装したメモ

2023/04/08に公開

はじめに

2023年3月末にiOS 16.4がリリースされたことで、ついにすべてのモダンブラウザユーザーに対してWeb Pushを送れるようになりました。
本記事は、筆者が個人開発しているWebサービスでWeb Push機能を実装したときに調べたことや行ったことをメモとして残すものです。Web Push機能の実装を検討されている方の参考になりましたら幸いです。
なお、筆者は外部サービスへの依存をなるべく減らしたかったため、FCMなどのプッシュ通知機能を提供してくれるものはなるべく使わずに実装したのですが、大変だったので基本的には素直にSaaS等を使った方がよいと思います。

注意事項として、筆者はバックエンドに専門性がありません。そのため、何か間違った記述があるかもしれません。特に暗号化周りは理解が甘い点があると思います。もし誤りを発見された場合は優しめに教えていただけると助かります。よろしくお願いします。

Web Pushとは

Push APIとNotification APIを組み合わせて実現する、ブラウザにネイティブアプリのようなプッシュ通知体験を提供する技術です。iPhoneが(PWA化限定とはいえ)iOS 16.4以降で対応したことで、全てのモダンブラウザで利用できるようになりました。

Web Pushの購読・送受信のおおまかな流れ

プッシュ通知の購読や送受信には、主に3つの登場人物が関係します。

  • クライアント(ブラウザ)
  • バックエンド(アプリケーションサーバ)
  • プッシュサーバ

プッシュ通知の送信は以下の流れで行われます。

  1. バックエンドがプッシュサーバにプッシュ通知依頼を送信
  2. 依頼を受けたプッシュサーバがクライアントにプッシュ通知を送信
  3. プッシュ通知を受信したブラウザが通知を表示

それぞれのフェーズにおいて何が起きるのかを簡単に説明します。

1. バックエンドからプッシュサーバへのプッシュ通知依頼の送信

バックエンドは開発者が作るサーバアプリケーションのことです。それはいいとして、では「プッシュサーバ」とはなんでしょうか。これは各ブラウザベンダ(ChromeであればGoogle、FirefoxであればMDN、SafariであればApple、EgdeであればMicrosoft)が提供しているサーバです。アプリケーション開発者がプッシュサーバを用意したり管理したりする必要はありません。
通知依頼は、プッシュ通知の購読作成時にブラウザとプッシュサーバの間で発行されるエンドポイントURLへのHTTP POSTリクエストとして行われます。翻って言うと、その際に発行されたエンドポイントURLは事前にクライアントからバックエンドになんらかの方法で送り、バックエンドに保存しておく必要があります。
エンドポイントURLは単なるHTTPのURLなので、第三者に漏れてしまった場合、悪意ある第三者から正規のプッシュ通知を装ったスパムを送ったりされる恐れがありそうです。そういった脅威を和らげるために、POSTリクエストはVAPID認証というRFCに仕様が定義されている認証方式で、バックエンドからプッシュサーバへのプッシュ通知が正しいものであることを証明する必要があります。VAPID認証の具体的な実装については後に述べます。
それとは別に、プッシュ通知の内容であるリクエストボディ(メッセージ)はAES128GCMという方式で暗号化する必要があります。これはバックエンドからクライアントに送りたい通知の内容をプッシュサーバに対して秘密にできるように行います。
より正確にいうと、バックエンド-プッシュサーバ間の認証にしても、リクエストボディの暗号化にしても、それ以外のやり方が存在するようなのですが、それらはだいたい過渡期に生まれた独自仕様だったりするようです。

2. 依頼を受けたプッシュサーバがクライアントにプッシュ通知を送信

ここはブラックボックスです。アプリケーション開発者は特に意識する必要はありません。

3.プッシュ通知を受信したブラウザが通知を表示

プッシュ通知の受け取りはサービスワーカーへのイベント通知として行われます。そのため、プッシュ通知の受け取りイベントで発火してNotificationを表示するようなサービスワーカーを、開発者自身が実装する必要があります。

Web Pushの購読を実装する

ここからは実際にサンプルコードを交えながら実装をしていきます。当記事ではバックエンドの言語にpythonを使います。

1.バックエンドにWeb Pushの購読に必要な鍵を置き、クライアントから公開鍵が取得可能な状態にする

まずVAPID認証に必要な鍵を作ります。本記事ではpyca/cryptographyパッケージを使って鍵生成を行います。作りたい鍵はW3CのPushSubscriptions Interfaceの仕様に記述されているapplicationServerKeyとして渡すものなので、それに従い、P-256楕円曲線を用いた鍵を作成します。

# 一度生成すればいいものなので、REPLで実行してもよいです

from cryptography.hazmat.primitives.asymmetric import ec

# P-256曲線を用いた楕円曲線暗号鍵を生成
private_key = ec.generate_private_key(ec.SECP256R1)

# int数値に変換する
print(private_key.private_numbers().private_value)
# printすると、170352493932252064701611... 
# と長い整数が表示されます。これは秘密鍵なので、秘匿情報として扱います。

生成した鍵はVAPID認証のたびに使用するため、バックエンドアプリケーションからアクセスできるように保存します。本記事では簡単のため、設定ファイルsettings.pyがあると仮定し、そこにベタで置きます。

settings.py
# 上記手順で生成した秘密鍵です。本記事では簡単のためベタで書いていますが、できればそれは避け、環境変数等を用いて流し込んでください
VAPID_PRIVATE_KEY_NUMBER = 17035249393225206470161140466929184275621592490495471687131617843968286182894

次に、クライアントがこの鍵の公開鍵を取得できるようにします。本記事では/api/webpush/vapid_public_keyへのGETリクエストで取得できるようにします。ここで取得可能にするのは公開鍵なので、誰でもアクセスできるようにして問題ありません。
ですがその実装に入る前に、クライアントとの鍵のやりとりは基本的にRFC7515で定義されているBase64形式で行うので、その変換をするヘルパーを作っておきます。

utils/urlsafebase64.py
import base64


class PaddingLessUrlSafeBase64Helper:
    # Base64エンコードされた文字列に足りないパディングを補って返す
    @classmethod
    def __add_padding(cls, urlsafe_base64_encoded_str: str) -> str:
        return urlsafe_base64_encoded_str + (
            (-len(urlsafe_base64_encoded_str) % 4) * "="
        )

    # urlsafe_b64encode()したあとでパディング=を取り除く
    @classmethod
    def encode(cls, data: bytes) -> str:
        return base64.urlsafe_b64encode(data).decode().replace("=", "")

    # パディング=が削られているbase64エンコード文字列にパディングを補い、その上でデコードする
    @classmethod
    def decode(cls, data: str) -> bytes:
        return base64.urlsafe_b64decode(cls.__add_padding(data))

上記ヘルパーができたら、それを使ってVAPID認証用の公開鍵を配信するエンドポイントを作成します。

api.py
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import (
    Encoding,
    PublicFormat,
)

# 加えて、本記事で作成しているsettingsとPaddingLessUrlSafeBase64Helperをimport

# ご使用のフレームワークに置き換えて読んでください
@app.get("/api/webpush/vapid_public_key")
async def get_vapid_public_key():
    # P-256楕円を指定し、private numberから秘密鍵オブジェクトを生成する
    private_key = ec.derive_private_key(
        private_value=settings.VAPID_PRIVATE_KEY_NUMBER,
        curve=ec.SECP256R1(),
    )
    # 秘密鍵オブジェクトからは公開鍵が取れる
    public_key = private_key.public_key()

    # Encoding, PublicFormatをこうしている根拠は下記仕様を参考にしています
    # https://w3c.github.io/push-api/#pushsubscriptionoptions-interface
    public_key_bytes = public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
    return {
        # 誤って秘密鍵が公開されたりすることがないよう注意してください
        "public_key": PaddingLessUrlSafeBase64Helper.encode(public_key_bytes)
    }

これでWeb Push購読に必要なVAPID認証用の公開鍵をクライアントからGETリクエストを介して取得できるようになりました。

2.クライアントがPWAとして動作できるようにmanifest.jsonを設置する

クライアントの実装に移ります。iOS 16.4以降のiPhoneを対応範囲に含めたい場合、PWA化は必須です。WebサービスをPWAに対応するには、manifest.jsonというファイルをWebサービスのルートに配置します。manifest.jsonの詳しい仕様はMDNを参照してください。
https://developer.mozilla.org/ja/docs/Web/Manifest

manifest.json
{
  "name": "わたしの運営しているサービスのなまえ",
  "short_name": "わたサー",
  "lang": "ja",
  "display": "standalone",
  "start_url": "/"
}

iOS 16.4+でプッシュ通知を有効にするには、displayをstandalonefullscreenに設定する必要があります。それについてはこのWebkitの記事を参照してください。

/manifest.jsonから配信されるようにしたら、各ページのHTMLにmanifest.jsonを読み込むようなlinkタグをheadの中に追記します。

<!-- どのページからPWA化されてもよいように、なるべく全ページのheadにおいたほうがよいです -->
<link rel="manifest" href="/manifest.json">

以上でPWA化の準備は完了です。

3.クライアントのWeb Push購読処理を実装する

クライアントでWeb Push購読を作成し、エンドポイントURLやメッセージ暗号化のための情報をバックエンドに送信する機能を実装します。JavaScriptを書きましょう。

まず、Web Push通知機能が使えない環境はまだまだ多数存在するので、機能が使えるかどうかを判定する処理は必須になるはずです。それはユーザーのブラウザが以下に対応しているかどうかで判定できます。

  • Service Worker
  • Push API
  • Notification API

具体的な実装としては以下の判定関数でだいたい満たせると思います。

const checkIsWebPushSupported = async () => {
  // グローバル空間にNotificationがあればNotification APIに対応しているとみなす
  if (!('Notification' in window)) {
    return false;
  }
  // グローバル変数navigatorにserviceWorkerプロパティがあればサービスワーカーに対応しているとみなす
  if (!('serviceWorker' in navigator)) {
    return false;
  }
  try {
    const sw = await navigator.serviceWorker.ready;
    // 利用可能になったサービスワーカーがpushManagerプロパティがあればPush APIに対応しているとみなす
    if (!('pushManager' in sw)) {
      return false;
    }
    return true;
  } catch (error) {
    return false;
  }
}

次に、前のセクションで作成したバックエンド側のVAPID認証用公開鍵を返すエンドポイントを叩いて公開鍵を取得する関数を定義します。

const getVapidPublicKey = async () => {
  const res = await fetch("/api/webpush/vapid_public_key");
  if (!res.ok) {
    throw new Error("VAPID公開鍵取得失敗");
  }
  return (await res.json()).public_key;
};

上記のそれぞれを使って、購読開始したいときに呼ぶ関数を定義します。

// iOS 16.4のPWAでNotification.requestPermission()を呼ぶ場合、
// ユーザーのアクション(クリックなど)から呼ばれた関数内でないと失敗します。
// ですので、このsubscribe関数はクリックやボタン押下を起因に発火するようにしてください。
const subscribe = async () => {
  if (!(await checkIsWebPushSupported()) {
    throw new Error('ご利用のブラウザではWeb Pushは使えません');
  }
  const validPublicKey = await getVapidPublicKey();
  
  // Notification APIを使って、利用者から通知の許可を得ます。「通知を有効化しますか?」のような確認ダイアログが出ます。
  // なお、メインスレッドで許可を得ておけばServiceWorkerでも使えるようになります(当たり前ですが)。
  if (window.Notification.permission === "default") {
    const result = await window.Notification.requestPermission();
    if (result === "default") {
      throw new Error(
        "プッシュ通知の有効化がキャンセルされました。はじめからやり直してください。"
      );
    }
  }
  if (window.Notification.permission === "denied") {
    throw new Error(
      "プッシュ通知がブロックされています。ブラウザの設定から通知のブロックを解除してください。"
    );
  }
  
  // ブラウザのPush APIを利用し、バックエンドからもらったVAPID認証用の公開鍵を用いてプッシュ通知の購読を作成する。
  // なお、サービスワーカーは次のセクションで追加しますので、現時点ではまだ動きません。
  const currentLocalSubscription = await navigator.serviceWorker.ready.then(
    (worker) =>
      worker.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: validPublicKey,
      })
  );

  // currentLocalSubscriptionの中身は下記MDNのページを参照してください。
  // https://developer.mozilla.org/ja/docs/Web/API/PushSubscription
  // JSONシリアライズできるオブジェクトになっていた方が取り回しやすいので、toJSON()します。
  // endpoint, keysなどが空だった場合は対応外なクライアントと見做し弾きます。
  const subscriptionJSON = currentLocalSubscription.toJSON();
  if (subscriptionJSON.endpoint == null || subscriptionJSON.keys == null) {
    throw new Error(
      "ご利用のブラウザが発行したトークンは未対応のため、プッシュ通知はご利用いただけません。"
    );
  }

  try {
    // /api/webpush/subscriptionエンドポイントの実装は本記事では行いません。
    // 要するにendpoint, expiration_time, keys.p256dh, keys.authを
    // それぞれ受け取ってログインユーザーと紐づけて保存しておけばよいだけです。
    //
    // そして現実の実装では、購読作成のほかに、クライアントが現在持っている購読がバックエンドから見ても有効なのかどうかを確かめる際に
    // endpointをキーにしたGETリクエストで購読が存在するか確認する処理が必要になるはずです。
    const res = await fetch("/api/webpush/subscription", {
      method: "post",
      body: JSON.stringify({
        endpoint: subscriptionJSON.endpoint,
        expiration_time: subscriptionJSON.expirationTime ?? null,
        keys: {
          p256dh: subscriptionJSON.keys.p256dh,
          auth: subscriptionJSON.keys.auth,
        },
      }),
    });
    if (!res.ok) {
      throw new Error("購読失敗");
    }
    alert('プッシュ通知を購読しました')
  } catch (err) {
    alert("プッシュ通知の購読に失敗しました");
  }
};

上記のクライアントとバックエンドの実装が揃っていれば、最低限のプッシュ通知の購読ができます。
実際のプロダクトではこれらに加え、①クライアントがプッシュサーバとの間に持っている購読が期待通りバックエンドに保存されているかどうかクライアントとバックエンドの間で確認し、バックエンドにレコードがなかった場合はクライアントがプッシュサーバとの間の購読を解除する、②クライアント側に購読解除機能を実装し、その際はクライアントからバックエンド側に購読オブジェクトを削除するリクエストを送り、その後でクライアントとプッシュサーバの間の購読を解除する、③購読解除処理が為されないままバックエンドに残ってしまったレコードの処理をどうするか決める、④2023年現在のモダンブラウザが発行するSubscriptionにはexpireが設定されていないが、設定されたときにどう再購読を作成するか検討する、など色々考えることはあると思います。

(補足)iOS 16.4+にプッシュ通知を登録してもらうには

iOS以外のモダンブラウザはPWA化してもらわなくてもプッシュ通知の購読や送信ができます。しかしiOS 16.4+はユーザーにWebサービスを能動的にPWAとしてインストールしてもらわないとプッシュ通知関連APIが使用できません。
ユーザーにしてもらう必要があるiOSでのPWA化は、①対象のWebサービスを開いた状態で、Safariのメニューから「ホーム画面に追加」を押す、②ホームに追加されたアイコンから起動する、という手順になります。WebアプリのPWA化に慣れ親しんでいるユーザーは2023年現在あまり多くないと思われるので、ここの導線をいかにわかりやすく自然に説明できるかがプッシュ通知購読率に直結すると筆者は考えます。

クライアントのWeb Push受信と通知の表示を実装する

Web Pushの購読はできるようになったので、ここからはクライアント側のWeb Pushの受信機能を実装します。

1.クライアントで、Push通知を受信したときに通知を出すようなサービスワーカーを実装する

Pushイベントはサービスワーカーが受け取ります。そして、受け取ったサービスワーカーの中でNotification APIを利用し、通知を表示します。
そのようなサービスワーカーのJSを実装し、利用したいWebアプリのルートに配置しましょう。

serviceworker.js
self.addEventListener('push', (event) => {
  try {
    if (
      self.Notification == null ||
      self.Notification.permission !== 'granted'
    ) {
      console.debug('notification is disabled.');
      return;
    }

    const payload = event.data?.json() ?? null;
    const title = payload?.title ?? 'プッシュ通知で表示されるタイトルのデフォルト値';
    const tag = payload?.tag ?? '';
    const body = payload?.body ?? '';
    const icon = payload?.icon ?? 'プッシュ通知で表示させたいアイコン画像URLのデフォルト値';
    const data = payload?.data ?? null;

    self.registration.showNotification(title, {
      body,
      tag,
      icon,
      data,
    });
  } catch (e) {
    // デバッグ用なので本番では消してもよいです
    console.error(e);
  }
});

self.addEventListener('notificationclick', (event) => {
  try {
    event.notification.close();
    clients.openWindow(event.notification.data?.url ?? '/');
  } catch (e) {
    // デバッグ用なので本番では消してもよいです
    console.error(e);
  }
});

serviceworker.jsはWebサービスのなるべくルートに、つまりhttps://example.com/serviceworker.jsのように配置してください。サブパス以下、つまり/static/js/serviceworker.jsなどに配置してしまうと、/other_page のような/static/js/より上位のパスから配信されるリソースに対してサービスワーカーが関与することができなくなってしまいます。

サービスワーカーを/serviceworker.jsからアクセスできる状態にしたら、WebサービスのHTMLの中に読み込みスクリプトを書きます。

// どのページにランディングされてもサービスワーカーが登録されるように、
// 全ページでこのスクリプトが実行されるようにしたほうが無難です。
if (window.navigator.serviceWorker !== undefined) {
  window.navigator.serviceWorker.register('/serviceworker.js');
}

以上でWeb Push受信の準備は完了です。

バックエンドからのWeb Pushの送信を実装する

今までの実装の中で、①クライアントがプッシュサーバとの間に購読を作成し、②クライアントが作成された購読情報をバックエンドに送信し、③バックエンドがユーザー情報(ログイン状態など)と紐づけて保存している、という3つの状態が満たされたと思います。ここからは、それらを用いてWeb Push通知を送信する機能を実装していきます。

1.リクエストボディの暗号化

プッシュ通知を通してバックエンドからクライアントに送りたいデータが次のようなJSON文字列だったとします。

push.json
{
  "title": "あたらしいWeb Pushが送信されました",
  "body": "Web Pushです",
  "data": {
    "url": "https://example.com/クリック後に遷移されたいページのURL"
  },
  "icon": "https://example.com/プッシュ通知に表示させたいアイコンのURL"
}

これをプッシュサーバに対しPOSTリクエストのボディとして送るわけですが、その際、平文ではなくAES128GCMに則って暗号化する必要があります。仕様はRFC8188で定義されています。本記事では、それのpython実装であるhttp-eceパッケージを使います。
この暗号化にもVAPID認証のために作ったのと同じような(しかし、別の)秘密鍵が必要になります。同様の手法で新たに生成してください。鍵は以下のように設定ファイルから読み込める状態にしてください。

settings.py
VAPID_PRIVATE_KEY_NUMBER = 17035249393225206470161140466929184275621592490495471687131617843968286182894
# VAPID認証用秘密鍵と同様、できればベタ書きではなく環境変数などを使って流し込む方法に書き換えてください
+ WEB_PUSH_CONTENT_ENCRYPTION_PRIVATE_KEY_NUMBER = 61905741361535604126036403626659705600256543354235527851513243188031394432080

AES128GCMでのメッセージ暗号化関数を作成します。

utils/webpush.py
import os
from cryptography.hazmat.primitives.asymmetric import ec

import http_ece

# 本記事で作成しているsettingsとPaddingLessUrlSafeBase64Helperのimportなどは省略しています

class WebPushHelper:
    @classmethod
    def _encrypt_message(
        cls,
        msg: bytes,
        # クライアントから送られてきたkeys.p256dh文字列です。
        encoded_user_public_key: str,
        # クライアントから送られてきたkeys.auth文字列です。
        encoded_user_auth: str,
    ) -> str:
        salt = os.urandom(16)
        server_private_key = ec.derive_private_key(
        # settings.pyに置いたコンテンツ暗号化用の鍵
            private_value=settings.WEB_PUSH_CONTENT_ENCRYPTION_PRIVATE_KEY_NUMBER,
            curve=ec.SECP256R1(),
        )
        user_public_key = ec.EllipticCurvePublicKey.from_encoded_point(
            ec.SECP256R1(),
            PaddingLessUrlSafeBase64Helper.decode(encoded_user_public_key),
        )
        user_auth = PaddingLessUrlSafeBase64Helper.decode(encoded_user_auth)
        return http_ece.encrypt(
            content=msg,
            salt=salt,
            private_key=server_private_key,
            dh=user_public_key,
            auth_secret=user_auth,
            version="aes128gcm",
        )

2.VAPID認証でヘッダに入れるためのJWTを作成

次に、VAPID認証のためのJWTを作成する関数を作ります。JWTの作成にはpyJWTパッケージを使います。

utils/webpush.py
+from urllib.parse import urlparse
+from datetime import datetime, timedelta

+import jwt
+from cryptography.hazmat.primitives.serialization import (
+    Encoding,
+    NoEncryption,
+    PrivateFormat,
+)


class WebPushHelper:
    ...
    
+    @classmethod
+    # ここでいうendpointは、ユーザーから送られてきたSubscriptionのendpointです
+    def _generate_jwt(cls, endpoint: str) -> str:
+        o = urlparse(endpoint)
+        return jwt.encode(
+            {
+                # audはendpointから生成します
+                "aud": o.scheme + "://" + o.netloc,
+                # 有効期限はとりあえず6時間とします
+                "exp": int((datetime.now() + timedelta(hours=6)).timestamp()),
+                # subはプッシュサーバが問い合わせに使うことを想定しているようです
+                # 連絡がつくメールアドレスを書いてください
+                "sub": "mailto:hogehoge@example.com",
+            },
+            # VAPID認証用秘密鍵で署名します。PEM形式でpyjwtに与えます。
+            ec.derive_private_key(
+               private_value=settings.VAPID_PRIVATE_KEY_NUMBER,
+               curve=ec.SECP256R1()
+	     ).private_bytes(
+                Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()
+            ).decode(),
+            algorithm="ES256",
+        )

3.バックエンドからプッシュサーバへプッシュ通知を送る

最後に、これらの関数を組み合わせつつプッシュサーバにVAPID認証でPOSTリクエストをするメソッドを作成します。本記事ではHTTPリクエストにはrequestsパッケージを使います。

utils/webpush.py
+import requests

class WebPushHelper:
    ...

+    @classmethod
+    def send_push(
+        cls,
+        # クライアントから送られてきたendpoint
+        endpoint: str,
+        # クライアントから送られてきたkeys.p256dh
+        encoded_user_public_key: str,
+        # クライアントから送られてきたkeys.auth
+        encoded_user_auth: str,
+        # プッシュ通知で送信したいJSONシリアライザブルな辞書
+        payload: dict,
+    ) -> requests.Response:
+        body = cls._encrypt_message(
+            msg=json.dumps(payload).encode(),
+            encoded_user_public_key=encoded_user_public_key,
+            encoded_user_auth=encoded_user_auth,
+        )
+
+        # see: https://github.com/web-push-libs/web-push/issues/278#issuecomment-356783840
+        # fcmのwp対応が不十分らしく、VAPID認証する場合はURLを書き換えないといけない
+        # おそらく一時的なハック
+        if endpoint.startswith("https://fcm.googleapis.com/fcm/send"):
+            endpoint = endpoint.replace(
+                "https://fcm.googleapis.com/fcm/send",
+                "https://fcm.googleapis.com/wp",
+            )
+
+        token = cls._generate_jwt(endpoint=endpoint)
+        encoded_vapid_public_key = PaddingLessUrlSafeBase64Helper.encode(
+            ec.derive_private_key(
+                private_value=settings.VAPID_PRIVATE_KEY_NUMBER,
+                curve=ec.SECP256R1(),
+            )
+            .public_key()
+            .public_bytes(
+                Encoding.X962,
+                PublicFormat.UncompressedPoint
+            )
+        )
+        res = requests.post(
+            endpoint,
+            headers={
+                "Authorization": f"vapid t={token}, k={encoded_vapid_public_key}",
+                "Content-Encoding": "aes128gcm",
+                "Content-Type": "application/octet-stream",
+                "TTL": "86400",
+                # see: https://github.com/web-push-libs/web-push/pull/764
+                # see: https://developer.apple.com/documentation/usernotifications/sending_web_push_notifications_in_safari_and_other_browsers#3994592
+                # 本来Urgencyヘッダはoptionalなはずだが、iOS 16.4相手にPushする場合は入れないとプッシュ通知が到達しない
+                "Urgency": "normal",
+            },
+            data=body,
+        )
+        return res

POSTリクエスト時、ヘッダにVAPID認証のためのAuthorizationの設定、メッセージがAES128GCM暗号化されていることを示すためのContent-EncodingおよびContent-Typeの設定、他にはTTLとUrgencyの設定が必須になります。リクエストのボディ部は、AES128GCMで暗号化したクライアントに送りたいテキストを設定します。テキストは深い理由がなければJSONにするのがよいかと思います。

これまでの実装が揃ったら、WebPushHelper.send_push()を使ってプッシュ通知を飛ばしてみましょう。うまくいっていれば、プッシュサーバから201レスポンスが返ってきて、クライアントでプッシュ通知が確認できたはずです。

(補足)chrome向けの注意

参考コードに記述していますが、VAPID認証でプッシュ通知する場合、エンドポイントURLを書き換えないと不通になるようです。

(補足)iOS向けの注意

こちらも参考コード上に記述していますが、仕様上Urgencyヘッダは非必須のはずが、iOSに対してプッシュ通知をする場合は欠けていると届かないため、指定します。指定する値は基本的にはnormalでよいはずです。

(補足)プッシュサーバから201レスポンスが返ってきたのにプッシュ通知が送られてこない場合

VAPID認証が成功し、その他リクエスト形式に問題がなければ、プッシュサーバから201レスポンスが返ってきます。しかしこれはメッセージのAES128GCMでの暗号化が正しいかどうかとは別です(当たり前ではありますが……)。そのようなケースでは、メッセージの暗号化が正しく行えているか確認してください。

参考にした記事

MDNやRFC、W3Cのページ、WebPush機能を実装しているOSSライブラリのコードのほかに、以下のような記事を参考にさせていただきました。

Discussion