diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index 6d72162bf7..2115f02363 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 2145b6733e..b793389f4a 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() @@ -323,8 +324,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 +335,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 +576,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 f151cda431..f87de03615 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 8d61630ce4..ddc9798ce2 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 f6e88f94ea..276ef223b4 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 10821e9b7c..641886b691 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 3ca110b1db..56fc0024c0 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..5d33f2de81 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 120016c43d..39cc4d47cf 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", + ] + ) +}