LoginSignup
19
5

iOS定期購読サーバ実装 verifyReceiptとApp Store Server APIの使い方

Last updated at Posted at 2023-12-14

はじめに

こんばんは
この記事はand factory.inc Advent Calendar 2023の15日目の記事です。

みなさん、iOSの課金処理で使用されるverifyReceiptはご存知でしょうか?
もちろん知ってますよね。みんな大好きverifyReceiptは、iOSアプリから受け取ったレシートの購入情報をサーバ側で検証する為に使用されることで国民的な知名度があります。

もしも、ある日突然verifyReceiptがDeprecatedになったらどうしますか?
大丈夫、そんな日はまず来ません。まさか俺たちのAppleがそんなことをするはずがありません。
でも備えあれば憂いなし、ということで今日はもしもに備えての妄想をしていきます。

これは妄想の話ですが、verifyReceiptがDeprecatedになるようなことがあればきっと新しいAPIが用意されます。
名前はたぶんそう......App Store Server APIとかだと思います。いい名前ですね。


(どうしても茶番を書きたかったんです......)
そういうわけで、今年DeprecatedになったverifyReceiptと新しく出てきたApp Store Server APIの使い方について書いていきます。
今年業務でiOS/Androidのサブスクのサーバ側を実装したのでそこで得た知見の放出です。Android側の話もたぶんそのうち書きます。
Swift側の実装の話は出てきませんのでご了承ください。実装は全てGo言語です。

ちなみに今回は開発の途中でverifyReceiptがDeprecatedになってしまったので、レシート検証自体はverifyReceiptで行っていて一部の処理でApp Store Server APIを使用しています。

verifyReceiptの使い方

リクエスト

リクエスト内容は下記です。
passwordはAppStoreConnectで発行する必要があります。

{
    "receipt-data": "(必須)Base64エンコード済みのレシートデータ", // Base64エンコードといいつつ、Apple側で暗号化した上でBase64エンコードしたものが必要とされるのでレシートのJsonデータからこの状態に戻すことはできません
    "password": "(任意)アプリ共有シークレット", // アプリ内課金のみの場合不要。定期購読のレシートが含まれる場合必須。
    "exclude-old-transactions": true, // (任意)trueにするとレスポンスのlatest_receipt_infoの配列に最新のレシート1つのみが返されるようになる。自動更新可能な定期購読でのみ指定可。falseでも最新日順で返ってはこない。
}

レスポンス

レスポンスは下記です。
長いのでたたみますが、実際に返ってくるレスポンスにコメントで説明を入れています。
ドキュメントはこちら
https://developer.apple.com/documentation/appstorereceipts/responsebody

