iOSエンジニアのつぶやき

毎朝8:30に iOS 関連の技術について1つぶやいています。まれに釣りについてつぶやく可能性があります。

Firebase で Sign in with Apple 🔥

みなさん、おはようございます。今年も Qiita Advent Calendar に申し込みましたが、ネタが無い Yamato です🧑‍🔧

ということで、今回はタイトルの通り Firebase で Sign in with Apple を実装する手順を紹介していきたいと思います。

前提

  • iOS13 以上をアプリターゲットとします
  • App Store Connect に該当の App ID および、その他諸々の証明書の設定が完了しているものとします

ざっくり手順

  1. App ID の Capabilities で、Sign in with Apple を有効にする

  2. Xcode で、アプリの Capabilities に Sign in with Apple を追加する

  3. Firebase Authentication で Apple をログインプロバイダを有効にする

  4. Sign in with Apple に必要な実装をゴリゴリ書いていく

それではやっていきましょう🔥

App ID の Capabilities で、Sign in with Apple を有効にする

まずは、Apple Developer ポータルの Certificates, Identifiers & ProfilesIdentifiers で該当のアプリを選択します。

次に、Capabilities の項目の中にある Sign in with Apple を有効にします。

f:id:yum_fishing:20201127193454p:plain

Capabilities を変更すると、下記のようなメッセージが恐らく表示されます(僕は表示されました)。どうやら、機能を追加すると、App ID に該当する既存の Provisioning Profile が無効になるらしいので、更新してねとのことでした。

Modify App Capabilities

Adding or removing any capabilities will invalidate any provisioning profiles that include this App ID and they must be regenerated for future use.

というわけで、Provisioning Profile を更新したいと思います。

僕は Fastlane match を使用して、証明書を管理しているので Fastlane で Provisioning Profile を更新します。

更新って、一回 Certificates を nuke で消してから再生成するんだっけ?と疑問を持ちながら作業していましたが、結果デバイスを追加した時に使用していた match 関数で普通に更新できました。

  lane :update_develop_certificates do
    match(type: "development", app_identifier: ["hoge.hoge.develop"], force_for_new_devices: true)
  end

Fastlane match での証明書の更新は以前の記事でも書いているので、気になった方はぜひ目を通してみてください。

yamato8010.hatenablog.com

また、ユーザにメールを送信する必要がある Firebase Authentication の機能(e.g メールリンク ログイン、メールアドレスの確認、アカウントの変更の取り消し)を使用する場合は、ApplePrivate Email Relay Service を構成する必要があるそうで、具体的には、リンクの手順にしたがい noreply@YOUR_FIREBASE_PROJECT_ID.firebaseapp.com またはカスタムしたメール テンプレート ドメインを登録する必要があるそうです。

ここに関しては、まだ動作確認を行って無いので触れる機会があったらまた記事を書きたいと思います🏃🏻‍♂️

Xcode で、アプリの Capabilities に Sign in with Apple を追加する

次に、アプリの CapabilitiesSign in with Apple を追加します。

target>Signing & Capabilities+ Capability ボタンをタップして Sign in with Apple を追加します。

f:id:yum_fishing:20201127200752p:plain

Firebase Authentication で Apple をログインプロバイダを有効にする

次に、Firebase コンソールで、Apple のログインプロバイダを有効にします。

f:id:yum_fishing:20201128213101p:plain

また、iOS アプリでのみ Sign in with Apple を有効にする場合は、サービスID を初めとした各フィールドを空のままにすることができます。

Sign in with Apple に必要な実装をゴリゴリ書いていく🧑🏻‍💻

Firebase とは分離した Sign in with Apple の結果((appleIdToken: String, nonce: String, email: String)) を取得するためのコードが下記になります。func signInWithApple() -> Observable<AppleSignInResult> で、Sign in with Apple のためのリクエスト作成と実行を行い Observable として返却しています。request.requestedScopes では、.email のみを指定していますが、.fullName なども取得可能です。また、.email に関しては初回ログイン時のみ取得可能なので注意が必要です。たしか、Firebase Auth を使用する場合は、自動的に Auth.auth().currentUser?.email でキャッシュされた気がします🙄

