Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,24 @@ import Observation
@MainActor
@Observable
public final class PasswordPromptCoordinator {
var isPromptingPassword = false
public var isPromptingPassword = false
private var continuation: CheckedContinuation<String, Error>?

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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)?

Expand Down Expand Up @@ -199,7 +203,7 @@ public final class AuthService {
: .authenticated
}

public var shouldHandleAnonymousUpgrade: Bool {
private var shouldHandleAnonymousUpgrade: Bool {
currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -957,7 +968,6 @@ public extension AuthService {
updateAuthenticationState()

// Clear MFA resolution state
currentMFARequired = nil
currentMFAResolver = nil

} catch {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ public struct AuthPickerView<Content: View> {
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
Expand All @@ -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):
Expand All @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -115,9 +114,6 @@ extension UpdatePasswordView: View {
} message: {
Text("Your password has been successfully updated.")
}
.sheet(isPresented: $passwordPrompt.isPromptingPassword) {
PasswordPromptSheet(coordinator: authService.passwordPrompt)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading