Skip to content

Commit 16131d6

Browse files
refactor: use retry reauthenticate logic and create password prompt
1 parent b92e0aa commit 16131d6

File tree

6 files changed

+205
-52
lines changed

6 files changed

+205
-52
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
2+
import SwiftUI
3+
4+
public enum AuthServiceError: LocalizedError {
5+
case invalidEmailLink
6+
case notConfiguredProvider(String)
7+
case clientIdNotFound(String)
8+
case notConfiguredActionCodeSettings
9+
case reauthenticationRequired(String)
10+
case invalidCredentials(String)
11+
case signInFailed(underlying: Error)
12+
13+
public var errorDescription: String? {
14+
switch self {
15+
case .invalidEmailLink:
16+
return "Invalid sign in link. Most likely, the link you used has expired. Try signing in again."
17+
case let .notConfiguredProvider(description):
18+
return description
19+
case let .clientIdNotFound(description):
20+
return description
21+
case .notConfiguredActionCodeSettings:
22+
return "ActionCodeSettings has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`"
23+
case let .reauthenticationRequired(description):
24+
return description
25+
case let .invalidCredentials(description):
26+
return description
27+
case let .signInFailed(underlying: error):
28+
return "Failed to sign in: \(error.localizedDescription)"
29+
}
30+
}
31+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
@preconcurrency import FirebaseAuth
2+
import Observation
3+
4+
protocol EmailPasswordOperationReauthentication {
5+
var passwordPrompt: PasswordPromptCoordinator { get }
6+
}
7+
8+
extension EmailPasswordOperationReauthentication {
9+
func reauthenticate() async throws -> AuthenticationToken {
10+
guard let user = Auth.auth().currentUser else {
11+
throw AuthServiceError.reauthenticationRequired("No user currently signed-in")
12+
}
13+
14+
guard let email = user.email else {
15+
throw AuthServiceError.invalidCredentials("User does not have an email address")
16+
}
17+
18+
do {
19+
let password = try await passwordPrompt.confirmPassword()
20+
21+
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
22+
try await Auth.auth().currentUser?.reauthenticate(with: credential)
23+
24+
return .firebase("")
25+
} catch {
26+
throw AuthServiceError.signInFailed(underlying: error)
27+
}
28+
}
29+
}
30+
31+
class EmailPasswordDeleteUserOperation: DeleteUserOperation,
32+
EmailPasswordOperationReauthentication {
33+
let passwordPrompt: PasswordPromptCoordinator
34+
35+
init(passwordPrompt: PasswordPromptCoordinator) {
36+
self.passwordPrompt = passwordPrompt
37+
}
38+
}
39+
40+
@MainActor
41+
@Observable
42+
public final class PasswordPromptCoordinator {
43+
var isPromptingPassword = false
44+
private var continuation: CheckedContinuation<String, Error>?
45+
46+
func confirmPassword() async throws -> String {
47+
return try await withCheckedThrowingContinuation { continuation in
48+
self.continuation = continuation
49+
self.isPromptingPassword = true
50+
}
51+
}
52+
53+
func submit(password: String) {
54+
continuation?.resume(returning: password)
55+
cleanup()
56+
}
57+
58+
func cancel() {
59+
continuation?
60+
.resume(throwing: AuthServiceError.reauthenticationRequired("Password entry cancelled"))
61+
cleanup()
62+
}
63+
64+
private func cleanup() {
65+
continuation = nil
66+
isPromptingPassword = false
67+
}
68+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import AuthenticationServices
2+
import FirebaseAuth
3+
4+
extension NSError {
5+
var requiresReauthentication: Bool {
6+
domain == AuthErrorDomain && code == AuthErrorCode.requiresRecentLogin.rawValue
7+
}
8+
9+
var credentialAlreadyInUse: Bool {
10+
domain == AuthErrorDomain && code == AuthErrorCode.credentialAlreadyInUse.rawValue
11+
}
12+
}
13+
14+
enum AuthenticationToken {
15+
case apple(ASAuthorizationAppleIDCredential, String)
16+
case firebase(String)
17+
}
18+
19+
protocol AuthenticatedOperation {
20+
func callAsFunction(on user: User) async throws
21+
func reauthenticate() async throws -> AuthenticationToken
22+
func performOperation(on user: User, with token: AuthenticationToken?) async throws
23+
}
24+
25+
extension AuthenticatedOperation {
26+
func callAsFunction(on user: User) async throws {
27+
do {
28+
try await performOperation(on: user, with: nil)
29+
} catch let error as NSError where error.requiresReauthentication {
30+
let token = try await reauthenticate()
31+
try await performOperation(on: user, with: token)
32+
} catch AuthServiceError.reauthenticationRequired {
33+
let token = try await reauthenticate()
34+
try await performOperation(on: user, with: token)
35+
}
36+
}
37+
}
38+
39+
protocol DeleteUserOperation: AuthenticatedOperation {}
40+
41+
extension DeleteUserOperation {
42+
func performOperation(on user: User, with _: AuthenticationToken? = nil) async throws {
43+
try await user.delete()
44+
}
45+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 7 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@ public enum AuthView {
3131
case emailLink
3232
}
3333

34-
public enum AuthServiceError: Error {
35-
case invalidEmailLink(String)
36-
case notConfiguredProvider(String)
37-
case clientIdNotFound(String)
38-
case notConfiguredActionCodeSettings(String)
39-
}
40-
4134
@MainActor
4235
private final class AuthListenerManager {
4336
private var authStateHandle: AuthStateDidChangeListenerHandle?
@@ -89,12 +82,12 @@ public final class AuthService {
8982
public var authenticationState: AuthenticationState = .unauthenticated
9083
public var authenticationFlow: AuthenticationFlow = .login
9184
public var errorMessage = ""
85+
public let passwordPrompt: PasswordPromptCoordinator = .init()
9286

9387
private var listenerManager: AuthListenerManager?
9488
private let googleProvider: GoogleProviderProtocol?
9589
private let facebookProvider: FacebookProviderProtocol?
9690
private let phoneAuthProvider: PhoneAuthProviderProtocol?
97-
private var signedInCredential: AuthCredential?
9891

9992
private var safeGoogleProvider: GoogleProviderProtocol {
10093
get throws {
@@ -131,9 +124,7 @@ public final class AuthService {
131124
guard let actionCodeSettings = configuration
132125
.emailLinkSignInActionCodeSettings else {
133126
throw AuthServiceError
134-
.notConfiguredActionCodeSettings(
135-
"ActionCodeSettings has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`"
136-
)
127+
.notConfiguredActionCodeSettings
137128
}
138129
return actionCodeSettings
139130
}
@@ -183,7 +174,6 @@ public final class AuthService {
183174
} else {
184175
do {
185176
try await auth.signIn(with: credentials)
186-
signedInCredential = credentials
187177
updateAuthenticationState()
188178
} catch {
189179
authenticationState = .unauthenticated
@@ -212,32 +202,14 @@ public final class AuthService {
212202

213203
// MARK: - User API
214204

215-
extension Date {
216-
func isWithinPast(minutes: Int) -> Bool {
217-
let calendar = Calendar.current
218-
guard let timeAgo = calendar.date(byAdding: .minute, value: -minutes, to: Date()) else {
219-
return false
220-
}
221-
return self >= timeAgo && self <= Date()
222-
}
223-
}
224-
225205
public extension AuthService {
226-
func reauthenticate() async throws {
227-
if let user = auth.currentUser, let credential = signedInCredential {
228-
try await user.reauthenticate(with: credential)
229-
}
230-
}
231-
232206
func deleteUser() async throws {
233207
do {
234-
if let user = auth.currentUser, let lastSignInDate = user.metadata.lastSignInDate {
235-
let needsReauth = !lastSignInDate.isWithinPast(minutes: 5)
236-
if needsReauth {
237-
try await reauthenticate()
238-
}
239-
try await user.delete()
208+
if let user = auth.currentUser {
209+
let operation = EmailPasswordDeleteUserOperation(passwordPrompt: passwordPrompt)
210+
try await operation(on: user)
240211
}
212+
241213
} catch {
242214
errorMessage = string.localizedErrorMessage(
243215
for: error
@@ -261,7 +233,6 @@ public extension AuthService {
261233
do {
262234
try await auth.createUser(withEmail: email, password: password)
263235
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
264-
signedInCredential = credential
265236
updateAuthenticationState()
266237
} catch {
267238
authenticationState = .unauthenticated
@@ -305,9 +276,7 @@ public extension AuthService {
305276
func handleSignInLink(url url: URL) async throws {
306277
do {
307278
guard let email = emailLink else {
308-
throw AuthServiceError.invalidEmailLink(
309-
"Invalid sign in link. Most likely, the link you used has expired. Try signing in again."
310-
)
279+
throw AuthServiceError.invalidEmailLink
311280
}
312281
let link = url.absoluteString
313282
if auth.isSignIn(withEmailLink: link) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import SwiftUI
2+
3+
struct PasswordPromptSheet {
4+
@Bindable var coordinator: PasswordPromptCoordinator
5+
@State private var password = ""
6+
}
7+
8+
extension PasswordPromptSheet: View {
9+
var body: some View {
10+
VStack(spacing: 20) {
11+
SecureField("Enter Password", text: $password)
12+
.textFieldStyle(.roundedBorder)
13+
.padding()
14+
15+
HStack {
16+
Button("Cancel") {
17+
coordinator.cancel()
18+
}
19+
Spacer()
20+
Button("Submit") {
21+
coordinator.submit(password: password)
22+
}
23+
.disabled(password.isEmpty)
24+
}
25+
.padding(.horizontal)
26+
}
27+
.padding()
28+
}
29+
}
Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import SwiftUI
22

3+
@MainActor
34
public struct SignedInView {
45
@Environment(AuthService.self) private var authService
56
}
67

78
extension SignedInView: View {
9+
private var isShowingPasswordPrompt: Binding<Bool> {
10+
Binding(
11+
get: { authService.passwordPrompt.isPromptingPassword },
12+
set: { authService.passwordPrompt.isPromptingPassword = $0 }
13+
)
14+
}
15+
816
public var body: some View {
917
VStack {
1018
Text("Signed in")
@@ -13,22 +21,25 @@ extension SignedInView: View {
1321
if authService.currentUser?.isEmailVerified == false {
1422
VerifyEmailView()
1523
}
16-
}
17-
Button("Sign out") {
18-
Task {
19-
do {
20-
try await authService.signOut()
21-
} catch {}
24+
25+
Button("Sign out") {
26+
Task {
27+
do {
28+
try await authService.signOut()
29+
} catch {}
30+
}
2231
}
23-
}
24-
Divider()
25-
Button("Delete account") {
26-
Task {
27-
do {
28-
try await authService.deleteUser()
29-
} catch {}
32+
Divider()
33+
Button("Delete account") {
34+
Task {
35+
do {
36+
try await authService.deleteUser()
37+
} catch {}
38+
}
3039
}
40+
Text(authService.errorMessage).foregroundColor(.red)
41+
}.sheet(isPresented: isShowingPasswordPrompt) {
42+
PasswordPromptSheet(coordinator: authService.passwordPrompt)
3143
}
32-
Text(authService.errorMessage).foregroundColor(.red)
3344
}
3445
}

0 commit comments

Comments
 (0)