nonce は、結果として取得できた IDトークンが本アプリの認証リクエストのレスポンスとして付与されたことを確認するのに使用され、リプレイス攻撃を防ぐことにつながるそうです。詳しくは Firebase の Sign in with Apple のドキュメントを覗いてみてください。

firebase.google.com

import RxSwift
import RxCocoa
import AuthenticationServices
import CryptoKit

typealias AppleSignInResult = (appleIdToken: String, nonce: String, email: String)

final class AppleSignInRepository: NSObject, AppleSignInRepositoryInterface {
    private var rootViewController: UIViewController {
        // https://stackoverflow.com/a/57899013/14219079
        return UIApplication.shared.windows.filter {$0.isKeyWindow}.first!.rootViewController!
    }
    private var authorizationRelay = PublishSubject<AppleSignInResult>.init()
    private var currentNonce: String?

    func signInWithApple() -> Observable<AppleSignInResult> {
        authorizationRelay = PublishSubject<AppleSignInResult>.init()
        let nonce = randomNonceString()
        currentNonce = nonce

        let provider = ASAuthorizationAppleIDProvider()
        let request = provider.createRequest()

        request.requestedScopes = [.email]
        request.nonce = sha256(nonce)

        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.presentationContextProvider = self
        controller.performRequests()
        return authorizationRelay.asObserver()
    }

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

      return hashString
    }

    // Adapted from https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce
    private func randomNonceString(length: Int = 32) -> String {
      precondition(length > 0)
      let charset: Array<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
          if remainingLength == 0 {
            return
          }

          if random < charset.count {
            result.append(charset[Int(random)])
            remainingLength -= 1
          }
        }
      }

      return result
    }
}

extension AppleSignInRepository: ASAuthorizationControllerDelegate {
    func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential,
              let nonce = currentNonce,
              let appleIDToken = appleIDCredential.identityToken,
              let idTokenString = String(data: appleIDToken, encoding: .utf8) else {
            authorizationRelay.onError(FideeOnboardSignUpError.unknownSignInWithAppleError)
            return
        }
        authorizationRelay.onNext(AppleSignInResult(idTokenString, nonce, appleIDCredential.email ?? ""))
        authorizationRelay.onCompleted()
    }

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

extension AppleSignInRepository: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return rootViewController.view.window!
    }
}

実際に、Firebase Auth を使用して上記コードで取得した結果を使ってサインインするメソッドが下記のようになります。Sign in with Apple で取得した IDトークンを下に OAuthProvidercredential を作成して、Firebase Auth のサインインメソッドに渡します。

一つ注意するポイントは、サインインもしくはサインアップでのハンドリングです。一般的に Firebase で使用される email ログインの場合は、createUsersignIn の二つのメソッドが分かれているので、createUser で、既に作成済みの email を使用した場合は、Error になります。しかし、Sign in with Apple などのプロバイダログインの場合は、メソッドがひとつのみなので、サインアップなのかサインインなのかを知った上でハンドリングしたい時には少し工夫が必要です。一つの方法としては、初回時にのみ取得できる .email の機能を使用して判断することができますが、かりに Firebase Auth 側の Error で、正常にアカウントが作成できずに処理が中断してしまった場合にも次回以降は .eamil の値が取得できないので注意が必要です。僕の場合は、Firestore に uid をドキュメントIDとしたユーザ一覧の Collection を用意しているので、そこで確認を行って、サインインなのかサインアップなのかの判断を行っています👷‍♀️

    func signInWithApple(appleIdToken: String, nonce: String) -> Observable<String> {
        let credential = OAuthProvider.credential(withProviderID: "apple.com", idToken: appleIdToken, rawNonce: nonce)

        return Observable<String>.create { observer -> Disposable in
            Auth.auth().signIn(with: credential) {[weak self] authResult, error in
                self?.completionHandler(observer: observer, authResult: authResult, error: error)
            }
            return Disposables.create()
        }
    }

    private func completionHandler(observer: AnyObserver<String>, authResult: AuthDataResult?, error: Error?) {
        if let e = error {
            observer.onError(e)
            return
        }
        guard let uid = authResult?.user.uid else {
            observer.onError(FideeOnboardSignUpError.notFoundCurrentUser)
            return
        }
        observer.onNext(uid)
        observer.onCompleted()
    }

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com