diff --git a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift index 050f267fd8a..412396aa015 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift @@ -199,7 +199,7 @@ import Foundation } let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth) - try await recaptchaVerifier.retrieveRecaptchaConfig(forceRefresh: true) + try await recaptchaVerifier.retrieveRecaptchaConfig(forceRefresh: false) switch recaptchaVerifier.enablementStatus(forProvider: .phone) { case .off: @@ -228,10 +228,10 @@ import Foundation } } - private func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String, - retryOnInvalidAppCredential: Bool, - uiDelegate: AuthUIDelegate?, - recaptchaVerifier: AuthRecaptchaVerifier) async throws + func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String, + retryOnInvalidAppCredential: Bool, + uiDelegate: AuthUIDelegate?, + recaptchaVerifier: AuthRecaptchaVerifier) async throws -> String? { let request = SendVerificationCodeRequest(phoneNumber: phoneNumber, codeIdentity: CodeIdentity.empty, @@ -260,9 +260,9 @@ import Foundation /// - 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, diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift index c599c6955c1..2a968aa5029 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift @@ -42,7 +42,7 @@ private let kRecaptchaVersion = "recaptchaVersion" 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 diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index e4997d07c2d..9bf83a59d9f 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -203,6 +203,10 @@ class AuthErrorUtils: NSObject { 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 c9c5775102d..6047a87cf4e 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift @@ -72,10 +72,9 @@ private(set) var agentConfig: AuthRecaptchaConfig? private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:] private(set) var recaptchaClient: RCARecaptchaClientProtocol? - - private static let _shared = AuthRecaptchaVerifier() + private static var _shared = AuthRecaptchaVerifier() private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE" - private init() {} + init() {} class func shared(auth: Auth?) -> AuthRecaptchaVerifier { if _shared.auth != auth { @@ -86,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] { diff --git a/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift b/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift index 4475e736cf5..ae2bf574e3c 100644 --- a/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift +++ b/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift @@ -98,6 +98,116 @@ 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. @@ -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) @@ -551,6 +682,7 @@ forwardingNotification: forwardingNotification) let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) let provider = PhoneAuthProvider.provider(auth: auth) + var expectations: [XCTestExpectation] = [] if !reCAPTCHAfallback { // Fake out appCredentialManager flow. @@ -558,8 +690,11 @@ secret: kTestSecret) } else { // 1. Intercept, handle, and test the projectConfiguration RPC calls. + let projectConfigExpectation = expectation(description: "projectConfiguration") + expectations.append(projectConfigExpectation) rpcIssuer?.projectConfigRequester = { request in XCTAssertEqual(request.apiKey, PhoneAuthProviderTests.kFakeAPIKey) + projectConfigExpectation.fulfill() do { // Response for the underlying VerifyClientRequest RPC call. try self.rpcIssuer?.respond( @@ -586,6 +721,8 @@ ) } if errorURLString == nil, presenterError == nil { + let requestExpectation = expectation(description: "verifyRequester") + expectations.append(requestExpectation) rpcIssuer?.verifyRequester = { request in XCTAssertEqual(request.phoneNumber, self.kTestPhoneNumber) switch request.codeIdentity { @@ -599,6 +736,7 @@ case .empty: XCTAssertTrue(testMode) } + requestExpectation.fulfill() do { // Response for the underlying SendVerificationCode RPC call. if let errorString { @@ -627,6 +765,7 @@ // expected value XCTAssertEqual((error as NSError).code, errorCode) } + await fulfillment(of: expectations, timeout: 5.0) } private func initApp(_ functionName: String, @@ -680,11 +819,16 @@ } class FakeAuthRecaptchaVerifier: AuthRecaptchaVerifier { - var captchaResponse: String = "captchaResponse" - var fakeError: Error? + 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 = fakeError { + if let error = error { throw error } return captchaResponse