レスポンスのJson
{
  "status": 0, // 0以外は失敗
  "environment": "Sandbox", // "Sandbox" or "Production"
  // 定期購読では"receipt"の項目は使用しないのでこの項目はin_app以外省略します
  "receipt": {
    // ここに入るレシートは端末の中に残っているレシートであり、必ずしも最新のレシートとは限らない。
    // よって定期購読ではこのデータは使用しない。
    "in_app": [
      {
        "quantity": "1",
        "product_id": "sample.app.subscription.ios.1000",
        "transaction_id": "2000000000000000",
        "original_transaction_id": "2000000000000000",
        "purchase_date": "2023-06-19 07:00:53 Etc/GMT",
        "purchase_date_ms": "1687158053000",
        "purchase_date_pst": "2023-06-19 00:00:53 America/Los_Angeles",
        "original_purchase_date": "2023-06-19 07:01:00 Etc/GMT",
        "original_purchase_date_ms": "1687158060000",
        "original_purchase_date_pst": "2023-06-19 00:01:00 America/Los_Angeles",
        "is_trial_period": "false",
        "is_updated": false
      }
    ]
  },
  "latest_receipt_info": [ // ここに最新のレシート情報が含まれるので定期購読ではこれを使用します。
    {
      "app_account_token": "",
      "cancellation_date": "",
      "cancellation_date_ms": "",
      "cancellation_date_pst": "",
      "cancellation_reason": "",
      "expires_date": "2023-06-28 07:08:25 Etc/GMT", // 定期購読の有効期限(グリニッジ標準時, UTCではないのでパースに注意)
      "expires_date_ms": "1687936105000", // UNIX時間
      "expires_date_pst": "2023-06-28 00:08:25 America/Los_Angeles", // アメリカ太平洋標準時
      "in_app_ownership_type": "PURCHASED",
      "is_in_intro_offer_period": "false", // 定期購読がお試し価格期間中であるかどうか
      "is_trial_period": "false", // 定期購読が無料お試し期間中であるかどうか
      "is_upgraded": "",
      "offer_code_ref_name": "",
      "original_purchase_date": "2023-06-28 07:03:33 Etc/GMT", // ユーザーが購入した日時
      "original_purchase_date_ms": "1687935813000",
      "original_purchase_date_pst": "2023-06-28 00:03:33 America/Los_Angeles",
      "original_transaction_id": "2000000000000000", // 元の購入時のトランザクションID(transaction_idは更新ごとに変わるのに対しこちらは変わらないから最初に買った時のtransaction_idが追える。)
      "product_id": "sample.app.subscription.1000", // ストアに登録している一意の定期購読商品のID
      "promotional_offer_id": "",
      "purchase_date": "2023-06-28 07:03:25 Etc/GMT", // Appleがユーザーに請求した日時
      "purchase_date_ms": "1687935805000",
      "purchase_date_pst": "2023-06-28 00:03:25 America/Los_Angeles",
      "quantity": "1",
      "subscription_group_identifier": "21356649",
      "web_order_line_item_id": "2000000000000000",
      "transaction_id": "2000000000000000" // 購入・復元・更新ごと更新される一意のトランザクションID
    },
    {
      "app_account_token": "",
      "cancellation_date": "",
      "cancellation_date_ms": "",
      "cancellation_date_pst": "",
      "cancellation_reason": "",
      "expires_date": "2023-06-21 08:11:35 Etc/GMT",
      "expires_date_ms": "1687335095000",
      "expires_date_pst": "2023-06-21 01:11:35 America/Los_Angeles",
      "in_app_ownership_type": "PURCHASED",
      "is_in_intro_offer_period": "false",
      "is_upgraded": "",
      "offer_code_ref_name": "",
      "original_purchase_date": "2023-06-19 07:01:00 Etc/GMT",
      "original_purchase_date_ms": "1687158060000",
      "original_purchase_date_pst": "2023-06-19 00:01:00 America/Los_Angeles",
      "original_transaction_id": "2000000000000001",
      "product_id": "sample.app.subscription.2000", // exclude-old-transactions:true にしているとプロダクトごとの最新のレシートが1つずつ返る
      "promotional_offer_id": "",
      "purchase_date": "2023-06-21 08:06:35 Etc/GMT",
      "purchase_date_ms": "1687334795000",
      "purchase_date_pst": "2023-06-21 01:06:35 America/Los_Angeles",
      "quantity": "1",
      "subscription_group_identifier": "21286055",
      "web_order_line_item_id": "2000000000000001",
      "transaction_id": "2000000000000001"
    }
  ],
  "latest_receipt": "...", // このレスポンス自体のBase64文字列。これをverifyReceiptにかけると同じ内容が返ってくる
  "pending_renewal_info": [ // 定期購読の更新状態に関する情報が入ってくる
    {
      "auto_renew_product_id": "sample.app.subscription.1000", // 更新対象のプロダクトID
      "auto_renew_status": "1", // 更新状態. 1:自動更新有効, 0:自動更新無効
      "expiration_intent": "",
      "grace_period_expires_date": "",
      "grace_period_expires_date_ms": "",
      "grace_period_expires_date_pst": "",
      "is_in_billing_retry_period": "", // 定期購読が課金再試行期間にあるかどうかのフラグ. 1: 更新再試行期間中, 0: 更新試行停止
      "offer_code_ref_name": "",
      "original_transaction_id": "2000000000000000",
      "price_consent_status": "",
      "product_id": "sample.app.subscription.1000",
      "promotional_offer_id": "",
      "price_increase_status": ""
    }
  ]
}

使い方

このレスポンスを取得する=iOSアプリから受け取ったレシートの正当性を検証する、なのでユーザーの購入情報を取得したい時に呼び出します。
verifyReceiptの方は単純にHTTPリクエストを送るだけですがこのように送ります。

