diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift index e2be260f56..0e07bebacd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift @@ -100,12 +100,12 @@ public struct EnrollmentSession { } } -public enum MFAHint { +public enum MFAHint: Hashable { case phone(displayName: String?, uid: String, phoneNumber: String?) case totp(displayName: String?, uid: String) } -public struct MFARequired { +public struct MFARequired: Hashable { public let hints: [MFAHint] public init(hints: [MFAHint]) { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift index 6f77ef6d67..2bb0b053eb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift @@ -18,22 +18,24 @@ import Observation @MainActor @Observable public final class PasswordPromptCoordinator { - var isPromptingPassword = false + public var isPromptingPassword = false private var continuation: CheckedContinuation? - func confirmPassword() async throws -> String { + public init() {} + + public func confirmPassword() async throws -> String { return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation self.isPromptingPassword = true } } - func submit(password: String) { + public func submit(password: String) { continuation?.resume(returning: password) cleanup() } - func cancel() { + public func cancel() { continuation? .resume(throwing: AuthServiceError .signInCancelled("Password entry cancelled for Email provider")) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 0640ef415f..42cccfb5ea 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -49,7 +49,7 @@ public enum AuthView: Hashable { case updatePassword case mfaEnrollment case mfaManagement - case mfaResolution + case mfaResolution(MFARequired) case enterPhoneNumber case enterVerificationCode(verificationID: String, fullPhoneNumber: String) } @@ -132,14 +132,18 @@ public final class AuthService { public var authenticationState: AuthenticationState = .unauthenticated public var authenticationFlow: AuthenticationFlow = .signIn - public let passwordPrompt: PasswordPromptCoordinator = .init() - public var currentMFARequired: MFARequired? private var currentMFAResolver: MultiFactorResolver? // MARK: - Provider APIs private var listenerManager: AuthListenerManager? + private var emailProvider: EmailProviderSwift? + + public var passwordPrompt: PasswordPromptCoordinator { + emailProvider?.passwordPrompt ?? PasswordPromptCoordinator() + } + var emailSignInEnabled = false private var emailSignInCallback: (() -> Void)? @@ -199,7 +203,7 @@ public final class AuthService { : .authenticated } - public var shouldHandleAnonymousUpgrade: Bool { + private var shouldHandleAnonymousUpgrade: Bool { currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers } @@ -317,14 +321,16 @@ public extension AuthService { public extension AuthService { /// Enable email sign-in with default behavior (navigates to email link view) - func withEmailSignIn() -> AuthService { - return withEmailSignIn { [weak self] in + func withEmailSignIn(_ provider: EmailProviderSwift? = nil) -> AuthService { + return withEmailSignIn(provider) { [weak self] in self?.navigator.push(.emailLink) } } /// Enable email sign-in with custom callback - func withEmailSignIn(onTap: @escaping () -> Void) -> AuthService { + func withEmailSignIn(_ provider: EmailProviderSwift? = nil, + onTap: @escaping () -> Void) -> AuthService { + emailProvider = provider ?? EmailProviderSwift() emailSignInEnabled = true emailSignInCallback = onTap return self @@ -747,8 +753,14 @@ public extension AuthService { guard let email = user.email else { throw AuthServiceError.invalidCredentials("User does not have an email address") } - let password = try await passwordPrompt.confirmPassword() - let credential = EmailAuthProvider.credential(withEmail: email, password: password) + + guard let emailProvider = emailProvider else { + throw AuthServiceError.providerNotFound( + "Email provider not configured. Call withEmailSignIn() first." + ) + } + + let credential = try await emailProvider.createReauthCredential(email: email) _ = try await user.reauthenticate(with: credential) } else if providerId == PhoneAuthProviderID { // Phone auth requires manual reauthentication via sign out and sign in otherwise it will take @@ -879,7 +891,6 @@ public extension AuthService { private func handleMFARequiredError(resolver: MultiFactorResolver) -> SignInOutcome { let hints = extractMFAHints(from: resolver) - currentMFARequired = MFARequired(hints: hints) currentMFAResolver = resolver return .mfaRequired(MFARequired(hints: hints)) } @@ -957,7 +968,6 @@ public extension AuthService { updateAuthenticationState() // Clear MFA resolution state - currentMFARequired = nil currentMFAResolver = nil } catch { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/EmailProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/EmailProviderAuthUI.swift new file mode 100644 index 0000000000..6149b3fdeb --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/EmailProviderAuthUI.swift @@ -0,0 +1,33 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth + +/// Email/Password authentication provider +/// This provider is special and doesn't render in the button list +@MainActor +public class EmailProviderSwift: AuthProviderSwift { + public let passwordPrompt: PasswordPromptCoordinator + public let providerId = EmailAuthProviderID + + public init(passwordPrompt: PasswordPromptCoordinator = .init()) { + self.passwordPrompt = passwordPrompt + } + + /// Create credential for reauthentication + func createReauthCredential(email: String) async throws -> AuthCredential { + let password = try await passwordPrompt.confirmPassword() + return EmailAuthProvider.credential(withEmail: email, password: password) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index fbdc965238..26754e6038 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -33,10 +33,10 @@ public struct AuthPickerView { extension AuthPickerView: View { public var body: some View { @Bindable var authService = authService - @Bindable var passwordPrompt = authService.passwordPrompt content() .sheet(isPresented: $authService.isPresented) { @Bindable var navigator = authService.navigator + @Bindable var passwordPrompt = authService.passwordPrompt NavigationStack(path: $navigator.routes) { authPickerViewInternal .navigationTitle(authService.authenticationState == .unauthenticated ? authService @@ -57,8 +57,8 @@ extension AuthPickerView: View { MFAEnrolmentView() case AuthView.mfaManagement: MFAManagementView() - case AuthView.mfaResolution: - MFAResolutionView() + case let .mfaResolution(mfaRequired): + MFAResolutionView(mfaRequired: mfaRequired) case AuthView.enterPhoneNumber: EnterPhoneNumberView() case let .enterVerificationCode(verificationID, fullPhoneNumber): @@ -79,10 +79,10 @@ extension AuthPickerView: View { .accountConflictHandler() // Apply MFA handling at NavigationStack level .mfaHandler() - } - // Centralized password prompt sheet to prevent conflicts - .sheet(isPresented: $passwordPrompt.isPromptingPassword) { - PasswordPromptSheet(coordinator: authService.passwordPrompt) + // Centralized password prompt sheet inside auth flow + .sheet(isPresented: $passwordPrompt.isPromptingPassword) { + PasswordPromptSheet(coordinator: passwordPrompt) + } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAHandlerModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAHandlerModifier.swift index 9c29ab8056..d593ee7d44 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAHandlerModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAHandlerModifier.swift @@ -39,8 +39,8 @@ struct MFAHandlerModifier: ViewModifier { } /// Handle MFA required - navigate to MFA resolution view - func handleMFARequired(_: MFARequired) { - authService.navigator.push(.mfaResolution) + func handleMFARequired(_ mfaRequired: MFARequired) { + authService.navigator.push(.mfaResolution(mfaRequired)) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index 03e56a1e98..8ffedbe885 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -23,6 +23,8 @@ private enum FocusableField: Hashable { @MainActor public struct MFAResolutionView { + let mfaRequired: MFARequired + @Environment(AuthService.self) private var authService @Environment(\.reportError) private var reportError @@ -34,16 +36,12 @@ public struct MFAResolutionView { @FocusState private var focus: FocusableField? - public init() {} - - private var mfaRequired: MFARequired? { - // This would be set by the sign-in flow when MFA is required - authService.currentMFARequired + public init(mfaRequired: MFARequired) { + self.mfaRequired = mfaRequired } private var selectedHint: MFAHint? { - guard let mfaRequired = mfaRequired, - selectedHintIndex < mfaRequired.hints.count else { + guard selectedHintIndex < mfaRequired.hints.count else { return nil } return mfaRequired.hints[selectedHintIndex] @@ -63,7 +61,7 @@ public struct MFAResolutionView { } private func startSMSChallenge() { - guard selectedHintIndex < (mfaRequired?.hints.count ?? 0) else { return } + guard selectedHintIndex < mfaRequired.hints.count else { return } Task { isLoading = true @@ -128,7 +126,7 @@ extension MFAResolutionView: View { .padding(.horizontal) // MFA Hints Selection (if multiple available) - if let mfaRequired = mfaRequired, mfaRequired.hints.count > 1 { + if mfaRequired.hints.count > 1 { mfaHintsSelectionView(mfaRequired: mfaRequired) } @@ -368,34 +366,36 @@ private extension MFAHint { #Preview("Phone SMS Only") { FirebaseOptions.dummyConfigurationForPreview() let authService = AuthService() - authService.currentMFARequired = MFARequired(hints: [ + let mfaRequired = MFARequired(hints: [ .phone(displayName: "Work Phone", uid: "phone-uid-1", phoneNumber: "+15551234567"), ]) - return MFAResolutionView().environment(authService) + return MFAResolutionView(mfaRequired: mfaRequired).environment(authService) } #Preview("TOTP Only") { FirebaseOptions.dummyConfigurationForPreview() let authService = AuthService() - authService.currentMFARequired = MFARequired(hints: [ + let mfaRequired = MFARequired(hints: [ .totp(displayName: "Authenticator App", uid: "totp-uid-1"), ]) - return MFAResolutionView().environment(authService) + return MFAResolutionView(mfaRequired: mfaRequired).environment(authService) } #Preview("Multiple Methods") { FirebaseOptions.dummyConfigurationForPreview() let authService = AuthService() - authService.currentMFARequired = MFARequired(hints: [ + let mfaRequired = MFARequired(hints: [ .phone(displayName: "Mobile", uid: "phone-uid-1", phoneNumber: "+15551234567"), .totp(displayName: "Google Authenticator", uid: "totp-uid-1"), ]) - return MFAResolutionView().environment(authService) + return MFAResolutionView(mfaRequired: mfaRequired).environment(authService) } -#Preview("No MFA Required") { +#Preview("Single TOTP") { FirebaseOptions.dummyConfigurationForPreview() let authService = AuthService() - // currentMFARequired is nil by default - return MFAResolutionView().environment(authService) + let mfaRequired = MFARequired(hints: [ + .totp(displayName: "Authenticator", uid: "totp-uid-1"), + ]) + return MFAResolutionView(mfaRequired: mfaRequired).environment(authService) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 9e8a1cb263..290f616262 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -53,7 +53,6 @@ public struct UpdatePasswordView { extension UpdatePasswordView: View { public var body: some View { - @Bindable var passwordPrompt = authService.passwordPrompt VStack(spacing: 24) { AuthTextField( text: $password, @@ -115,9 +114,6 @@ extension UpdatePasswordView: View { } message: { Text("Your password has been successfully updated.") } - .sheet(isPresented: $passwordPrompt.isPromptingPassword) { - PasswordPromptSheet(coordinator: authService.passwordPrompt) - } } } diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift index 76d10a1a74..2d629af39e 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift @@ -25,7 +25,7 @@ import FirebaseOAuthSwiftUI struct ContentView: View { init() { - Auth.auth().useEmulator(withHost: "127.0.0.1", port: 9099) +// Auth.auth().useEmulator(withHost: "127.0.0.1", port: 9099) let actionCodeSettings = ActionCodeSettings()