diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index de22db9c955..f9432e35353 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1297,6 +1297,8 @@ extension Auth: AuthInterop { return false } + let recaptchaVerifier: AuthRecaptchaVerifier + #if os(iOS) && !targetEnvironment(macCatalyst) /// Initializes reCAPTCHA using the settings configured for the project or tenant. @@ -1326,8 +1328,10 @@ extension Auth: AuthInterop { open func initializeRecaptchaConfig() async throws { // Trigger recaptcha verification flow to initialize the recaptcha client and // config. Recaptcha token will be returned. - let verifier = AuthRecaptchaVerifier.shared(auth: self) - _ = try await verifier.verify(forceRefresh: true, action: AuthRecaptchaAction.defaultAction) + _ = try await recaptchaVerifier.verify( + forceRefresh: true, + action: AuthRecaptchaAction.defaultAction + ) } #endif @@ -1627,7 +1631,8 @@ extension Auth: AuthInterop { init(app: FirebaseApp, keychainStorageProvider: AuthKeychainStorage = AuthKeychainStorageReal(), backend: AuthBackend = .init(rpcIssuer: AuthBackendRPCIssuer()), - authDispatcher: AuthDispatcher = .init()) { + authDispatcher: AuthDispatcher = .init(), + recaptchaVerifier: AuthRecaptchaVerifier = .init()) { self.app = app mainBundleUrlTypes = Bundle.main .object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] @@ -1653,6 +1658,7 @@ extension Auth: AuthInterop { appCheck: appCheck) self.backend = backend self.authDispatcher = authDispatcher + self.recaptchaVerifier = recaptchaVerifier let keychainServiceName = Auth.keychainServiceName(for: app) keychainServices = AuthKeychainServices(service: keychainServiceName, @@ -1664,6 +1670,7 @@ extension Auth: AuthInterop { super.init() requestConfiguration.auth = self + self.recaptchaVerifier.auth = self protectedDataInitialization() } @@ -2307,7 +2314,6 @@ extension Auth: AuthInterop { func injectRecaptcha(request: T, action: AuthRecaptchaAction) async throws -> T .Response { - let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: self) if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) != .off { try await recaptchaVerifier.injectRecaptchaFields(request: request, provider: AuthRecaptchaProvider.password, diff --git a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift index 61e5693f374..cb629b71d55 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift @@ -202,8 +202,6 @@ import Foundation throw AuthErrorUtils.notificationNotForwardedError() } - let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth) - if let settings = auth.settings, settings.isAppVerificationDisabledForTesting { // If app verification is disabled for testing @@ -218,9 +216,9 @@ import Foundation ) } - try await recaptchaVerifier.retrieveRecaptchaConfig(forceRefresh: true) + try await auth.recaptchaVerifier.retrieveRecaptchaConfig(forceRefresh: true) - switch recaptchaVerifier.enablementStatus(forProvider: .phone) { + switch auth.recaptchaVerifier.enablementStatus(forProvider: .phone) { case .off: return try await verifyClAndSendVerificationCode( toPhoneNumber: phoneNumber, @@ -233,31 +231,28 @@ import Foundation toPhoneNumber: phoneNumber, retryOnInvalidAppCredential: true, multiFactorSession: multiFactorSession, - uiDelegate: uiDelegate, - recaptchaVerifier: recaptchaVerifier + uiDelegate: uiDelegate ) case .enforce: return try await verifyClAndSendVerificationCodeWithRecaptcha( toPhoneNumber: phoneNumber, retryOnInvalidAppCredential: false, multiFactorSession: multiFactorSession, - uiDelegate: uiDelegate, - recaptchaVerifier: recaptchaVerifier + uiDelegate: uiDelegate ) } } func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String, retryOnInvalidAppCredential: Bool, - uiDelegate: AuthUIDelegate?, - recaptchaVerifier: AuthRecaptchaVerifier) async throws + uiDelegate: AuthUIDelegate?) async throws -> String? { let request = SendVerificationCodeRequest(phoneNumber: phoneNumber, codeIdentity: CodeIdentity.empty, requestConfiguration: auth .requestConfiguration) do { - try await recaptchaVerifier.injectRecaptchaFields( + try await auth.recaptchaVerifier.injectRecaptchaFields( request: request, provider: .phone, action: .sendVerificationCode @@ -319,8 +314,7 @@ import Foundation private func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String, retryOnInvalidAppCredential: Bool, multiFactorSession session: MultiFactorSession?, - uiDelegate: AuthUIDelegate?, - recaptchaVerifier: AuthRecaptchaVerifier) async throws + uiDelegate: AuthUIDelegate?) async throws -> String? { if let settings = auth.settings, settings.isAppVerificationDisabledForTesting { @@ -336,8 +330,7 @@ import Foundation return try await verifyClAndSendVerificationCodeWithRecaptcha( toPhoneNumber: phoneNumber, retryOnInvalidAppCredential: retryOnInvalidAppCredential, - uiDelegate: uiDelegate, - recaptchaVerifier: recaptchaVerifier + uiDelegate: uiDelegate ) } let startMFARequestInfo = AuthProtoStartMFAPhoneRequestInfo(phoneNumber: phoneNumber, @@ -347,7 +340,7 @@ import Foundation let request = StartMFAEnrollmentRequest(idToken: idToken, enrollmentInfo: startMFARequestInfo, requestConfiguration: auth.requestConfiguration) - try await recaptchaVerifier.injectRecaptchaFields( + try await auth.recaptchaVerifier.injectRecaptchaFields( request: request, provider: .phone, action: .mfaSmsEnrollment @@ -359,7 +352,7 @@ import Foundation MFAEnrollmentID: session.multiFactorInfo?.uid, signInInfo: startMFARequestInfo, requestConfiguration: auth.requestConfiguration) - try await recaptchaVerifier.injectRecaptchaFields( + try await auth.recaptchaVerifier.injectRecaptchaFields( request: request, provider: .phone, action: .mfaSmsSignIn @@ -641,7 +634,6 @@ import Foundation private let auth: Auth private let callbackScheme: String private let usingClientIDScheme: Bool - private var recaptchaVerifier: AuthRecaptchaVerifier? init(auth: Auth) { self.auth = auth @@ -662,7 +654,6 @@ import Foundation return } callbackScheme = "" - recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth) } private let kAuthTypeVerifyApp = "verifyApp" diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift index 352ae35baf6..227d6a78c53 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift @@ -12,86 +12,71 @@ // See the License for the specific language governing permissions and // limitations under the License. -#if os(iOS) +import Foundation - import Foundation +#if SWIFT_PACKAGE + import FirebaseAuthInternal +#endif - #if SWIFT_PACKAGE - import FirebaseAuthInternal - #endif +#if os(iOS) import RecaptchaInterop +#endif // os(iOS) - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - class AuthRecaptchaConfig { - var siteKey: String? - let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] - - init(siteKey: String? = nil, - enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) { - self.siteKey = siteKey - self.enablementStatus = enablementStatus - } - } +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +class AuthRecaptchaConfig { + var siteKey: String? + let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] - @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 } + init(siteKey: String? = nil, + enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) { + self.siteKey = siteKey + self.enablementStatus = enablementStatus } +} - @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" +@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 } - } + // 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 mfaSmsSignIn - case mfaSmsEnrollment +@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 } - } + // Convenience property for mapping values + var stringValue: String { rawValue } +} - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - class AuthRecaptchaVerifier { - private(set) weak var auth: Auth? - private(set) var agentConfig: AuthRecaptchaConfig? - private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:] - private(set) var recaptchaClient: RCARecaptchaClientProtocol? - private static var _shared = AuthRecaptchaVerifier() - private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE" - init() {} +@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 mfaSmsSignIn + case mfaSmsEnrollment - class func shared(auth: Auth?) -> AuthRecaptchaVerifier { - if _shared.auth != auth { - _shared.agentConfig = nil - _shared.tenantConfigs = [:] - _shared.auth = auth - } - return _shared - } + // Convenience property for mapping values + var stringValue: String { rawValue } +} - /// This function is only for testing. - class func setShared(_ instance: AuthRecaptchaVerifier, auth: Auth?) { - _shared = instance - _ = shared(auth: auth) - } +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +class AuthRecaptchaVerifier { + private let recaptchaVersion = "RECAPTCHA_ENTERPRISE" + weak var auth: Auth? + private var agentConfig: AuthRecaptchaConfig? + private var tenantConfigs: [String: AuthRecaptchaConfig] = [:] + #if os(iOS) + private var recaptchaClient: RCARecaptchaClientProtocol? - func siteKey() -> String? { + private func siteKey() -> String? { if let tenantID = auth?.tenantID { if let config = tenantConfigs[tenantID] { return config.siteKey @@ -125,7 +110,6 @@ // No recaptcha on internal build system. return actionString #else - let (token, error, linked, actionCreated) = await recaptchaToken( siteKey: siteKey, actionString: actionString, @@ -154,8 +138,6 @@ #endif // !(COCOAPODS || SWIFT_PACKAGE) } - private static var recaptchaClient: (any RCARecaptchaClientProtocol)? - private func recaptchaToken(siteKey: String, actionString: String, fakeToken: String) async -> (token: String, error: Error?, @@ -171,6 +153,8 @@ if let recaptcha = NSClassFromString("RecaptchaEnterprise.RCARecaptcha") as? RCARecaptchaProtocol.Type { do { + // Note, reCAPTCHA does not support multi-tenancy, so only one site key can be used per + // runtime. let client = try await recaptcha.fetchClient(withSiteKey: siteKey) recaptchaClient = client return await retrieveToken( @@ -225,7 +209,7 @@ try await parseRecaptchaConfigFromResponse(response: response) } - func parseRecaptchaConfigFromResponse(response: GetRecaptchaConfigResponse) async throws { + private func parseRecaptchaConfigFromResponse(response: GetRecaptchaConfigResponse) async throws { var enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] = [:] var isRecaptchaEnabled = false if let enforcementState = response.enforcementState { @@ -268,10 +252,10 @@ try await retrieveRecaptchaConfig(forceRefresh: false) if enablementStatus(forProvider: provider) != .off { let token = try await verify(forceRefresh: false, action: action) - request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: kRecaptchaVersion) + request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: recaptchaVersion) } else { - request.injectRecaptchaFields(recaptchaResponse: nil, recaptchaVersion: kRecaptchaVersion) + request.injectRecaptchaFields(recaptchaResponse: nil, recaptchaVersion: recaptchaVersion) } } - } -#endif + #endif // os(iOS) +} diff --git a/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift b/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift index 94880a3d856..16db1816d4a 100644 --- a/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift +++ b/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift @@ -102,12 +102,13 @@ @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise enforced */ func testVerifyPhoneNumberWithRceEnforceSuccess() async throws { - initApp(#function) + initApp( + #function, + mockRecaptchaVerifier: FakeAuthRecaptchaVerifier(captchaResponse: kCaptchaResponse) + ) 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 @@ -128,8 +129,7 @@ let result = try await provider.verifyClAndSendVerificationCodeWithRecaptcha( toPhoneNumber: kTestPhoneNumber, retryOnInvalidAppCredential: false, - uiDelegate: nil, - recaptchaVerifier: mockVerifier + uiDelegate: nil ) XCTAssertEqual(result, kTestVerificationID) } catch { @@ -143,12 +143,10 @@ @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise enforced */ func testVerifyPhoneNumberWithRceEnforceInvalidRecaptcha() async throws { - initApp(#function) + initApp(#function, mockRecaptchaVerifier: FakeAuthRecaptchaVerifier()) 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 @@ -170,8 +168,7 @@ _ = try await provider.verifyClAndSendVerificationCodeWithRecaptcha( toPhoneNumber: kTestPhoneNumber, retryOnInvalidAppCredential: false, - uiDelegate: nil, - recaptchaVerifier: mockVerifier + uiDelegate: nil ) // XCTAssertEqual(result, kTestVerificationID) } catch { @@ -211,11 +208,12 @@ /// @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise in /// audit mode func testVerifyPhoneNumberWithRceAuditSuccess() async throws { - initApp(#function) + initApp( + #function, + mockRecaptchaVerifier: FakeAuthRecaptchaVerifier(captchaResponse: kCaptchaResponse) + ) let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) let provider = PhoneAuthProvider.provider(auth: auth) - let mockVerifier = FakeAuthRecaptchaVerifier(captchaResponse: kCaptchaResponse) - AuthRecaptchaVerifier.setShared(mockVerifier, auth: auth) rpcIssuer.rceMode = "AUDIT" let requestExpectation = expectation(description: "verifyRequester") rpcIssuer.verifyRequester = { request in @@ -236,8 +234,7 @@ let result = try await provider.verifyClAndSendVerificationCodeWithRecaptcha( toPhoneNumber: kTestPhoneNumber, retryOnInvalidAppCredential: false, - uiDelegate: nil, - recaptchaVerifier: mockVerifier + uiDelegate: nil ) XCTAssertEqual(result, kTestVerificationID) } catch { @@ -250,11 +247,9 @@ /// @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise in /// audit mode func testVerifyPhoneNumberWithRceAuditInvalidRecaptcha() async throws { - initApp(#function) + initApp(#function, mockRecaptchaVerifier: FakeAuthRecaptchaVerifier()) let auth = try XCTUnwrap(PhoneAuthProviderTests.auth) let provider = PhoneAuthProvider.provider(auth: auth) - let mockVerifier = FakeAuthRecaptchaVerifier() - AuthRecaptchaVerifier.setShared(mockVerifier, auth: auth) rpcIssuer.rceMode = "AUDIT" let requestExpectation = expectation(description: "verifyRequester") rpcIssuer?.verifyRequester = { request in @@ -278,8 +273,7 @@ _ = try await provider.verifyClAndSendVerificationCodeWithRecaptcha( toPhoneNumber: kTestPhoneNumber, retryOnInvalidAppCredential: false, - uiDelegate: nil, - recaptchaVerifier: mockVerifier + uiDelegate: nil ) } catch { let underlyingError = (error as NSError).userInfo[NSUnderlyingErrorKey] as? NSError @@ -532,20 +526,17 @@ } private func testRecaptchaFlowError(function: String, rceError: Error) async throws { - initApp(function) + initApp(function, mockRecaptchaVerifier: FakeAuthRecaptchaVerifier(error: rceError)) 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 + uiDelegate: nil ) } catch { XCTAssertEqual((error as NSError).code, (rceError as NSError).code) @@ -861,7 +852,8 @@ bothClientAndAppID: Bool = false, testMode: Bool = false, forwardingNotification: Bool = true, - fakeToken: Bool = false) { + fakeToken: Bool = false, + mockRecaptchaVerifier: AuthRecaptchaVerifier? = nil) { let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", gcmSenderID: "00000000000000000-00000000000-000000000") options.apiKey = PhoneAuthProviderTests.kFakeAPIKey @@ -879,7 +871,15 @@ let strippedName = functionName.replacingOccurrences(of: "(", with: "") .replacingOccurrences(of: ")", with: "") FirebaseApp.configure(name: strippedName, options: options) - let auth = Auth(app: FirebaseApp.app(name: strippedName)!, backend: authBackend) + let auth = if let mockRecaptchaVerifier { + Auth( + app: FirebaseApp.app(name: strippedName)!, + backend: authBackend, + recaptchaVerifier: mockRecaptchaVerifier + ) + } else { + Auth(app: FirebaseApp.app(name: strippedName)!, backend: authBackend) + } kAuthGlobalWorkQueue.sync { // Wait for Auth protectedDataInitialization to finish.