Skip to content

Commit c1d54e1

Browse files
refactor: add phone auth reauth and move reauth out of authservice
1 parent bc65909 commit c1d54e1

10 files changed

Lines changed: 499 additions & 70 deletions

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public enum AuthServiceError: LocalizedError {
7373
case clientIdNotFound(String)
7474
case notConfiguredActionCodeSettings(String)
7575
case reauthenticationRequired(String)
76+
case phoneReauthenticationRequired(phoneNumber: String)
7677
case invalidCredentials(String)
7778
case signInFailed(underlying: Error)
7879
case accountConflict(AccountConflictContext)
@@ -94,6 +95,8 @@ public enum AuthServiceError: LocalizedError {
9495
return description
9596
case let .reauthenticationRequired(description):
9697
return description
98+
case let .phoneReauthenticationRequired(phoneNumber):
99+
return "Phone reauthentication required for \(phoneNumber)"
97100
case let .invalidCredentials(description):
98101
return description
99102
// Use when failed to sign-in with Firebase

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 44 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -301,19 +301,15 @@ public extension AuthService {
301301
throw AuthServiceError.noCurrentUser
302302
}
303303

304-
try await withReauthenticationIfNeeded(on: user) {
305-
try await user.delete()
306-
}
304+
try await user.delete()
307305
}
308306

309307
func updatePassword(to password: String) async throws {
310308
guard let user = auth.currentUser else {
311309
throw AuthServiceError.noCurrentUser
312310
}
313311

314-
try await withReauthenticationIfNeeded(on: user) {
315-
try await user.updatePassword(to: password)
316-
}
312+
try await user.updatePassword(to: password)
317313
}
318314
}
319315

@@ -708,44 +704,18 @@ public extension AuthService {
708704
}
709705

710706
// Complete the enrollment
711-
try await withReauthenticationIfNeeded(on: user) {
712-
try await user.multiFactor.enroll(with: assertion, displayName: displayName)
713-
}
707+
try await user.multiFactor.enroll(with: assertion, displayName: displayName)
714708
currentUser = auth.currentUser
715709
}
716710

717-
/// Gets the provider ID that was used for the current sign-in session
718-
private func getCurrentSignInProvider() async throws -> String {
711+
/// Reauthenticates the current user with their sign-in provider
712+
/// - Throws: `AuthServiceError.phoneReauthenticationRequired` for phone auth users
713+
/// - Throws: `AuthServiceError.providerNotFound` if provider is not configured
714+
func reauthenticate() async throws {
719715
guard let user = currentUser else {
720716
throw AuthServiceError.noCurrentUser
721717
}
722718

723-
// Get the ID token result which contains the signInProvider claim
724-
let tokenResult = try await user.getIDTokenResult(forcingRefresh: false)
725-
726-
// The signInProvider property tells us which provider was used for this session
727-
let signInProvider = tokenResult.signInProvider
728-
729-
// If signInProvider is not empty, use it
730-
if !signInProvider.isEmpty {
731-
return signInProvider
732-
}
733-
734-
// Fallback: if signInProvider is empty, try to infer from providerData
735-
// Prefer non-password providers as they're more specific
736-
let providerId = user.providerData.first(where: { $0.providerID != "password" })?.providerID
737-
?? user.providerData.first?.providerID
738-
739-
guard let providerId = providerId else {
740-
throw AuthServiceError.reauthenticationRequired(
741-
"Unable to determine sign-in provider for reauthentication"
742-
)
743-
}
744-
745-
return providerId
746-
}
747-
748-
func reauthenticateCurrentUser(on user: User) async throws {
749719
// Get the provider from the token instead of stored credential
750720
let providerId = try await getCurrentSignInProvider()
751721

@@ -761,36 +731,52 @@ public extension AuthService {
761731
}
762732

763733
let credential = try await emailProvider.createReauthCredential(email: email)
764-
_ = try await user.reauthenticate(with: credential)
734+
try await user.reauthenticate(with: credential)
765735
} else if providerId == PhoneAuthProviderID {
766-
// Phone auth requires manual reauthentication via sign out and sign in otherwise it will take
767-
// the user out of the existing flow
768-
throw AuthServiceError.reauthenticationRequired(
769-
"Phone authentication requires you to sign out and sign in again to continue"
770-
)
736+
guard let phoneNumber = user.phoneNumber else {
737+
throw AuthServiceError.invalidCredentials("User does not have a phone number")
738+
}
739+
740+
// Throw error with context for phone reauthentication
741+
throw AuthServiceError.phoneReauthenticationRequired(phoneNumber: phoneNumber)
771742
} else if let matchingProvider = providers.first(where: { $0.id == providerId }),
772743
let credentialProvider = matchingProvider.provider as? CredentialAuthProviderSwift {
773744
let credential = try await credentialProvider.createAuthCredential()
774-
_ = try await user.reauthenticate(with: credential)
745+
try await user.reauthenticate(with: credential)
775746
} else {
776747
throw AuthServiceError.providerNotFound("No provider found for \(providerId)")
777748
}
778749
}
779750