リクエスト送信のコード
func getReceipt(isSandbox bool) (*Receipt, error) {
    appStoreURL := "https://buy.itunes.apple.com/verifyReceipt"
    if isSandbox {
        appStoreURL = "https://sandbox.itunes.apple.com/verifyReceipt"
    }

	jsonStr := `{"receipt-data":"アプリからもらったbase64Receipt","password":"xxx","exclude-old-transactions":"true"}`

	client := &http.Client{}
	req, err := http.NewRequest(
		"POST",
		appStoreURL,
		bytes.NewBuffer([]byte(jsonStr)),
	)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")

	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	if res != nil {
		defer func() {
			cerr := res.Body.Close()
			if err != nil {
				err = cerr
			}
		}()
	}
    if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("AppStoreとの通信失敗 statusCode: %d", res.StatusCode)
	}

	body, err = io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}

    receipt := Receipt{} // 前述のレスポンスの内容の型を独自定義しているとします
	if err := json.Unmarshal(body, &receipt); err != nil {
		return nil, err
	}
    return receipt, nil
}

小ネタですが、レスポンスで返ってくるグリニッジ標準時のEtc/GMTは以下のような形でEtc/GMTを直接指定するとJSTでパースできます。
GoはUTCだと2006-01-02 15:04:05だけフォーマット指定でパースできるのですが、Etc/GMTの場合は明示しないとできないようです。

const EtcGMTLayout = "2006-01-02 15:04:05 Etc/GMT" // パース用フォーマット
const expiresDate := "2023-06-21 08:11:35 Etc/GMT" // レシートに入ってくるデータ
jst, _ := time.LoadLocation("Asia/Tokyo")

expireDate, err := time.ParseInLocation(EtcGMTLayout, expiresDate, jst) 

verifyReceiptの今後など

verifyReceiptはDeprecatedになってはいますが現時点ではいつ廃止にされるかという情報は公開されていないため、少なくとも直近では廃止されないはずです。
その為急いでApp Store Server APIに更新しないといけない、というわけではないと思います。
ただ場合によっては更新にそこそこ時間かかるとかあると思うので用意はしておいた方が良さそうなのと、今後新しく作るなら余程の理由がない限りApp Store Server APIを使う方が無難だと思います。

App Store Server API

こちらはverifyReceiptと違い用途ごとに様々なAPIが用意されています。
(今回は使っていないのですが)定期購読の状態管理に利用するAPIを紹介します。

  • Get Transaction Info
    • トランザクションIDに紐づく購入情報を取得
  • Get Transaction History
    • ユーザーに紐づく過去の購入履歴を取得。期限、プロダクトIDなどのパラメータも付与可能。
    • 過去の購入情報が取得できることで、「このユーザーはサーバには購入データが存在しないがAppStoreには購入データが存在する。つまり他のアプリケーションアカウントで過去に購入済みなので何らかの処理をする」などの判別が可能になります。
  • Get All Subscription Statuses
    • サブスクリプショングループ(AppStoreで登録できるプロダクトの1つ上の階層の括り)ごとの定期購読の状態を取得

リクエスト

App Store Server APIの特徴として、transactionIdのみでユーザーの購入情報を取得できるというのがあります(全てのAPIがそうではありません)。
認証方式としては事前に設定した秘密鍵やIDなどで生成したトークンによるBearer認証を利用します(後述)。

レスポンス

App Store Server APIの各レスポンスは基本的にデータ部分がJWSとして署名済み文字列になっているのですが、そのペイロードを取り出すとそれぞれ以下のようになっています。

Get Transaction Info
https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload

// レスポンス
{
  "signedTransactionInfo": "xxx..."
}

// signedTransactionInfoのペイロード
{
  "appAccountToken": "",
  "bundleId": "sample.app.debug",
  "environment": "Sandbox",
  "expiresDate": 1701317120000, // 有効期限
  "inAppOwnershipType": "PURCHASED",
  "isUpgraded": false,
  "offerIdentifier": "",
  "offerType": 0,
  "originalPurchaseDate": 1693812945000, // 購入日時
  "originalTransactionId": "2000000404699492",
  "productId": "sample.app.subscription.1000", // 購入したトランザクションの商品
  "purchaseDate": 1701316820000,
  "quantity": 1,
  "revocationDate": 0,
  "revocationReason": 0,
  "signedDate": 1702411910923,
  "subscriptionGroupIdentifier": "21356649",
  "transactionId": "2000000000000000", // 問い合わせに使用したトランザクションID
  "type": "Auto-Renewable Subscription", // トランザクションの種類(自動更新可能なサブスクリプション, 非消費型のアプリ内購入, 消耗品のアプリ内購入, 非更新のサブスクリプション)
  "webOrderLineItemId": "2000000000000000"
}

