Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Sign in with Apple Revocation API & Consent Revoked Notification 対応 2022

こんにちは、@nov です。 それ以前も数年間、技術顧問として MoneyForward ID 基盤の立ち上げから関わっていたのですが、2022年6月からは MoneyForward に入社して (社員として) MoneyForward ID 開発チームでエンジニアをしています。

ちょうど先日試用期間も終わり、この3ヶ月ほどチームで対応していた Sign in with Apple まわりの Apple ガイドライン更新対応も完了したので、当該案件についてこちらのブログに初投稿してみます。

Sign in with Apple、ドキュメントもいまいち揃ってないし構造も複雑な割に、やたら RP にいろいろ要求してきますよね。

Safari 上での UX 最高なんでやめないですけど。

事の発端

Apple から、下記のようなリリースが出ました。

Appleでサインインに対応したAppでは、AppleでサインインのREST APIを使用して、アカウントの削除時にユーザーのトークンを無効化する必要があること。

ref.) アカウントの削除機能に関する要件の適用が6月30日開始 - 最新ニュース - Apple Developer

Sign in with Apple 経由で登録しているユーザーに関しては、当該ユーザーが MoneyForward ID 退会時に Sign in with Apple のトークンを REST API (Revocation API) 経由で無効化 (Revoke) する必要が出てきました。

それまで Sign in with Apple のトークンなど一切保存していなかった MoneyForward ID では、当然保存していないトークンを Revocation API に投げることなどできません。

さて、どうする? ってなったのが、ちょうど入社後2週間くらい経った時でした。

暫定対応

まずは Apple からアプリを Reject されるのが一番のリスクなので、Apple Developer Forums のこちらの記事を参考に、MoneyForward ID の退会完了画面に Apple が指定する FAQ (の日本語版) へのリンクを追加しました。

Handling account deletions and revoking tokens for Sign in with Apple | Apple Developer Forums

そして、上記記事で要求されている consent-revoked notification 対応も含めて、Apple の要求事項に対応したのち、暫定対応で追加した FAQ リンクを削除するまでを MoneyForward ID としての対応方針としました。

本格対応

ということで、ここから以下5ステップに分けて順次対応していきました。

  1. Token, Client, Notification Event の関係整理
  2. Primary App <-> Service Relationship の保存
  3. Consent Revoked Notification 対応
  4. Refresh Token の保存
  5. Revocation API の呼び出し

<a name="step1"></a>Step 1. Token, Client, Revocation, Notification Event の関係整理

Sign in with Apple がリリースされた当初に #idcon というイベントでお話ししたこともあるのですが、Sign in with Apple には以下の4つの概念があり、それぞれ下の図のような包含関係になっています。

  • Team
  • Primary App
  • App
  • Service

<figure> <figcaption>図1. Team, Primary App, App and Service in Sign in with Apple</figcaption> </figure>

マネーフォワード社では、会社として1つの「Team」を持ち、その中で家計簿サービスの iOS アプリや経費精算サービスの iOS アプリなど、複数の「Primary App」を持っています。

それらの各「Primary App」に紐づいて、家計簿サービスの Web サイトや経費精算サービスの Web サイトが「Service」として登録されています。これら各 Web サイトは、MoneyForward ID から見た Relying Party (RP) にもなっています。

また iOS アプリを持たないサービス等では、複数サービスの Web サイト (= 複数の MoneyForward ID の RPs) が Apple Developer Portal 上では同一の「Service」として登録されているケースもあります。

MoneyForward ID を経由して家計簿サービス等にログインするフローは下図のようになっていますが、この図の例では MoneyForward ID は家計簿サービスから Authorization Request を受け取ったのち、家計簿サービスの Sign in with Apple Service の Client ID をつけて Apple に Authorization Request を送信します。こうすることで Apple 側の画面に「MoneyForward ID」への同意画面ではなく「家計簿サービス」への同意画面を表示することができます。

<figure> <figcaption>図2. Sign in with Apple での MoneyForward 家計簿サービスへのログインフロー</figcaption> </figure>

これらを踏まえた上で、まずは Token・Client・Revocation・Notification Event の関係を整理します。

まず、発行されるトークンは全て Service 単位です。Sign in with Apple 利用時に Apple から発行される Access / Refresh Token は、「家計簿の Web サイト」や「経費精算の Web サイト」等に向けて発行されます。

<figure> <figcaption>図3. Sign in with Apple のトークン発行対象</figcaption> </figure>

一方で Apple の「Revocation API」は、1 Service のトークンが Revoke されるとそれが属する Primary App およびその配下の全 App および全 Service の全トークンが Revoke されます。「家計簿の Web サイト」向けのトークンが Revoke されれば、「経費精算の Web サイト」向けのトークンも Revoke されます。つまり Revocation は Primary App 単位で実行されます。

