AkkeyLab

Sign in with Apple thinking in the stream by RxSwift

Sign in with Apple は WWDC2019 で発表された Apple ID による認証を実現する仕組みです。今回は、その Sign in with Apple をストリーム思考な設計で実装する事例をご紹介します。 環境としては、アカウントの管理は FirebaseAuth を利用し、 RxSwift を導入しているものとします。

完成形

まずは完成形をみていただきます。呼び出し部分はたったコレだけです。実際にユーザ作成 API を叩くトリガーは debugPrint("Sign in OK") の部分に指定するだけです。なお、その処理はプロダクトによって異なる独自機能であるため、この記事では全体的に省略させていただきます。
この実装方法に興味を持っていただけたのであれば、記事を読み進めることをお勧めします。

if #available(iOS 13.0, *) {
    let authorizationProvider = ASAuthorizationProvider()
    authorizationProvider.authResult
        .signUp()
        .subscribe(onNext: { _ in
            debugPrint("Sign in OK")
        })
        .disposed(by: bag)

    signUpButton.rx.tapIfNeeded
        .subscribe(onNext: { [weak self] _ in
            guard let self = self else { return }
            authorizationProvider.show(vc: self)
        })
        .disposed(by: bag)
}

事前準備:RxSwift 対応

まず、 FirebaseAuth の処理を Observable として扱えるように拡張します。各種エラーは独自で定義、ハンドリングする必要があります。ここでは省略して表示しています。

import FirebaseAuth
import RxCocoa
import RxSwift

extension Reactive where Base: FirebaseAuth.User {
    func loadIdToken() -> Single<String> {
        return Single.create { observer in
            self.base.getIDToken { token, error in
                guard let token = token, error == nil else {
                    observer(.error(AuthError.unAuthorized))
                    return
                }
                observer(.success(token))
            }
            return Disposables.create()
        }
    }
}

extension Reactive where Base: Auth {
    func loadCurrentUser() -> Single<FirebaseAuth.User?> {
        return Single.create { observer in
            let hander = self.base.addStateDidChangeListener { _, user in
                observer(.success(user))
            }
            return Disposables.create {
                self.base.removeStateDidChangeListener(hander)
            }
        }
    }

    func signIn(with credential: AuthCredential) -> Single<AuthDataResult> {
        return Single.create { observer in
            self.base.signIn(with: credential) { result, error in
                guard let result = result, error == nil else {
                    observer(.error(AuthError.unAuthorized))
                    return
                }
                observer(.success(result))
            }
            return Disposables.create()
        }
    }

    func delete() -> Single<Void> {
         return Single.create { observer in
             self.base.delete { error in
                 if let error = error {
                     observer(.error(AuthError.unknown(error)))
                     return
                 }
                 observer(.success(()))
             }
             return Disposables.create()
         }
     }
}

次に、 Apple から 提供される ASAuthorizationController の delegate を RxSwift に対応させます。ここで注意しなければならない点として、 AuthenticationServices.framework が iOS12 未満でサポートされないということです。 iOS12 未満をサポートする場合は weak link させることを忘れないでください。

- sdk: AuthenticationServices.framework
  weak: true
import AuthenticationServices
import RxCocoa
import RxSwift

@available(iOS 13.0, *)
extension ASAuthorizationController: HasDelegate {
    public typealias Delegate = ASAuthorizationControllerDelegate
}

@available(iOS 13.0, *)
final class RxASAuthorizationDelegateProxy: DelegateProxy<ASAuthorizationController, ASAuthorizationControllerDelegate> {
    init(controller: ASAuthorizationController) {
        super.init(parentObject: controller, delegateProxy: RxASAuthorizationDelegateProxy.self)
    }

    static func registerKnownImplementations() {
        self.register { RxASAuthorizationDelegateProxy(controller: $0) }
    }

    var didChangeAuthorization = PublishSubject<(ASAuthorizationController, ASAuthorization)>()

    func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        didChangeAuthorization.onNext((controller, authorization))
    }

    func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
        didChangeAuthorization.onError(error)
    }

    deinit {
        didChangeAuthorization.on(.completed)
    }
}

@available(iOS 13.0, *)
extension RxASAuthorizationDelegateProxy: ASAuthorizationControllerDelegate {}