780-
private func withReauthenticationIfNeeded(on user: User,
781-
operation: () async throws -> Void) async throws {
782-
do {
783-
try await operation()
784-
} catch let error as NSError {
785-
if error.domain == AuthErrorDomain,
786-
error.code == AuthErrorCode.requiresRecentLogin.rawValue || error.code == AuthErrorCode
787-
.userTokenExpired.rawValue {
788-
try await reauthenticateCurrentUser(on: user)
789-
try await operation()
790-
} else {
791-
throw error
792-
}
751+
/// Gets the provider ID that was used for the current sign-in session
752+
func getCurrentSignInProvider() async throws -> String {
753+
guard let user = currentUser else {
754+
throw AuthServiceError.noCurrentUser
755+
}
756+
757+
// Get the ID token result which contains the signInProvider claim
758+
let tokenResult = try await user.getIDTokenResult(forcingRefresh: false)
759+
760+
// The signInProvider property tells us which provider was used for this session
761+
let signInProvider = tokenResult.signInProvider
762+
763+
// If signInProvider is not empty, use it
764+
if !signInProvider.isEmpty {
765+
return signInProvider
766+
}
767+
768+
// Fallback: if signInProvider is empty, try to infer from providerData
769+
// Prefer non-password providers as they're more specific
770+
let providerId = user.providerData.first(where: { $0.providerID != "password" })?.providerID
771+
?? user.providerData.first?.providerID
772+
773+
guard let providerId = providerId else {
774+
throw AuthServiceError.reauthenticationRequired(
775+
"Unable to determine sign-in provider for reauthentication"
776+
)
793777
}
778+
779+
return providerId
794780
}
795781

796782
func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] {
@@ -800,9 +786,7 @@ public extension AuthService {
800786

801787
let multiFactorUser = user.multiFactor
802788

803-
try await withReauthenticationIfNeeded(on: user) {
804-
try await multiFactorUser.unenroll(withFactorUID: factorUid)
805-
}
789+
try await multiFactorUser.unenroll(withFactorUID: factorUid)
806790

807791
// This is the only we to get the actual latest enrolledFactors
808792
currentUser = Auth.auth().currentUser
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
15+
import FirebaseAuth
16+
import Observation
17+
18+
/// Context information for reauthentication UI
19+
public struct ReauthContext {
20+
public let providerId: String
21+
public let providerName: String
22+
public let phoneNumber: String?
23+
public let email: String?
24+
25+
public init(providerId: String, providerName: String, phoneNumber: String?, email: String?) {
26+
self.providerId = providerId
27+
self.providerName = providerName
28+
self.phoneNumber = phoneNumber
29+
self.email = email
30+
}
31+
32+
public var displayMessage: String {
33+
switch providerId {
34+
case EmailAuthProviderID:
35+
return "Please enter your password to continue"
36+
case PhoneAuthProviderID:
37+
return "Please verify your phone number to continue"
38+
default:
39+
return "Please sign in with \(providerName) to continue"
40+
}
41+
}
42+
}
43+
44+
/// Coordinator for handling reauthentication flows
45+
@MainActor
46+
@Observable
47+
public final class ReauthenticationCoordinator {
48+
public var isReauthenticating = false
49+
public var reauthContext: ReauthContext?
50+
public var showingPhoneReauth = false
51+
52+
private var continuation: CheckedContinuation<Void, Error>?
53+
54+
public init() {}
55+
56+
/// Request reauthentication from the user
57+
public func requestReauth(context: ReauthContext) async throws {
58+
return try await withCheckedThrowingContinuation { continuation in
59+
self.continuation = continuation
60+
self.reauthContext = context
61+
62+
// Show different UI based on provider
63+
if context.providerId == PhoneAuthProviderID {
64+
self.showingPhoneReauth = true
65+
} else {
66+
self.isReauthenticating = true
67+
}
68+
}
69+
}
70+
71+
/// Called when reauthentication completes successfully
72+
public func reauthCompleted() {
73+
continuation?.resume()
74+
cleanup()
75+
}
76+
77+
/// Called when reauthentication is cancelled
78+
public func reauthCancelled() {
79+
continuation?.resume(throwing: AuthServiceError.signInCancelled("Reauthentication cancelled"))
80+
cleanup()
81+
}
82+
83+
private func cleanup() {
84+
continuation = nil
85+
isReauthenticating = false
86+
showingPhoneReauth = false
87+
reauthContext = nil
88+
}
89+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
15+
import FirebaseAuth
16+
17+
/// Execute an operation that may require reauthentication
18+
/// - Parameters:
19+
/// - authService: The auth service managing authentication
20+
/// - coordinator: The coordinator managing reauthentication UI
21+
/// - operation: The operation to execute
22+
/// - Throws: Rethrows errors from the operation or reauthentication process
23+
@MainActor
24+
public func withReauthenticationIfNeeded(authService: AuthService,
25+
coordinator: ReauthenticationCoordinator,
26+
operation: @escaping () async throws
27+
-> Void) async throws {
28+
do {
29+
try await operation()
30+
} catch let error as NSError {
31+
// Check if reauthentication is needed
32+
if error.domain == AuthErrorDomain,
33+
error.code == AuthErrorCode.requiresRecentLogin.rawValue ||
34+
error.code == AuthErrorCode.userTokenExpired.rawValue {
35+
// Determine the provider context
36+
let providerId = try await authService.getCurrentSignInProvider()
37+
let context = ReauthContext(
38+
providerId: providerId,
39+
providerName: getProviderDisplayName(providerId),
40+
phoneNumber: authService.currentUser?.phoneNumber,
41+
email: authService.currentUser?.email
42+
)
43+
44+
// Request reauthentication from user with context
45+
try await coordinator.requestReauth(context: context)
46+
47+
// Retry the operation after successful reauth
48+
try await operation()
49+
} else {
50+
throw error
51+
}
52+
}
53+
}
54+
55+
/// Get a user-friendly display name for a provider ID
56+
/// - Parameter providerId: The provider ID from Firebase Auth
57+
/// - Returns: A user-friendly name for the provider
58+
public func getProviderDisplayName(_ providerId: String) -> String {
59+
switch providerId {
60+
case EmailAuthProviderID:
61+
return "Email"
62+
case PhoneAuthProviderID:
63+
return "Phone"
64+
case "google.com":
65+
return "Google"
66+
case "apple.com":
67+
return "Apple"
68+
case "facebook.com":
69+
return "Facebook"
70+
case "twitter.com":
71+
return "Twitter"
72+
default:
73+
return providerId
74+
}
75+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public struct MFAEnrolmentView {
3737
@State private var isLoading = false
3838
@State private var displayName = ""
3939
@State private var showCopiedFeedback = false
40+
@State private var reauthCoordinator = ReauthenticationCoordinator()
4041

4142
@FocusState private var focus: FocusableField?
4243

@@ -135,12 +136,17 @@ public struct MFAEnrolmentView {
135136

136137
do {
137138
let code = session.type == .sms ? verificationCode : totpCode
138-
try await authService.completeEnrollment(
139-
session: session,
140-
verificationId: session.verificationId,
141-
verificationCode: code,
142-
displayName: displayName
143-
)
139+
try await withReauthenticationIfNeeded(
140+
authService: authService,
141+
coordinator: reauthCoordinator
142+
) {
143+
try await authService.completeEnrollment(
144+
session: session,
145+
verificationId: session.verificationId,
146+
verificationCode: code,
147+
displayName: displayName
148+
)
149+
}
144150

145151
// Reset form state on success
146152
resetForm()
@@ -281,6 +287,7 @@ extension MFAEnrolmentView: View {
281287
}
282288
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
283289
.safeAreaPadding()
290+
.withReauthentication(coordinator: reauthCoordinator)
284291
.navigationTitle("Two-Factor Authentication")
285292
.onAppear {
286293
// Initialize selected factor type to first allowed type

0 commit comments

Comments
 (0)