Last active
November 5, 2024 16:58
-
-
Save SwiftfulThinking/4fea7f4d070fa7bd0eccb2d3320a247c to your computer and use it in GitHub Desktop.
Sign In With Apple for iOS (async support)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// | |
// | |
// | |
// | |
// | |
// ***************************** | |
// * THIS GIST HAS BEEN DEPRECATED. USE CODE HERE: | |
// https://github.com/SwiftfulThinking/SwiftfulFirebaseAuth/blob/main/Sources/SwiftfulFirebaseAuth/Helpers/SignInWithApple.swift | |
// ***************************** | |
// | |
// | |
// | |
// | |
// | |
// | |
import Foundation | |
import CryptoKit | |
import AuthenticationServices | |
import UIKit | |
struct SignInWithAppleResult { | |
let token: String | |
let nonce: String | |
} | |
// Usage | |
// let signInWithAppleResult = try await SignInWithAppleHelper.shared.startSignInWithAppleFlow() | |
final class SignInWithAppleHelper: NSObject { | |
static let shared = SignInWithAppleHelper() | |
private override init() { } | |
private var completionHandler: ((Result<SignInWithAppleResult, Error>) -> Void)? = nil | |
private var currentNonce: String? = nil | |
/// Start Sign In With Apple and present OS modal. | |
/// | |
/// - Parameter viewController: ViewController to present OS modal on. If nil, function will attempt to find the top-most ViewController. Throws an error if no ViewController is found. | |
@MainActor | |
func startSignInWithAppleFlow(viewController: UIViewController? = nil) async throws -> SignInWithAppleResult { | |
return try await withCheckedThrowingContinuation { continuation in | |
startSignInWithAppleFlow { result in | |
switch result { | |
case .success(let signInWithAppleResult): | |
continuation.resume(returning: signInWithAppleResult) | |
return | |
case .failure(let error): | |
continuation.resume(throwing: error) | |
return | |
} | |
} | |
} | |
} | |
@MainActor | |
func startSignInWithAppleFlow(viewController: UIViewController? = nil, completion: @escaping (Result<SignInWithAppleResult, Error>) -> Void) { | |
guard let topVC = viewController ?? topViewController() else { | |
completion(.failure(URLError(.cannotConnectToHost))) | |
return | |
} | |
let nonce = randomNonceString() | |
currentNonce = nonce | |
completionHandler = completion | |
showOSPrompt(nonce: nonce, on: topVC) | |
} | |
} | |
// MARK: PRIVATE | |
private extension SignInWithAppleHelper { | |
// 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: [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 | |
} | |
private func sha256(_ input: String) -> String { | |
let inputData = Data(input.utf8) | |
let hashedData = SHA256.hash(data: inputData) | |
let hashString = hashedData.compactMap { | |
String(format: "%02x", $0) | |
}.joined() | |
return hashString | |
} | |
private func showOSPrompt(nonce: String, on viewController: UIViewController) { | |
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 = viewController | |
authorizationController.performRequests() | |
} | |
private enum SignInWithAppleError: LocalizedError { | |
case invalidCredential | |
case invalidState | |
case unableToFetchToken | |
case unableToSerializeToken | |
case unableToFindNonce | |
var errorDescription: String? { | |
switch self { | |
case .invalidCredential: | |
return "Invalid credential: ASAuthorization failure." | |
case .invalidState: | |
return "Invalid state: A login callback was received, but no login request was sent." | |
case .unableToFetchToken: | |
return "Unable to fetch identity token" | |
case .unableToSerializeToken: | |
return "Unable to serialize token string from data" | |
case .unableToFindNonce: | |
return "Unable to find current nonce." | |
} | |
} | |
} | |
private func getTokenFromAuthorization(authorization: ASAuthorization) throws -> String { | |
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { | |
throw SignInWithAppleError.invalidCredential | |
} | |
guard let appleIDToken = appleIDCredential.identityToken else { | |
throw SignInWithAppleError.unableToFetchToken | |
} | |
guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else { | |
throw SignInWithAppleError.unableToSerializeToken | |
} | |
return idTokenString | |
} | |
private func getCurrentNonce() throws -> String { | |
guard let currentNonce else { | |
throw SignInWithAppleError.unableToFindNonce | |
} | |
return currentNonce | |
} | |
@MainActor | |
private func topViewController(controller: UIViewController? = nil) -> UIViewController? { | |
let controller = controller ?? UIApplication.shared.keyWindow?.rootViewController | |
if let navigationController = controller as? UINavigationController { | |
return topViewController(controller: navigationController.visibleViewController) | |
} | |
if let tabController = controller as? UITabBarController { | |
if let selected = tabController.selectedViewController { | |
return topViewController(controller: selected) | |
} | |
} | |
if let presented = controller?.presentedViewController { | |
return topViewController(controller: presented) | |
} | |
return controller | |
} | |
} | |
extension SignInWithAppleHelper: ASAuthorizationControllerDelegate { | |
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { | |
do { | |
let token = try getTokenFromAuthorization(authorization: authorization) | |
let nonce = try getCurrentNonce() | |
let result = SignInWithAppleResult(token: token, nonce: nonce) | |
completionHandler?(.success(result)) | |
} catch { | |
completionHandler?(.failure(error)) | |
return | |
} | |
} | |
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { | |
completionHandler?(.failure(error)) | |
return | |
} | |
} | |
extension UIViewController: ASAuthorizationControllerPresentationContextProviding { | |
public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { | |
return self.view.window! | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment