『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

Sign in with Apple のトークン取り消しを実装してみた

この記事はフラー株式会社 Advent Calendar 2022 12日目の記事です。
11日目の記事は @ManabuSeki さんで「X-Accel-RedirectとgoでS3ファイルに認証をつけて配信する方法」でした。
(10日目と12日目を担当することにしたので完全に囲む形になってしまった。)


はじめに

Sign in with Apple (以下、 SiwA ) 使用していますか?

developer.apple.com

SiwA は WWDC2019 で発表された Apple ID によるシングルサインオンの仕組みです。
iOS アプリや macOS アプリ以外にも Web や Android にも実装ができ、実装自体も簡単にできるようになっています。

しかしながら2021年10月6日、突如としてアプリ内での退会機能の提供が必須となるレビューガイドラインが追加されることが明らかになりました。*1
実際適用されるまでに延期がありましたが2022年6月30日からそのガイドラインが適用されました。*2

その中に以下のような表記があります。

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

おっと...これどうすんだ...
少なくと私はそうなりました。

すでにマネーフォワードさんなどが記事に書かれていますが、私自身でも理解するために記事にすることにしました。

moneyforward.com

環境

  • Xcode14.1
    • iOS 16.1.2
    • composable-architecture 0.47.2
  • Python 3.10.8

環境は以上のようになっていますが結局は対サーバなので問題ないかと思います。

今回使用したソースコードは秘匿情報は隠していますがすべて以下にプッシュしています。参考までに

bitbucket.org

準備

まずは準備が必要ですね。アプリを作ったり、秘密鍵を作ったりと。

今回の記事での流れ

極端なところがありますが、今回は別にシステムを作っているわけではないので、サインイン == 会員登録でサインアウト == 退会処理とします。
ナンスの作成がユーザがサインイン開始より前に来ているのは次の節で理由を書いています。

アプリ側の動きとしては以下

REST API に使用する秘密鍵の生成

節タイトルの通り、 SiwA REST API にアクセスするためには Apple から発行される秘密鍵が必要です。

https://developer.apple.com/account/resources/authkeys/list

上記のプラスボタンより生成します。

Sign in with Apple にチェックをつけ Configure ボタンより Primary App ID を選択します。
生成できたら PKCS 8 の秘密鍵がダウンロードされます。ファイル名は AuthKey_XXXXXXXXXX.p8 となっていて、ダウンロード画面に表示される Key IDXXXXXXXXXX と一緒になっているはずです。

client_secret を作る共通メソッド

SiwA REST API では APNs Provider API のように Authorization ヘッダーに JWT をつけるのではなくフォームデータとして生成した JWT を投げます。
今回も例のごとく Python での開発を想定しています。

JWT の生成についての公式ドキュメントは以下です。

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048

$ pip install "pyjwt[crypto]"

また、 requests もよく使うので入れておきます。

$ pip install requests
from datetime import datetime, timedelta
from pathlib import Path

import jwt


def create_client_secret() -> str:
    key_path = Path("/") / "path" / "to" / "AuthKey_XXXXXXXXXX.p8"
    iat = datetime.now()
    exp = iat + timedelta(minutes=10)
    return jwt.encode(
        payload={
            "iss": "YOUR_TEAM_ID",
            "iat": int(iat.timestamp()),
            "exp": int(exp.timestamp()),
            "aud": "https://appleid.apple.com",
            "sub": "YOUR_BUNDLE_ID",
        },
        key=key_path.read_text(),
        algorithm="ES256",
        headers={
            "alg": "ES256",
            "kid": "XXXXXXXXXX", # AuthKey_XXXXXXXXXX.p8 の Key ID
        },
    )

アプリを作る

めっちゃシンプルなんですが、 Sign in with Apple ボタンだけを配置する画面を作りました。

import SwiftUI
import _AuthenticationServices_SwiftUI

struct SignInPage: View {
    var body: some View {
        SignInWithAppleButton(
            .signIn,
            onRequest: { request in
                request.requestedScopes = [.fullName]
            },
            onCompletion: { result in
                // 後で処理を追加する
            }
        )
        .signInWithAppleButtonStyle(.black)
        .frame(width: 256, height: 48)
    }
}

雑ですがこんな感じです。 SwiftUI で Apple が提供する _AuthenticationServices_SwiftUI を使うことで簡単にボタンを提供することができます。
ただ問題があります。

ナンスが非同期で追加できない!!!

onRequest: { request in
    request.requestedScopes = [.fullName]
    request.nonce = // ここに追加したい
},

