Skip to content

Commit f2a85ac

Browse files
feat: MFA management and resolution logic
1 parent dd1e92b commit f2a85ac

File tree

6 files changed

+816
-0
lines changed

6 files changed

+816
-0
lines changed

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,17 @@ public struct EnrollmentSession {
9898
return !isExpired &&
9999
(status == .initiated || status == .verificationSent || status == .verificationPending)
100100
}
101+
}
102+
103+
public enum MFAHint {
104+
case phone(displayName: String?, uid: String, phoneNumber: String?)
105+
case totp(displayName: String?, uid: String)
106+
}
107+
108+
public struct MFARequired {
109+
public let hints: [MFAHint]
110+
111+
public init(hints: [MFAHint]) {
112+
self.hints = hints
113+
}
101114
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@ public enum AuthView {
5050
case emailLink
5151
case updatePassword
5252
case mfaEnrollment
53+
case mfaManagement
54+
case mfaResolution
5355
}
5456

5557
public enum SignInOutcome: @unchecked Sendable {
58+
case mfaRequired(MFARequired)
5659
case signedIn(AuthDataResult?)
5760
}
5861

@@ -104,6 +107,8 @@ public final class AuthService {
104107
public var authenticationFlow: AuthenticationFlow = .signIn
105108
public var errorMessage = ""
106109
public let passwordPrompt: PasswordPromptCoordinator = .init()
110+
public var currentMFARequired: MFARequired?
111+
private var currentMFAResolver: MultiFactorResolver?
107112

108113
// MARK: - AuthPickerView Modal APIs
109114

@@ -719,4 +724,169 @@ public extension AuthService {
719724
try await user.multiFactor.enroll(with: assertion, displayName: displayName)
720725
currentUser = auth.currentUser
721726
}
727+
728+
func reauthenticateCurrentUser(on user: User) async throws {
729+
if let providerId = signedInCredential?.provider {
730+
if providerId == EmailAuthProviderID {
731+
guard let email = user.email else {
732+
throw AuthServiceError.invalidCredentials("User does not have an email address")
733+
}
734+
let password = try await passwordPrompt.confirmPassword()
735+
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
736+
try await user.reauthenticate(with: credential)
737+
} else if let matchingProvider = providers.first(where: { $0.id == providerId }) {
738+
let credential = try await matchingProvider.provider.createAuthCredential()
739+
try await user.reauthenticate(with: credential)
740+
} else {
741+
throw AuthServiceError.providerNotFound("No provider found for \(providerId)")
742+
}
743+
} else {
744+
throw AuthServiceError
745+
.reauthenticationRequired("Recent login required to perform this operation.")
746+
}
747+
}
748+
749+
func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] {
750+
guard let user = auth.currentUser else {
751+
throw AuthServiceError.noCurrentUser
752+
}
753+
754+
let multiFactorUser = user.multiFactor
755+
756+
do {
757+
try await multiFactorUser.unenroll(withFactorUID: factorUid)
758+
} catch let error as NSError {
759+
if error.domain == AuthErrorDomain,
760+
error.code == AuthErrorCode.requiresRecentLogin.rawValue || error.code == AuthErrorCode
761+
.userTokenExpired.rawValue {
762+
try await reauthenticateCurrentUser(on: user)
763+
try await multiFactorUser.unenroll(withFactorUID: factorUid)
764+
} else {
765+
throw AuthServiceError
766+
.multiFactorAuth(
767+
"Invalid second factor: \(error.localizedDescription)"
768+
)
769+
}
770+
}
771+
772+
// This is the only we to get the actual latest enrolledFactors
773+
currentUser = Auth.auth().currentUser
774+
let freshFactors = currentUser?.multiFactor.enrolledFactors ?? []
775+
776+
return freshFactors
777+
}
778+
779+
// MARK: - MFA Helper Methods
780+
781+
private func extractMFAHints(from resolver: MultiFactorResolver) -> [MFAHint] {
782+
return resolver.hints.map { hint -> MFAHint in
783+
if hint.factorID == PhoneMultiFactorID {
784+
let phoneHint = hint as! PhoneMultiFactorInfo
785+
return .phone(
786+
displayName: phoneHint.displayName,
787+
uid: phoneHint.uid,
788+
phoneNumber: phoneHint.phoneNumber
789+
)
790+
} else if hint.factorID == TOTPMultiFactorID {
791+
return .totp(
792+
displayName: hint.displayName,
793+
uid: hint.uid
794+
)
795+
} else {
796+
// Fallback for unknown hint types
797+
return .totp(displayName: hint.displayName, uid: hint.uid)
798+
}
799+
}
800+
}
801+
802+
private func handleMFARequiredError(resolver: MultiFactorResolver) -> SignInOutcome {
803+
let hints = extractMFAHints(from: resolver)
804+
currentMFARequired = MFARequired(hints: hints)
805+
currentMFAResolver = resolver
806+
authView = .mfaResolution
807+
return .mfaRequired(MFARequired(hints: hints))
808+
}
809+
810+
func resolveSmsChallenge(hintIndex: Int) async throws -> String {
811+
guard let resolver = currentMFAResolver else {
812+
throw AuthServiceError.multiFactorAuth("No MFA resolver available")
813+
}
814+
815+
guard hintIndex < resolver.hints.count else {
816+
throw AuthServiceError.multiFactorAuth("Invalid hint index")
817+
}
818+
819+
let hint = resolver.hints[hintIndex]
820+
guard hint.factorID == PhoneMultiFactorID else {
821+
throw AuthServiceError.multiFactorAuth("Selected hint is not a phone hint")
822+
}
823+
let phoneHint = hint as! PhoneMultiFactorInfo
824+
825+
return try await withCheckedThrowingContinuation { continuation in
826+
PhoneAuthProvider.provider().verifyPhoneNumber(
827+
with: phoneHint,
828+
uiDelegate: nil,
829+
multiFactorSession: resolver.session
830+
) { verificationId, error in
831+
if let error = error {
832+
continuation
833+
.resume(throwing: AuthServiceError.multiFactorAuth(error.localizedDescription))
834+
} else if let verificationId = verificationId {
835+
continuation.resume(returning: verificationId)
836+
} else {
837+
continuation
838+
.resume(throwing: AuthServiceError.multiFactorAuth("Unknown error occurred"))
839+
}
840+
}
841+
}
842+
}
843+
844+
func resolveSignIn(code: String, hintIndex: Int, verificationId: String? = nil) async throws {
845+
guard let resolver = currentMFAResolver else {
846+
throw AuthServiceError.multiFactorAuth("No MFA resolver available")
847+
}
848+
849+
guard hintIndex < resolver.hints.count else {
850+
throw AuthServiceError.multiFactorAuth("Invalid hint index")
851+
}
852+
853+
let hint = resolver.hints[hintIndex]
854+
let assertion: MultiFactorAssertion
855+
856+
// Create the appropriate assertion based on the hint type
857+
if hint.factorID == PhoneMultiFactorID {
858+
guard let verificationId = verificationId else {
859+
throw AuthServiceError.multiFactorAuth("Verification ID is required for SMS MFA")
860+
}
861+
862+
let credential = PhoneAuthProvider.provider().credential(
863+
withVerificationID: verificationId,
864+
verificationCode: code
865+
)
866+
assertion = PhoneMultiFactorGenerator.assertion(with: credential)
867+
868+
} else if hint.factorID == TOTPMultiFactorID {
869+
assertion = TOTPMultiFactorGenerator.assertionForSignIn(
870+
withEnrollmentID: hint.uid,
871+
oneTimePassword: code
872+
)
873+
874+
} else {
875+
throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type")
876+
}
877+
878+
do {
879+
let result = try await resolver.resolveSignIn(with: assertion)
880+
signedInCredential = result.credential
881+
updateAuthenticationState()
882+
883+
// Clear MFA resolution state
884+
currentMFARequired = nil
885+
currentMFAResolver = nil
886+
887+
} catch {
888+
throw AuthServiceError
889+
.multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)")
890+
}
891+
}
722892
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ extension AuthPickerView: View {
5353
switch authService.authView {
5454
case .mfaEnrollment:
5555
MFAEnrolmentView()
56+
case .mfaManagement:
57+
MFAManagementView()
5658
default:
5759
SignedInView()
5860
}
@@ -64,6 +66,8 @@ extension AuthPickerView: View {
6466
EmailLinkView()
6567
case .mfaEnrollment:
6668
MFAEnrolmentView()
69+
case .mfaResolution:
70+
MFAResolutionView()
6771
case .authPicker:
6872
if authService.emailSignInEnabled {
6973
Text(authService.authenticationFlow == .signIn ? authService.string

0 commit comments

Comments
 (0)