From c6b394886c7783320959960f5c218d43c4a1a3da 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/2] 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/MFAResolutionView.swift | 53 +++++++- .../Sources/Views/PhoneReauthView.swift | 5 + .../Views/ReauthenticationCoordinator.swift | 10 ++ .../Views/ReauthenticationModifier.swift | 50 +++++++ FirebaseSwiftUI/README.md | 2 + .../FirebaseSwiftUIExampleTests.swift | 33 +++++ .../TestHarness.swift | 125 ++++++++++++++++++ 11 files changed, 311 insertions(+), 10 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index 6d72162bf70..2115f023639 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 2145b6733e7..6077703a3e0 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -323,8 +323,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 @@ -335,7 +334,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 } } @@ -560,7 +575,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 f151cda4311..f87de036152 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -27,6 +27,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 @@ -53,6 +54,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) @@ -145,6 +148,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 8d61630ce47..ddc9798ce2a 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/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index f6e88f94ea0..276ef223b4a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -24,6 +24,8 @@ private enum FocusableField: Hashable { @MainActor public struct MFAResolutionView { let mfaRequired: MFARequired + private let onResolved: (() -> Void)? + private let onCancel: (() -> Void)? @Environment(AuthService.self) private var authService @Environment(\.reportError) private var reportError @@ -33,11 +35,32 @@ public struct MFAResolutionView { @State private var isLoading = false @State private var selectedHintIndex = 0 @State private var verificationId: String? + @State private var error: AlertError? @FocusState private var focus: FocusableField? 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 + } + + private func handleError(_ error: Error) { + if let reportError = reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } } private var selectedHint: MFAHint? { @@ -71,7 +94,7 @@ public struct MFAResolutionView { self.verificationId = verificationId isLoading = false } catch { - reportError?(error) + handleError(error) isLoading = false } } @@ -88,19 +111,27 @@ public struct MFAResolutionView { hintIndex: selectedHintIndex, verificationId: verificationId ) - // On success, the AuthService will update the authentication state - // and we should navigate back to the main app - authService.navigator.clear() isLoading = false + if let onResolved { + onResolved() + } else { + // On success, the AuthService will update the authentication state + // and we should navigate back to the main app + authService.navigator.clear() + } } catch { - reportError?(error) + handleError(error) isLoading = false } } } private func cancelResolution() { - authService.navigator.clear() + if let onCancel { + onCancel() + } else { + authService.navigator.clear() + } } } @@ -169,6 +200,16 @@ extension MFAResolutionView: View { .padding(.horizontal) } .padding(.vertical, 20) + // The number pad has no return key and would otherwise cover the action buttons + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + focus = nil + } + } + } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } @ViewBuilder diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index 10821e9b7c9..641886b691e 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 3ca110b1db9..56fc0024c0c 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 3ddab359342ee96c02e3b0089333bef226ac8bf7 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/2] 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 6077703a3e0..b793389f4aa 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -234,6 +234,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,