UIKit ベースだとボタン自体はボタンだけの形で提供されているので特に問題はなかったので良かったのですが _AuthenticationServices_SwiftUI での SignInWithAppleButton はタップすると onRequestクロージャにスコープが流れてきてしまいます。
どうするかなと考えた結果...画面自体をサインインのセッションとみなす方法を選びました。

ナンス自体はアプリ側で生成することもいいと思うのですがやはりサーバと通信することを考えるとサーバサイドに生成してもらったものを利用することがセキュリティ的に良いかなと考えています。(リプレイ攻撃対策)

なので今回は、以下のようにしていい感じに逃しました。

var body: some View {
    WithViewStore(store, observe: { $0 }, content: { viewStore in
        Group {
            if let nonce = viewStore.nonce {
                SignInWithAppleButton(
                    .signIn,
                    onRequest: { request in
                        request.requestedScopes = [.fullName]
                        request.nonce = nonce
                    },
                    onCompletion: { result in
                        // 後で処理を追加する
                    }
                )
                .signInWithAppleButtonStyle(.black)
            } else {
                RoundedRectangle(cornerRadius: 6, style: .continuous)
                    .overlay {
                        ActivityIndicator( // これは UIActivityView を UIViewRepresentable でラップしたもの
                            style: .medium,
                            color: .white,
                            isAnimation: .constant(true)
                        )
                    }
            }
        }
        .frame(width: 256, height: 48)
    }
}

サインイン画面は一旦できました。ロジックはともかく。
SiwA 自体のサインイン結果は onCompletion クロージャに流れてきます。

onCompletion: { result in
    viewStore.send(.signInResponse(result))
}

という感じで Reducer に逃します。

switch action {
case let .signInResponse(.success(authorization)):
    guard let nonce = state.nonce,
          let authorization = authorization.credential as? ASAuthorizationAppleIDCredential,
          let authorizationCodeData = authorization.authorizationCode,
          let authorizationCode = String(data: authorizationCodeData, encoding: .utf8) else {
        return .none
    }
    return .task {
        await .sendAuthorizationCode(
            TaskResult {
                // Repository に通信処理等を記述しています
                try await repository.sendAuthorizationCode(
                    authorizationCode: authorizationCode,
                    nonce: nonce
                )
            }
        )
    }
case let .sendAuthorizationCode(.success(response)):
    // アクセストークンの保存
    return .none
}

重要な部分は、 authorization_code を取得することなので特に詳しい説明はしません。
onCompletion で受け取れる Result<ASAuthorization, Error> を処理して authorization_code を入手しましょうという感じです。
また Firebase Auth 等を使う場合は同時に identityToken が取得できるので同じフローに入れておくと良いでしょう。

これで一旦、サインイン画面のロジックもできました。
続いてサインアウト画面です。

こちらもめっちゃ雑ですが、サインアウトボタンだけを配置しています。

実装コード

var body: some View {
    WithViewStore(store, observe: { $0 }, content: { viewStore in
        VStack {
            Button(
                action: {
                    viewStore.send(.signOut, animation: .default)
                },
                label: {
                    ZStack(alignment: .center) {
                        Color.red
                        Text("Sign Out")
                            .bold()
                            .foregroundColor(.white)
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            )
            .frame(width: 256, height: 48)
            .cornerRadius(6)
        }
    })
}

という感じです。

SiwA のリフレッシュトークンの取得

クライアントから送られてきた authorization_code を使用してリフレッシュトークンを取得します。

これまた例のごとく djangorestframework の環境を想定します。

$ pip install django djangorestframework

ナンスの生成

POST /nonce エンドポイントを想定します。

ナンスを管理するモデルを定義します。

import secrets

from django.db import models


def _create_code() -> str:
    return secrets.token_urlsafe(120)


class Nonce(models.Model):
    code = models.CharField(default=_create_code, max_length=500)
    active = models.BooleanField(default=True)

    class Meta:
        db_table = "nonce"

続いて Serializer の実装です。 view 側はこの Serializer を追加するだけなので省略します。

from rest_framework import serializers


class NonceSerializer(serializers.Serializer):
    nonce = serializers.CharField(read_only=True)

    class Meta:
        fields = ("nonce",)

    def create(self, validated_date):
        nonce = Nonce.objects.create()
        return {
            "nonce": nonce.code,
        }

リクエストごとに毎回ナンスを発行するということにしました。

REST API からリフレッシュトークンを取得する

この章の本題です。
SiwA REST API と通信をして SiwA のリフレッシュトークンを取得します。
実際に取得される値は id_tokenaccess_tokenexpires なども含まれています。今回は SiwA REST API のアクセスはこのエンドポイントと Revoke 用のエンドポイントなので無視しておきます。

developer.apple.com

あとで SiwA のリフレッシュトークンは Revoke したりユーザの確認をしたりしなければならないのでナンスと同様にモデルを定義します。

from django.conf import settings
from django.db import models


class RefreshToken(models.Model):
    token = models.CharField(default="", max_length=500)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)

    class Meta:
        db_table = "refresh_token"

簡単ですが、リフレッシュトークン本体を登録する token と誰に紐付いているかを確認するため user を外部キーで持つようにしました。
今回はサインインごとにユーザが生成される仕組みなので user に unique の制約をつけておいてもいいかもしれませんね。

ここではサービス用のリフレッシュトークンとアクセストークンを発行するとするので djangorestframework-simplejwt を導入します。

$ pip install djangorestframework-simplejwt

クライアントから authorization_code を受け取り REST API にリクエストします。
リクエストエンドポイントは POST https://appleid.apple.com/auth/token です。

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

import requests
import uuid
from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework import serializers
from rest_framework.exceptions import AuthenticationFailed, ValidationError
from rest_framework_simplejwt.tokens import RefreshToken as AuthRefreshToken

from .models import Nonce, RefreshToken


class AuthorizeSerializer(serializers.Serializer):
    nonce = serializers.CharField(write_only=True)
    authorization_code = serializers.CharField(write_only=True)
    refresh_token = serializers.CharField(read_only=True)
    access_token = serializers.CharField(read_only=True)

    class Meta:
        fields = ("nonce", "authorization_code", "refresh_token", "access_token",)

    def create(self, validated_data):
        nonce_code = validated_data.pop("nonce")
        try:
            nonce = Nonce.objects.get(code=nonce_code, active=True)
        except Nonce.DoesNotExist:
            raise ValidationError(detail={"nonce": "不明な値です"})

        code = validated_data.pop("authorization_code")

        res = requests.post(
            url="https://appleid.apple.com/auth/token",
            data={
                "client_id": "BUNDLE_ID",  # client_secret のペイロードの sub と一致させる
                "client_secret": create_client_secret(),
                "code": code,  # クライアントから受け取った authorization_code
                "grant_type": "authorization_code"
            },
            headers={
                "content-type": "application/x-www-form-urlencoded",
            }
        )

        if res.status_code != 200:
            raise AuthenticationFailed()

        # 成功
        data = res.json()
        with transaction.atomic:
            # ナンスの無効化 & ユーザの生成 & SiwA リフレッシュトークンの生成
            nonce.active = False
            nonce.save()

            user = get_user_model().objects.create_user(username=str(uuid.uuid4()))  # 雑した

            RefreshToken.objects.create(
                token=data["refresh_token"],
                user=user,
            )

        token = AuthRefreshToken.for_user(user)

        return {
            "refresh_token": str(token),
            "access_token": str(token.access_token),
        }

ここまでで、 SiwA REST API を使用して SiwA のリフレッシュトークンを取得することがきました。

リフレッシュトークンの検証

公式ドキュメントに図で示されているのですが、このリフレッシュトークンを使用して1日1回リフレッシュトークンを検証しなければなりません。

引用: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user

from .models import RefreshToken


for refresh_token in RefreshToken.objects.all():
    res = requests.post(
        url="https://appleid.apple.com/auth/token",
        data={
            "client_id": "BUNDLE_ID",
            "client_secret": create_client_secret(),
            "refresh_token": refresh_token.token,
            "grant_type": "refresh_token",
        },
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
        }
    )

    if res.status_code == 200:
        continue

    # エラーハンドリング

また、ここでは省略しましたがすでに無効になっているリフレッシュトークンで検証をすると BadRequest で以下のようなエラーが返ってきます。

{
    "error": "invalid_grant",
    "error_description": "The token has expired or has been revoked."
}

error_description にもちゃんと入ってきてるのありがたい。

また、他のエラーについても公式ドキュメントにて定義されています。

https://developer.apple.com/documentation/sign_in_with_apple/errorresponse

ここで上記のようなエラーが取得されたら、ユーザが設定アプリから故意的にアプリの SiwA を取り消した操作をしたので SiwA のリフレッシュトークンを削除するなりしておきましょう。
また、ここではサービスからユーザを退会させなくてもよいような記述があります。

あくまでも退会時に SiwA REST API を使用して取り消しをしなければならないということなのでこれは退会と見做されないようですね。

SiwA トークンの取り消し

本題ですね。実際これは簡単。

POST https://appleid.apple.com/auth/revoke エンドポイントにリクエストを送信するのみです。

developer.apple.com

また、ここでのサーバサイドのエンドポイントは DELETE /signout としています。

このエンドポイントはサービスへの認可を必要としています。 SiwA のリフレッシュトークンをユーザに対して紐付けているのでそれを取り出すためもありますね。

import requests
from django.db import transaction
from rest_framework.exceptions import ValidationError
from rest_framework.generics import DestroyAPIView

from .models import RefreshToken


class SignOutDestroyAPIView(DestroyAPIView):
    def get_object():
        return RefreshToken.objects.get(user=self.request.user)

    def perform_destroy(self, instance):
        res = requests.post(
            url="https://appleid.apple.com/auth/revoke",
            data={
                "client_id": "BUNDLE_ID",
                "client_secret": create_client_secret(),
                "token": instance.token,
                "token_type_hint": "refresh_token",
            },
            headers={
                "Content-Type": "application/x-www-form-urlencoded",
            }
        )

        if res.status_code != 200:
            raise ValidationError(detail=res.json())

        # 成功
        with transaction.atomic():
            instance.delete()
            self.request.user.delete()

ユーザの操作による取り消しへの対応

ここまでで退会導線でリフレッシュトークンの取り消しはできるようになりました。
ただ、前章で少しだけ触れましたが以下のような操作をすることでユーザは SiwA のアクセス権を取り消すことができます。

Server to Server Notification Endpoint の設定

前章の最終節でも触れたのですが、1日1回のリフレッシュトークンの検証でエラーが取得されるので結局はリフレッシュトークンはデータベースから消えたりすると思うのですが、どうせならリアルタイムに処理をしたいですよね。

developer.apple.com

ユーザの操作による Apple ID の操作によって Apple サーバから API を叩きに来てくれる仕組みが提供されています。

今回は、 ngrok を使用してローカルのサーバを外部に公開できるようにしました。 Django のデフォルトポートは 8000 です。

$ ngrok http 8000

Apple Developer のサイト から App ID を選択し Sign in with Apple の横の Configure から今回の URL を設定することができます。

リクエストのペイロード

{
  "payload": "eyJraWQiOiJmaDZCczhDIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoibW9lLm5uc25vZG5iLlNpd0FSZXZva2UiLCJleHAiOjE2NzA4Mjg1MzUsImlhdCI6MTY3MDc0MjEzNSwianRpIjoiTzk0eURORkx5ZWRDWVMzYzRwb(省略)OjE2NzA3NDIxMjkyOTF9In0.YxSIiHtkYdfyY9wcahH49lQQoU4z9onBwSBGt9s11JTJDPo2kfnIb9IeWgW7xXFvpohqPffqbkhwkLmAae0GP98iNgpb6-b0mv7auK3kwpI13HewJfZImAQY2qkIUOq6XFhyTk7H4lh3zq47J3fr7ptexocTJsQvuyWRYcy-(省略)P1v9MrftPBG8Yc1POZKSKwN0pBMoOIL4k0j0Q"
}

のようなペイロードが飛んできます。 payload は JWS 形式の暗号で署名されいるので検証しましょう。もちろん検証をしないでもいいですがしておくべきですね。

import jwt


jwt.decode(
    jwt=request.data["payload"],
    options={
        "verify_signature": False
    }
)

ちなみにこんな感じにすることで署名検証をせずにスルーすることができますね。

Apple の署名を検証するための公開鍵の取得

developer.apple.com

こいつは client_secret とかいらないですね。

import requests


res = requests.get(
    url="https://appleid.apple.com/auth/keys"
)
keys = res.json()["keys"]

これで取得可能です。

また、データは以下の様な感じになっています。

{
    "keys": [
        {
            "kty": "RSA",
            "kid": "YuyXoY",
            "use": "sig",
            "alg": "RS256",
            "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw",
            "e": "AQAB"
        }
    ]
}

keys 自体は複数入っています。

続いて、 Apple からの送られきたペイロードのヘッダー部を見て上記で取得した JWK のどれを使うかを決定します。

import jwt


headers = jwt.get_unverified_header(jwt=request.data["payload"])
kid = headers["kid"]  # こいつを特定に使用する
alg = headers["alg"]  # 補助要員で使用することにした


jwk_json = None
for key in keys:
    if key["kid"] == kid and key["alg"] == alg:
        jwk_json = key
        break

これで使用するキーが確定しました。
では JWK を公開鍵の形に変換してあげましょう!!

from cryptography.hazmat.primitives import serialization
from jwt.algorithms import RSAAlgorithm


if jwt_json:
    public_key = RSAAlgorithm.from_jwk(jwt_json)
    pem = public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )

