Skip to content
Draft
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 @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ public final class AuthService {
// Clear email link reauth state
emailLinkReauth = nil
isReauthenticating = false
currentMFAResolver = nil
legacySignInRecovery = nil
suggestedEmailAddress = nil
updateAuthenticationState()
Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
demolaf marked this conversation as resolved.
throw AuthServiceError
.multiFactorReauthenticationRequired(MFARequired(hints: extractMFAHints(from: resolver)))
}
currentUser = auth.currentUser
}
}
Expand Down Expand Up @@ -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
}
Comment thread
demolaf marked this conversation as resolved.
throw error
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -145,6 +148,7 @@ extension EmailLinkReauthView: View {
.task {
await sendEmailLink()
}
.mfaReauthResolution($mfaRequired, coordinator: coordinator)
}
.errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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? {
Expand Down Expand Up @@ -71,7 +94,7 @@ public struct MFAResolutionView {
self.verificationId = verificationId
isLoading = false
} catch {
reportError?(error)
handleError(error)
isLoading = false
}
}
Expand All @@ -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()
}
}
}

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

Expand Down Expand Up @@ -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()
Expand All @@ -84,6 +92,8 @@ public final class ReauthenticationCoordinator {
showingEmailPasswordPrompt = false
showingEmailLinkReauth = false
showingEmailLinkReauthAlert = false
showingMFAReauth = false
mfaReauthRequired = nil
reauthContext = nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}) {
Comment thread
demolaf marked this conversation as resolved.
if let mfaRequired = coordinator.mfaReauthRequired {
NavigationStack {
MFAResolutionView(
mfaRequired: mfaRequired,
onResolved: { coordinator.reauthCompleted() },
onCancel: { coordinator.reauthCancelled() }
)
}
.environment(\.reportError, nil)
}
}
}
Expand All @@ -107,13 +138,41 @@ 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()
}
}
}
}

/// 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<MFARequired?>,
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
Expand Down
Loading
Loading