<figure> <figcaption>図4. Sign in with Apple の Revocation 対象範囲</figcaption> </figure>

ただし、Revocation API 呼び出し時には Service の Client ID & Client Secret を用いる必要があるため、Revocation API の呼び出しは Service 単位です。

<figure> <figcaption>図5. Sign in with Apple の Revocation API 呼び出し主体</figcaption> </figure>

また、iOS 設定アプリの「iCloud > パスワードとセキュリティ > Apple ID を使用中の App」等、Apple 側の画面での表示は Primary App 単位であり、そこで Revocation が発生した場合に送られる Notification も、Primary App 単位で発行されます。

<figure> <figcaption>図6.「Apple ID を使用中の App」表示例</figcaption> </figure>

<figure> <figcaption>図7. Sign in with Apple の Server-to-server Notification 送信対象</figcaption> </figure>

OpenID Connect / OAuth の用語で話すと、以下のようになります。

  • MoneyForward 各サービス -> MoneyForward ID への Authorization Request は MoneyForward 各サービス (家計簿サービス等) が持つ MoneyForward ID 用の Client ID 単位で実行
  • MoneyForward ID -> Apple への Authorization Request は MoneyForward ID 用の Client ID から割り出される Sign in with Apple 用の Service 単位で実行
  • MoneyForward ID -> Apple への Authorization Request 時に Apple 側の画面に表示されるのは Primary App ではなく Service
  • MoneyForward ID -> Apple への Token Request は Service 単位で実行
  • MoneyForward ID -> Apple への Token Revocation API は Service 単位で実行
  • MoneyForward ID -> Apple への Token Revocation API 呼び出しの結果として Revoke されるのは Primary App のアクセス
  • Apple 側の「連携済アプリ一覧」画面に表示されるのは Service ではなく Primary App
  • Apple 側の画面での Revocation は Primary App 単位
  • Apple 側で Revocation が実施された場合に送られる Server-to-server Notification は Primary App 単位

ちょっとややこしいですが、Service 単位で処理されるものと Primary App 単位で処理されるものがある、ということを覚えておいてください。

<a name="step2"></a>Step 2. Primary App <-> Service Relationship の保存

元々 MoneyForward ID では Service の Client ID さえあれば事足りたため、Primary App の情報は一切保持されていませんでした。

しかし、consent-revoked Notification で送られてくる Event 情報には Primary App の情報しか含まれていません。下図の「Consent Revoked Event」を受け取ったタイミングでは、どの Service への同意が Revoke されたかはわからず、どの Primary App への同意が Revoke されたのかしか通知されないのです。

<figure> <figcaption>図8. Apple 側画面起点の Revocation & Consent Revoked Notification フロー</figcaption> </figure>

そのため、受け取った Primary App の情報をもとに特定の Service に対して連携解除処理を行うには、 Primary App から個別の Service 群を割り出す必要があります。

よって、まずは MoneyForward 各サービスの Client ID に紐づいていた Sign in with Apple の Service を 、さらにその Primary App に紐づける処理を行いました。MoneyForward 社の場合、これ自体は7つの Primary App と8つの Service を紐付けるだけでしたので、比較的簡単な作業でした。

Primary App と Service の紐付けが完了したのち、まずは Apple から consent-revoked Notification を受け取ることにしました。

ちなみに Sign in with Apple の Server-to-server Notification については、あまりドキュメントがありません。

今回はこの辺りを眺めつつ、実際に Heroku 上にデモアプリを作って送信されてくる Event JWT をもとに仕様を理解しました。

実際にデモアプリで受け取った Event JWT を一つ以下に挙げておきます。Processing changes for Sign in with Apple accounts | Apple Developer Documentatio の記載とは異なり、events は Object ではなく String になっているので注意してください。

<figure> <figcaption>図9. Sign in with Apple Server-to-server Notification JWT 例</figcaption> </figure>

JWT の検証はこの辺りのコードが参考になるかもしれませんが、基本的にやることは以下の通りです。

  1. Sign in with Apple の ID Token の署名検証でも使う JWKS を取得
  2. 取得した JWKS を使って JWT の署名を検証
  3. JWT の issiatexp も検証
  4. Primary App が一つだけの場合は aud もこの時点で検証してもよい

そして Primary App から個別の Service 群を割り出した後は、保存しているトークンを削除する処理を実行すべきなのですが、実際にはこの時点ではトークンは保存されていなかったのでその処理は後回しにしました。