これで JWT の署名検証に使える公開鍵が取得できました。

また、このキーは、時間によって変更されることがあるとのことなので保存などせずに都度取得する、もしくは、保存しておいた場合は使用する JWK が見つからなかったときに再取得するなどの実装を施しておく必要があります。

署名の検証

前節で取得した公開鍵を使用して検証をします。

import jwt


payload = jwt.encode(
    jwt=request.data["payload"],
    key=pem,
    audience="BUNDLE_ID",
    algorithms=alg
)

これで以下のようなペイロードが取得できるようになりました。

{
    "iss": "https://appleid.apple.com",
    "aud": "BUNDLE_ID",
    "exp": 1670831934,
    "iat": 1670745534,
    "jti": "wtgIE-CRkuqojt8989FGOw",
    "events": "{\"type\":\"consent-revoked\",\"sub\":\"SUBJECT\",\"event_time\":1670745528321}"
}

イベントについて

公式ドキュメントにも書かれてあるとおり、ここではイベントが以下のものが取得できます。

  • email-disabled
  • email-enabled
  • consent-revoked
  • account-delete

今回は、 consent-revoked にのみフォーカスします。
まずは前節で取得したペイロードから events のみを取得しましょう。本当は aud とか iat < exp とかも検証したほうがいいけど PyJWT が検証してくれてた気がする。
また、上で SUBJECT としている部分が Apple ID の識別子となっています。(あとでクライアント側とサーバも修正をします。)

