From 839bbb21b4628ea9a57c982ae9d430a9922a43a3 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Mon, 11 Nov 2024 08:48:34 -0800 Subject: [PATCH] Adding rCE support for phone auth flows. (#14047) --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 2 +- .../AuthProvider/PhoneAuthProvider.swift | 243 +++++++++---- .../RPC/SendVerificationTokenRequest.swift | 30 +- .../Swift/Utilities/AuthErrorUtils.swift | 4 + .../Utilities/AuthRecaptchaVerifier.swift | 118 ++++--- .../PhoneAuthViewController.swift | 34 +- .../Unit/Fakes/FakeBackendRPCIssuer.swift | 28 +- .../Tests/Unit/GetRecaptchaConfigTests.swift | 36 +- .../Tests/Unit/PhoneAuthProviderTests.swift | 334 +++++++++++++----- 9 files changed, 608 insertions(+), 221 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index e5ccdaafac7..748745c48cd 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -2293,7 +2293,7 @@ extension Auth: AuthInterop { action: AuthRecaptchaAction) async throws -> T .Response { let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: self) - if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) { + if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) != .off { try await recaptchaVerifier.injectRecaptchaFields(request: request, provider: AuthRecaptchaProvider.password, action: action) diff --git a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift index 2a1de385aa4..9bd0094dcbe 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift @@ -72,23 +72,19 @@ import Foundation uiDelegate: AuthUIDelegate? = nil, multiFactorSession: MultiFactorSession? = nil, completion: ((_: String?, _: Error?) -> Void)?) { - guard AuthWebUtils.isCallbackSchemeRegistered(forCustomURLScheme: callbackScheme, - urlTypes: auth.mainBundleUrlTypes) else { - fatalError( - "Please register custom URL scheme \(callbackScheme) in the app's Info.plist file." - ) - } - kAuthGlobalWorkQueue.async { - Task { - do { - let verificationID = try await self.internalVerify( - phoneNumber: phoneNumber, - uiDelegate: uiDelegate, - multiFactorSession: multiFactorSession - ) - Auth.wrapMainAsync(callback: completion, withParam: verificationID, error: nil) - } catch { - Auth.wrapMainAsync(callback: completion, withParam: nil, error: error) + Task { + do { + let verificationID = try await verifyPhoneNumber( + phoneNumber, + uiDelegate: uiDelegate, + multiFactorSession: multiFactorSession + ) + await MainActor.run { + completion?(verificationID, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -107,16 +103,19 @@ import Foundation uiDelegate: AuthUIDelegate? = nil, multiFactorSession: MultiFactorSession? = nil) async throws -> String { - return try await withCheckedThrowingContinuation { continuation in - self.verifyPhoneNumber(phoneNumber, - uiDelegate: uiDelegate, - multiFactorSession: multiFactorSession) { result, error in - if let error { - continuation.resume(throwing: error) - } else if let result { - continuation.resume(returning: result) - } - } + guard AuthWebUtils.isCallbackSchemeRegistered(forCustomURLScheme: callbackScheme, + urlTypes: auth.mainBundleUrlTypes) else { + fatalError( + "Please register custom URL scheme \(callbackScheme) in the app's Info.plist file." + ) + } + + if let verificationID = try await internalVerify(phoneNumber: phoneNumber, + uiDelegate: uiDelegate, + multiFactorSession: multiFactorSession) { + return verificationID + } else { + throw AuthErrorUtils.invalidVerificationIDError(message: "Invalid verification ID") } } @@ -133,11 +132,22 @@ import Foundation uiDelegate: AuthUIDelegate? = nil, multiFactorSession: MultiFactorSession?, completion: ((_: String?, _: Error?) -> Void)?) { - multiFactorSession?.multiFactorInfo = multiFactorInfo - verifyPhoneNumber(multiFactorInfo.phoneNumber, - uiDelegate: uiDelegate, - multiFactorSession: multiFactorSession, - completion: completion) + Task { + do { + let verificationID = try await verifyPhoneNumber( + with: multiFactorInfo, + uiDelegate: uiDelegate, + multiFactorSession: multiFactorSession + ) + await MainActor.run { + completion?(verificationID, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) + } + } + } } /// Verify ownership of the second factor phone number by the current user. @@ -152,17 +162,10 @@ import Foundation open func verifyPhoneNumber(with multiFactorInfo: PhoneMultiFactorInfo, uiDelegate: AuthUIDelegate? = nil, multiFactorSession: MultiFactorSession?) async throws -> String { - return try await withCheckedThrowingContinuation { continuation in - self.verifyPhoneNumber(with: multiFactorInfo, - uiDelegate: uiDelegate, - multiFactorSession: multiFactorSession) { result, error in - if let error { - continuation.resume(throwing: error) - } else if let result { - continuation.resume(returning: result) - } - } - } + multiFactorSession?.multiFactorInfo = multiFactorInfo + return try await verifyPhoneNumber(multiFactorInfo.phoneNumber, + uiDelegate: uiDelegate, + multiFactorSession: multiFactorSession) } /// Creates an `AuthCredential` for the phone number provider identified by the @@ -185,7 +188,7 @@ import Foundation uiDelegate: AuthUIDelegate?, multiFactorSession: MultiFactorSession? = nil) async throws -> String? { - guard phoneNumber.count > 0 else { + guard !phoneNumber.isEmpty else { throw AuthErrorUtils.missingPhoneNumberError(message: nil) } guard let manager = auth.notificationManager else { @@ -194,37 +197,155 @@ import Foundation guard await manager.checkNotificationForwarding() else { throw AuthErrorUtils.notificationNotForwardedError() } - return try await verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber, - retryOnInvalidAppCredential: true, - multiFactorSession: multiFactorSession, - uiDelegate: uiDelegate) + + let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth) + try await recaptchaVerifier.retrieveRecaptchaConfig(forceRefresh: false) + + switch recaptchaVerifier.enablementStatus(forProvider: .phone) { + case .off: + return try await verifyClAndSendVerificationCode( + toPhoneNumber: phoneNumber, + retryOnInvalidAppCredential: true, + multiFactorSession: multiFactorSession, + uiDelegate: uiDelegate + ) + case .audit: + return try await verifyClAndSendVerificationCodeWithRecaptcha( + toPhoneNumber: phoneNumber, + retryOnInvalidAppCredential: true, + multiFactorSession: multiFactorSession, + uiDelegate: uiDelegate, + recaptchaVerifier: recaptchaVerifier + ) + case .enforce: + return try await verifyClAndSendVerificationCodeWithRecaptcha( + toPhoneNumber: phoneNumber, + retryOnInvalidAppCredential: false, + multiFactorSession: multiFactorSession, + uiDelegate: uiDelegate, + recaptchaVerifier: recaptchaVerifier + ) + } + } + + func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String, + retryOnInvalidAppCredential: Bool, + uiDelegate: AuthUIDelegate?, + recaptchaVerifier: AuthRecaptchaVerifier) async throws + -> String? { + let request = SendVerificationCodeRequest(phoneNumber: phoneNumber, + codeIdentity: CodeIdentity.empty, + requestConfiguration: auth + .requestConfiguration) + do { + try await recaptchaVerifier.injectRecaptchaFields( + request: request, + provider: .phone, + action: .sendVerificationCode + ) + let response = try await AuthBackend.call(with: request) + return response.verificationID + } catch { + return try await handleVerifyErrorWithRetry(error: error, + phoneNumber: phoneNumber, + retryOnInvalidAppCredential: retryOnInvalidAppCredential, + multiFactorSession: nil, + uiDelegate: uiDelegate) + } } /// Starts the flow to verify the client via silent push notification. - /// - Parameter retryOnInvalidAppCredential: Whether or not the flow should be retried if an + /// - Parameter retryOnInvalidAppCredential: Whether of not the flow should be retried if an /// AuthErrorCodeInvalidAppCredential error is returned from the backend. /// - Parameter phoneNumber: The phone number to be verified. /// - Parameter callback: The callback to be invoked on the global work queue when the flow is /// finished. - private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String, - retryOnInvalidAppCredential: Bool, - uiDelegate: AuthUIDelegate?) async throws + func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String, + retryOnInvalidAppCredential: Bool, + uiDelegate: AuthUIDelegate?) async throws -> String? { let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate) let request = SendVerificationCodeRequest(phoneNumber: phoneNumber, codeIdentity: codeIdentity, requestConfiguration: auth .requestConfiguration) - do { - let response = try await auth.backend.call(with: request) + let response = try await AuthBackend.call(with: request) return response.verificationID } catch { - return try await handleVerifyErrorWithRetry(error: error, - phoneNumber: phoneNumber, - retryOnInvalidAppCredential: retryOnInvalidAppCredential, - multiFactorSession: nil, - uiDelegate: uiDelegate) + return try await handleVerifyErrorWithRetry( + error: error, + phoneNumber: phoneNumber, + retryOnInvalidAppCredential: retryOnInvalidAppCredential, + multiFactorSession: nil, + uiDelegate: uiDelegate + ) + } + } + + /// Starts the flow to verify the client via silent push notification. + /// - Parameter retryOnInvalidAppCredential: Whether of not the flow should be retried if an + /// AuthErrorCodeInvalidAppCredential error is returned from the backend. + /// - Parameter phoneNumber: The phone number to be verified. + private func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String, + retryOnInvalidAppCredential: Bool, + multiFactorSession session: MultiFactorSession?, + uiDelegate: AuthUIDelegate?, + recaptchaVerifier: AuthRecaptchaVerifier) async throws + -> String? { + if let settings = auth.settings, + settings.isAppVerificationDisabledForTesting { + let request = SendVerificationCodeRequest( + phoneNumber: phoneNumber, + codeIdentity: CodeIdentity.empty, + requestConfiguration: auth.requestConfiguration + ) + let response = try await AuthBackend.call(with: request) + return response.verificationID + } + guard let session else { + return try await verifyClAndSendVerificationCodeWithRecaptcha( + toPhoneNumber: phoneNumber, + retryOnInvalidAppCredential: retryOnInvalidAppCredential, + uiDelegate: uiDelegate, + recaptchaVerifier: recaptchaVerifier + ) + } + let startMFARequestInfo = AuthProtoStartMFAPhoneRequestInfo(phoneNumber: phoneNumber, + codeIdentity: CodeIdentity.empty) + do { + if let idToken = session.idToken { + let request = StartMFAEnrollmentRequest(idToken: idToken, + enrollmentInfo: startMFARequestInfo, + requestConfiguration: auth.requestConfiguration) + try await recaptchaVerifier.injectRecaptchaFields( + request: request, + provider: .phone, + action: .startMfaEnrollment + ) + let response = try await AuthBackend.call(with: request) + return response.phoneSessionInfo?.sessionInfo + } else { + let request = StartMFASignInRequest(MFAPendingCredential: session.mfaPendingCredential, + MFAEnrollmentID: session.multiFactorInfo?.uid, + signInInfo: startMFARequestInfo, + requestConfiguration: auth.requestConfiguration) + try await recaptchaVerifier.injectRecaptchaFields( + request: request, + provider: .phone, + action: .startMfaSignin + ) + let response = try await AuthBackend.call(with: request) + return response.responseInfo?.sessionInfo + } + } catch { + return try await handleVerifyErrorWithRetry( + error: error, + phoneNumber: phoneNumber, + retryOnInvalidAppCredential: retryOnInvalidAppCredential, + multiFactorSession: session, + uiDelegate: uiDelegate + ) } } @@ -474,8 +595,9 @@ import Foundation private let auth: Auth private let callbackScheme: String private let usingClientIDScheme: Bool + private var recaptchaVerifier: AuthRecaptchaVerifier? - init(auth: Auth) { + init(auth: Auth, recaptchaVerifier: AuthRecaptchaVerifier? = nil) { self.auth = auth if let clientID = auth.app?.options.clientID { let reverseClientIDScheme = clientID.components(separatedBy: ".").reversed() @@ -494,6 +616,7 @@ import Foundation return } callbackScheme = "" + self.recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth) } private let kAuthTypeVerifyApp = "verifyApp" diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift index af090fdd3b3..2a968aa5029 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift @@ -29,11 +29,20 @@ private let kSecretKey = "iosSecret" /// The key for the reCAPTCHAToken parameter in the request. private let kreCAPTCHATokenKey = "recaptchaToken" +/// The key for the "clientType" value in the request. +private let kClientType = "clientType" + +/// The key for the "captchaResponse" value in the request. +private let kCaptchaResponseKey = "captchaResponse" + +/// The key for the "recaptchaVersion" value in the request. +private let kRecaptchaVersion = "recaptchaVersion" + /// The key for the tenant id value in the request. private let kTenantIDKey = "tenantId" /// A verification code can be an appCredential or a reCaptcha Token -enum CodeIdentity { +enum CodeIdentity: Equatable { case credential(AuthAppCredential) case recaptcha(String) case empty @@ -50,6 +59,12 @@ class SendVerificationCodeRequest: IdentityToolkitRequest, AuthRPCRequest { /// verification code. let codeIdentity: CodeIdentity + /// Response to the captcha. + var captchaResponse: String? + + /// The reCAPTCHA version. + var recaptchaVersion: String? + init(phoneNumber: String, codeIdentity: CodeIdentity, requestConfiguration: AuthRequestConfiguration) { self.phoneNumber = phoneNumber @@ -71,10 +86,21 @@ class SendVerificationCodeRequest: IdentityToolkitRequest, AuthRPCRequest { postBody[kreCAPTCHATokenKey] = reCAPTCHAToken case .empty: break } - + if let captchaResponse { + postBody[kCaptchaResponseKey] = captchaResponse + } + if let recaptchaVersion { + postBody[kRecaptchaVersion] = recaptchaVersion + } if let tenantID { postBody[kTenantIDKey] = tenantID } + postBody[kClientType] = clientType return postBody } + + func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) { + captchaResponse = recaptchaResponse + self.recaptchaVersion = recaptchaVersion + } } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index 6e6a1a74353..ee397bfc670 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -203,6 +203,10 @@ class AuthErrorUtils { error(code: .missingAndroidPackageName, message: message) } + static func invalidRecaptchaTokenError() -> Error { + error(code: .invalidRecaptchaToken) + } + static func unauthorizedDomainError(message: String?) -> Error { error(code: .unauthorizedDomain, message: message) } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift index 859496fac82..20ab4a6e397 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift @@ -23,24 +23,47 @@ @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class AuthRecaptchaConfig { - let siteKey: String - let enablementStatus: [String: Bool] + var siteKey: String? + let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] - init(siteKey: String, enablementStatus: [String: Bool]) { + init(siteKey: String? = nil, + enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) { self.siteKey = siteKey self.enablementStatus = enablementStatus } } - enum AuthRecaptchaProvider { - case password + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + enum AuthRecaptchaEnablementStatus: String, CaseIterable { + case enforce = "ENFORCE" + case audit = "AUDIT" + case off = "OFF" + + // Convenience property for mapping values + var stringValue: String { rawValue } } - enum AuthRecaptchaAction { + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + enum AuthRecaptchaProvider: String, CaseIterable { + case password = "EMAIL_PASSWORD_PROVIDER" + case phone = "PHONE_PROVIDER" + + // Convenience property for mapping values + var stringValue: String { rawValue } + } + + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + enum AuthRecaptchaAction: String { case defaultAction case signInWithPassword case getOobCode case signUpPassword + case sendVerificationCode + case startMfaSignin + case startMfaEnrollment + + // Convenience property for mapping values + var stringValue: String { rawValue } } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @@ -49,14 +72,9 @@ private(set) var agentConfig: AuthRecaptchaConfig? private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:] private(set) var recaptchaClient: RCARecaptchaClientProtocol? - - private static let _shared = AuthRecaptchaVerifier() - private let providerToStringMap = [AuthRecaptchaProvider.password: "EMAIL_PASSWORD_PROVIDER"] - private let actionToStringMap = [AuthRecaptchaAction.signInWithPassword: "signInWithPassword", - AuthRecaptchaAction.getOobCode: "getOobCode", - AuthRecaptchaAction.signUpPassword: "signUpPassword"] + private static var _shared = AuthRecaptchaVerifier() private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE" - private init() {} + init() {} class func shared(auth: Auth?) -> AuthRecaptchaVerifier { if _shared.auth != auth { @@ -67,6 +85,11 @@ return _shared } + class func setShared(_ instance: AuthRecaptchaVerifier, auth: Auth?) { + _shared = instance + _ = shared(auth: auth) + } + func siteKey() -> String? { if let tenantID = auth?.tenantID { if let config = tenantConfigs[tenantID] { @@ -77,22 +100,17 @@ return agentConfig?.siteKey } - func enablementStatus(forProvider provider: AuthRecaptchaProvider) -> Bool { - guard let providerString = providerToStringMap[provider] else { - return false - } - if let tenantID = auth?.tenantID { - guard let tenantConfig = tenantConfigs[tenantID], - let status = tenantConfig.enablementStatus[providerString] else { - return false - } + func enablementStatus(forProvider provider: AuthRecaptchaProvider) + -> AuthRecaptchaEnablementStatus { + if let tenantID = auth?.tenantID, + let tenantConfig = tenantConfigs[tenantID], + let status = tenantConfig.enablementStatus[provider] { return status - } else { - guard let agentConfig, - let status = agentConfig.enablementStatus[providerString] else { - return false - } + } else if let agentConfig = agentConfig, + let status = agentConfig.enablementStatus[provider] { return status + } else { + return AuthRecaptchaEnablementStatus.off } } @@ -101,7 +119,7 @@ guard let siteKey = siteKey() else { throw AuthErrorUtils.recaptchaSiteKeyMissing() } - let actionString = actionToStringMap[action] ?? "" + let actionString = action.stringValue #if !(COCOAPODS || SWIFT_PACKAGE) // No recaptcha on internal build system. return actionString @@ -156,25 +174,35 @@ let request = GetRecaptchaConfigRequest(requestConfiguration: auth.requestConfiguration) let response = try await auth.backend.call(with: request) AuthLog.logInfo(code: "I-AUT000029", message: "reCAPTCHA config retrieval succeeded.") - // Response's site key is of the format projects//keys/' - guard let keys = response.recaptchaKey?.components(separatedBy: "/"), - keys.count == 4 else { - throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey") - } - let siteKey = keys[3] - var enablementStatus: [String: Bool] = [:] + try await parseRecaptchaConfigFromResponse(response: response) + } + + func parseRecaptchaConfigFromResponse(response: GetRecaptchaConfigResponse) async throws { + var enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] = [:] + var isRecaptchaEnabled = false if let enforcementState = response.enforcementState { for state in enforcementState { - if let provider = state["provider"], - provider == providerToStringMap[AuthRecaptchaProvider.password] { - if let enforcement = state["enforcementState"] { - if enforcement == "ENFORCE" || enforcement == "AUDIT" { - enablementStatus[provider] = true - } else if enforcement == "OFF" { - enablementStatus[provider] = false - } - } + guard let providerString = state["provider"], + let enforcementString = state["enforcementState"], + let provider = AuthRecaptchaProvider(rawValue: providerString), + let enforcement = AuthRecaptchaEnablementStatus(rawValue: enforcementString) else { + continue // Skip to the next state in the loop + } + enablementStatus[provider] = enforcement + if enforcement != .off { + isRecaptchaEnabled = true + } + } + } + var siteKey = "" + // Response's site key is of the format projects//keys/' + if isRecaptchaEnabled { + if let recaptchaKey = response.recaptchaKey { + let keys = recaptchaKey.components(separatedBy: "/") + if keys.count != 4 { + throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey") } + siteKey = keys[3] } } let config = AuthRecaptchaConfig(siteKey: siteKey, enablementStatus: enablementStatus) @@ -190,7 +218,7 @@ provider: AuthRecaptchaProvider, action: AuthRecaptchaAction) async throws { try await retrieveRecaptchaConfig(forceRefresh: false) - if enablementStatus(forProvider: provider) { + if enablementStatus(forProvider: provider) != .off { let token = try await verify(forceRefresh: false, action: action) request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: kRecaptchaVersion) } else { diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift index a4e19077aad..cbb86430685 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift @@ -30,17 +30,18 @@ class PhoneAuthViewController: OtherAuthViewController { private func phoneAuthLogin(_ phoneNumber: String) { let phoneNumber = String(format: "+%@", phoneNumber) - PhoneAuthProvider.provider() - .verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationID, error in - guard error == nil else { return self.displayError(error) } - - guard let verificationID = verificationID else { return } - self.presentPhoneAuthController { verificationCode in - let credential = PhoneAuthProvider.provider() - .credential(withVerificationID: verificationID, verificationCode: verificationCode) - self.signin(with: credential) - } + Task { + do { + let phoneAuthProvider = PhoneAuthProvider.provider() + let verificationID = try await phoneAuthProvider.verifyPhoneNumber(phoneNumber) + let verificationCode = try await getVerificationCode() + let credential = phoneAuthProvider.credential(withVerificationID: verificationID, + verificationCode: verificationCode) + self.signin(with: credential) + } catch { + self.displayError(error) } + } } private func signin(with credential: PhoneAuthCredential) { @@ -74,4 +75,17 @@ class PhoneAuthViewController: OtherAuthViewController { present(phoneAuthController, animated: true, completion: nil) } + + private func getVerificationCode() async throws -> String { + return try await withCheckedThrowingContinuation { continuation in + self.presentPhoneAuthController { code in + if code != "" { + continuation.resume(returning: code) + } else { + // Cancelled + continuation.resume(throwing: NSError()) + } + } + } + } } diff --git a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift index e09e8d2106c..5db026b186c 100644 --- a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift +++ b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift @@ -75,7 +75,8 @@ class FakeBackendRPCIssuer: AuthBackendRPCIssuer { var fakeSecureTokenServiceJSON: [String: AnyHashable]? var secureTokenNetworkError: NSError? var secureTokenErrorString: String? - var recaptchaSiteKey = "unset recaptcha siteKey" + var recaptchaSiteKey = "projects/fakeProjectId/keys/mockSiteKey" + var rceMode: String = "OFF" override func asyncCallToURL(with request: T, body: Data?, contentType: String) async -> (Data?, Error?) @@ -121,9 +122,28 @@ class FakeBackendRPCIssuer: AuthBackendRPCIssuer { } return } else if let _ = request as? GetRecaptchaConfigRequest { - guard let _ = try? respond(withJSON: ["recaptchaKey": recaptchaSiteKey]) - else { - fatalError("GetRecaptchaConfigRequest respond failed") + if rceMode != "OFF" { // Check if reCAPTCHA is enabled + let recaptchaKey = recaptchaSiteKey // iOS key from your config + let enforcementState = [ + ["provider": "EMAIL_PASSWORD_PROVIDER", "enforcementState": rceMode], + ["provider": "PHONE_PROVIDER", "enforcementState": rceMode], + ] + guard let _ = try? respond(withJSON: [ + "recaptchaKey": recaptchaKey, + "recaptchaEnforcementState": enforcementState, + ]) else { + fatalError("GetRecaptchaConfigRequest respond failed") + } + } else { // reCAPTCHA OFF + let enforcementState = [ + ["provider": "EMAIL_PASSWORD_PROVIDER", "enforcementState": "OFF"], + ["provider": "PHONE_PROVIDER", "enforcementState": "OFF"], + ] + guard let _ = try? respond(withJSON: [ + "recaptchaEnforcementState": enforcementState, + ]) else { + fatalError("GetRecaptchaConfigRequest respond failed") + } } return } else if let _ = request as? SecureTokenRequest { diff --git a/FirebaseAuth/Tests/Unit/GetRecaptchaConfigTests.swift b/FirebaseAuth/Tests/Unit/GetRecaptchaConfigTests.swift index 75149c38d45..c293a10ee42 100644 --- a/FirebaseAuth/Tests/Unit/GetRecaptchaConfigTests.swift +++ b/FirebaseAuth/Tests/Unit/GetRecaptchaConfigTests.swift @@ -39,16 +39,42 @@ class GetRecaptchaConfigTests: RPCBaseTests { ) } - /** @fn testSuccessfulGetRecaptchaConfigRequest - @brief This test simulates a successful @c getRecaptchaConfig Flow. + /** @fn testSuccessfulGetRecaptchaConfigRequestRecaptchaEnabled + @brief This test simulates a successful @c getRecaptchaConfig Flow when recaptcha is enabled. */ - func testSuccessfulGetRecaptchaConfigRequest() async throws { + func testSuccessfulGetRecaptchaConfigRequestRecaptchaEnabled() async throws { let kTestRecaptchaKey = "projects/123/keys/456" let request = GetRecaptchaConfigRequest(requestConfiguration: makeRequestConfiguration()) rpcIssuer.recaptchaSiteKey = kTestRecaptchaKey - let response = try await authBackend.call(with: request) + let enforcementMode = "AUDIT" + rpcIssuer.rceMode = enforcementMode + let response = try await AuthBackend.call(with: request) XCTAssertEqual(response.recaptchaKey, kTestRecaptchaKey) - XCTAssertNil(response.enforcementState) + XCTAssertEqual( + response.enforcementState, + [ + ["provider": "EMAIL_PASSWORD_PROVIDER", "enforcementState": enforcementMode], + ["provider": "PHONE_PROVIDER", "enforcementState": enforcementMode], + ] + ) + } + + /** @fn testSuccessfulGetRecaptchaConfigRequestRecaptchaDisabled + @brief This test simulates a successful @c getRecaptchaConfig Flow when recaptcha is disabled. + */ + func testSuccessfulGetRecaptchaConfigRequestRecaptchaDisabled() async throws { + let request = GetRecaptchaConfigRequest(requestConfiguration: makeRequestConfiguration()) + let enforcementMode = "OFF" + rpcIssuer.rceMode = enforcementMode + let response = try await AuthBackend.call(with: request) + XCTAssertEqual(response.recaptchaKey, nil) + XCTAssertEqual( + response.enforcementState, + [ + ["provider": "EMAIL_PASSWORD_PROVIDER", "enforcementState": enforcementMode], + ["provider": "PHONE_PROVIDER", "enforcementState": enforcementMode], + ] + ) } } diff --git a/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift b/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift index 9a319ea9755..6bbc28b2d37 100644 --- a/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift +++ b/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift @@ -41,8 +41,11 @@ private let kVerificationIDKey = "sessionInfo" private let kFakeEncodedFirebaseAppID = "app-1-123456789-ios-123abc456def" private let kFakeReCAPTCHAToken = "fakeReCAPTCHAToken" + private let kCaptchaResponse: String = "captchaResponse" + private let kRecaptchaVersion: String = "RECAPTCHA_ENTERPRISE" static var auth: Auth? + // static var authRecaptchaVerifier: AuthRecaptchaVerifier /** @fn testCredentialWithVerificationID @brief Tests the @c credentialWithToken method to make sure that it returns a valid AuthCredential instance. @@ -62,89 +65,196 @@ } /** @fn testVerifyEmptyPhoneNumber - @brief Tests a failed invocation @c verifyPhoneNumber:completion: because an empty phone + @brief Tests a failed invocation verifyPhoneNumber because an empty phone number was provided. */ - func testVerifyEmptyPhoneNumber() throws { + func testVerifyEmptyPhoneNumber() async throws { initApp(#function) let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) let provider = PhoneAuthProvider.provider(auth: auth) - let expectation = self.expectation(description: #function) - // Empty phone number is checked on the client side so no backend RPC is faked. - provider.verifyPhoneNumber("", uiDelegate: nil) { verificationID, error in - XCTAssertNotNil(error) - XCTAssertNil(verificationID) - XCTAssertEqual((error as? NSError)?.code, AuthErrorCode.missingPhoneNumber.rawValue) - expectation.fulfill() + do { + _ = try await provider.verifyPhoneNumber("") + XCTFail("Expected an error, but verification succeeded.") + } catch { + XCTAssertEqual((error as NSError).code, AuthErrorCode.missingPhoneNumber.rawValue) } - waitForExpectations(timeout: 5) } /** @fn testVerifyInvalidPhoneNumber @brief Tests a failed invocation @c verifyPhoneNumber:completion: because an invalid phone number was provided. */ - func testVerifyInvalidPhoneNumber() throws { - try internalTestVerify(errorString: "INVALID_PHONE_NUMBER", - errorCode: AuthErrorCode.invalidPhoneNumber.rawValue, - function: #function) + func testVerifyInvalidPhoneNumber() async throws { + try await internalTestVerify(errorString: "INVALID_PHONE_NUMBER", + errorCode: AuthErrorCode.invalidPhoneNumber.rawValue, + function: #function) } /** @fn testVerifyPhoneNumber @brief Tests a successful invocation of @c verifyPhoneNumber:completion:. */ - func testVerifyPhoneNumber() throws { - try internalTestVerify(function: #function) + func testVerifyPhoneNumber() async throws { + try await internalTestVerify(function: #function) + } + + /** + @fn testVerifyPhoneNumberWithRceEnforce + @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise enforced + */ + func testVerifyPhoneNumberWithRceEnforceSuccess() async throws { + initApp(#function) + let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) + // TODO: Figure out how to mock objective C's FIRRecaptchaGetToken response + let provider = PhoneAuthProvider.provider(auth: auth) + let mockVerifier = FakeAuthRecaptchaVerifier(captchaResponse: kCaptchaResponse) + AuthRecaptchaVerifier.setShared(mockVerifier, auth: auth) + rpcIssuer.rceMode = "ENFORCE" + let requestExpectation = expectation(description: "verifyRequester") + rpcIssuer?.verifyRequester = { request in + XCTAssertEqual(request.phoneNumber, self.kTestPhoneNumber) + XCTAssertEqual(request.captchaResponse, self.kCaptchaResponse) + XCTAssertEqual(request.recaptchaVersion, "RECAPTCHA_ENTERPRISE") + XCTAssertEqual(request.codeIdentity, CodeIdentity.empty) + requestExpectation.fulfill() + do { + try self.rpcIssuer? + .respond(withJSON: [self.kVerificationIDKey: self.kTestVerificationID]) + } catch { + XCTFail("Failure sending response: \(error)") + } + } + do { + let result = try await provider.verifyClAndSendVerificationCodeWithRecaptcha( + toPhoneNumber: kTestPhoneNumber, + retryOnInvalidAppCredential: false, + uiDelegate: nil, + recaptchaVerifier: mockVerifier + ) + XCTAssertEqual(result, kTestVerificationID) + } catch { + XCTFail("Unexpected error") + } + await fulfillment(of: [requestExpectation], timeout: 5.0) + } + + /** + @fn testVerifyPhoneNumberWithRceEnforceInvalidRecaptcha + @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise enforced + */ + func testVerifyPhoneNumberWithRceEnforceInvalidRecaptcha() async throws { + initApp(#function) + let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) + // TODO: Figure out how to mock objective C's FIRRecaptchaGetToken response + let provider = PhoneAuthProvider.provider(auth: auth) + let mockVerifier = FakeAuthRecaptchaVerifier() + AuthRecaptchaVerifier.setShared(mockVerifier, auth: auth) + rpcIssuer.rceMode = "ENFORCE" + let requestExpectation = expectation(description: "verifyRequester") + rpcIssuer?.verifyRequester = { request in + XCTAssertEqual(request.phoneNumber, self.kTestPhoneNumber) + XCTAssertEqual(request.captchaResponse, "NO_RECAPTCHA") + XCTAssertEqual(request.recaptchaVersion, "RECAPTCHA_ENTERPRISE") + XCTAssertEqual(request.codeIdentity, CodeIdentity.empty) + requestExpectation.fulfill() + do { + try self.rpcIssuer? + .respond( + serverErrorMessage: "INVALID_RECAPTCHA_TOKEN", + error: AuthErrorUtils.invalidRecaptchaTokenError() as NSError + ) + } catch { + XCTFail("Failure sending response: \(error)") + } + } + do { + _ = try await provider.verifyClAndSendVerificationCodeWithRecaptcha( + toPhoneNumber: kTestPhoneNumber, + retryOnInvalidAppCredential: false, + uiDelegate: nil, + recaptchaVerifier: mockVerifier + ) + // XCTAssertEqual(result, kTestVerificationID) + } catch { + // Traverse the nested error to find the root cause + let underlyingError = (error as NSError).userInfo[NSUnderlyingErrorKey] as? NSError + let rootError = underlyingError?.userInfo[NSUnderlyingErrorKey] as? NSError + + // Compare the root error code to the expected error code + XCTAssertEqual(rootError?.code, AuthErrorCode.invalidRecaptchaToken.code.rawValue) + } + await fulfillment(of: [requestExpectation], timeout: 5.0) + } + + /** + @fn testVerifyPhoneNumberWithRceEnforceSDKNotLinked + @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise enforced + */ + func testVerifyPhoneNumberWithRceEnforceRecaptchaSDKNotLinked() async throws { + return try await testRecaptchaFlowError( + function: #function, + rceError: AuthErrorUtils.recaptchaSDKNotLinkedError() + ) + } + + /** + @fn testVerifyPhoneNumberWithRceEnforceSDKNotLinked + @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise enforced + */ + func testVerifyPhoneNumberWithRceEnforceRecaptchaActionCreationFailed() async throws { + return try await testRecaptchaFlowError( + function: #function, + rceError: AuthErrorUtils.recaptchaActionCreationFailed() + ) } /** @fn testVerifyPhoneNumberInTestMode @brief Tests a successful invocation of @c verifyPhoneNumber:completion: when app verification is disabled. */ - func testVerifyPhoneNumberInTestMode() throws { - try internalTestVerify(function: #function, testMode: true) + func testVerifyPhoneNumberInTestMode() async throws { + try await internalTestVerify(function: #function, testMode: true) } /** @fn testVerifyPhoneNumberInTestModeFailure @brief Tests a failed invocation of @c verifyPhoneNumber:completion: when app verification is disabled. */ - func testVerifyPhoneNumberInTestModeFailure() throws { - try internalTestVerify(errorString: "INVALID_PHONE_NUMBER", - errorCode: AuthErrorCode.invalidPhoneNumber.rawValue, - function: #function, testMode: true) + func testVerifyPhoneNumberInTestModeFailure() async throws { + try await internalTestVerify(errorString: "INVALID_PHONE_NUMBER", + errorCode: AuthErrorCode.invalidPhoneNumber.rawValue, + function: #function, testMode: true) } /** @fn testVerifyPhoneNumberUIDelegateFirebaseAppIdFlow @brief Tests a successful invocation of @c verifyPhoneNumber:UIDelegate:completion:. */ - func testVerifyPhoneNumberUIDelegateFirebaseAppIdFlow() throws { - try internalTestVerify(function: #function, reCAPTCHAfallback: true) + func testVerifyPhoneNumberUIDelegateFirebaseAppIdFlow() async throws { + try await internalTestVerify(function: #function, reCAPTCHAfallback: true) } /** @fn testVerifyPhoneNumberUIDelegateFirebaseAppIdWhileClientIdPresentFlow @brief Tests a successful invocation of @c verifyPhoneNumber:UIDelegate:completion: when the client ID is present in the plist file, but the encoded app ID is the registered custom URL scheme. */ - func testVerifyPhoneNumberUIDelegateFirebaseAppIdWhileClientIdPresentFlow() throws { - try internalTestVerify(function: #function, useClientID: true, - bothClientAndAppID: true, reCAPTCHAfallback: true) + func testVerifyPhoneNumberUIDelegateFirebaseAppIdWhileClientIdPresentFlow() async throws { + try await internalTestVerify(function: #function, useClientID: true, + bothClientAndAppID: true, reCAPTCHAfallback: true) } /** @fn testVerifyPhoneNumberUIDelegateClientIdFlow @brief Tests a successful invocation of @c verifyPhoneNumber:UIDelegate:completion:. */ - func testVerifyPhoneNumberUIDelegateClientIdFlow() throws { - try internalTestVerify(function: #function, useClientID: true, reCAPTCHAfallback: true) + func testVerifyPhoneNumberUIDelegateClientIdFlow() async throws { + try await internalTestVerify(function: #function, useClientID: true, reCAPTCHAfallback: true) } /** @fn testVerifyPhoneNumberUIDelegateInvalidClientID @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in an invalid client ID error. */ - func testVerifyPhoneNumberUIDelegateInvalidClientID() throws { - try internalTestVerify( + func testVerifyPhoneNumberUIDelegateInvalidClientID() async throws { + try await internalTestVerify( errorURLString: PhoneAuthProviderTests.kFakeRedirectURLStringInvalidClientID, errorCode: AuthErrorCode.invalidClientID.rawValue, function: #function, @@ -157,8 +267,8 @@ @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in a web network request failed error. */ - func testVerifyPhoneNumberUIDelegateWebNetworkRequestFailed() throws { - try internalTestVerify( + func testVerifyPhoneNumberUIDelegateWebNetworkRequestFailed() async throws { + try await internalTestVerify( errorURLString: PhoneAuthProviderTests.kFakeRedirectURLStringWebNetworkRequestFailed, errorCode: AuthErrorCode.webNetworkRequestFailed.rawValue, function: #function, @@ -171,8 +281,8 @@ @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in a web internal error. */ - func testVerifyPhoneNumberUIDelegateWebInternalError() throws { - try internalTestVerify( + func testVerifyPhoneNumberUIDelegateWebInternalError() async throws { + try await internalTestVerify( errorURLString: PhoneAuthProviderTests.kFakeRedirectURLStringWebInternalError, errorCode: AuthErrorCode.webInternalError.rawValue, function: #function, @@ -182,11 +292,11 @@ } /** @fn testVerifyPhoneNumberUIDelegateUnexpectedError - @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in an - invalid client ID. + @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in an + invalid client ID. */ - func testVerifyPhoneNumberUIDelegateUnexpectedError() throws { - try internalTestVerify( + func testVerifyPhoneNumberUIDelegateUnexpectedError() async throws { + try await internalTestVerify( errorURLString: PhoneAuthProviderTests.kFakeRedirectURLStringUnknownError, errorCode: AuthErrorCode.webSignInUserInteractionFailure.rawValue, function: #function, @@ -196,12 +306,12 @@ } /** @fn testVerifyPhoneNumberUIDelegateUnstructuredError - @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in an - error being surfaced with a default NSLocalizedFailureReasonErrorKey due to an unexpected - structure of the error response. + @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in an + error being surfaced with a default NSLocalizedFailureReasonErrorKey due to an unexpected + structure of the error response. */ - func testVerifyPhoneNumberUIDelegateUnstructuredError() throws { - try internalTestVerify( + func testVerifyPhoneNumberUIDelegateUnstructuredError() async throws { + try await internalTestVerify( errorURLString: PhoneAuthProviderTests.kFakeRedirectURLStringUnstructuredError, errorCode: AuthErrorCode.appVerificationUserInteractionFailure.rawValue, function: #function, @@ -214,10 +324,10 @@ // The test runs correctly, but it's not clear how to automate fatal_error testing. Switching to // Swift exceptions would break the API. /** @fn testVerifyPhoneNumberUIDelegateRaiseException - @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in an - exception. + @brief Tests a invocation of @c verifyPhoneNumber:UIDelegate:completion: which results in an + exception. */ - func SKIPtestVerifyPhoneNumberUIDelegateRaiseException() throws { + func SKIPtestVerifyPhoneNumberUIDelegateRaiseException() async throws { initApp(#function) let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) auth.mainBundleUrlTypes = [["CFBundleURLSchemes": ["fail"]]] @@ -228,11 +338,11 @@ } /** @fn testNotForwardingNotification - @brief Tests returning an error for the app failing to forward notification. + @brief Tests returning an error for the app failing to forward notification. */ func testNotForwardingNotification() throws { - func testVerifyPhoneNumberUIDelegateUnstructuredError() throws { - try internalTestVerify( + func testVerifyPhoneNumberUIDelegateUnstructuredError() async throws { + try await internalTestVerify( errorURLString: PhoneAuthProviderTests.kFakeRedirectURLStringUnstructuredError, errorCode: AuthErrorCode.appVerificationUserInteractionFailure.rawValue, function: #function, @@ -244,10 +354,10 @@ } /** @fn testMissingAPNSToken - @brief Tests returning an error for the app failing to provide an APNS device token. + @brief Tests returning an error for the app failing to provide an APNS device token. */ - func testMissingAPNSToken() throws { - try internalTestVerify( + func testMissingAPNSToken() async throws { + try await internalTestVerify( errorCode: AuthErrorCode.missingAppToken.rawValue, function: #function, useClientID: true, @@ -268,28 +378,28 @@ } /** @fn testVerifyClient - @brief Tests verifying client before sending verification code. + @brief Tests verifying client before sending verification code. */ func testVerifyClient() throws { try internalFlow(function: #function, useClientID: true, reCAPTCHAfallback: false) } /** @fn testSendVerificationCodeFailedRetry - @brief Tests failed retry after failing to send verification code. + @brief Tests failed retry after failing to send verification code. */ func testSendVerificationCodeFailedRetry() throws { try internalFlowRetry(function: #function) } /** @fn testSendVerificationCodeSuccessfulRetry - @brief Tests successful retry after failing to send verification code. + @brief Tests successful retry after failing to send verification code. */ func testSendVerificationCodeSuccessfulRetry() throws { try internalFlowRetry(function: #function, goodRetry: true) } /** @fn testPhoneAuthCredentialCoding - @brief Tests successful archiving and unarchiving of @c PhoneAuthCredential. + @brief Tests successful archiving and unarchiving of @c PhoneAuthCredential. */ func testPhoneAuthCredentialCoding() throws { let kVerificationID = "My verificationID" @@ -315,7 +425,7 @@ } /** @fn testPhoneAuthCredentialCodingPhone - @brief Tests successful archiving and unarchiving of @c PhoneAuthCredential after other constructor. + @brief Tests successful archiving and unarchiving of @c PhoneAuthCredential after other constructor. */ func testPhoneAuthCredentialCodingPhone() throws { let kTemporaryProof = "Proof" @@ -340,6 +450,27 @@ XCTAssertEqual(unarchivedCredential.provider, PhoneAuthProvider.id) } + private func testRecaptchaFlowError(function: String, rceError: Error) async throws { + initApp(function) + let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) + // TODO: Figure out how to mock objective C's FIRRecaptchaGetToken response + // Mocking the output of verify() method + let provider = PhoneAuthProvider.provider(auth: auth) + let mockVerifier = FakeAuthRecaptchaVerifier(error: rceError) + AuthRecaptchaVerifier.setShared(mockVerifier, auth: auth) + rpcIssuer.rceMode = "ENFORCE" + do { + let _ = try await provider.verifyClAndSendVerificationCodeWithRecaptcha( + toPhoneNumber: kTestPhoneNumber, + retryOnInvalidAppCredential: false, + uiDelegate: nil, + recaptchaVerifier: mockVerifier + ) + } catch { + XCTAssertEqual((error as NSError).code, (rceError as NSError).code) + } + } + private func internalFlowRetry(function: String, goodRetry: Bool = false) throws { let function = function initApp(function, useClientID: true, fakeToken: true) @@ -536,7 +667,6 @@ /** @fn testVerifyClient @brief Tests verifying client before sending verification code. */ - private func internalTestVerify(errorString: String? = nil, errorURLString: String? = nil, errorCode: Int = 0, @@ -546,13 +676,13 @@ bothClientAndAppID: Bool = false, reCAPTCHAfallback: Bool = false, forwardingNotification: Bool = true, - presenterError: Error? = nil) throws { + presenterError: Error? = nil) async throws { initApp(function, useClientID: useClientID, bothClientAndAppID: bothClientAndAppID, testMode: testMode, forwardingNotification: forwardingNotification) let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) let provider = PhoneAuthProvider.provider(auth: auth) - let expectation = self.expectation(description: function) + var expectations: [XCTestExpectation] = [] if !reCAPTCHAfallback { // Fake out appCredentialManager flow. @@ -560,7 +690,8 @@ secret: kTestSecret) } else { // 1. Intercept, handle, and test the projectConfiguration RPC calls. - let projectConfigExpectation = self.expectation(description: "projectConfiguration") + let projectConfigExpectation = expectation(description: "projectConfiguration") + expectations.append(projectConfigExpectation) rpcIssuer?.projectConfigRequester = { request in XCTAssertEqual(request.apiKey, PhoneAuthProviderTests.kFakeAPIKey) projectConfigExpectation.fulfill() @@ -575,9 +706,23 @@ } } } - + if reCAPTCHAfallback { + // Use fake authURLPresenter so we can test the parameters that get sent to it. + let urlString = errorURLString ?? + PhoneAuthProviderTests.kFakeRedirectURLStringWithReCAPTCHAToken + let errorTest = errorURLString != nil + PhoneAuthProviderTests.auth?.authURLPresenter = + FakePresenter( + urlString: urlString, + clientID: useClientID ? PhoneAuthProviderTests.kFakeClientID : nil, + firebaseAppID: useClientID ? nil : PhoneAuthProviderTests.kFakeFirebaseAppID, + errorTest: errorTest, + presenterError: presenterError + ) + } if errorURLString == nil, presenterError == nil { - let requestExpectation = self.expectation(description: "verifyRequester") + let requestExpectation = expectation(description: "verifyRequester") + expectations.append(requestExpectation) rpcIssuer?.verifyRequester = { request in XCTAssertEqual(request.phoneNumber, self.kTestPhoneNumber) switch request.codeIdentity { @@ -605,38 +750,22 @@ } } } - if reCAPTCHAfallback { - // Use fake authURLPresenter so we can test the parameters that get sent to it. - let urlString = errorURLString ?? - PhoneAuthProviderTests.kFakeRedirectURLStringWithReCAPTCHAToken - let errorTest = errorURLString != nil - PhoneAuthProviderTests.auth?.authURLPresenter = - FakePresenter( - urlString: urlString, - clientID: useClientID ? PhoneAuthProviderTests.kFakeClientID : nil, - firebaseAppID: useClientID ? nil : PhoneAuthProviderTests.kFakeFirebaseAppID, - errorTest: errorTest, - presenterError: presenterError - ) - } let uiDelegate = reCAPTCHAfallback ? FakeUIDelegate() : nil - // 2. After setting up the parameters, call `verifyPhoneNumber`. - provider - .verifyPhoneNumber(kTestPhoneNumber, uiDelegate: uiDelegate) { verificationID, error in - - // 8. After the response triggers the callback in the FakePresenter, verify the callback. - XCTAssertTrue(Thread.isMainThread) - if errorCode != 0 { - XCTAssertNil(verificationID) - XCTAssertEqual((error as? NSError)?.code, errorCode) - } else { - XCTAssertNil(error) - XCTAssertEqual(verificationID, self.kTestVerificationID) - } - expectation.fulfill() - } - waitForExpectations(timeout: 5) + do { + // Call the async function to verify the phone number + let verificationID = try await provider.verifyPhoneNumber( + kTestPhoneNumber, + uiDelegate: uiDelegate + ) + // Assert that the verificationID matches the expected value + XCTAssertEqual(verificationID, kTestVerificationID) + } catch { + // If an error occurs, assert that verificationID is nil and the error code matches the + // expected value + XCTAssertEqual((error as NSError).code, errorCode) + } + await fulfillment(of: expectations, timeout: 5.0) } private func initApp(_ functionName: String, @@ -689,6 +818,23 @@ } } + class FakeAuthRecaptchaVerifier: AuthRecaptchaVerifier { + var captchaResponse: String + var error: Error? + init(captchaResponse: String? = nil, error: Error? = nil) { + self.captchaResponse = captchaResponse ?? "NO_RECAPTCHA" + self.error = error + super.init() + } + + override func verify(forceRefresh: Bool, action: AuthRecaptchaAction) async throws -> String { + if let error = error { + throw error + } + return captchaResponse + } + } + class FakeTokenManager: AuthAPNSTokenManager { override func getTokenInternal(callback: @escaping (Result) -> Void) { let error = NSError(domain: "dummy domain", code: AuthErrorCode.missingAppToken.rawValue)