diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 7b7212c441c..d083581e3d7 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -2289,7 +2289,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 61a78271347..cd2aab02194 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift @@ -185,7 +185,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,29 +194,63 @@ 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 + ) + } } - /// 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. - /// - 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 + /// Initiates the verification flow by sending a verification code with reCAPTCHA protection to + /// the provided phone number. + /// - Parameters: + /// - phoneNumber: The phone number to which the verification code should be sent. + /// - retryOnInvalidAppCredential: A boolean indicating whether to retry the flow if an + /// AuthErrorCodeInvalidAppCredential error occurs. + /// - uiDelegate: An optional delegate for handling UI events during the verification process. + /// - recaptchaVerifier: An instance of `AuthRecaptchaVerifier` to inject reCAPTCHA fields + /// into the request. + /// - Returns: A string containing the verification ID if the request is successful; otherwise, + /// handles the error and returns nil. + private func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String, + retryOnInvalidAppCredential: Bool, + uiDelegate: AuthUIDelegate?, + recaptchaVerifier: AuthRecaptchaVerifier) async throws -> String? { - let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate) let request = SendVerificationCodeRequest(phoneNumber: phoneNumber, - codeIdentity: codeIdentity, + 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 { @@ -228,15 +262,57 @@ import Foundation } } - /// 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. + /// Initiates the verification flow by sending a verification code to the provided phone number + /// using a silent push notification. + /// - Parameters: + /// - phoneNumber: The phone number to which the verification code should be sent. + /// - retryOnInvalidAppCredential: A boolean indicating whether to retry the flow if an + /// AuthErrorCodeInvalidAppCredential error occurs. + /// - uiDelegate: An optional delegate for handling UI events during the verification process. + /// - Returns: A string containing the verification ID if the request is successful; otherwise, + /// handles the error and returns nil. private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String, retryOnInvalidAppCredential: Bool, - multiFactorSession session: MultiFactorSession?, 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 AuthBackend.call(with: request) + return response.verificationID + } catch { + return try await handleVerifyErrorWithRetry( + error: error, + phoneNumber: phoneNumber, + retryOnInvalidAppCredential: retryOnInvalidAppCredential, + multiFactorSession: nil, + uiDelegate: uiDelegate + ) + } + } + + /// Initiates the verification flow by sending a verification code with reCAPTCHA protection, + /// optionally considering a multi-factor session. + /// - Parameters: + /// - phoneNumber: The phone number to which the verification code should be sent. + /// - retryOnInvalidAppCredential: A boolean indicating whether to retry the flow if an + /// AuthErrorCodeInvalidAppCredential error occurs. + /// - session: An optional `MultiFactorSession` instance to include in the verification flow + /// for multi-factor authentication. + /// - uiDelegate: An optional delegate for handling UI events during the verification process. + /// - recaptchaVerifier: An instance of `AuthRecaptchaVerifier` to inject reCAPTCHA fields + /// into the request. + /// - Returns: A string containing the verification ID or session info if the request is + /// successful; otherwise, handles the error and returns nil. + 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( @@ -244,7 +320,78 @@ import Foundation 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 + ) + } + } + /// Initiates the verification flow by sending a verification code, optionally considering a + /// multi-factor session using silent push notification. + /// - Parameters: + /// - phoneNumber: The phone number to which the verification code should be sent. + /// - retryOnInvalidAppCredential: A boolean indicating whether to retry the flow if an + /// AuthErrorCodeInvalidAppCredential error occurs. + /// - session: An optional `MultiFactorSession` instance to include in the verification flow + /// for multi-factor authentication. + /// - uiDelegate: An optional delegate for handling UI events during the verification process. + /// - Returns: A string containing the verification ID or session info if the request is + /// successful; otherwise, handles the error and returns nil. + private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String, + retryOnInvalidAppCredential: Bool, + multiFactorSession session: MultiFactorSession?, + uiDelegate: AuthUIDelegate?) 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 } @@ -477,8 +624,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() @@ -497,6 +645,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..c599c6955c1 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift @@ -29,6 +29,15 @@ 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" @@ -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/AuthRecaptchaVerifier.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift index 6919ac40807..c9c5775102d 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 } + } + + @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 } } - enum AuthRecaptchaAction { + @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, *) @@ -51,10 +74,6 @@ 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 let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE" private init() {} @@ -77,22 +96,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 +115,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 +170,35 @@ let request = GetRecaptchaConfigRequest(requestConfiguration: requestConfiguration) let response = try await AuthBackend.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 +214,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 d51ccd255da..82c1a9afd1e 100644 --- a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift +++ b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift @@ -75,7 +75,8 @@ class FakeBackendRPCIssuer: NSObject, 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" func asyncCallToURL(with request: T, body: Data?, @@ -111,9 +112,28 @@ class FakeBackendRPCIssuer: NSObject, 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 df64689c7c0..95e5bf39963 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 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 0030c52776d..07f867b7091 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,86 @@ } /** @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 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 +157,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 +171,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 +182,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 +196,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 +214,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 +228,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 +244,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 +268,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 +315,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" @@ -534,9 +534,8 @@ } /** @fn testVerifyClient - @brief Tests verifying client before sending verification code. + @brief Tests verifying client before sending verification code. */ - private func internalTestVerify(errorString: String? = nil, errorURLString: String? = nil, errorCode: Int = 0, @@ -546,13 +545,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 +559,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 +575,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 +619,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 +687,18 @@ } } + class FakeAuthRecaptchaVerifier: AuthRecaptchaVerifier { + var captchaResponse: String = "captchaResponse" + var fakeError: Error? + + override func verify(forceRefresh: Bool, action: AuthRecaptchaAction) async throws -> String { + if let error = fakeError { + throw error + } + return captchaResponse + } + } + class FakeTokenManager: AuthAPNSTokenManager { override func getTokenInternal(callback: @escaping (Result) -> Void) { let error = NSError(domain: "dummy domain", code: AuthErrorCode.missingAppToken.rawValue)