@available(iOS 13.0, *)
extension RxASAuthorizationDelegateProxy: DelegateProxyType {}

@available(iOS 13.0, *)
extension Reactive where Base: ASAuthorizationController {
    public var didChangeAuthorization: Observable<(ASAuthorizationController, ASAuthorization)> {
        return RxASAuthorizationDelegateProxy.proxy(for: base).didChangeAuthorization.asObservable()
    }
}

ASAuthorizationAppleIDProvider に関連する処理の共通化

ASAuthorizationController を動作させるためには複数の処理が必要になります。まず、ランダムな文字列を生成する処理、そして Sign in with Apple の根幹でもある ASAuthorizationAppleIDProvider の生成です。これからリクエストを生成、取得をユーザに要求する項目(例えば e-mail)を指定し、先程生成した文字列 nonce を指定します。
このリクエストを引数に取ることで ASAuthorizationController を生成することが可能になるわけですが、リクエストと ASAuthorizationController は責務が異なるため、分離させます。

import AuthenticationServices
import CryptoKit

struct ASAuthorizationHelper {
    @available(iOS 13, *)
    struct Result {
        let requests: [ASAuthorizationAppleIDRequest]
        let nonce: String
    }

    @available(iOS 13, *)
    static func authorizationRequests() -> Result {
        let nonce = randomNonceString()
        let appleIDProvider = ASAuthorizationAppleIDProvider()
        let request = appleIDProvider.createRequest()
        request.requestedScopes = [.fullName, .email]
        request.nonce = sha256(nonce)
        return .init(requests: [request], nonce: nonce)
    }
}

また、ここでも注意しなければならない点があります。 CryptoKit.framework が iOS13 以降でのサポートであるため、 iOS13 未満をサポートする場合は weak link させなければなりません。

- sdk: CryptoKit.framework
  weak: true

ランダムな文字列の生成方法は FirebaseAuth のドキュメントに記載があります。参考として以下に示しますが、最新の情報は公式ドキュメントを参照するようにしてください。

struct ASAuthorizationHelper {
    private static func randomNonceString(length: Int = 32) -> String {
        precondition(length > 0)
        let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
        var result = ""
        var remainingLength = length

        while remainingLength > 0 {
            let randoms: [UInt8] = (0 ..< 16).map { _ in
                var random: UInt8 = 0
                let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
                if errorCode != errSecSuccess {
                    fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)")
                }
                return random
            }

            randoms.forEach { random in
                guard remainingLength != 0 else { return }
                if random < charset.count {
                    result.append(charset[Int(random)])
                    remainingLength -= 1
                }
            }
        }
        return result
    }

    @available(iOS 13, *)
    private static func sha256(_ input: String) -> String {
        let inputData = Data(input.utf8)
        let hashedData = SHA256.hash(data: inputData)
        return hashedData
            .compactMap { String(format: "%02x", $0) }
            .joined()
    }
}

ASAuthorizationController に関連する処理の共通化

ASAuthorizationAppleIDProvider を用いて作成したリクエストを引数に指定して Controller を生成します。表示用の show メソッドでは引数に表示先の Window を取得するための UIViewController を引数に指定しています。それとともに、 ASAuthorizationController に対して認証開始を合図します。
次に authResult Observable の返却値に注目してください。 nonce は FirebaseAuth による認証処理でも必要になるため、伝搬させる必要があります。

import AuthenticationServices
import RxSwift

@available(iOS 13.0, *)
final class ASAuthorizationProvider: NSObject {
    struct Result {
        let jsonWebToken: String
        let nonce: String
    }

    private let result = ASAuthorizationHelper.authorizationRequests()
    private lazy var authorizationController = ASAuthorizationController(authorizationRequests: result.requests)
    private var showWindow: UIWindow?

    override init() {
        super.init()
        authorizationController.presentationContextProvider = self
    }

    var authResult: Observable<Result> {
        authorizationController.rx.didChangeAuthorization
            .flatMap { [weak self] (_, auth) -> Observable<Result> in
                guard let self = self, let token = auth.credential.toJsonWebToken else {
                    return .error(AuthError.credentialParseFailure)
                }
                return .just(.init(jsonWebToken: token, nonce: self.result.nonce))
            }
    }

    func show(vc: UIViewController) {
        showWindow = vc.view.window
        authorizationController.performRequests()
    }
}

