初心者でもわかるiOSサブスク課金のサーバ側の実装！App Store Server Notifications Version 2（StoreKit 2）のJWS検証と判定方法を解説！

こんにちは。[virapture株式会社](https://virapture.com)の[もぐめっと](https://mogmet.com)です。

![mogmet.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/5b025955-52c6-3688-ac45-e374e3227297.jpeg)
最近桜が咲いてたので京都で花見をしてきました。なかなか最高だったのでおすすめです。

本日は久々にiOSのサブスク実装をしたらStoreKit2なるものが出ていて、新しくなってたのでサーバ側での検証の仕方や実装方針などを解説しようと思います。

今回はApp Store Server Notifications Version2での解説をいたします。

payload内容の解説、JWSの検証方法、イベントによる判定基準を紹介します。
詳細はこの動画が参考になるので見ておくことをおすすめです。

https://developer.apple.com/videos/play/wwdc2021/10174/

## payload内容の解説

V2での通知を受け取るための事前設定とpayloadの内容について解説します。

### 事前設定
まずV2で通知を受け取れるように設定をしましょう。
V2でのpayloadを受け取るにはappstoreのページでApp Storeサーバ通知のURLを設定して、v2を設定することで受け取ることができます。

![スクリーンショット 2022-04-04 20.43.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/a045395e-0a1d-34b7-2e4f-e837a4ce54c5.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/7be23010-9aa4-8280-0bd5-d26910701b32.png)

### payloadの解説

そしていざサブスク課金をすると下記のようなペイロードがイベントごとに通知されます。

```
{ signedPayload: eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTU.... }
```

**signedPayloadというkeyに長ったらしい文字列しか入っていない！！！！**

[ドキュメントによると](https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2)jws形式で通知されるのでBase64URL decodeしてくれということなのでデコードすると、下記の情報が入ってきます。
[payload詳細はドキュメント参照](https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload)

- signature: String // 文字列が入ってる
- header
    - alg: String // ES256が指定されている
    - x5c: String[] // ３つのpem形式の証明書が入っている
- palyload: Map // 情報本体
    - notificationType: String // 通知された起因になったイベント。[notificationType一覧](https://developer.apple.com/documentation/appstoreservernotifications/notificationtype)
    - subtype: String // notificationTypeに対する詳細情報。[subtype一覧](https://developer.apple.com/documentation/appstoreservernotifications/subtype)
    - notificationUUID: String // 通知の一意の識別子。この値を使用して、重複する通知を識別する。
    - version: String // 通知のバージョン番号。2.0がはいってる
    - data: Map // トランザクション情報。[詳細はこちら](https://developer.apple.com/documentation/appstoreservernotifications/data)
        - bundleId: String // アプリのbundleID
        - bundleVersion: String // 大体数字だけどビルド番号
        - environment: String // サンドボックスか本番か
        - signedTransactionInfo: String // Base64 URL-encodeされた文字列が入ってる
        - signedRenewalInfo: String // Base64 URL-encodeされた文字列が入ってる

[signedTransactionInfo](https://developer.apple.com/documentation/appstoreserverapi/jwstransaction?changes=_7)をデコードすると中身はこんな感じ。トランザクション情報が入る。
[payload詳細はドキュメント参照](https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload?changes=_7)

- signature: String // 文字列が入ってる
- header
    - alg: String // ES256が指定されている
    - x5c: String[] // ３つのpem形式の証明書が入っている
- palyload: Map // 情報本体
    - transactionId: String // トランザクションID
    - originalTransactionId: String // オリジナルトランザクションID
    - webOrderLineItemId: String // サブスクリプションの更新など、デバイス全体のサブスクリプション購入イベントを識別する一意のID
    - bundleId: String // アプリのバンドル識別子
    - productId: String // サブスクの商品ID
    - subscriptionGroupIdentifier: String // 数字だけどサブスクリプションが属するサブスクリプショングループの識別子
    - purchaseDate: Int // 購入日のタイムスタンプ
    - originalPurchaseDate: Int // 最初に購入した日のタイムスタンプ
    - expiresDate: Int // 期限切れタイムスタンプ
    - quantity: Int // 個数
    - type: String // アプリ内課金のタイプ。サブスクだとAuto-Renewable Subscription
    - inAppOwnershipType: String // トランザクションがユーザーによって購入されたか、ファミリー共有を通じてユーザーが利用できるかを説明する文字列。 購入されたらPURCHASED
    - signedDate: Int // 署名日
    - environment: String // サンドボックスか本番か

レスポンス例
```
    ┌─────────────────────────────┬─────────────────────────────────────────────────────┐
    │           (index)           │                       Values                        │
    ├─────────────────────────────┼─────────────────────────────────────────────────────┤
    │        transactionId        │                 '2000000020452744'                  │
    │    originalTransactionId    │                 '2000000020430616'                  │
    │     webOrderLineItemId      │                 '2000000001536916'                  │
    │          bundleId           │           'com.xxxxxxxxxxxx.xxxxxx'                 │
    │          productId          │ 'com.xxxxxxxxxxxx.xxxxxx.subscription'              │
    │ subscriptionGroupIdentifier │                     '20938917'                      │
    │        purchaseDate         │                    1648519805000                    │
    │    originalPurchaseDate     │                    1648516507000                    │
    │         expiresDate         │                    1648520105000                    │
    │          quantity           │                          1                          │
    │            type             │            'Auto-Renewable Subscription'            │
    │     inAppOwnershipType      │                     'PURCHASED'                     │
    │         signedDate          │                    1648520106884                    │
    │         environment         │                      'Sandbox'                      │
    └─────────────────────────────┴─────────────────────────────────────────────────────┘
```

[signedRenewalInfo](https://developer.apple.com/documentation/appstoreservernotifications/jwsrenewalinfo?changes=_7)をデコードするとこんな感じ。サブスクリプションの更新情報が入る。
[payload詳細はドキュメント参照](https://developer.apple.com/documentation/appstoreservernotifications/jwsrenewalinfodecodedpayload?changes=_7)


- signature: String // 文字列が入ってる
- header
    - alg: String // ES256が指定されている
    - x5c: String[] // ３つのpem形式の証明書が入っている
- palyload: Map // 情報本体
    - expirationIntent: Int // サブスクがきれた理由。[1-4まである](https://developer.apple.com/documentation/appstoreservernotifications/expirationintent?changes=_7)
    - originalTransactionId: String // 購入の元のトランザクションID
    - autoRenewProductId: String // 次の請求期間に更新される製品の製品ID
    - productId: String // アプリ内課金の商品ID。
    - autoRenewStatus: Int // 自動更新可能なサブスクリプションの更新ステータス。[0か１](https://developer.apple.com/documentation/appstoreservernotifications/autorenewstatus?changes=_7)
    - isInBillingRetryPeriod: Bool // AppStoreが期限切れのサブスクリプションを自動的に更新しようとしているかどうか
    - signedDate: Int // 署名日
    - environment: String // サンドボックスか本番か

レスポンス例
```
    ┌────────────────────────┬─────────────────────────────────────────────────────┐
    │        (index)         │                       Values                        │
    ├────────────────────────┼─────────────────────────────────────────────────────┤
    │    expirationIntent    │                          1                          │
    │ originalTransactionId  │                 '2000000020430616'                  │
    │   autoRenewProductId   │ 'com.xxxxxxxxxxxx.xxxxxx.subscription'              │
    │       productId        │ 'com.xxxxxxxxxxxx.xxxxxx.subscription'              │
    │    autoRenewStatus     │                          0                          │
    │ isInBillingRetryPeriod │                        false                        │
    │       signedDate       │                    1648520106862                    │
    │      environment       │                      'Sandbox'                      │
    └────────────────────────┴─────────────────────────────────────────────────────┘
```

## JWSの検証方法

どうやってJWS形式のペイロードを検証するのかというところですが下記モンスト様の記事に答えが書いてありました。

https://mixi-developers.mixi.co.jp/verify-app-store-server-notification-version-2-22f6cb88add1

ざっくりいうとこんな感じです。

1. JWSには3つの証明書が同封されている
2. Appleが公開しているルート証明書を公開鍵として使う
3. 3つの証明書をルート証明書を使って検証する

証明書をうまく使うことでAppleのサーバに通信しなくてもその妥当性を保証することができるわけですね。

ということで、ルート証明書の準備、検証といった形で解説していきます。

### ルート証明書の準備

ということで、ルート証明書（[Apple Root CA - G3 Root](https://www.apple.com/certificateauthority/AppleRootCA-G3.cer)）を[Apple PKI](https://www.apple.com/certificateauthority/)からダウンロードします。
さらにこれを検証できるようにpem形式に変換します。
ダウンロードしたルート証明書をダブルクリックしてインストールしちゃいます。

その後キーチェーンを開いて、書き出しを行います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/129deb3f-c6ce-335e-7ef8-54941791326d.png)

PEMにして書き出します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/33f1b159-1172-e7c8-4fb3-c31964dbf029.png)

これで準備完了です！

### 証明書の検証

モンスト様ではrubyのライブラリを使ってしれっと検証していましたが、今回私はnodeで実装をしました。
しかし、意外と新しい検証方法らしく対応しているライブラリがみつかりませんでした・・・
（有名なのはnode-forgeみたいなのですが動かなかった）
そのため、下記を参考にしてjsrsasignを用いて頑張ってロジックを書きました。

https://crieit.net/posts/Node-js-jsrsasign

https://github.com/kjur/jsrsasign/issues/176#issuecomment-1073434816

```ts
import jws from 'jws'
import jsrsasign from 'jsrsasign'

class AppleVerifier {
    private readonly appleRootCAG3Pem = '-----BEGIN CERTIFICATE-----...' // appleのルート証明書
    verify(signedPayload: string) {
        const decodedJws = jws.decode(signedPayload)
        verifyCertificates(decodedJws)
    }

    private verifyCertificates(decodedJws: jws.Signature): void {
        const certs = this.createCerts(decodedJws.header.x5c)
        for (let i = 0; i < certs.length - 1; i++) {
            const issuerPubKey = jsrsasign.KEYUTIL.getKey(certs[i + 1]) // 上位の証明書を比較
            const certificate = new jsrsasign.X509()
            certificate.readCertPEM(certs[i])
            const isValid = certificate.verifySignature(issuerPubKey)
            if (!isValid) {
                throw new Error('invalid signature')
            }
        }
    }

    private createCerts(x5c?: readonly string[]): string[] {
        if (!x5c) {
            throw new Error('invalid header')
        }
        const certs = x5c.map((cert) => {
            return '-----BEGIN CERTIFICATE-----' + cert + '-----END CERTIFICATE-----' // x5cの中身はpemの形になってないので、pemの形にする
        })
        certs.push(this.appleRootCAG3Pem)
        return certs
    }
```

3つの証明書にAppleのルート証明書を追加して上位の証明書と比較するような形にしています。
前準備で作ったルート証明書はPEMで出力してますが、ただのテキストなのでエディタなどで出力してあげることでコピペできます。
pemの内容をappleRootCAG3Pemの変数に突っ込むことで上記ソースは動きます！

## イベントによる判定基準

payloadの中身もわかって、検証もできたので次に何をもって会員情報を変更するのかというところで解説いたします。
そのためにイベントの種類とイベントフローについて紹介した後、実際の判定をどうしたかを紹介します。

### イベントの種類

今回V2によって[NotificationType](https://developer.apple.com/documentation/appstoreservernotifications/notificationtype)でいくつかが廃止・追加されました。

| Notification Type | V1 | V2 |
| --- | --- | --- |
|INITIAL_BUY |○ | X|
|DID_CHANGE_RENEWAL_STATUS | ○| ○|
|DID_CHANGE_RENEWAL_PREF|○ | ○|
|INTERACTIVE_RENEWAL | ○| X|
|DID_FAIL_TO_RENEW |○ | ○|
|DID_RECOVER |○ | ○|
|DID_RENEW |○ | ○|
|CANCEL | ○| X|
|PRICE_INCREASE_CONSENT |○ | X|
|REFUND | ○| ○|
|REVOKE | ○| ○|
|SUBSCRIBED | | NEW|
|OFFER_REDEEMED | | NEW|
|EXPIRED | | NEW|
|GRACE_PERIOD_EXPIRED | | NEW|
|PRICE_INCREASE | | NEW|
|CONSUMPTION_REQUEST||NEW|
| REFUND_DECLINED||NEW|
| RENEWAL_EXTENDED||NEW|

さらに詳細な情報を表すために[subtype](https://developer.apple.com/documentation/appstoreservernotifications/subtype)というものが追加されました。

| Notification Type|Substate value|
| --- | --- |
|SUBSCRIBED|INITIAL_BUY, RESUBSCRIBE|
|DID_CHANGE_RENEWAL_STATUS|AUTO_RENEW_ENABLED, AUTO_RENEW_DISABLED|
|DID_CHANGE_RENEWAL_PREF|DOWNGRADE, UPGRADE|
|OFFER_REDEEMED|AUTO_RENEW_ENABLED, INITIAL_BUY, RESUBSCRIBE, UPGRADE, DOWNGRADE|
|EXPIRED|VOLUNTARY, PRICE_INCREASE, BILLING_RETRY|
|PRICE_INCREASE|PENDING, ACCEPTED|


### イベントフロー

初期購入後にSUBSCRIBEDのイベントが届きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/a379dfe8-a545-4efc-c8a2-29e2a1691939.png)


更新があるときはDID_RENEWのイベントが届きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/ff5a6a76-45fc-5bca-275d-f619d3d7682a.png)

期限が切れた場合はEXPIRED（+VOLUNTARY）のイベントが届きます。


### 判定

では、実際の判定をどうしたかというと下記の判定にしました。

|Notification Type|説明|会員にするか|
|-|-|-|
| CONSUMPTION_REQUEST | お客様が消耗品のアプリ内課金に対して返金リクエストを開始し、App Storeが消費データの提供を要求していることを示します。ConsumptionというAPIリクエストで、別途ユーザーのアプリ内情報をアップルに送信することで返金処理がスムーズに進む。cf: [App Store Server Notifications CONSUMPTION_REQUESTとは](https://programwiz.org/2021/06/19/app-store-server-notifications-what-is-consumption_request/)| null<br /> 返金処理の速度を気にしないので何もしない|
|DID_CHANGE_RENEWAL_PREF|サブタイプと共に、ユーザーがサブスクリプションプランに変更を加えたことを示す通知タイプ。サブタイプがUPGRADEである場合、ユーザーはサブスクリプションをアップグレードしました。アップグレードは直ちに有効となり、新しい請求期間が始まり、ユーザーは前の期間の未使用部分の比例払い戻しを受け取ります。サブタイプがDOWNGRADEである場合、ユーザーはサブスクリプションをダウングレードまたはクロス・グレードしたことになります。ダウングレードは、次回の更新時に有効になります。現在アクティブなプランは影響を受けません。サブタイプが空の場合、ユーザーは更新の優先順位を現在のサブスクリプションに戻し、ダウングレードを効果的にキャンセルしました。 | true<br />詳細はsubtypeを見る必要があるが、いずれにせよ購入してくれているのでサービスを提供する|
| DID_CHANGE_RENEWAL_STATUS |サブタイプとともに、ユーザーがサブスクリプションの更新ステータスに変更を加えたことを示す通知タイプ。サブタイプがAUTO_RENEW_ENABLEDの場合、ユーザーはサブスクリプションの自動更新を再度有効にしました。サブタイプがAUTO_RENEW_DISABLEDの場合、ユーザーがサブスクリプションの自動更新を無効にしたか、またはユーザーが返金を要求した後にApp Storeがサブスクリプションの自動更新を無効にしたことを意味します。 |null<br />自動更新の有効無効であって期限が来るまでは会員状態は変わらないので何もしない|
| DID_FAIL_TO_RENEW | サブタイプとともに、課金の問題でサブスクリプションが更新できなかったことを示す通知タイプ。サブスクリプションは課金再試行期間に入る。サブタイプがGRACE_PERIODの場合、猶予期間中もサービスを提供し続けます。サブタイプが空の場合、サブスクリプションは猶予期間ではないので、サブスクリプションサービスの提供を停止することができます。課金情報に問題がある可能性があることをユーザーに通知します。App Storeは、60日間、またはユーザーが課金の問題を解決するか、サブスクリプションをキャンセルするまで、どちらか先に課金を再試行しつづけます| null<br />停止することもできるが、expiredくるまでサービスを提供していいと考えるため何もしない|
|DID_RENEW | サブタイプとともに、サブスクリプションが正常に更新されたことを示す通知タイプ。サブタイプがBILLING_RECOVERYの場合、以前更新に失敗した期限切れのサブスクリプションが今、正常に更新されたことを示します。サブタイプが空の場合、アクティブなサブスクリプションは新しいトランザクション期間で正常に自動更新されました。サブスクリプションのコンテンツまたはサービスへのアクセスを顧客に提供する。| true<br />更新通知なのでユーザ状態を再処理してサービスを提供する|
| EXPIRED |サブタイプと共にサブスクリプションが期限切れであることを示す通知タイプ。サブタイプがVOLUNTARYの場合、ユーザーがサブスクリプションの更新を無効にした後、サブスクリプションは期限切れとなる。サブタイプがBILLING_RETRYの場合、課金再試行期間が課金処理に成功せずに終了したため、サブスクリプションは期限切れとなった。サブタイプがPRICE_INCREASEである場合、ユーザーが価格の上昇に同意しなかったため、サブスクリプションは失効しました。 | false<br />期限きれのため、サービス提供停止|
| GRACE_PERIOD_EXPIRED |サブスクリプションを更新せずに課金猶予期間が終了したことを示し、サービスまたはコンテンツへのアクセスをオフにすることができます。課金情報に問題がある可能性があることをユーザーに通知します。App Storeは、60日間、またはユーザーが課金の問題を解決するか、サブスクリプションをキャンセルするまで、どちらか先に課金の再試行を継続します。|false<br />猶予期間きれているのでサービス提供を停止する。|
| OFFER_REDEEMED |通知タイプは、そのサブタイプとともに、ユーザーがプロモーション・オファーまたはオファー・コードを利用したことを示す。サブタイプがINITIAL_BUYの場合、ユーザーは初回購入のためのオファーを利用したことになります。サブタイプがRESUBSCRIBEであれば、ユーザーは非アクティブなサブスクリプションの再サブスクライブのためのオファーを償還した。サブタイプがUPGRADEである場合、ユーザーはアクティブなサブスクリプションをアップグレードするためのオファーを利用しました（即時発効）。サブタイプがDOWNGRADEである場合、ユーザーは、次の更新日に発効するアクティブなサブスクリプションのダウングレードのオファーを引き換えました。ユーザーがアクティブなサブスクリプションのオファーを引き換えた場合、サブタイプのないOFFER_REDEEMED通知タイプを受け取ります。|ｔｒue<br />プロモーションオファーなどをつかってサブスク購入してくれたのでサービスを提供する|
| PRICE_INCREASE |サブタイプとともに、システムがサブスクリプションの値上げを顧客に通知したことを示す通知タイプ。サブタイプがPENDINGの場合、顧客はまだ値上げに反応していない。サブタイプが ACCEPTED の場合、顧客は値上げを受諾したことになります。アプリの実行中にシステムが顧客に値上げを通知する方法については、こちらをご覧ください。|null<br />通知なので何もしない|
| REFUND |App Storeが、消費型アプリ内課金、非消費型アプリ内課金、自動更新型サブスクリプション、または非更新型サブスクリプションのトランザクションの払い戻しに成功したことを示す。revocationDateは、返金されたトランザクションのタイムスタンプを含みます。originalTransactionIdとproductIdは、元の取引と製品を識別します。revocationReason には、理由が記載されています。|false<br />返金処理されたので会員状態も終わらす。消耗型もくるので条件分けに注意|
| REFUND_DECLINED |アプリ開発者が行った返金要求をApp Storeが拒否したことを示す。|null<br />ユーザには関与しないことなので何もしない|
| RENEWAL_EXTENDED |App Storeが、開発者が要求したサブスクリプションの更新日を延長したことを示す。|true<br />更新日が伸びてるので念のために再更新|
| REVOKE |ユーザーがファミリー共有を通じて購入することができたアプリ内課金が、共有を通じて利用できなくなったことを示します。購入者が商品のファミリー共有を無効にした場合、購入者（または家族）がファミリーグループを脱退した場合、購入者が返金を要求し受け取った場合に、App Storeはこの通知を送信します。また、アプリは、paymentQueue(_:didRevokeEntitlementsForProductIdentifiers:)の呼び出しを受け取ります。ファミリー共有は、非消費型のアプリ内購入と自動更新の定期購入に適用されます。Family Sharingの詳細については、Supporting Family Sharing in Your Appを参照してください。|false<br />使用できなくなったのでサービス提供停止|
| SUBSCRIBED |通知タイプは、そのサブタイプとともに、ユーザーが製品を購読していることを示す。サブタイプがINITIAL_BUYの場合、ユーザーは初めてサブスクリプションを購入したか、ファミリーシェアリングを通じてアクセスを受けたかのいずれかである。サブタイプが RESUBSCRIBE の場合、ユーザーは同じサブスクリプションまたは同じサブスクリプショングループ内の別のサブスクリプションにファミリーシェアリングを通じて再サブスクライブしたかアクセスを受けた。|true<br />最初に購入成功したら飛んでくるのでサービスを提供する|

よく飛んでくるイベントとしては**SUBSCRIBED**、**DID_RENEW**、**EXPIRED**を抑えておくといいです。
また、飛んできた通知は後から辿れるように必ず全て保存しておきましょう！

注意事項としては上記は悪魔でもぐめっとの見解で采配しているので、もしここ違うよ！というのがあったら是非指摘いただけると幸いです！！

## その他補足事項

### 取り扱うIDについて

transactionIdは変わる場合があるので、webOrderLineItemIdを使ったほうがいいというお話もありますが、もぐめっとの場合、originalTransactionIdを親としてレコードを持ち、その下に子供として紐づくように通知内容を**notificationUUID**をIDとして各トランザクションを保存するようにしました。
このIDは通知ごとにこのIDは一意とのことなので、通知を中心としたトランザクション管理にしています。

### originalTransactionIdを親とするレコード管理

このやり方は確認した限りではクライアントのトランザクションが終わった後にappleからイベントが届くという順番になっているようなので安心して使えそうでした。
ただし、Google Playstoreだとこのあたりの初期イベントとクライアントからのトランザクションは並列で飛んでくるので、実装には要注意です。（クライアントの処理が終わる前にイベントが飛んでくることがある）

### 検証にかかる時間について

意外と証明書の検証処理はcpuを使うようで処理に時間がかかるので要注意です。
タイムアウト時間増やしたり、サーバスペック上げときましょう。

### ルート証明書の準備について

今回ダウンロードしてきてハードコーディングしちゃってますが、もしかするとルート証明書は変わる可能性もあるかもしれないので、随時DLしてpem形式に変換するといった実装に変えたほうがいいかもしれません。
（ただもぐめっとの実力不足でコードでcerファイルをpemに変換する方法がわからなかったので誰かわかる方に託します）


## まとめ

version2を使うことでより詳細なイベント内容を知ることができるようになりました。
（ただ、それにともなって検証方法もちょっと複雑で大変・・・）
今後実装記事も増えてくると思うのでそれらの記事に期待します。

最後に、[ワンナイト人狼オンライン](https://online.1nite-jinro.com/link/blog)というゲームを作ってます！よかったら遊んでね！

https://online.1nite-jinro.com/link/blog

他にも[Camecon](https://camecon.me/)や[Offcha](https://offcha.com/)といったサービスも作ってるのでよかったら使ってね！

https://camecon.me/

https://offcha.com/

また、チームビルディングや技術顧問、Firebaseの設計やアドバイスといったお話も受け付けてますので御用の方は弊社までお問い合わせください。

https://virapture.com/