diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index b78576082f..a4db209c53 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -161,6 +161,10 @@ public enum AuthServiceError: LocalizedError { /// Phone reauthentication required - user must handle SMS verification flow externally case phoneReauthenticationRequired(context: PhoneReauthContext) + /// Reauthentication requires a second factor. Complete it with `resolveSmsChallenge(hintIndex:)` + /// and `resolveSignIn(code:hintIndex:verificationId:)`, as for sign-in MFA resolution + case multiFactorReauthenticationRequired(MFARequired) + case invalidCredentials(String) case signInFailed(underlying: Error) case accountConflict(AccountConflictContext) @@ -189,6 +193,8 @@ public enum AuthServiceError: LocalizedError { return "Please check your email to verify your identity" case .phoneReauthenticationRequired: return "Please verify your phone number to continue" + case .multiFactorReauthenticationRequired: + return "Please complete two-factor authentication to continue" case let .invalidCredentials(description): return description // Use when failed to sign-in with Firebase diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index df9888f797..df1fdaae60 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -290,6 +290,7 @@ public final class AuthService { // Clear email link reauth state emailLinkReauth = nil isReauthenticating = false + currentMFAResolver = nil legacySignInRecovery = nil suggestedEmailAddress = nil updateAuthenticationState() @@ -379,8 +380,7 @@ public final class AuthService { } let credential = try await credentialProvider.createAuthCredential() - try await user.reauthenticate(with: credential) - currentUser = auth.currentUser + try await reauthenticate(user: user, credential: credential) } /// Reauthenticates with a pre-obtained credential @@ -391,7 +391,23 @@ public final class AuthService { guard let user = currentUser else { throw AuthServiceError.noCurrentUser } - try await user.reauthenticate(with: credential) + try await reauthenticate(user: user, credential: credential) + } + + /// Throws `multiFactorReauthenticationRequired` when the user has enrolled second factors + private func reauthenticate(user: User, credential: AuthCredential) async throws { + do { + try await user.reauthenticate(with: credential) + } catch let error as NSError + where error.code == AuthErrorCode.secondFactorRequired.rawValue { + guard let resolver = error + .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver else { + throw error + } + currentMFAResolver = resolver + throw AuthServiceError + .multiFactorReauthenticationRequired(MFARequired(hints: extractMFAHints(from: resolver))) + } currentUser = auth.currentUser } } @@ -616,7 +632,12 @@ public extension AuthService { // - credentialAlreadyInUse: credential is already linked to another account try handleErrorWithConflictCheck(error: error, credential: credential) } else { - // For reauth, just rethrow + // For reauth, just rethrow. The link is consumed even when a second factor is still + // required, so clean up reauth state in that case. + if case AuthServiceError.multiFactorReauthenticationRequired = error { + emailLinkReauth = nil + isReauthenticating = false + } throw error } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index fc896270f9..a4987fd06c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -28,6 +28,7 @@ public struct EmailLinkReauthView { @State private var emailSent = false @State private var isLoading = false @State private var error: AlertError? + @State private var mfaRequired: MFARequired? private func sendEmailLink() async { isLoading = true @@ -54,6 +55,8 @@ public struct EmailLinkReauthView { do { try await authService.handleSignInLink(url: url) coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired } catch { if let reportError = reportError { reportError(error) @@ -148,6 +151,7 @@ extension EmailLinkReauthView: View { .task { await sendEmailLink() } + .mfaReauthResolution($mfaRequired, coordinator: coordinator) } .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index 34a3fb497b..42b6af1f3c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -28,6 +28,7 @@ public struct EmailReauthView { @State private var password = "" @State private var isLoading = false @State private var error: AlertError? + @State private var mfaRequired: MFARequired? private func verifyPassword() { guard !password.isEmpty else { return } @@ -39,6 +40,9 @@ public struct EmailReauthView { try await authService.reauthenticate(with: credential) coordinator.reauthCompleted() isLoading = false + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + isLoading = false } catch { if let reportError = reportError { reportError(error) @@ -123,6 +127,7 @@ extension EmailReauthView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationBarTitleDisplayMode(.inline) + .mfaReauthResolution($mfaRequired, coordinator: coordinator) } .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift index a764de6f45..ea2c48217c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift @@ -107,7 +107,18 @@ public struct MFAResolutionContentState { public struct MFAResolutionScreen: View { public init(mfaRequired: MFARequired, @ViewBuilder content: @escaping (MFAResolutionContentState) -> Content) { + self.init(mfaRequired: mfaRequired, onResolved: nil, onCancel: nil, content: content) + } + + /// Used outside the auth navigation stack (e.g. reauthentication), where completion and + /// cancellation are handled by the caller instead of clearing the navigator + init(mfaRequired: MFARequired, + onResolved: (() -> Void)?, + onCancel: (() -> Void)?, + @ViewBuilder content: @escaping (MFAResolutionContentState) -> Content) { self.mfaRequired = mfaRequired + self.onResolved = onResolved + self.onCancel = onCancel self.content = content } @@ -119,8 +130,11 @@ public struct MFAResolutionScreen: View { @State private var verificationId: String? @State private var isWorking = false @State private var errorMessage: String? + @State private var alertError: AlertError? private let mfaRequired: MFARequired + private let onResolved: (() -> Void)? + private let onCancel: (() -> Void)? private let content: (MFAResolutionContentState) -> Content public var body: some View { @@ -133,7 +147,7 @@ public struct MFAResolutionScreen: View { totpCode: $totpCode, sendCode: sendCode, resolve: resolve, - cancel: { authService.navigator.clear() }, + cancel: cancel, hints: mfaRequired.hints, isCodeSent: verificationId != nil, isValid: isValid, @@ -143,6 +157,29 @@ public struct MFAResolutionScreen: View { } .onChange(of: verificationCode) { errorMessage = nil } .onChange(of: totpCode) { errorMessage = nil } + // Without a parent reporter (e.g. inside a reauth sheet), show failures here + .errorAlert(error: $alertError, okButtonLabel: authService.string.okButtonLabel) + } + + private func cancel() { + if let onCancel { + onCancel() + } else { + authService.navigator.clear() + } + } + + private func report(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + alertError = AlertError( + title: "Error", + message: errorMessage ?? error.localizedDescription, + underlyingError: error + ) + } } private func selectHint(_ index: Int) { @@ -186,8 +223,7 @@ public struct MFAResolutionScreen: View { guard selectedHintIndex == hintIndex else { return } verificationId = id } catch { - errorMessage = authService.string.localizedErrorMessage(for: error) - reportError?(error) + report(error) } } } @@ -211,10 +247,13 @@ public struct MFAResolutionScreen: View { hintIndex: selectedHintIndex, verificationId: verificationId ) - authService.navigator.clear() + if let onResolved { + onResolved() + } else { + authService.navigator.clear() + } } catch { - errorMessage = authService.string.localizedErrorMessage(for: error) - reportError?(error) + report(error) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index b53e34d81d..3f0de0ba7a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -18,15 +18,29 @@ import SwiftUI @MainActor public struct MFAResolutionView { let mfaRequired: MFARequired + private let onResolved: (() -> Void)? + private let onCancel: (() -> Void)? public init(mfaRequired: MFARequired) { + self.init(mfaRequired: mfaRequired, onResolved: nil, onCancel: nil) + } + + /// Used outside the auth navigation stack (e.g. reauthentication), where completion and + /// cancellation are handled by the caller instead of clearing the navigator + init(mfaRequired: MFARequired, onResolved: (() -> Void)?, onCancel: (() -> Void)?) { self.mfaRequired = mfaRequired + self.onResolved = onResolved + self.onCancel = onCancel } } extension MFAResolutionView: View { public var body: some View { - MFAResolutionScreen(mfaRequired: mfaRequired) { DefaultMFAResolutionContent(state: $0) } + MFAResolutionScreen( + mfaRequired: mfaRequired, + onResolved: onResolved, + onCancel: onCancel + ) { DefaultMFAResolutionContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index deed8e5799..de8166f3c7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -29,6 +29,7 @@ public struct PhoneReauthView { @State private var verificationCode = "" @State private var isLoading = false @State private var error: AlertError? + @State private var mfaRequired: MFARequired? private func sendSMS() { Task { @MainActor in @@ -64,6 +65,9 @@ public struct PhoneReauthView { try await authService.reauthenticate(with: credential) coordinator.reauthCompleted() isLoading = false + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + isLoading = false } catch { if let reportError = reportError { reportError(error) @@ -196,6 +200,7 @@ extension PhoneReauthView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationBarTitleDisplayMode(.inline) + .mfaReauthResolution($mfaRequired, coordinator: coordinator) } .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift index 2e14fa17f4..6d54627a17 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift @@ -26,6 +26,8 @@ public final class ReauthenticationCoordinator { public var showingEmailPasswordPrompt = false public var showingEmailLinkReauth = false public var showingEmailLinkReauthAlert = false + var showingMFAReauth = false + var mfaReauthRequired: MFARequired? private var continuation: CheckedContinuation? @@ -64,6 +66,12 @@ public final class ReauthenticationCoordinator { showingEmailLinkReauth = true } + /// Called when reauthentication outside a reauth sheet (OAuth) requires a second factor + func requireMFAResolution(_ mfaRequired: MFARequired) { + mfaReauthRequired = mfaRequired + showingMFAReauth = true + } + /// Called when reauthentication completes successfully public func reauthCompleted() { continuation?.resume() @@ -84,6 +92,8 @@ public final class ReauthenticationCoordinator { showingEmailPasswordPrompt = false showingEmailLinkReauth = false showingEmailLinkReauthAlert = false + showingMFAReauth = false + mfaReauthRequired = nil reauthContext = nil } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift index b20b23a59f..07ced40de4 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -55,21 +55,31 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for phone reauthentication - .sheet(isPresented: $coordinator.showingPhoneReauth) { + .sheet(isPresented: $coordinator.showingPhoneReauth, onDismiss: { + // No-op after completion; resumes the caller when swiped away + coordinator.reauthCancelled() + }) { if case let .phone(context) = coordinator.reauthContext { PhoneReauthView( phoneNumber: context.phoneNumber, coordinator: coordinator ) + // Show errors inside the sheet; a parent's reportError alert would sit behind it + .environment(\.reportError, nil) } } // Sheet for email reauthentication - .sheet(isPresented: $coordinator.showingEmailPasswordPrompt) { + .sheet(isPresented: $coordinator.showingEmailPasswordPrompt, onDismiss: { + // No-op after completion; resumes the caller when swiped away + coordinator.reauthCancelled() + }) { if case let .email(context) = coordinator.reauthContext { EmailReauthView( email: context.email, coordinator: coordinator ) + // Show errors inside the sheet; a parent's reportError alert would sit behind it + .environment(\.reportError, nil) } } // Alert for email link reauthentication @@ -89,12 +99,33 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for email link reauthentication - .sheet(isPresented: $coordinator.showingEmailLinkReauth) { + .sheet(isPresented: $coordinator.showingEmailLinkReauth, onDismiss: { + // No-op after completion; resumes the caller when swiped away + coordinator.reauthCancelled() + }) { if case let .emailLink(context) = coordinator.reauthContext { EmailLinkReauthView( email: context.email, coordinator: coordinator ) + // Show errors inside the sheet; a parent's reportError alert would sit behind it + .environment(\.reportError, nil) + } + } + // Sheet for second-factor resolution after OAuth reauthentication + .sheet(isPresented: $coordinator.showingMFAReauth, onDismiss: { + // No-op after completion; resumes the caller when swiped away + coordinator.reauthCancelled() + }) { + if let mfaRequired = coordinator.mfaReauthRequired { + NavigationStack { + MFAResolutionView( + mfaRequired: mfaRequired, + onResolved: { coordinator.reauthCompleted() }, + onCancel: { coordinator.reauthCancelled() } + ) + } + .environment(\.reportError, nil) } } } @@ -107,6 +138,8 @@ struct ReauthenticationModifier: ViewModifier { // For OAuth providers (Google, Apple, etc.), call reauthenticate with context try await authService.reauthenticate(context: context) coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + coordinator.requireMFAResolution(mfaRequired) } catch { coordinator.reauthCancelled() } @@ -114,6 +147,32 @@ struct ReauthenticationModifier: ViewModifier { } } +/// Pushes second-factor resolution inside a reauthentication sheet +struct MFAReauthResolutionModifier: ViewModifier { + @Binding var mfaRequired: MFARequired? + let coordinator: ReauthenticationCoordinator + + func body(content: Content) -> some View { + content + .navigationDestination(item: $mfaRequired) { mfaRequired in + MFAResolutionView( + mfaRequired: mfaRequired, + onResolved: { coordinator.reauthCompleted() }, + onCancel: { coordinator.reauthCancelled() } + ) + // Going back would return to a credential step that has already been used + .navigationBarBackButtonHidden() + } + } +} + +extension View { + func mfaReauthResolution(_ mfaRequired: Binding, + coordinator: ReauthenticationCoordinator) -> some View { + modifier(MFAReauthResolutionModifier(mfaRequired: mfaRequired, coordinator: coordinator)) + } +} + public extension View { /// Adds reauthentication handling to the view /// - Parameter coordinator: The coordinator managing the reauthentication state diff --git a/FirebaseSwiftUI/README.md b/FirebaseSwiftUI/README.md index 4e879a1507..1f06991173 100644 --- a/FirebaseSwiftUI/README.md +++ b/FirebaseSwiftUI/README.md @@ -1705,6 +1705,8 @@ Thrown by sensitive operations when Firebase requires recent authentication. Eac - **`phoneReauthenticationRequired(context: PhoneReauthContext)`**: Phone provider. Context contains `phoneNumber` and `displayMessage`. Handle SMS verification, then call `reauthenticate(with:)`. +- **`multiFactorReauthenticationRequired(MFARequired)`**: Thrown by `reauthenticate(with:)` and `reauthenticate(context:)` when the user has enrolled second factors. Complete it as for sign-in MFA resolution: `resolveSmsChallenge(hintIndex:)` for SMS hints, then `resolveSignIn(code:hintIndex:verificationId:)`. Default views handle this inside the reauthentication sheet. + --- ### Best Practices diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index 863c104589..0b6f69eb34 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -254,4 +254,40 @@ struct FirebaseSwiftUIExampleTests { #expect(service.currentUser != nil) #expect(service.authView == nil) } + + @Test + @MainActor + func reauthenticateMFAUserRequiresSecondFactor() async throws { + let service = try await prepareFreshAuthService() + let email = createEmail() + let phoneNumber = "+1555123\(Int.random(in: 1000 ... 9999))" + try await service.createUser(email: email, password: kPassword) + try await waitForStateChange { service.currentUser != nil } + try await enrollSMSSecondFactor(email: email, password: kPassword, phoneNumber: phoneNumber) + + let credential = EmailAuthProvider.credential(withEmail: email, password: kPassword) + var mfaRequired: MFARequired? + do { + try await service.reauthenticate(with: credential) + } catch let AuthServiceError.multiFactorReauthenticationRequired(required) { + mfaRequired = required + } + + let hints = try #require(mfaRequired).hints + #expect(hints.count == 1) + guard case let .phone(_, _, hintPhoneNumber) = hints.first else { + Issue.record("Expected a phone hint, got \(hints)") + return + } + #expect(hintPhoneNumber?.hasSuffix(String(phoneNumber.suffix(4))) == true) + + // The resolver is kept so the second factor is resolved as for sign-in + let verificationId = try await service.resolveSmsChallenge(hintIndex: 0) + try await service.resolveSignIn( + code: fetchLatestSMSCode(for: phoneNumber), + hintIndex: 0, + verificationId: verificationId + ) + #expect(service.currentUser?.email?.caseInsensitiveCompare(email) == .orderedSame) + } } diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift index 120016c43d..0cd0a14f97 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift @@ -174,3 +174,133 @@ func fetchEmailSignInLinkFromEmulator(email: String, userInfo: [NSLocalizedDescriptionKey: "No EMAIL_SIGNIN OOB link found for \(email)"] ) } + +// MARK: - MFA + +@MainActor +@discardableResult +private func postToAuthEmulator(_ path: String, + body: [String: Any], + asOwner: Bool = false, + emulatorHost: String = "127.0.0.1:9099") async throws + -> [String: Any] { + var request = URLRequest(url: URL(string: "http://\(emulatorHost)/\(path)")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if asOwner { + request.setValue("Bearer owner", forHTTPHeaderField: "Authorization") + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200, + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NSError( + domain: "EmulatorError", + code: 20, + userInfo: [ + NSLocalizedDescriptionKey: "\(path) failed: \(String(data: data, encoding: .utf8) ?? "")", + ] + ) + } + return json +} + +@MainActor +func fetchLatestSMSCode(for phoneNumber: String, + projectID: String = "flutterfire-e2e-tests", + emulatorHost: String = "127.0.0.1:9099") async throws -> String { + let url = + URL(string: "http://\(emulatorHost)/emulator/v1/projects/\(projectID)/verificationCodes")! + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError( + domain: "EmulatorError", + code: 25, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch SMS verification codes"] + ) + } + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let codes = json?["verificationCodes"] as? [[String: Any]] ?? [] + // Codes sent for MFA resolution carry a masked number, e.g. "+*******4567" + let matches: ([String: Any]) -> Bool = { entry in + guard let number = entry["phoneNumber"] as? String else { return false } + return number == phoneNumber + || (number.contains("*") && number.suffix(4) == phoneNumber.suffix(4)) + } + guard let code = codes.last(where: matches)?["code"] as? String else { + throw NSError( + domain: "EmulatorError", + code: 21, + userInfo: [NSLocalizedDescriptionKey: "No SMS code found for \(phoneNumber)"] + ) + } + return code +} + +/// Verifies the user's email and enrolls an SMS second factor via the emulator REST API +@MainActor +func enrollSMSSecondFactor(email: String, + password: String, + phoneNumber: String, + projectID: String = "flutterfire-e2e-tests") async throws { + let signInPath = + "identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" + let signInBody: [String: Any] = [ + "email": email, + "password": password, + "returnSecureToken": true, + ] + guard let localId = try await postToAuthEmulator(signInPath, body: signInBody)["localId"] + as? String else { + throw NSError( + domain: "EmulatorError", + code: 23, + userInfo: [NSLocalizedDescriptionKey: "signInWithPassword returned no localId"] + ) + } + try await postToAuthEmulator( + "identitytoolkit.googleapis.com/v1/projects/\(projectID)/accounts:update", + body: ["localId": localId, "emailVerified": true], + asOwner: true + ) + guard let idToken = try await postToAuthEmulator(signInPath, body: signInBody)["idToken"] + as? String else { + throw NSError( + domain: "EmulatorError", + code: 24, + userInfo: [NSLocalizedDescriptionKey: "signInWithPassword returned no idToken"] + ) + } + + let start = try await postToAuthEmulator( + "identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start?key=fake-api-key", + body: [ + "idToken": idToken, + "phoneEnrollmentInfo": [ + "phoneNumber": phoneNumber, + "recaptchaToken": "fake-recaptcha-token", + ], + ] + ) + guard let sessionInfo = (start["phoneSessionInfo"] as? [String: Any])?["sessionInfo"] + as? String else { + throw NSError( + domain: "EmulatorError", + code: 22, + userInfo: [NSLocalizedDescriptionKey: "mfaEnrollment:start returned no sessionInfo"] + ) + } + + try await postToAuthEmulator( + "identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize?key=fake-api-key", + body: [ + "idToken": idToken, + "phoneVerificationInfo": [ + "sessionInfo": sessionInfo, + "code": await fetchLatestSMSCode(for: phoneNumber), + ], + "displayName": "Test Phone", + ] + ) +}