@available(iOS 13.0, *)
extension ASAuthorizationProvider: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        showWindow ?? UIWindow()
    }
}

皆さんは toJsonWebToken という見慣れないパラメータが使われていることに気づきましたか?これは以下のように定義しているものです。
変換処理をまとめたものであり、 ASAuthorizationCredential から JSON Web Token の文字列形式に変換を順に行っています。 Sign in with Apple のみを実装している限り、基本的に例外は発生しない想定ですが、下記のエラー内容に従ってエラーハンドリングを行う必要もあります。

@available(iOS 13.0, *)
extension ASAuthorizationCredential {
    var toJsonWebToken: String? {
        guard let appleIdCredential = self as? ASAuthorizationAppleIDCredential else {
            Logger.warn("Casting to ASAuthorizationAppleIDCredential failed.")
            return nil
        }
        guard let appleIdToken = appleIdCredential.identityToken else {
            Logger.warn("Failed to get JSON Web Token.")
            return nil
        }
        guard let idTokenString = String(data: appleIdToken, encoding: .utf8) else {
            Logger.warn("Serialization from JWT to token failed.")
            return nil
        }
        return idTokenString
    }
}

FirebaseAuth に関連する処理の共通化

はじめにお見せしたコードで、以下に示す箇所の秘密がここで暴かれることになります。

authorizationProvider.authResult
    .signUp() // < ---- This
    .subscribe(onNext: { _ in
        debugPrint("Sign in OK")
    })
    .disposed(by: bag)

Observable を拡張することで RxSwift 特有のメソッドチェーンで処理を記述することができるようになります。
ここで注意しなければならないのは、 Firebase 側で既にユーザを作成済みにも関わらず signUp しようとした場合と、 Firebase 側でユーザを作成したことがないにも関わらず signIn しようとした場合の処理です。前者に関しては、 additionalUserInfo.isNewUser == true であることを確認して、ユーザ作成済みであれば signIn として 自前サーバの API を叩く処理を挟む必要があります。後者に関しても同様に柔軟に signUp 処理に切り替えるか、以下に示すように新規作成してしまったユーザを削除して、 signIn をするようにユーザに提示するなどする必要があります。

import FirebaseAuth
import RxSwift

@available(iOS 13.0, *)
extension Observable where Element == ASAuthorizationProvider.Result {
    func signUp() -> Observable<AuthDataResult> {
        self.map { OAuthProvider.credential(withProviderID: "apple.com", idToken: $0.jsonWebToken, rawNonce: $0.nonce) }
            .flatMap { Auth.auth().rx.signIn(with: $0) }
    }

    func signIn() -> Observable<AuthDataResult> {
        self.map { OAuthProvider.credential(withProviderID: "apple.com", idToken: $0.jsonWebToken, rawNonce: $0.nonce) }
            .flatMap { Auth.auth().rx.signIn(with: $0) }
            .flatMap { result -> Observable<AuthDataResult> in
                guard result.additionalUserInfo?.isNewUser == false else {
                    return Observable<AuthDataResult>.error(AuthError.notExistsAccount)
                        .sample(result.user.rx.delete().asObservable())
                }
                return .just(result)
            }
    }
 }

実装完了

以上の実装で以下のようなサインインフローを実現できるようになります。アイデア、ご指摘等ございましたら、このホームページの連絡フォームより気軽に送っていただけると嬉しいです。
最後までお読みいただき、ありがとうございます。

if #available(iOS 13.0, *) {
    let authorizationProvider = ASAuthorizationProvider()
    authorizationProvider.authResult
        .signUp()
        .subscribe(onNext: { _ in
            debugPrint("Sign in OK")
        })
        .disposed(by: bag)

    signUpButton.rx.tapIfNeeded
        .subscribe(onNext: { [weak self] _ in
            guard let self = self else { return }
            authorizationProvider.show(vc: self)
        })
        .disposed(by: bag)
}

Author

Next post

Re-Authenticate with Sign in with Apple

Sign in with Apple は WWDC2019 で発表された Apple ID による認証を実現する仕組みです。今回は、その Sign in with Apple で再認証処理を行う方法をご紹介します。 環境としては、アカウントの管理は FirebaseAuth を利用し、 RxSwift を導入しているものとします。

Read More →