Get Transaction History
https://developer.apple.com/documentation/appstoreserverapi/historyresponse

// レスポンス
{
  "appAppleId": 0,
  "bundleId": "sample.app.debug",
  "environment": "Sandbox",
  "hasMore": true,
  "revision": "16939583502000_2000000405915712_4",
  "signedTransactions": [ // 購入履歴の配列
    "xxx...", // この署名文字列からペイロードを取り出すとGet Transaction Infoに記載のデータになる
    "xxx..."
  ]
}

Get All Subscription Statuses
https://developer.apple.com/documentation/appstoreserverapi/statusresponse

// レスポンス
{
  "environment": "Sandbox",
  "appAppleId": 0,
  "bundleId": "sample.app.debug",
  "data": [ // グループごとの最新情報
    {
      "subscriptionGroupIdentifier": "222222222",
      "lastTransactions": [ // プロダクトごとの最新情報
        {
          "originalTransactionId": "2000000000000000",
          "status": 2,
          "signedRenewalInfo": "xxx...",
          "signedTransactionInfo": "xxx..." // これからペイロードを取り出すとGet Transaction Infoに記載のデータになる
        }
      ]
    }
  ]
}

// signedRenewalInfoのペイロード
{
  "autoRenewProductId": "sample.app.debug",
  "autoRenewStatus": 1, // 更新状態. 1:自動更新有効, 0:自動更新無効
  "environment": "Sandbox",
  "expirationIntent": 0,
  "gracePeriodExpiresDate": 0,
  "isInBillingRetryPeriod": false, // 定期購読が課金再試行期間にあるかどうかのフラグ. verifyReceiptの同値は0/1だったがブールになっている
  "offerIdentifier": "",
  "offerType": "",
  "originalTransactionId": "2000000000000000", // 元のトランザクションID
  "priceIncreaseStatus": 0,
  "productId": "sample.app.subscription.1000",
  "recentSubscriptionStartDate": 1702571077000,
  "renewalDate": 1702572931000, // 有効期限
  "signedDate": 1702572804593
}

こうしてレスポンスを比べてみると、この3本を合わせるとverifyReceiptで取得できる結果とさほど変わらないことがわかります。
その為、現在verifyReceiptを使用している箇所をApp Store Server APIに置き換える場合は基本的にこの3本に置き換えれば良さそうです。

使い方

App Store Server APIはリクエストの時はJWTで生成したトークンをBearer認証で送り、レスポンスを受け取る時はJWSとして署名された文字列を検証して取得しないといけないので若干面倒です。
ですがこの部分は今回はgo-iapというライブラリを使わせていただきこの面倒な部分を全てライブラリに任せられました。
OSSありがとう!!!
基本的にはサーバでのこのへんはほぼgo-iapの関数を呼び出すだけで済んでいます。

何をやっているか自体は知っておいた方が良いので簡単に説明します。この記事の本懐はJWTの解説ではない為軽くで済ませますのでご了承ください。

ベース知識として、JWTは大まかに以下のような構成になっています。関数は疑似関数です。

header := base64Encode(headerJsonStr) // ヘッダ部分
claims := base64Encode(claimsJsonStr) // ペイロード部分 この部分がJWT
signed := base64Encode(任意encrypt(header+.+claims)) // シグニチャ部分 復号するとheader+.+claimsと必ず一致する為署名として使える
jws := header+.+claims+.+signed // .で連結 この部分をJWSという

リクエスト送信時のJWT作成

  1. リクエストに必要な情報としてPrivateKey, KeyID, IssuerIDをそれぞれAppStoreConnectで発行
  2. JWT生成の為に1の情報+有効期限などを発行しHeader, Claimsを設定しjwtの構造体を生成
  3. Header, ClaimsのJson文字列をそれぞれBase64エンコードし"."で連結
  4. 1のPrivateKeyと指定の暗号化方式(今回はES256 = ECDSA using P-256 and SHA-256)を用いて4の連結文字列を暗号化(これが署名になり改竄検知に利用できる)
  5. 暗号化した部分をトークンとして利用しBearerトークンに使用
  6. Bearerトークンに使用する際有効期限が切れていたら再発行してから使用