import json


event = json.loads(payload["events"])
if event["type"] == "consent-revoked":
    print(event["sub"]) # こいつで特定できるようにする

サーバサイド側の修正

はい。直します。
AuthorizeSerializer の実装をちょっと変えます

-import uuid
...

class AuthorizeSerializer(serializers.Serializer):
+   user_id = serializers.CharField(write_only=True)
...

    class Meta:
+       fields = ("user_id", "nonce", ...,)
-       fields = ("nonce", ...,)

    def create(self, validated_data):
        ...
        with transaction.atomic():
            ...
+           user_id = validated_data.pop("user_id")
+           user = get_user_model().objects.create(username=user_id)
-           user = get_user_model().objects.create(username=str(uuid.uuid4()))

POST /authorize エンドポイントに user_id: str を必須パラメータとします。
その上で、雑にユーザ生成時に UUID4 を投げていた部分に受け取った user_id を設定することにします。

クライアント側の修正

続けてやります。
前節で実装した通りに ASAuthorizationAppleIDCredential から user を取得します。この user が今回修正分の user_id と一致します。

await .sendAuthorizationCode(
    TaskResult {
        try await userRepository.sendAuthorizationCode(
            authorizationCode: authorizationCode,
+           userID: authorization.user,
            nonce: nonce
        )
    }
)

