Skip to content

Commit 36b1c77

Browse files
feat: MFA enrolment flow
1 parent 9e58282 commit 36b1c77

File tree

4 files changed

+321
-2
lines changed

4 files changed

+321
-2
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
@preconcurrency import FirebaseAuth
15+
import SwiftUI
16+
17+
public enum SecondFactorType {
18+
case sms
19+
case totp
20+
}
21+
22+
public struct TOTPEnrollmentInfo {
23+
public let sharedSecretKey: String
24+
public let qrCodeURL: URL?
25+
public let accountName: String?
26+
public let issuer: String?
27+
public let verificationStatus: VerificationStatus
28+
29+
public enum VerificationStatus {
30+
case pending
31+
case verified
32+
case failed
33+
}
34+
35+
public init(sharedSecretKey: String,
36+
qrCodeURL: URL? = nil,
37+
accountName: String? = nil,
38+
issuer: String? = nil,
39+
verificationStatus: VerificationStatus = .pending) {
40+
self.sharedSecretKey = sharedSecretKey
41+
self.qrCodeURL = qrCodeURL
42+
self.accountName = accountName
43+
self.issuer = issuer
44+
self.verificationStatus = verificationStatus
45+
}
46+
}
47+
48+
public struct EnrollmentSession {
49+
public let id: String
50+
public let type: SecondFactorType
51+
public let session: MultiFactorSession
52+
public let totpInfo: TOTPEnrollmentInfo?
53+
public let phoneNumber: String?
54+
public let verificationId: String?
55+
public let status: EnrollmentStatus
56+
public let createdAt: Date
57+
public let expiresAt: Date
58+
59+
// Internal handle to finish TOTP
60+
fileprivate let _totpSecret: AnyObject?
61+
62+
public enum EnrollmentStatus {
63+
case initiated
64+
case verificationSent
65+
case verificationPending
66+
case completed
67+
case failed
68+
case expired
69+
}
70+
71+
public init(id: String = UUID().uuidString,
72+
type: SecondFactorType,
73+
session: MultiFactorSession,
74+
totpInfo: TOTPEnrollmentInfo? = nil,
75+
phoneNumber: String? = nil,
76+
verificationId: String? = nil,
77+
status: EnrollmentStatus = .initiated,
78+
createdAt: Date = Date(),
79+
expiresAt: Date = Date().addingTimeInterval(600), // 10 minutes default
80+
_totpSecret: AnyObject? = nil) {
81+
self.id = id
82+
self.type = type
83+
self.session = session
84+
self.totpInfo = totpInfo
85+
self.phoneNumber = phoneNumber
86+
self.verificationId = verificationId
87+
self.status = status
88+
self.createdAt = createdAt
89+
self.expiresAt = expiresAt
90+
self._totpSecret = _totpSecret
91+
}
92+
93+
public var isExpired: Bool {
94+
return Date() > expiresAt
95+
}
96+
97+
public var canProceed: Bool {
98+
return !isExpired &&
99+
(status == .initiated || status == .verificationSent || status == .verificationPending)
100+
}
101+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ public enum AuthServiceError: LocalizedError {
3838
case accountMergeConflict(context: AccountMergeConflictContext)
3939
case invalidPhoneAuthenticationArguments(String)
4040
case providerNotFound(String)
41+
case multiFactorAuth(String)
42+
4143

4244
public var errorDescription: String? {
4345
switch self {
@@ -61,6 +63,8 @@ public enum AuthServiceError: LocalizedError {
6163
return description
6264
case let .invalidPhoneAuthenticationArguments(description):
6365
return description
66+
case let .multiFactorAuth(description):
67+
return description
6468
}
6569
}
6670
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,23 @@ public struct AuthConfiguration {
2525
public let emailLinkSignInActionCodeSettings: ActionCodeSettings?
2626
public let verifyEmailActionCodeSettings: ActionCodeSettings?
2727

28+
// MARK: - MFA Configuration
29+
30+
public let mfaEnabled: Bool
31+
public let allowedSecondFactors: Set<SecondFactorType>
32+
public let mfaIssuer: String
33+
2834
public init(shouldHideCancelButton: Bool = false,
2935
interactiveDismissEnabled: Bool = true,
3036
shouldAutoUpgradeAnonymousUsers: Bool = false,
3137
customStringsBundle: Bundle? = nil,
3238
tosUrl: URL? = nil,
3339
privacyPolicyUrl: URL? = nil,
3440
emailLinkSignInActionCodeSettings: ActionCodeSettings? = nil,
35-
verifyEmailActionCodeSettings: ActionCodeSettings? = nil) {
41+
verifyEmailActionCodeSettings: ActionCodeSettings? = nil,
42+
mfaEnabled: Bool = false,
43+
allowedSecondFactors: Set<SecondFactorType> = [.sms, .totp],
44+
mfaIssuer: String = "Firebase Auth") {
3645
self.shouldHideCancelButton = shouldHideCancelButton
3746
self.interactiveDismissEnabled = interactiveDismissEnabled
3847
self.shouldAutoUpgradeAnonymousUsers = shouldAutoUpgradeAnonymousUsers
@@ -41,5 +50,8 @@ public struct AuthConfiguration {
4150
self.privacyPolicyUrl = privacyPolicyUrl
4251
self.emailLinkSignInActionCodeSettings = emailLinkSignInActionCodeSettings
4352
self.verifyEmailActionCodeSettings = verifyEmailActionCodeSettings
53+
self.mfaEnabled = mfaEnabled
54+
self.allowedSecondFactors = allowedSecondFactors
55+
self.mfaIssuer = mfaIssuer
4456
}
4557
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,15 @@ public enum AuthView {
4949
case passwordRecovery
5050
case emailLink
5151
case updatePassword
52+
case mfaEnrollment
5253
}
5354

5455
public enum SignInOutcome: @unchecked Sendable {
5556
case signedIn(AuthDataResult?)
5657
}
5758

59+
60+
5861
@MainActor
5962
private final class AuthListenerManager {
6063
private var authStateHandle: AuthStateDidChangeListenerHandle?
@@ -517,4 +520,203 @@ public extension AuthService {
517520
throw error
518521
}
519522
}
520-
}
523+
}
524+
525+
// MARK: - MFA Methods (Placeholder implementations)
526+
527+
public extension AuthService {
528+
func startMfaEnrollment(type: SecondFactorType, accountName: String? = nil,
529+
issuer: String? = nil) async throws -> EnrollmentSession {
530+
guard let user = auth.currentUser else {
531+
throw AuthServiceError.noCurrentUser
532+
}
533+
534+
// Check if MFA is enabled in configuration
535+
guard configuration.mfaEnabled else {
536+
throw AuthServiceError.multiFactorAuth("MFA is not enabled in configuration, please enable `AuthConfiguration.mfaEnabled`")
537+
}
538+
539+
// Check if the requested factor type is allowed
540+
guard configuration.allowedSecondFactors.contains(type) else {
541+
throw AuthServiceError
542+
.multiFactorAuth(
543+
"The requested MFA factor type '\(type)' is not allowed in AuthConfiguration.allowedSecondFactors"
544+
)
545+
}
546+
547+
let multiFactorUser = user.multiFactor
548+
549+
// Get the multi-factor session
550+
let session = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<
551+
MultiFactorSession,
552+
Error
553+
>) in
554+
multiFactorUser.getSessionWithCompletion { session, error in
555+
if let error = error {
556+
continuation.resume(throwing: error)
557+
} else if let session = session {
558+
continuation.resume(returning: session)
559+
} else {
560+
continuation.resume(throwing: AuthServiceError.multiFactorAuth("Failed to get MFA session for '\(type)'"))
561+
}
562+
}
563+
}
564+
565+
switch type {
566+
case .sms:
567+
// For SMS, we just return the session - phone number will be provided in
568+
// sendSmsVerificationForEnrollment
569+
return EnrollmentSession(
570+
type: .sms,
571+
session: session,
572+
status: .initiated
573+
)
574+
575+
case .totp:
576+
// For TOTP, generate the secret and QR code
577+
let totpSecret = try await TOTPMultiFactorGenerator.generateSecret(with: session)
578+
579+
// Generate QR code URL
580+
let resolvedAccountName = accountName ?? user.email ?? "User"
581+
let resolvedIssuer = issuer ?? configuration.mfaIssuer
582+
583+
let qrCodeURL = totpSecret.generateQRCodeURL(
584+
withAccountName: resolvedAccountName,
585+
issuer: resolvedIssuer
586+
)
587+
588+
let totpInfo = TOTPEnrollmentInfo(
589+
sharedSecretKey: totpSecret.sharedSecretKey(),
590+
qrCodeURL: URL(string: qrCodeURL),
591+
accountName: resolvedAccountName,
592+
issuer: resolvedIssuer,
593+
verificationStatus: .pending
594+
)
595+
596+
return EnrollmentSession(
597+
type: .totp,
598+
session: session,
599+
totpInfo: totpInfo,
600+
status: .initiated,
601+
_totpSecret: totpSecret
602+
)
603+
}
604+
}
605+
606+
func sendSmsVerificationForEnrollment(session: EnrollmentSession,
607+
phoneNumber: String) async throws -> String {
608+
// Validate session
609+
guard session.type == .sms else {
610+
throw AuthServiceError.multiFactorAuth("Session is not configured for SMS enrollment")
611+
}
612+
613+
guard session.canProceed else {
614+
if session.isExpired {
615+
throw AuthServiceError.multiFactorAuth("Enrollment session has expired")
616+
} else {
617+
throw AuthServiceError
618+
.multiFactorAuth("Session is not in a valid state for SMS verification")
619+
}
620+
}
621+
622+
// Validate phone number format
623+
guard !phoneNumber.isEmpty else {
624+
throw AuthServiceError.multiFactorAuth("Phone number cannot be empty for SMS enrollment")
625+
}
626+
627+
// Send SMS verification using Firebase Auth PhoneAuthProvider
628+
let verificationID =
629+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<
630+
String,
631+
Error
632+
>) in
633+
PhoneAuthProvider.provider().verifyPhoneNumber(
634+
phoneNumber,
635+
uiDelegate: nil,
636+
multiFactorSession: session.session
637+
) { verificationID, error in
638+
if let error = error {
639+
continuation.resume(throwing: error)
640+
} else if let verificationID = verificationID {
641+
continuation.resume(returning: verificationID)
642+
} else {
643+
continuation
644+
.resume(throwing: AuthServiceError
645+
.multiFactorAuth("Failed to send SMS verification code to verify phone number"))
646+
}
647+
}
648+
}
649+
650+
return verificationID
651+
}
652+
653+
func completeEnrollment(session: EnrollmentSession, verificationId: String?,
654+
verificationCode: String, displayName: String) async throws {
655+
// Validate session state
656+
guard session.canProceed else {
657+
if session.isExpired {
658+
throw AuthServiceError.multiFactorAuth("Enrollment session has expired, cannot complete enrollment")
659+
} else {
660+
throw AuthServiceError.multiFactorAuth("Enrollment session is not in a valid state for completion")
661+
}
662+
}
663+
664+
// Validate verification code
665+
guard !verificationCode.isEmpty else {
666+
throw AuthServiceError.multiFactorAuth("Verification code cannot be empty")
667+
}
668+
669+
guard let user = auth.currentUser else {
670+
throw AuthServiceError.noCurrentUser
671+
}
672+
673+
let multiFactorUser = user.multiFactor
674+
675+
// Create the appropriate assertion based on factor type
676+
let assertion: MultiFactorAssertion
677+
678+
switch session.type {
679+
case .sms:
680+
// For SMS, we need the verification ID
681+
guard let verificationId = verificationId else {
682+
throw AuthServiceError
683+
.multiFactorAuth("Verification ID is required for SMS enrollment")
684+
}
685+
686+
// Create phone credential and assertion
687+
let credential = PhoneAuthProvider.provider().credential(
688+
withVerificationID: verificationId,
689+
verificationCode: verificationCode
690+
)
691+
assertion = PhoneMultiFactorGenerator.assertion(with: credential)
692+
693+
case .totp:
694+
// For TOTP, we need the secret from the session
695+
guard let totpInfo = session.totpInfo else {
696+
throw AuthServiceError
697+
.multiFactorAuth("TOTP info is missing from enrollment session")
698+
}
699+
700+
// Use the stored TOTP secret from the enrollment session
701+
guard let secret = session._totpSecret else {
702+
throw AuthServiceError
703+
.multiFactorAuth("TOTP secret is missing from enrollment session")
704+
}
705+
706+
// The concrete type is FirebaseAuth.TOTPSecret (kept as AnyObject to avoid exposing it)
707+
guard let totpSecret = secret as? TOTPSecret else {
708+
throw AuthServiceError
709+
.multiFactorAuth("Invalid TOTP secret type in enrollment session")
710+
}
711+
712+
assertion = TOTPMultiFactorGenerator.assertionForEnrollment(
713+
with: totpSecret,
714+
oneTimePassword: verificationCode
715+
)
716+
}
717+
718+
// Complete the enrollment
719+
try await user.multiFactor.enroll(with: assertion, displayName: displayName)
720+
currentUser = auth.currentUser
721+
}
722+
}

0 commit comments

Comments
 (0)