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)
}