API リクエスト周りの修正はここでは飛ばします。

SiwA リフレッシュトークンの削除

話はイベントの取得まで戻ります。
イベントの取得で取得できた consent-revoked の中にある sub で SiwA のリフレッシュトークンを検索して削除します!!

from .models import RefreshToken

RefreshToken.objects.filter(user__username=event["sub"]).delete()

削除😆😆😆😆

最後に

今回は、 SiwA REST API によるトークン取り消しを実際にアプリとサーバサイドを開発して一連の流れを組んでみました。
クライアント側の開発自体は本当に大したことがないんですが、サーバサイドがやること多すぎて...

Server to Server Notification について今まで実装したことがなかったのでいい機会になりました。
飛んでくるリクエストヘッダーを見たら User-Agent に Java って書いてあったので「あ、やっぱり」ってなりました。

また、 REST API にてユーザのトークンを取り消した場合、以前に確認したときは来なかったのですが、今ではユーザに対してメールで通知しているようです。

(これ知ってるユーザがいたら、メール飛んでこなかったら「あ、このサービスちゃんと対応してないんだ」って思われちゃいますね😅)

(さすがに3記事は疲れた...)
それでは良い SiwA ライフを


明日は @Juliennu さんで「インスタなどでよく見る重なり合うアイコンをUIStackViewで作ってみた」です!
お楽しみにノシ