SwiftUIでFirebaseAuthを使用してSign in with Apple 実装

やっと個人開発しているアプリでSign in with Appleを実装したので備忘録として残します。
# 環境
- SwiftUI
- Xcode beta 14
- macOS beta 13 Ventura

# import
```swift SignInUpWithAppleButton.swift
import Foundation
import SwiftUI
import AuthenticationServices
import CryptoKit
import FirebaseAuth
import FirebaseFirestore
```

`以下全て同じファイルに記述します`
# Viewの作成
```swift SignInUpWithAppleButton.swift
struct SignInUpWithAppleButton: UIViewRepresentable {
    var buttonType: ASAuthorizationAppleIDButton.ButtonType // .sigIn or .signUp
    var completion: (Result<Bool, Error>) -> Void           // 認証完了後の処理のため
    
    init(buttonType: ASAuthorizationAppleIDButton.ButtonType, completion: @escaping (Result<Bool, Error>) -> Void) {
        self.completion = completion
        self.buttonType = buttonType
    }
    
    func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) {}
    
    func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
        let button = ASAuthorizationAppleIDButton(authorizationButtonType: buttonType, authorizationButtonStyle: .white)
        button.addTarget(context.coordinator,
                         action: #selector(Coordinator.startSignInWithAppleFlow),
                         for: .touchUpInside)
        return button
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
}
```
> Viewの引数で「Appleでサインイン」と「Appleでサインアップ」を分けています。

# Coordinatorの作成
```swift SignInUpWithAppleButton.swift
private var currentNonce: String?
private struct AuthError: Error {}

final class Coordinator: NSObject {
    var parent: SignInUpWithAppleButton
    
    init(_ parent: SignInUpWithAppleButton){
        self.parent = parent
        super.init()
    }
    
    // MARK: - Appleのログインフロー
    @objc func startSignInWithAppleFlow() {
        let nonce: String = randomNonceString()
        currentNonce = nonce
        let appleIDProvider = ASAuthorizationAppleIDProvider()
        let request = appleIDProvider.createRequest()
        request.requestedScopes = [.fullName, .email]
        request.nonce = sha256(nonce)
        
        let authorizationController = ASAuthorizationController(authorizationRequests: [request])
        authorizationController.delegate = self
        authorizationController.presentationContextProvider = self
        authorizationController.performRequests()
    }
    
    // MARK: - ログインリクエスト用のランダムな文字列を生成
    private func randomNonceString(length: Int = 32) -> String {
        precondition(length > 0)
        let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
        var result: String = ""
        var remainingLength: Int = 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. SecRandomCopyByte 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
    }

    // MARK: - SHA256ハッシュを生成
    private func sha256(_ input: String) -> String {
        let inputDate = Data(input.utf8)
        let hashedDate = SHA256.hash(data: inputDate)
        let hashString = hashedDate.compactMap {
            String(format: "%02x", $0)
        }.joined()
        
        return hashString
    }
    
}

extension Coordinator: ASAuthorizationControllerDelegate {
    func authorizationController(controller _: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
            guard let nonce = currentNonce else {
                fatalError("Invalid state: A login callback was received, but no login request was sent.")
                self.parent.completion(.failure(AuthError()))
            }
            guard let appleIDToken = appleIDCredential.identityToken else {
                print("Unable to fetch identity token")
                self.parent.completion(.failure(AuthError()))
                return
            }
            guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else {
                print("Unable to serialize token string from data: \(appleIDToken.debugDescription)")
                self.parent.completion(.failure(AuthError()))
                return
            }
            
            let credential = OAuthProvider.credential(withProviderID: "apple.com",
                                                      idToken: idTokenString,
                                                      rawNonce: nonce)
            Auth.auth().signIn(with: credential) { authResult, error in
                if let error = error {
                    self.parent.completion(.failure(AuthError()))
                    return
                }
                // TODO: - 認証成功フロー
                if let user = authResult?.user {
                    switch self.parent.buttonType {
                    // signUpの場合はFireStoreのドキュメントを確認しない
                    case .signUp:
                        self.parent.completion(.success(true))
                    /*
                    signInの場合はFireStoreにドキュメントが存在するかチェックする
                    ドキュメントが存在しない場合サインアップが完了していないと判断し、
                    completionでFailureを返す
                    */
                    case .signIn: 
                        let db = Firestore.firestore()
                        db.collection("USERS").document("\(user.uid)").getDocument { documentSnapshot, error in
                            if let documentSnapshot = documentSnapshot {
                                if !documentSnapshot.exists {
                                    self.parent.completion(.failure(AuthError()))
                                } else {
                                    self.parent.completion(.success(true))
                                }
                            } else {
                                self.parent.completion(.failure(AuthError()))
                            }
                        }
                    default:
                        self.parent.completion(.failure(AuthError()))
                    }
                }
            }
        }
    }
    
    func authorizationController(controller _: ASAuthorizationController, didCompleteWithError error: Error) {
        // TODO: - 認証失敗フロー
        debugPrint(error.localizedDescription)
    }
}

extension Coordinator: ASAuthorizationControllerPresentationContextProviding {
    // 認証ダイアログを表示するためのUIWindowを返すためのコールバック
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        let vc = UIApplication.shared.windows.last?.rootViewController
        return (vc?.view.window!)!
    }
}
```
> Coordinateの中身はすべてドキュメントからコピーして作成しました

# SwiftUI Viewで使用する
```swift SignInView.swift
import SwiftUI

let bounds: CGRect = UIScreen.main.bounds
struct SignInView: View {
    var body: some View {
        SignInUpWithAppleButton(buttonType: .signIn,
                                completion: { result in
                                    switch result {
                                    case .success(_):
                                        // TODO: - DO SOMETHING
                                        return
                                    case .failure(_):
                                        // TODO: - DO SOMETHING
                                        return
                                    }
                                })
            .frame(width: bounds.width * 0.8, height: bounds.height * 0.06)
        // 関数を用意する場合はこっち
        SignInUpWithAppleButton(buttonType: .signIn, completion: self.handleWithAppleButton)
            .frame(width: bounds.width * 0.8, height: bounds.height * 0.06)
    }

    private func handleWithAppleButton(result: Result<Bool, Error>){
        switch result {
        case .success(_):
            // TODO: - DO SOMETHING
            return
        case .failure(_):
            // TODO: - DO SOMETHING
            return
        }
    }
}
```
> TCAを採用しているので引数に直接クロージャを書きましたが、関数として用意してもよいと思います

以上です。
参考: [Appleを使用して認証する](https://firebase.google.com/docs/auth/ios/apple)