Skip to content

Commit 6639a43

Browse files
committed
Merge branch 'auth-provider-swiftui' into mc/buttons
2 parents 5be4e0d + dfc7e68 commit 6639a43

File tree

11 files changed

+305
-120
lines changed

11 files changed

+305
-120
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: 21 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,6 @@ public enum AuthView {
3535
case emailLink
3636
}
3737

38-
public enum AuthServiceError: Error {
39-
case invalidEmailLink(String)
40-
case notConfiguredProvider(String)
41-
case clientIdNotFound(String)
42-
case notConfiguredActionCodeSettings(String)
43-
}
44-
4538
@MainActor
4639
private final class AuthListenerManager {
4740
private var authStateHandle: AuthStateDidChangeListenerHandle?
@@ -93,6 +86,7 @@ public final class AuthService {
9386
public var authenticationState: AuthenticationState = .unauthenticated
9487
public var authenticationFlow: AuthenticationFlow = .login
9588
public var errorMessage = ""
89+
public let passwordPrompt: PasswordPromptCoordinator = .init()
9690

9791
public var googleProvider: GoogleProviderProtocol?
9892
public var facebookProvider: FacebookProviderProtocol?
@@ -136,9 +130,7 @@ public final class AuthService {
136130
guard let actionCodeSettings = configuration
137131
.emailLinkSignInActionCodeSettings else {
138132
throw AuthServiceError
139-
.notConfiguredActionCodeSettings(
140-
"ActionCodeSettings has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`"
141-
)
133+
.notConfiguredActionCodeSettings
142134
}
143135
return actionCodeSettings
144136
}
@@ -188,7 +180,6 @@ public final class AuthService {
188180
} else {
189181
do {
190182
try await auth.signIn(with: credentials)
191-
signedInCredential = credentials
192183
updateAuthenticationState()
193184
} catch {
194185
authenticationState = .unauthenticated
@@ -217,32 +208,14 @@ public final class AuthService {
217208

218209
// MARK: - User API
219210

220-
extension Date {
221-
func isWithinPast(minutes: Int) -> Bool {
222-
let calendar = Calendar.current
223-
guard let timeAgo = calendar.date(byAdding: .minute, value: -minutes, to: Date()) else {
224-
return false
225-
}
226-
return self >= timeAgo && self <= Date()
227-
}
228-
}
229-
230211
public extension AuthService {
231-
func reauthenticate() async throws {
232-
if let user = auth.currentUser, let credential = signedInCredential {
233-
try await user.reauthenticate(with: credential)
234-
}
235-
}
236-
237212
func deleteUser() async throws {
238213
do {
239-
if let user = auth.currentUser, let lastSignInDate = user.metadata.lastSignInDate {
240-
let needsReauth = !lastSignInDate.isWithinPast(minutes: 5)
241-
if needsReauth {
242-
try await reauthenticate()
243-
}
244-
try await user.delete()
214+
if let user = auth.currentUser {
215+
let operation = EmailPasswordDeleteUserOperation(passwordPrompt: passwordPrompt)
216+
try await operation(on: user)
245217
}
218+
246219
} catch {
247220
errorMessage = string.localizedErrorMessage(
248221
for: error
@@ -265,8 +238,6 @@ public extension AuthService {
265238

266239
do {
267240
try await auth.createUser(withEmail: email, password: password)
268-
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
269-
signedInCredential = credential
270241
updateAuthenticationState()
271242
} catch {
272243
authenticationState = .unauthenticated
@@ -310,9 +281,7 @@ public extension AuthService {
310281
func handleSignInLink(url url: URL) async throws {
311282
do {
312283
guard let email = emailLink else {
313-
throw AuthServiceError.invalidEmailLink(
314-
"Invalid sign in link. Most likely, the link you used has expired. Try signing in again."
315-
)
284+
throw AuthServiceError.invalidEmailLink
316285
}
317286
let link = url.absoluteString
318287
if auth.isSignIn(withEmailLink: link) {
@@ -333,45 +302,25 @@ public extension AuthService {
333302

334303
public extension AuthService {
335304
func signInWithGoogle() async throws {
336-
authenticationState = .authenticating
337-
do {
338-
guard let clientID = auth.app?.options.clientID else {
339-
throw AuthServiceError
340-
.clientIdNotFound(
341-
"OAuth client ID not found. Please make sure Google Sign-In is enabled in the Firebase console. You may have to download a new GoogleService-Info.plist file after enabling Google Sign-In."
342-
)
343-
}
344-
let credential = try await safeGoogleProvider.signInWithGoogle(clientID: clientID)
345-
346-
try await signIn(credentials: credential)
347-
updateAuthenticationState()
348-
} catch {
349-
authenticationState = .unauthenticated
350-
errorMessage = string.localizedErrorMessage(
351-
for: error
352-
)
353-
throw error
305+
guard let clientID = auth.app?.options.clientID else {
306+
throw AuthServiceError
307+
.clientIdNotFound(
308+
"OAuth client ID not found. Please make sure Google Sign-In is enabled in the Firebase console. You may have to download a new GoogleService-Info.plist file after enabling Google Sign-In."
309+
)
354310
}
311+
let credential = try await safeGoogleProvider.signInWithGoogle(clientID: clientID)
312+
313+
try await signIn(credentials: credential)
355314
}
356315
}
357316

358317
// MARK: - Facebook Sign In
359318

360319
public extension AuthService {
361320
func signInWithFacebook(limitedLogin: Bool = true) async throws {
362-
authenticationState = .authenticating
363-
do {
364-
let credential = try await safeFacebookProvider
365-
.signInWithFacebook(isLimitedLogin: limitedLogin)
366-
try await signIn(credentials: credential)
367-
updateAuthenticationState()
368-
} catch {
369-
authenticationState = .unauthenticated
370-
errorMessage = string.localizedErrorMessage(
371-
for: error
372-
)
373-
throw error
374-
}
321+
let credential = try await safeFacebookProvider
322+
.signInWithFacebook(isLimitedLogin: limitedLogin)
323+
try await signIn(credentials: credential)
375324
}
376325
}
377326

@@ -390,18 +339,8 @@ public extension AuthService {
390339
}
391340

392341
func signInWithPhoneNumber(verificationID: String, verificationCode: String) async throws {
393-
authenticationState = .authenticating
394-
do {
395-
let credential = PhoneAuthProvider.provider()
396-
.credential(withVerificationID: verificationID, verificationCode: verificationCode)
397-
try await signIn(credentials: credential)
398-
updateAuthenticationState()
399-
} catch {
400-
authenticationState = .unauthenticated
401-
errorMessage = string.localizedErrorMessage(
402-
for: error
403-
)
404-
throw error
405-
}
342+
let credential = PhoneAuthProvider.provider()
343+
.credential(withVerificationID: verificationID, verificationCode: verificationCode)
344+
try await signIn(credentials: credential)
406345
}
407346
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,7 @@ public struct EmailAuthView {
3636
private func signInWithEmailPassword() async {
3737
do {
3838
try await authService.signIn(withEmail: email, password: password)
39-
} catch let error as NSError {
40-
switch AuthErrorCode(rawValue: error.code) {
41-
// case .credentialAlreadyInUse:
42-
default:
43-
// TODO: - how are we handling this?
44-
if let updatedCredential = error
45-
.userInfo[AuthErrorUserInfoUpdatedCredentialKey] as? AuthCredential {
46-
// user ought to merge accounts on their backend here
47-
}
48-
}
49-
}
39+
} catch {}
5040
}
5141

5242
private func createUserWithEmailPassword() async {
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+
}

0 commit comments

Comments
 (0)