PayPay API 支払リクエストのJWTを検証する方法

PayPayで後から請求をユーザーに対して送ることができる「支払リクエスト」を利用する際には、アカウント連携 (ユーザー認可を取得する) が必要です。

この際にレスポンスとしてJWTを得ることができますが、その検証方法について[マニュアル](https://developer.paypay.ne.jp/products/docs/pendingpayment)に記載がないので整理しておきます。

# 結論


* 署名鍵はAPIキー **シークレットをBASE64デコードしたバイト列**
* Audienceは **クライアントID**


# 「支払いリクエスト」とは

事前にユーザーから認可を得ておき、任意のタイミングで請求をPayPayに通知することができる機能です。

法人のみが利用できる機能ですが、病院などの診察において、予約時に認可をとっておき、診療後に請求するといったときに利用できる機能です。

# ユーザー認可を取得する時の応答

ユーザーからの認可は、サービス側のウェブアプリからPayPay側のURLにリダイレクトし、認可後にサービス側の戻りURLに戻ってくるという OAuth などでもよくある方式をとっています。

このとき、戻りURLには `responseToken` GETパラメータとしてJWTの文字列がついてきます。 (これもマニュアルに書いてない)

このJWTは検証が必要ですが、検証方法がマニュアルに書いてありません。
参考のために、JWTの例を示します。 (この例は検証環境の値であり、実環境ではありません)

```text:responseToken
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyZXN1bHQiOiJzdWNjZWVkZWQiLCJhdWQiOiJhX3MySTB3dHgwZ3ciLCJpc3MiOiJwYXlwYXkubmUuanAiLCJwcm9maWxlSWRlbnRpZmllciI6IioqKioqKio3OTQ5IiwiZXhwIjoxNzQwMzc5MTE5LCJub25jZSI6IjljMmE3MTVkLWQ2NjYtNGZmYS1iMGU3LTg3YTFjMDQ4OTU5MSIsInVzZXJBdXRob3JpemF0aW9uSWQiOiI2OGU1ZDcwNS0xOGY5LTRhZGQtODc2ZC02MWRkMjBlYjZlYTEiLCJyZWZlcmVuY2VJZCI6IjYyMzRiNTZhLWZiZmItNDc4Ni05NzgzLTE4MzZlOGY1Mzc2NCJ9.FeYLFNIvPHLBoNFvHU4at5iiB7HwocQ-tq42ll3I6Lw
```

```json:header
{
  "typ": "JWT",
  "alg": "HS256"
}
```

```json:payload
{
  "result": "succeeded",
  "aud": "a_s2I0wtx0gw",
  "iss": "paypay.ne.jp",
  "profileIdentifier": "*******7949",
  "exp": 1740379119,
  "nonce": "9c2a715d-d666-4ffa-b0e7-87a1c0489591",
  "userAuthorizationId": "68e5d705-18f9-4add-876d-61dd20eb6ea1",
  "referenceId": "6234b56a-fbfb-4786-9783-1836e8f53764"
}
```

JWTの検証において重要なのは 「署名鍵は何か」「Audience (aud)は何か」ですが、以下の通りでした。

* 署名鍵はAPIキー **シークレットをBASE64デコードしたバイト列**
* Audienceは **クライアントID**


# Pythonでの支払いリクエストにおける認可の例

```python
def paypay_begin_authorize(request):
    """PayPay支払いリクエストのユーザー認可を開始するビュー"""
    client = paypayopa.Client(
        auth=(
            settings.PAYPAY_API_KEY,
            settings.PAYPAY_API_SECRET,
        ),
        production_mode=settings.PAYPAY_PRODUCTION_MODE,
    )
    client.set_assume_merchant(settings.PAYPAY_MERCHANT_ID)

    # ユーザーを識別する文字列を referenceId として渡す
    user_id = str(request.user.id)
    # 必ずランダム文字列を生成し nonce として利用し後で検証する
    nonce = str(uuid.uuid4())
    redirect_url = request.build_absolute_uri(reverse("paypay_authorize_done"))
    payload = {
        "scopes": ["pending_payments"],
        "nonce": nonce,
        "redirectType": "WEB_LINK",
        "redirectUrl": redirect_url,
        "referenceId": user_id,
    }
    result = client.Account.create_qr_session(payload)

    if result["resultInfo"]["code"] != "SUCCESS":
        raise ValueError("Invalid PayPay ResultInfo Code {}".format(result["resultInfo"]["code"]))

    # この例では検証のためのノンスをセッションに保存することとする
    # NOTE: 異なるブラウザに戻ってくることを考慮してDBでも良い
    request.session["PAYPAY_NONCE"] = nonce
    return redirect(result["data"]["linkQRCodeURL"])


def paypay_authorize_done(request):
    """認可後に戻ってくるビュー"""
    response_token = request.GET.get("responseToken")
    if not response_token:
        raise PermissionDenied("Invalid request parameter")

    # JWTの検証 鍵とAudienceとアルゴリズムを検証すること
    payload = jwt.decode(
        jwt=response_token,
        key=base64.b64decode(settings.PAYPAY_API_SECRET),
        audience=settings.PAYPAY_CLIENT_ID,
        algorithms=["HS256"],
    )

    # 処理を開始した時のNonceと一致するか確認すること
    expected_nonce = request.session.get("PAYPAY_NONCE")
    if not expected_nonce or expected_nonce != payload["nonce"]:
        raise PermissionDenied("Invalid session state")

    # 認証結果として得られた userAuthorizationId は決済に利用するために保存しておく
    user.paypay_user_authorization_id = payload["userAuthorizationId"]
    user.save()

    return HttpResponse("OK")
    
```

# コメント

PayPayの方にはぜひJWTの検証についてマニュアルに記載して欲しいなと思いました。
書いてないので、もしかして検証せずにPayloadのuserAuthorizationIdだけを見ている実装をしている人もいるかもしれない...