リクエスト送信のコード
// トークン生成
// ref: https://github.com/awa/go-iap/blob/v1.21.2/appstore/api/token.go#L68
// Generate creates a new token.
func (t *Token) Generate() error {
    key, err := t.passKeyFromByte(t.KeyContent)
    if err != nil {
        return err
    }
    t.AuthKey = key

    issuedAt := time.Now().Unix()
    expiredAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()
    // 2.の部分
    jwtToken := &jwt.Token{
        Header: map[string]interface{}{
            "alg": "ES256",
            "kid": t.KeyID,
            "typ": "JWT",
        },

        Claims: jwt.MapClaims{
            "iss":   t.Issuer,
            "iat":   issuedAt,
            "exp":   expiredAt,
            "aud":   "appstoreconnect-v1",
            "nonce": uuid.New(),
            "bid":   t.BundleID,
        },
        Method: jwt.SigningMethodES256,
    }

    // 3. 4. の部分
    bearer, err := jwtToken.SignedString(t.AuthKey)
    if err != nil {
        return err
    }
    t.ExpiredAt = expiredAt
    // 5. の部分
    t.Bearer = bearer

    return nil
}

// リクエスト送信
// go-iapのライブラリでこの関数をラップして各APIを呼べるようになっている。サーバではそのラップされた関数を呼んでいる。
// ref: https://github.com/awa/go-iap/blob/v1.21.2/appstore/api/store.go#L505
// Do Per doc: https://developer.apple.com/documentation/appstoreserverapi#topics
func (a *StoreClient) Do(ctx context.Context, method string, url string, body io.Reader) (int, []byte, error) {
    // 6. の部分(中でトークン切れてたら再発行してる)
	authToken, err := a.Token.GenerateIfExpired()
	if err != nil {
		return 0, nil, fmt.Errorf("appstore generate token err %w", err)
	}

	req, err := http.NewRequest(method, url, body)
	if err != nil {
		return 0, nil, fmt.Errorf("appstore new http request err %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
    // 5. の部分
	req.Header.Set("Authorization", "Bearer "+authToken)
	req.Header.Set("User-Agent", "App Store Client")
	req = req.WithContext(ctx)

	resp, err := a.httpCli.Do(req)
	if err != nil {
		return 0, nil, fmt.Errorf("appstore http client do err %w", err)
	}
	defer resp.Body.Close()

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return resp.StatusCode, nil, fmt.Errorf("appstore read http body err %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		if rErr, ok := newAppStoreAPIError(bodyBytes, resp.Header); ok {
			return resp.StatusCode, bodyBytes, rErr
		}
	}

	return resp.StatusCode, bodyBytes, err
}

レスポンスのパース時のJWS検証

  1. レスポンスのsignedTransactionInfo(またはsignedRenewalInfo)を"."でヘッダ、ペイロード、シグニチャ部分に分割
  2. ヘッダのx5cという項目の配列からルート証明書、中間証明書、リーフ証明書を取得
  3. 3つの証明書を検証
    a. ルート証明書の公開鍵はAppleからダウンロードしたものをgo-iap内で保持
  4. 2のリーフ証明書から公開鍵を取得(これはJWT作成時に使用したPrivateKeyに対応するもの)
  5. JWT作成時に指定した暗号化方式で4の公開鍵を使いヘッダ.ペイロード部分とシグニチャが一致するか検証
  6. 検証に問題なければペイロードを取得

参考:

レスポンスの署名検証のコード
// https://github.com/awa/go-iap/blob/v1.21.2/appstore/api/store.go#L450
// JWSの署名検証処理
func (a *StoreClient) parseJWS(jwsEncode string, claims jwt.Claims) error {
    // 1. 2. の部分(ルート証明書)
	rootCertBytes, err := a.cert.extractCertByIndex(jwsEncode, 2)
	if err != nil {
		return err
	}
	rootCert, err := x509.ParseCertificate(rootCertBytes)
	if err != nil {
		return fmt.Errorf("appstore failed to parse root certificate")
	}

    // 1. 2. の部分(中間証明書)
	intermediaCertBytes, err := a.cert.extractCertByIndex(jwsEncode, 1)
	if err != nil {
		return err
	}
	intermediaCert, err := x509.ParseCertificate(intermediaCertBytes)
	if err != nil {
		return fmt.Errorf("appstore failed to parse intermediate certificate")
	}

    // 1. 2. の部分(リーフ証明書)
	leafCertBytes, err := a.cert.extractCertByIndex(jwsEncode, 0)
	if err != nil {
		return err
	}
	leafCert, err := x509.ParseCertificate(leafCertBytes)
	if err != nil {
		return fmt.Errorf("appstore failed to parse leaf certificate")
	}
     // 3. の部分(リーフ証明書)
	if err = a.cert.verifyCert(rootCert, intermediaCert, leafCert); err != nil {
		return err
	}

    // 4. の部分
	pk, ok := leafCert.PublicKey.(*ecdsa.PublicKey)
	if !ok {
		return fmt.Errorf("appstore public key must be of type ecdsa.PublicKey")
	}

    // 5. 6. の部分
	_, err = jwt.ParseWithClaims(jwsEncode, claims, func(token *jwt.Token) (interface{}, error) {
         // 引数に渡す関数で4.で取得した公開鍵を返してる
		return pk, nil
	})
	return err
}

// サーバでレスポンスで署名文字列を受け取ったらこの関数を使ってライブラリ先生にパースしてもらうだけで検証完了!(signedRenewalInfoは別の関数を利用)
// ParseSignedTransaction parse one jws singed transaction for API like GetTransactionInfo
func (a *StoreClient) ParseSignedTransaction(transaction string) (*JWSTransaction, error) {
	tran := &JWSTransaction{}

	err := a.parseJWS(transaction, tran)
	if err != nil {
		return nil, err
	}

	return tran, nil
}

// ---

// https://github.com/golang-jwt/jwt/blob/v4.5.0/token.go#L113
// ParseWithClaims is a shortcut for NewParser().ParseWithClaims().
//
// Note: If you provide a custom claim implementation that embeds one of the standard claims (such as RegisteredClaims),
// make sure that a) you either embed a non-pointer version of the claims or b) if you are using a pointer, allocate the
// proper memory for it before passing in the overall claims, otherwise you might run into a panic.
func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc, options ...ParserOption) (*Token, error) {
	return NewParser(options...).ParseWithClaims(tokenString, claims, keyFunc)
}

