Cloud FunctionsでiOSアプリ内課金のレシート検証を行う

# はじめに

App StoreのレシートをApp StoreサーバーのverifyReceiptエンドポイントで検証をする際、安全なサーバーから行うように公式ドキュメントで警告されています。

> AppからApp Storeサーバーの[verifyReceipt（英語）](https://developer.apple.com/documentation/appstorereceipts/verifyreceipt)エンドポイントを呼び出さないでください。接続のどちらの終端もコントロールすることができず、中間者攻撃を受けやすくなるため、ユーザーのデバイスとApp Storeとの間で信頼できる接続を直接構築することができません。
> 

[App Storeを使用してレシートを検証する - 日本語ドキュメント - Apple Developer](https://developer.apple.com/jp/documentation/storekit/in-app_purchase/validating_receipts_with_the_app_store/)

Cloud Functionsからでも代用できるので、今回はCloud FunctionsからApp Storeのレシート検証を行う方法をまとめていきます。

# クライアント

クライアントからはbase64にエンコードされたレシートデータと一緒に、Cloud FunctionsのhttpsCallable関数を呼び出します。

```jsx
const receiptVerification = firebase.functions().httpsCallable('receiptVerification');
try {
	const response = await receiptVerification({
	 receipt // base64にエンコードされたレシートデータ
	})
} catch (error) {
	console.error(`error: ${error}`)
}
```

# Cloud Functions

httpsCallable関数の中身はこのようになります。

```jsx
export const receiptVerification = functions.https.onCall(async ({ receipt }) => {
  let i = 0;
  let verificationEnv = 'buy';
  // 再試行が求められるステータスコード
  const retryStatuses = [21002, 21005, 21009]
  // レシート検証のリクエストBody
  const body = JSON.stringify({
    'receipt-data': receipt,
    password: '***', // 共有シークレット
  });
  do {
    const response = await axios.post(
      `https://${verificationEnv}.itunes.apple.com/verifyReceipt`,
      body
    );

    if (response.data.status === 0) {
      // レシート検証が成功
      return 'success';
    } else if (response.data.status === 21007) {
      // テスト環境のレシートを本番に送信。エンドポイントをsandboxに変更して再試行
      verificationEnv = 'sandbox';
    } else if (!retryStatuses.includes(response.data.status)) {
      // 再試行が求められるステータスコード以外はエラーを返す
      throw new functions.https.HttpsError('invalid-argument', 'レシート検証に失敗しました。');
    }
    if (i === 3) {
      // 3回試行した場合はエラーを返す
      throw new functions.https.HttpsError('invalid-argument', 'レシート検証に失敗しました。');
    }
    i++;
  } while (i < 4);
  throw new functions.https.HttpsError('invalid-argument', 'レシート検証に失敗しました。');
});
```

## ステータスコートと再試行

App Storeサーバーへのレシート検証は、ステータスコードによって再試行が求められます。例えば、21005の「レシートサーバーが一時的に利用できなくなった場合」などです。このようなケースに備え、verifyReceiptエンドポイントへのリクエストは3回まで再試行するようにdo...while文を使用しています。

App Storeサーバーからのレスポンスステータスの一覧をまとめると、以下のようになります。


|ステータスコード  |内容  |
|---|---|
|0  |有効なレシートです。  |
|21000  |App StoreへのリクエストがHTTP POSTリクエストメソッドを使用して行われませんでした。  |
|21001  |これ以上App Storeから送信されなくなりました。  |
|21002  |receipt-dataプロパティのデータの形式が正しくないか、サービスで一時的な問題が発生しました。再試行してください。  |
|21003 |レシートの認証ができませんでした。  |
|21004  |リクエストの共有シークレットがアカウントに登録されている共有シークレットと一致しませんでした。  |
|21005  |レシートサーバーが一時的に利用できなくなりました。再試行してください。  |
|21006  |レシートは有効ですがサブスクリプションの有効期限が切れています。  |
|21007  |テスト環境のレシートが本番環境に送信されました。  |
|21008  |本番環境のレシートがテスト環境に送信されました。  |
|21009  |内部データのアクセスエラーが発生しました。再試行してください。  |
|21010  |アカウントが見つからないか、削除されました。  |

今回は、ステータスコードが21002, 21005, 21009の場合にのみ再試行するよう条件分岐をしています。

```jsx
// 再試行が求められるステータスコード
const retryStatuses = [21002, 21005, 21009]

do {
    const response = await axios.post(
      `https://${verificationEnv}.itunes.apple.com/verifyReceipt`,
      body
    );
  if (!retryStatuses.includes(response.data.status)) {
    // 再試行が求められるステータスコード以外はエラーを返す
    throw new functions.https.HttpsError('failed-precondition', 'エラーが発生しました');
  }
  if (i === 3) {
    // 3回試行した場合はエラーを返す
    throw new functions.https.HttpsError('invalid-argument', 'レシート検証に失敗しました。');
  }
  i++;
} while (i < 4);
```

## 本番環境とテスト環境(Sandbox)のエンドポイント

App StoreサーバーのverifyReceiptエンドポイントは、本番環境とテスト環境(Sandbox)で異なります。

- 本番：[https://buy.itunes.apple.com/verifyReceipt](https://buy.itunes.apple.com/verifyReceipt)
- テスト：[https://sandbox.itunes.apple.com/verifyReceipt](https://sandbox.itunes.apple.com/verifyReceipt)

レスポンスコード21008のように、本番環境のレシートがテスト環境に送信されたことがレスポンスから分かります。今回は、初めに本番環境のエンドポイントへレシート検証を行い、21008のレスポンスが返ってきた場合に、テスト環境へのエンドポイントへ再度レシート検証を行うようにします。

```jsx
let verificationEnv = 'buy';
const response = await axios.post(
  `https://${verificationEnv}.itunes.apple.com/verifyReceipt`,
  body
);
if (response.data.status === 21007) {
  // テスト環境のレシートを本番に送信。エンドポイントをsandboxに変更して再試行
  verificationEnv = 'sandbox';
}
```

## リクエストBodyの中身

App StoreサーバーへのリクエストBodyには、base64にエンコードされたレシートデータと共有シークレットが必要です。レシートデータはクライアント側から受け取り、共有シークレットはApp Store Connect で生成される、32 個の英数字からなる 16 進数文字列です。

この内容をaxiosを用いてリクエストを送ります。

```jsx
// レシート検証のリクエストBody
const body = JSON.stringify({
  'receipt-data': receipt,
  password: '***', // 共有シークレット
});

const response = await axios.post(
  `https://${verificationEnv}.itunes.apple.com/verifyReceipt`,
  body
);
```

以上が、Cloud FunctionsでiOSアプリ内課金のレシート検証を行う方法についてでした。

httpsCallable関数でエラーを返す方法はこちらでまとめているので、参考にしてみてください。
[Cloud FunctionsのHttpsErrorのコード属性と内容について
](https://qiita.com/ya_ma/items/a09c7f88556a08ea1079)