なお、Handling account deletions and revoking tokens for Sign in with Apple | Apple Developer Forums には下記のように書かれていますが、ユーザーが Apple 側で家計簿アプリとの連携を解除 (Revoke) したとしても、それ以降二度と家計簿アプリや経費精算アプリにログイン出来なくなってしまうのはユーザーの意図とは異なると思いますので、特段 MoneyForward ID のデータを削除したりはしません。よって再同意してもらえれば既存の MoneyForward ID にログインできる状態は維持されます。

  1. Deleted all user-related account data, including:
    • The token used for token revocation;
    • Any user-related data stored in your app servers; and
    • Any user-related data store in the Keychain or securely on disk in the native app or locally on web client.
  2. Reverted the client to an unauthenticated state.

補足. Apple Event JWT Verification & Apple JWKS Caching

Apple Event JWT の Parse & Verification 処理は、個人で開発している apple_id という Rubygem に実装しており、こんな感じで使えるようになっています。

jwt_string = params[:payload] # "eyJ..."
event_token = AppleID::EventToken.decode(jwt_string).verify!

if event_token.consent_revoked?
primary_app = PrimaryApp.find_by(client_id: event_token.aud)
primary_app&amp;.services.consent_revoked!
end

ちなみに、このままでは全通知を受け取るたびに Apple サーバーに JWKS を取得しにいくことになります。そして Apple サーバーは比較的不安定なため、たまに遅くなったり50xエラーが返ってきたりします。

そこで、consent-revoked Notification 対応と同時に、apple_id gem が内部的に利用している json-jwt gem にて JWKS のキャッシング機能を実装しました。

JSON::JWK::Set::Fetcher.cache = Rails.cache

としておけば、あとは apple_id gem と json-jwt gem がよしなに JWKS を取得しキャッシュしてくれます。

<a name="step4"></a>Step 4. Refresh Token の保存

ここでようやく Revocation API を呼ぶためにトークンを保存します。

MoneyForward ID では、各 Primary App x ユーザーの組み合わせに一つだけ Refresh Token を保存することとし、ログイン毎に都度それを最新の Refresh Token に更新するような実装にしています。

Sign in with Apple の場合、ある Primary App 配下のいずれかの Service のいずれかの Refresh Token を Revoke すれば、その Primary App 配下の全 Service の全トークンが Revoke されます。

そういう特性上、各 Service や各 MoneyForward ID RPs 毎に Refresh Token を保存して全てを順に Revocation API に渡すということは非効率ですし、なにしろ Sign in with Apple の Refresh Token は Revocation API を呼ぶ以外に何も使い道がありません。

よって、ここは完全に Sign in with Apple 用の独自仕様になっています。

また Refresh Token の保存開始と同時に、consent-revoked Notification 受信時に該当する Primary App の Refresh Token を削除する処理を開始しました。

<a name="step5"></a>Step 5. Revocation API の呼び出し

ここまで来れば、Revocation API の呼び出しはもうすぐです。

Ruby の場合は apple_id gem に追加された Token Revocation 機能を利用するだけで処理は完成です。

@user.apple_primary_apps.each do |primary_app|
client = AppleID::Client.new(
identifier: primary_app.client_id,
team_id: MF_APPLE_TEAM_ID,
key_id: MF_APPLE_KEY_ID,
private_key: OpenSSL::PKey::EC.new(MF_APPLE_PRIVATE_KEY_PEM)
)
client.revoke!(
refresh_token: primary_app.refresh_tokens.for(@user)
)
end

なお、MoneyForward ID では複数の Primary App を扱っていますので、退会時には各 Primary App の Refresh Token を順番に Revoke していく必要があります。

<figure> <figcaption>図10. MoneyForward ID 退会時の Sign in with Apple Revocation フロー</figcaption> </figure>

ここまで完了したのち、暫定対応で退会完了画面に入れていた Apple の FAQ へのリンクを削除し、全対応完了としました。

最後に

ここまで、MoneyForward ID チームによる Sign in with Apple Revocation API & Consent Revoked Notification 対応について紹介してきました。

自社で ID 基盤を持ちながら Sign in with Apple にも対応している事業者さんは国内には少ないかもしれませんが、iOS アプリを Primary App として持ちながら Web サイトを Service として登録されている事業者さんはそれなりに多いものと思います。

今後各社で本稿冒頭で紹介したガイドライン項目を根拠として Reject される iOS アプリが出てくることも予想されますが、そういった際の対応方法の参考になれば幸いです。

本当の最後に (宣伝)

また MoneyForward ID チームでは英語と ID 技術が得意なエンジニアを大募集中ですので、よろしければこちらからご応募ください。

【バックエンドエンジニア】ID連携サービス開発CTO室東京(田町)

 


マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。

【会社情報】 ■Wantedly株式会社マネーフォワード福岡開発拠点関西開発拠点(大阪/京都)

【SNS】 ■マネーフォワード公式noteTwitter - 【公式】マネーフォワードTwitter - Money Forward Developersconnpass - マネーフォワードYouTube - Money Forward Developers