// 5. 6. の内部関数
// https://github.com/golang-jwt/jwt/blob/v4.5.0/parser.go#L52
// ParseWithClaims parses, validates, and verifies like Parse, but supplies a default object implementing the Claims
// interface. This provides default values which can be overridden and allows a caller to use their own type, rather
// than the default MapClaims implementation of Claims.
//
// Note: If you provide a custom claim implementation that embeds one of the standard claims (such as RegisteredClaims),
// make sure that a) you either embed a non-pointer version of the claims or b) if you are using a pointer, allocate the
// proper memory for it before passing in the overall claims, otherwise you might run into a panic.
func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {
    // ペイロードから検証していない状態のトークンを取得
	token, parts, err := p.ParseUnverified(tokenString, claims)
	if err != nil {
		return token, err
	}

	// Verify signing method is in the required set
	if p.ValidMethods != nil {
		var signingMethodValid = false
		var alg = token.Method.Alg()
		for _, m := range p.ValidMethods {
			if m == alg {
				signingMethodValid = true
				break
			}
		}
		if !signingMethodValid {
			// signing method is not in the listed set
			return token, NewValidationError(fmt.Sprintf("signing method %v is invalid", alg), ValidationErrorSignatureInvalid)
		}
	}

	// Lookup key
	var key interface{}
	if keyFunc == nil {
		// keyFunc was not provided.  short circuiting validation
		return token, NewValidationError("no Keyfunc was provided.", ValidationErrorUnverifiable)
	}
    // 4.で取得した公開鍵を取得
	if key, err = keyFunc(token); err != nil {
		// keyFunc returned an error
		if ve, ok := err.(*ValidationError); ok {
			return token, ve
		}
		return token, &ValidationError{Inner: err, Errors: ValidationErrorUnverifiable}
	}

	vErr := &ValidationError{}

	// Validate Claims
	if !p.SkipClaimsValidation {
		if err := token.Claims.Valid(); err != nil {

			// If the Claims Valid returned an error, check if it is a validation error,
			// If it was another error type, create a ValidationError with a generic ClaimsInvalid flag set
			if e, ok := err.(*ValidationError); !ok {
				vErr = &ValidationError{Inner: err, Errors: ValidationErrorClaimsInvalid}
			} else {
				vErr = e
			}
		}
	}

	// Perform validation
	token.Signature = parts[2]
    // JWT生成時に設定した暗号化方式に対応した検証関数(ペイロードを暗号化してシグニチャと比較)を実行(1つ下に貼った関数)
	if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil {
		vErr.Inner = err
		vErr.Errors |= ValidationErrorSignatureInvalid
	}

	if vErr.valid() {
		token.Valid = true
		return token, nil
	}

	return token, vErr
}

