From e23f688fba53efa53c56af113ca9284cad01fb1a Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:56:57 -0800 Subject: [PATCH] add reCAPTCHA enterprise support on phone auth and phone MFA (#14114) Co-authored-by: Srushti Vaidya Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 2 +- .../AuthProvider/PhoneAuthProvider.swift | 281 +++++++++--- .../Sources/Swift/Backend/AuthBackend.swift | 1 + .../Enroll/StartMFAEnrollmentRequest.swift | 23 + .../SignIn/StartMFASignInRequest.swift | 11 + .../AuthProtoStartMFAPhoneRequestInfo.swift | 28 ++ .../RPC/SendVerificationTokenRequest.swift | 30 +- .../Swift/Utilities/AuthErrorUtils.swift | 4 + .../Utilities/AuthRecaptchaVerifier.swift | 121 +++-- .../PhoneAuthViewController.swift | 34 +- .../Unit/Fakes/FakeBackendRPCIssuer.swift | 28 +- .../Tests/Unit/GetRecaptchaConfigTests.swift | 34 +- .../Tests/Unit/PhoneAuthProviderTests.swift | 413 ++++++++++++++---- FirebaseAuth/Tests/Unit/RPCBaseTests.swift | 1 + .../Unit/StartMFAEnrollmentRequestTests.swift | 58 +++ .../Unit/StartMFASignInRequestTests.swift | 89 ++++ 16 files changed, 934 insertions(+), 224 deletions(-) create mode 100644 FirebaseAuth/Tests/Unit/StartMFASignInRequestTests.swift diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 3600a90c478..b8abba0a933 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -2307,7 +2307,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..3e72368126c 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift @@ -22,6 +22,10 @@ import Foundation @objc(FIRPhoneAuthProvider) open class PhoneAuthProvider: NSObject { /// A string constant identifying the phone identity provider. @objc public static let id = "phone" + private static let recaptchaVersion = "RECAPTCHA_ENTERPRISE" + private static let clientType = "CLIENT_TYPE_IOS" + private static let fakeCaptchaResponse = "NO_RECAPTCHA" + #if os(iOS) /// Returns an instance of `PhoneAuthProvider` for the default `Auth` object. @objc(provider) open class func provider() -> PhoneAuthProvider { @@ -72,23 +76,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 +107,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 +136,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 +166,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 +192,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,10 +201,62 @@ 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: true) + + 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 auth.backend.call(with: request) + return response.verificationID + } catch { + return try await handleVerifyErrorWithRetry(error: error, + phoneNumber: phoneNumber, + retryOnInvalidAppCredential: retryOnInvalidAppCredential, + multiFactorSession: nil, + uiDelegate: uiDelegate, + auditFallback: true) + } } /// Starts the flow to verify the client via silent push notification. @@ -206,36 +265,116 @@ 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?, + auditFallback: Bool = false) async throws -> String? { let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate) let request = SendVerificationCodeRequest(phoneNumber: phoneNumber, codeIdentity: codeIdentity, requestConfiguration: auth .requestConfiguration) - + if auditFallback { + request.injectRecaptchaFields( + recaptchaResponse: PhoneAuthProvider.fakeCaptchaResponse, + recaptchaVersion: PhoneAuthProvider.recaptchaVersion + ) + } do { let response = try await auth.backend.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, + auditFallback: auditFallback + ) + } + } + + /// Starts the flow to verify the client via silent push notification. This is used in both + /// .Audit and .Enforce mode + /// - Parameter retryOnInvalidAppCredential: Whether or 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 auth.backend.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: .mfaSmsEnrollment + ) + let response = try await auth.backend.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: .mfaSmsSignIn + ) + let response = try await auth.backend.call(with: request) + return response.responseInfo?.sessionInfo + } + } catch { + // For Audit fallback only after rCE check failed + return try await handleVerifyErrorWithRetry( + error: error, + phoneNumber: phoneNumber, + retryOnInvalidAppCredential: retryOnInvalidAppCredential, + multiFactorSession: session, + uiDelegate: uiDelegate, + auditFallback: true + ) } } /// Starts the flow to verify the client via silent push notification. - /// - Parameter retryOnInvalidAppCredential: Whether of not the flow should be retried if an + /// This method is called in Audit fallback flow with "NO_RECAPTCHA" fake token and Off flow + /// - Parameter retryOnInvalidAppCredential: Whether or 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 verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String, retryOnInvalidAppCredential: Bool, multiFactorSession session: MultiFactorSession?, - uiDelegate: AuthUIDelegate?) async throws + uiDelegate: AuthUIDelegate?, + auditFallback: Bool = false) async throws -> String? { if let settings = auth.settings, settings.isAppVerificationDisabledForTesting { @@ -249,15 +388,25 @@ import Foundation return response.verificationID } guard let session else { + // Phone MFA flow return try await verifyClAndSendVerificationCode( toPhoneNumber: phoneNumber, retryOnInvalidAppCredential: retryOnInvalidAppCredential, - uiDelegate: uiDelegate + uiDelegate: uiDelegate, + auditFallback: auditFallback ) } + // MFA flows let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate) let startMFARequestInfo = AuthProtoStartMFAPhoneRequestInfo(phoneNumber: phoneNumber, codeIdentity: codeIdentity) + if auditFallback { + startMFARequestInfo.injectRecaptchaFields( + recaptchaResponse: PhoneAuthProvider.fakeCaptchaResponse, + recaptchaVersion: PhoneAuthProvider.recaptchaVersion, + clientType: PhoneAuthProvider.clientType + ) + } do { if let idToken = session.idToken { let request = StartMFAEnrollmentRequest(idToken: idToken, @@ -280,23 +429,27 @@ import Foundation phoneNumber: phoneNumber, retryOnInvalidAppCredential: retryOnInvalidAppCredential, multiFactorSession: session, - uiDelegate: uiDelegate + uiDelegate: uiDelegate, + auditFallback: auditFallback ) } } + /// This method is only called when Audit failed on rCE on invalid-app-credential exception private func handleVerifyErrorWithRetry(error: Error, phoneNumber: String, retryOnInvalidAppCredential: Bool, multiFactorSession session: MultiFactorSession?, - uiDelegate: AuthUIDelegate?) async throws -> String? { + uiDelegate: AuthUIDelegate?, + auditFallback: Bool = false) async throws -> String? { if (error as NSError).code == AuthErrorCode.invalidAppCredential.rawValue { if retryOnInvalidAppCredential { auth.appCredentialManager.clearCredential() return try await verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber, retryOnInvalidAppCredential: false, multiFactorSession: session, - uiDelegate: uiDelegate) + uiDelegate: uiDelegate, + auditFallback: auditFallback) } throw AuthErrorUtils.unexpectedResponse(deserializedResponse: nil, underlyingError: error) } @@ -474,6 +627,7 @@ import Foundation private let auth: Auth private let callbackScheme: String private let usingClientIDScheme: Bool + private var recaptchaVerifier: AuthRecaptchaVerifier? init(auth: Auth) { self.auth = auth @@ -494,6 +648,7 @@ import Foundation return } callbackScheme = "" + recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth) } private let kAuthTypeVerifyApp = "verifyApp" diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index f981701b98e..61be56b09ca 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -177,6 +177,7 @@ final class AuthBackend: AuthBackendProtocol { withJSONObject: postBody, options: JSONWritingOptions ) + if bodyData == nil { // This is an untested case. This happens exclusively when there is an error in the // framework implementation of dataWithJSONObject:options:error:. This shouldn't normally diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/Enroll/StartMFAEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/Enroll/StartMFAEnrollmentRequest.swift index 467733768fc..a36c7bb3bc6 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/Enroll/StartMFAEnrollmentRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/Enroll/StartMFAEnrollmentRequest.swift @@ -16,6 +16,18 @@ import Foundation private let kStartMFAEnrollmentEndPoint = "accounts/mfaEnrollment:start" +/// The key for the "clientType" value in the request. +private let kClientType = "clientType" + +/// The key for the reCAPTCHAToken parameter in the request. +private let kreCAPTCHATokenKey = "recaptchaToken" + +/// 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" @@ -79,4 +91,15 @@ class StartMFAEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { } return body } + + func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) { + // reCAPTCHA check is only available for phone based MFA + if let phoneEnrollmentInfo { + phoneEnrollmentInfo.injectRecaptchaFields( + recaptchaResponse: recaptchaResponse, + recaptchaVersion: recaptchaVersion, + clientType: clientType + ) + } + } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/SignIn/StartMFASignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/SignIn/StartMFASignInRequest.swift index 82e1a1721bd..ae28451fb9e 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/SignIn/StartMFASignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/SignIn/StartMFASignInRequest.swift @@ -57,4 +57,15 @@ class StartMFASignInRequest: IdentityToolkitRequest, AuthRPCRequest { } return body } + + func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) { + // reCAPTCHA check is only available for phone based MFA + if let signInInfo { + signInInfo.injectRecaptchaFields( + recaptchaResponse: recaptchaResponse, + recaptchaVersion: recaptchaVersion, + clientType: clientType + ) + } + } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/Phone/AuthProtoStartMFAPhoneRequestInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/Phone/AuthProtoStartMFAPhoneRequestInfo.swift index 274a7b97883..87d0d03f5be 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/Phone/AuthProtoStartMFAPhoneRequestInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/Phone/AuthProtoStartMFAPhoneRequestInfo.swift @@ -26,6 +26,15 @@ private let kSecretKey = "iosSecret" /// The key for the reCAPTCHAToken parameter in the request. private let kreCAPTCHATokenKey = "recaptchaToken" +/// 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 "clientType" value in the request. +private let kClientType = "clientType" + class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto { required init(dictionary: [String: AnyHashable]) { fatalError() @@ -33,6 +42,9 @@ class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto { var phoneNumber: String? var codeIdentity: CodeIdentity + var captchaResponse: String? + var recaptchaVersion: String? + var clientType: String? init(phoneNumber: String?, codeIdentity: CodeIdentity) { self.phoneNumber = phoneNumber self.codeIdentity = codeIdentity @@ -43,6 +55,15 @@ class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto { if let phoneNumber = phoneNumber { dict[kPhoneNumberKey] = phoneNumber } + if let captchaResponse = captchaResponse { + dict[kCaptchaResponseKey] = captchaResponse + } + if let recaptchaVersion = recaptchaVersion { + dict[kRecaptchaVersion] = recaptchaVersion + } + if let clientType = clientType { + dict[kClientType] = clientType + } switch codeIdentity { case let .credential(appCredential): dict[kReceiptKey] = appCredential.receipt @@ -54,4 +75,11 @@ class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto { } return dict } + + func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String, + clientType: String?) { + captchaResponse = recaptchaResponse + self.recaptchaVersion = recaptchaVersion + self.clientType = clientType + } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift index a87ff833c10..729cf1d200d 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..7fd2c8c092c 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 mfaSmsSignIn + case mfaSmsEnrollment + + // 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,12 @@ return _shared } + /// This function is only for testing. + 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 +101,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 +120,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,30 +175,40 @@ 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) - if let tenantID = auth.tenantID { + if let tenantID = auth?.tenantID { tenantConfigs[tenantID] = config } else { agentConfig = config @@ -190,7 +219,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 f1a29ded31d..a95b0fd8ca2 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 9131a19f98f..ceca635dacf 100644 --- a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift +++ b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift @@ -74,7 +74,8 @@ final class FakeBackendRPCIssuer: AuthBackendRPCIssuerProtocol, @unchecked Senda 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?, contentType: String) async -> (Data?, Error?) @@ -120,9 +121,28 @@ final class FakeBackendRPCIssuer: AuthBackendRPCIssuerProtocol, @unchecked Senda } 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 6638d6bfcc1..bf0b1cd5c38 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 9a319ea9755..fd7291f6820 100644 --- a/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift +++ b/FirebaseAuth/Tests/Unit/PhoneAuthProviderTests.swift @@ -41,6 +41,8 @@ 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? @@ -62,89 +64,276 @@ } /** @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 testVerifyPhoneNumberWithRceAudit + /// @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise in + /// audit mode + func testVerifyPhoneNumberWithRceAuditSuccess() async throws { + initApp(#function) + 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 + 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 testVerifyPhoneNumberWithRceAuditInvalidRecaptcha + /// @brief Tests a successful invocation of @c verifyPhoneNumber with recaptcha enterprise in + /// audit mode + func testVerifyPhoneNumberWithRceAuditInvalidRecaptcha() async throws { + initApp(#function) + 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 + 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 + ) + } catch { + let underlyingError = (error as NSError).userInfo[NSUnderlyingErrorKey] as? NSError + let rootError = underlyingError?.userInfo[NSUnderlyingErrorKey] as? NSError + XCTAssertEqual(rootError?.code, AuthErrorCode.invalidRecaptchaToken.code.rawValue) + } + await fulfillment(of: [requestExpectation], timeout: 5.0) } /** @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 +346,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 +360,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 +371,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 +385,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 +403,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 +417,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 +433,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 +457,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 +504,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 +529,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 +746,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 +755,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 +769,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 +785,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 +829,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 +897,23 @@ } } + class FakeAuthRecaptchaVerifier: AuthRecaptchaVerifier, @unchecked Sendable { + 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) diff --git a/FirebaseAuth/Tests/Unit/RPCBaseTests.swift b/FirebaseAuth/Tests/Unit/RPCBaseTests.swift index bb7a247fadf..8ff45d7d073 100644 --- a/FirebaseAuth/Tests/Unit/RPCBaseTests.swift +++ b/FirebaseAuth/Tests/Unit/RPCBaseTests.swift @@ -42,6 +42,7 @@ class RPCBaseTests: XCTestCase { let kCreationDateTimeIntervalInSeconds = 1_505_858_500.0 let kLastSignInDateTimeIntervalInSeconds = 1_505_858_583.0 let kTestPhoneNumber = "415-555-1234" + let kIdToken = "FAKE_ID_TOKEN" static let kOAuthSessionID = "sessionID" static let kOAuthRequestURI = "requestURI" let kGoogleIDToken = "GOOGLE_ID_TOKEN" diff --git a/FirebaseAuth/Tests/Unit/StartMFAEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/StartMFAEnrollmentRequestTests.swift index be39a361563..bde8558031a 100644 --- a/FirebaseAuth/Tests/Unit/StartMFAEnrollmentRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/StartMFAEnrollmentRequestTests.swift @@ -23,6 +23,13 @@ import XCTest @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class StartMFAEnrollmentRequestTests: RPCBaseTests { let kAPIKey = "APIKey" + let kIDToken = "idToken" + let kTOTPEnrollmentInfo = "totpEnrollmentInfo" + let kPhoneEnrollmentInfo = "enrollmentInfo" + let kPhoneNumber = "phoneNumber" + let kReCAPTCHAToken = "recaptchaToken" + let kCaptchaResponse = "captchaResponse" + let kRecaptchaVersion = "recaptchaVersion" /** @fn testTOTPStartMFAEnrollmentRequest @@ -58,4 +65,55 @@ class StartMFAEnrollmentRequestTests: RPCBaseTests { XCTAssertEqual(totpInfo, [:]) XCTAssertNil(requestDictionary[kPhoneEnrollmentInfo]) } + + /** + @fn testPhoneStartMFAEnrollmentRequest + @brief Tests the Start MFA Enrollment using SMS request. + */ + func testPhoneStartMFAEnrollmentInjectRecaptchaFields() async throws { + // created a base startMFAEnrollment Request + let testPhoneNumber = "1234567890" + let testRecaptchaToken = "RECAPTCHA_FAKE_TOKEN" + + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID") + let smsEnrollmentInfo = AuthProtoStartMFAPhoneRequestInfo( + phoneNumber: testPhoneNumber, + codeIdentity: CodeIdentity.recaptcha(testRecaptchaToken) + ) + let request = StartMFAEnrollmentRequest(idToken: kIDToken, + enrollmentInfo: smsEnrollmentInfo, + requestConfiguration: requestConfiguration) + + // inject reCAPTCHA response + let testRecaptchaResponse = "RECAPTCHA_FAKE_RESPONSE" + let testRecaptchaVersion = "RECAPTCHA_FAKE_ENTERPRISE" + request.injectRecaptchaFields( + recaptchaResponse: testRecaptchaResponse, + recaptchaVersion: testRecaptchaVersion + ) + + let expectedURL = + "https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start?key=\(kAPIKey)" + + do { + try await checkRequest( + request: request, + expected: expectedURL, + key: kIDToken, + value: kIDToken + ) + } catch { + // Ignore error from missing users array in fake JSON return. + return + } + + let requestDictionary = try XCTUnwrap(rpcIssuer.decodedRequest as? [String: AnyHashable]) + let smsInfo = try XCTUnwrap(requestDictionary["phoneEnrollmentInfo"] as? [String: String]) + XCTAssertEqual(smsInfo[kPhoneNumber], testPhoneNumber) + XCTAssertEqual(smsInfo[kReCAPTCHAToken], testRecaptchaToken) + XCTAssertEqual(smsInfo[kRecaptchaVersion], kRecaptchaVersion) + XCTAssertEqual(smsInfo[kCaptchaResponse], testRecaptchaResponse) + + XCTAssertNil(requestDictionary[kTOTPEnrollmentInfo]) + } } diff --git a/FirebaseAuth/Tests/Unit/StartMFASignInRequestTests.swift b/FirebaseAuth/Tests/Unit/StartMFASignInRequestTests.swift new file mode 100644 index 00000000000..837273c403a --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartMFASignInRequestTests.swift @@ -0,0 +1,89 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import XCTest + +@testable import FirebaseAuth + +/** @class StartMFASignInRequestTests + @brief Tests for @c StartMFASignInRequest + */ +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +class StartMFASignInRequestTests: RPCBaseTests { + let kAPIKey = "APIKey" + let kMfaEnrollmentId = "mfaEnrollmentId" + let kTOTPEnrollmentInfo = "totpEnrollmentInfo" + let kPhoneEnrollmentInfo = "enrollmentInfo" + let kPhoneNumber = "phoneNumber" + let kReCAPTCHAToken = "recaptchaToken" + let kCaptchaResponse = "captchaResponse" + let kRecaptchaVersion = "recaptchaVersion" + + /** + @fn testPhoneStartMFASignInRequest + @brief Tests the Start MFA Sign In using SMS request. + */ + func testPhoneStartMFASignInRequest() async throws { + let testPendingCredential = "FAKE_PENDING_CREDENTIAL" + let testEnrollmentID = "FAKE_ENROLLMENT_ID" + let testPhoneNumber = "1234567890" + let testRecaptchaToken = "RECAPTCHA_FAKE_TOKEN" + + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID") + let smsSignInInfo = AuthProtoStartMFAPhoneRequestInfo( + phoneNumber: testPhoneNumber, + codeIdentity: CodeIdentity.recaptcha(testRecaptchaToken) + ) + + let request = StartMFASignInRequest( + MFAPendingCredential: testPendingCredential, + MFAEnrollmentID: testEnrollmentID, + signInInfo: smsSignInInfo, + requestConfiguration: requestConfiguration + ) + + let expectedURL = + "https://identitytoolkit.googleapis.com/v2/accounts/mfaSignIn:start?key=\(kAPIKey)" + + // inject reCAPTCHA response + let testRecaptchaResponse = "RECAPTCHA_FAKE_RESPONSE" + let testRecaptchaVersion = "RECAPTCHA_FAKE_ENTERPRISE" + request.injectRecaptchaFields( + recaptchaResponse: testRecaptchaResponse, + recaptchaVersion: testRecaptchaVersion + ) + + do { + try await checkRequest( + request: request, + expected: expectedURL, + key: kMfaEnrollmentId, + value: testEnrollmentID + ) + } catch { + // Ignore error from missing users array in fake JSON return. + return + } + + let requestDictionary = try XCTUnwrap(rpcIssuer.decodedRequest as? [String: AnyHashable]) + let smsInfo = try XCTUnwrap(requestDictionary["phoneEnrollmentInfo"] as? [String: String]) + XCTAssertEqual(smsInfo[kPhoneNumber], testPhoneNumber) + XCTAssertEqual(smsInfo[kReCAPTCHAToken], testRecaptchaToken) + XCTAssertEqual(smsInfo[kRecaptchaVersion], kRecaptchaVersion) + XCTAssertEqual(smsInfo[kCaptchaResponse], testRecaptchaResponse) + + XCTAssertNil(requestDictionary[kTOTPEnrollmentInfo]) + } +}