From ce86b6c0aa9a4119c4c50cca111aef17ab378ce1 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:58:53 +0100 Subject: [PATCH 1/3] fix(auth): resolve second factor when reauthenticating MFA users Reauthenticating a user with enrolled second factors threw secondFactorRequired from user.reauthenticate(with:). The reauth views passed it to reportError, whose alert sits behind the reauth sheet, so tapping Confirm did nothing visible. AuthService now catches secondFactorRequired during reauthentication, keeps the MultiFactorResolver and throws AuthServiceError.multiFactorReauthenticationRequired(MFARequired), so the factor is resolved with resolveSmsChallenge and resolveSignIn as for sign-in. The email, phone and email link reauth sheets push MFAResolutionView inside the sheet, OAuth reauth presents it in its own sheet, and reauth sheets show errors in place instead of behind the sheet. --- .../Sources/AuthServiceError.swift | 6 + .../Sources/Services/AuthService.swift | 28 +++- .../Sources/Views/EmailLinkReauthView.swift | 4 + .../Sources/Views/EmailReauthView.swift | 5 + .../Sources/Views/MFAResolutionScreen.swift | 51 ++++++- .../Sources/Views/MFAResolutionView.swift | 16 ++- .../Sources/Views/PhoneReauthView.swift | 5 + .../Views/ReauthenticationCoordinator.swift | 10 ++ .../Views/ReauthenticationModifier.swift | 50 +++++++ FirebaseSwiftUI/README.md | 2 + .../FirebaseSwiftUIExampleTests.swift | 33 +++++ .../TestHarness.swift | 125 ++++++++++++++++++ 12 files changed, 324 insertions(+), 11 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index b78576082ff..a4db209c539 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 df9888f7975..fde2689bf61 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -379,8 +379,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 +390,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 +631,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 fc896270f90..a4987fd06cc 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 34a3fb497b1..42b6af1f3c2 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 a764de6f45f..ea2c48217c4 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 b53e34d81d8..3f0de0ba7ae 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 deed8e5799d..de8166f3c78 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 2e14fa17f4d..6d54627a178 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 b20b23a59f0..bc74b1a0776 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -61,6 +61,8 @@ struct ReauthenticationModifier: ViewModifier { 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 @@ -70,6 +72,8 @@ struct ReauthenticationModifier: ViewModifier { 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 @@ -95,6 +99,24 @@ struct ReauthenticationModifier: ViewModifier { 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 +129,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 +138,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 4e879a15073..1f069911730 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 863c1045890..5d33f2de81d 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -254,4 +254,37 @@ 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 can be resolved as for sign-in. Completing + // resolveSignIn is not asserted: the Auth emulator rejects it with + // ERROR_MULTI_FACTOR_INFO_NOT_FOUND (https://github.com/firebase/firebase-ios-sdk/issues/11079) + let verificationId = try await service.resolveSmsChallenge(hintIndex: 0) + #expect(!verificationId.isEmpty) + } } diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift index 120016c43d8..39cc4d47cfe 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift @@ -174,3 +174,128 @@ 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 +private 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]] ?? [] + guard let code = codes.last(where: { $0["phoneNumber"] as? String == phoneNumber })?["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", + ] + ) +} From e9e4fee433cfeb3082c761f16d62ccf9ed7509c4 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:08:03 +0100 Subject: [PATCH 2/3] fix(auth): cancel reauth when a reauth sheet is swiped away The phone, email and email link reauth sheets had no onDismiss, so swiping one away left the requestReauth continuation pending and the caller waiting forever. They now cancel reauthentication on dismiss, as the MFA reauth sheet already does. signOut also clears any stored MFA resolver so it does not outlive the session. --- .../Sources/Services/AuthService.swift | 1 + .../Sources/Views/ReauthenticationModifier.swift | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index fde2689bf61..df1fdaae60a 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() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift index bc74b1a0776..07ced40de4b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -55,7 +55,10 @@ 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, @@ -66,7 +69,10 @@ struct ReauthenticationModifier: ViewModifier { } } // 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, @@ -93,7 +99,10 @@ 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, From 168d48f5537a0bcc8a1b579b076de9bc65150e61 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:05:16 +0100 Subject: [PATCH 3/3] test(auth): assert MFA reauth completes after resolving the second factor The Auth emulator now accepts resolveSignIn for a resolver from reauthentication, so the integration test resolves the SMS factor instead of stopping at resolveSmsChallenge. The SMS code lookup also matches the masked number the emulator records for resolution codes. --- .../FirebaseSwiftUIExampleTests.swift | 11 +++++++---- .../FirebaseSwiftUIExampleTests/TestHarness.swift | 15 ++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index 5d33f2de81d..0b6f69eb34d 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -281,10 +281,13 @@ struct FirebaseSwiftUIExampleTests { } #expect(hintPhoneNumber?.hasSuffix(String(phoneNumber.suffix(4))) == true) - // The resolver is kept so the second factor can be resolved as for sign-in. Completing - // resolveSignIn is not asserted: the Auth emulator rejects it with - // ERROR_MULTI_FACTOR_INFO_NOT_FOUND (https://github.com/firebase/firebase-ios-sdk/issues/11079) + // The resolver is kept so the second factor is resolved as for sign-in let verificationId = try await service.resolveSmsChallenge(hintIndex: 0) - #expect(!verificationId.isEmpty) + 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 39cc4d47cfe..0cd0a14f97f 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift @@ -207,9 +207,9 @@ private func postToAuthEmulator(_ path: String, } @MainActor -private func fetchLatestSMSCode(for phoneNumber: String, - projectID: String = "flutterfire-e2e-tests", - emulatorHost: String = "127.0.0.1:9099") async throws -> String { +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) @@ -222,8 +222,13 @@ private func fetchLatestSMSCode(for phoneNumber: String, } let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] let codes = json?["verificationCodes"] as? [[String: Any]] ?? [] - guard let code = codes.last(where: { $0["phoneNumber"] as? String == phoneNumber })?["code"] - as? String else { + // 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,