// ECDSA using P-256 and SHA-256での検証関数
// https://github.com/golang-jwt/jwt/blob/v4.5.0/ecdsa.go#L58
// Verify implements token verification for the SigningMethod.
// For this verify method, key must be an ecdsa.PublicKey struct
func (m *SigningMethodECDSA) Verify(signingString, signature string, key interface{}) error {
	var err error

	// Decode the signature
	var sig []byte
	if sig, err = DecodeSegment(signature); err != nil {
		return err
	}

	// Get the key
	var ecdsaKey *ecdsa.PublicKey
	switch k := key.(type) {
	case *ecdsa.PublicKey:
		ecdsaKey = k
	default:
		return ErrInvalidKeyType
	}

	if len(sig) != 2*m.KeySize {
		return ErrECDSAVerification
	}

	r := big.NewInt(0).SetBytes(sig[:m.KeySize])
	s := big.NewInt(0).SetBytes(sig[m.KeySize:])

	// Create hasher
	if !m.Hash.Available() {
		return ErrHashUnavailable
	}
	hasher := m.Hash.New()
	hasher.Write([]byte(signingString))

	// Verify the signature
	if verifystatus := ecdsa.Verify(ecdsaKey, hasher.Sum(nil), r, s); verifystatus {
		return nil
	}

	return ErrECDSAVerification
}

この記事を書くにあたり3.の証明書の検証(a.cert.verifyCert(rootCert, intermediaCert, leafCert))で何してるかとかも見てみたのですがこっちは何してるか全然わかりませんでした・・・。
暗号化の話は難しいです。
興味ある方向けにソースコードのリンクだけ貼っておきますね。いつかこれらも理解できるようになりたい。

https://github.com/awa/go-iap/blob/v1.21.2/appstore/api/cert.go#L62
https://cs.opensource.google/go/go/+/refs/tags/go1.21.5:src/crypto/x509/verify.go;l=748


これでApp Store Server APIを使ったトランザクション情報の取得ができます。
(ライブラリを使えば)簡単ですね!

有効期限更新

本当は定期購読のライフサイクルと更新時の処理などについてもいろいろ詳しく書きたかったのですが力尽きてしまったので軽く触る程度にさせていただきます。

  • 現在は期限更新はサーバ側でバッチ処理を定期実行して更新確認している
    • 有効期限の近いユーザーに対してのみレシート検証を実施してAppleサーバに負担をかけないようにしている
    • レシート検証ではverifyReceiptを使用
  • App Store Server Notifications V2は期限更新には利用していない
    • 導入自体はしているが現在はあまり活用できていない
    • 通知にユーザーの状態更新を委ねてしまうのも怖かった(Appleが送れなかったら更新できなくなる)
    • ASSNを信頼した上で活用すると期限更新、期限更新の失敗(継続確認)、支払い保留、有効期限切れ、解約、払い戻し、などほとんどのイベントを取得でき、実装がだいぶ楽になる
      • 受け取った通知でイベントごとに処理を実行するキューだけ登録しておき、キューを定期実行して各種状態更新処理を実行するようになるので、作りをシンプルにできそう
      • 新しくやるならこっちが良さそうな気がする(あとはAppleの通知をどの程度信頼するか)
      • ついでにAndroidにもRTDNというユーザーの定期購読状態の変更通知機能があるのでこれも使えば両OS通知によってイベント検知することができバッチの定期実行が(おそらく)不要になり開発者が喜ぶ(今回はこれは未導入)
    • 通知のタイプ一覧はこちら
    • レスポンスはこちらもJWSで署名されているがApp Store Server APIと同じ方法で検証できる
  • ユーザーの返金検知にApp Store Server APIのGet Refund Historyを利用
    • たしか返金時刻か何かが取れなくて導入したが、今verifyReceiptのレスポンスをみるとcancellation_dateで取れたのではという気もする(小声)
    • いずれ発生するであろうverifyReceipt廃止→App Store Server APIへの更新に向けて導入だけしておきたかったというのもある

おわり

定期購読関連の実装は結構時間をかけたので知見が色々得られました。
また機会があればAndroidに関することもどこかで書きたいと思います。

最後まで読んでいただきありがとうございました。

明日のAdvent Calenderもお楽しみに!

19
5
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
19
5