diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index 7302beeda7..052e1e07c9 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -1,31 +1,48 @@ +import FirebaseAuth import SwiftUI +public struct AccountMergeConflictContext: LocalizedError { + public let credential: AuthCredential + public let underlyingError: Error + public let message: String + + public var errorDescription: String? { + return message + } +} + public enum AuthServiceError: LocalizedError { - case invalidEmailLink + case noCurrentUser + case invalidEmailLink(String) case notConfiguredProvider(String) case clientIdNotFound(String) - case notConfiguredActionCodeSettings + case notConfiguredActionCodeSettings(String) case reauthenticationRequired(String) case invalidCredentials(String) case signInFailed(underlying: Error) + case accountMergeConflict(context: AccountMergeConflictContext) public var errorDescription: String? { switch self { - case .invalidEmailLink: - return "Invalid sign in link. Most likely, the link you used has expired. Try signing in again." + case .noCurrentUser: + return "No user is currently signed in." + case let .invalidEmailLink(description): + return description case let .notConfiguredProvider(description): return description case let .clientIdNotFound(description): return description - case .notConfiguredActionCodeSettings: - return "ActionCodeSettings has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`" + case let .notConfiguredActionCodeSettings(description): + return description case let .reauthenticationRequired(description): return description case let .invalidCredentials(description): return description case let .signInFailed(underlying: error): return "Failed to sign in: \(error.localizedDescription)" + case let .accountMergeConflict(context): + return context.errorDescription } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 69e3536e14..696964ad99 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -131,7 +131,9 @@ public final class AuthService { guard let actionCodeSettings = configuration .emailLinkSignInActionCodeSettings else { throw AuthServiceError - .notConfiguredActionCodeSettings + .notConfiguredActionCodeSettings( + "ActionCodeSettings has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`" + ) } return actionCodeSettings } @@ -148,6 +150,10 @@ public final class AuthService { errorMessage = "" } + public var shouldHandleAnonymousUpgrade: Bool { + currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers + } + public func signOut() async throws { do { try await auth.signOut() @@ -174,21 +180,40 @@ public final class AuthService { } } - public func signIn(credentials credentials: AuthCredential) async throws { + public func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws { + if currentUser == nil { + throw AuthServiceError.noCurrentUser + } + do { + try await currentUser?.link(with: credentials) + } catch let error as NSError { + if error.code == AuthErrorCode.emailAlreadyInUse.rawValue { + let context = AccountMergeConflictContext( + credential: credentials, + underlyingError: error, + message: "Unable to merge accounts. Use the credential in the context to resolve the conflict." + ) + throw AuthServiceError.accountMergeConflict(context: context) + } + throw error + } + } + + public func signIn(credentials: AuthCredential) async throws { authenticationState = .authenticating - if currentUser?.isAnonymous == true, configuration.shouldAutoUpgradeAnonymousUsers { - try await linkAccounts(credentials: credentials) - } else { - do { + do { + if shouldHandleAnonymousUpgrade { + try await handleAutoUpgradeAnonymousUser(credentials: credentials) + } else { try await auth.signIn(with: credentials) - updateAuthenticationState() - } catch { - authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) - throw error } + updateAuthenticationState() + } catch { + authenticationState = .unauthenticated + errorMessage = string.localizedErrorMessage( + for: error + ) + throw error } } @@ -256,7 +281,12 @@ public extension AuthService { authenticationState = .authenticating do { - try await auth.createUser(withEmail: email, password: password) + if shouldHandleAnonymousUpgrade { + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + try await handleAutoUpgradeAnonymousUser(credentials: credential) + } else { + try await auth.createUser(withEmail: email, password: password) + } updateAuthenticationState() } catch { authenticationState = .unauthenticated @@ -284,7 +314,7 @@ public extension AuthService { public extension AuthService { func sendEmailSignInLink(to email: String) async throws { do { - let actionCodeSettings = try safeActionCodeSettings() + let actionCodeSettings = try updateActionCodeSettings() try await auth.sendSignInLink( toEmail: email, actionCodeSettings: actionCodeSettings @@ -300,11 +330,27 @@ public extension AuthService { func handleSignInLink(url url: URL) async throws { do { guard let email = emailLink else { - throw AuthServiceError.invalidEmailLink + throw AuthServiceError + .invalidEmailLink("email address is missing from app storage. Is this the same device?") } let link = url.absoluteString + guard let continueUrl = CommonUtils.getQueryParamValue(from: link, paramName: "continueUrl") + else { + throw AuthServiceError + .invalidEmailLink("`continueUrl` parameter is missing from the email link URL") + } + if auth.isSignIn(withEmailLink: link) { - let result = try await auth.signIn(withEmail: email, link: link) + let anonymousUserID = CommonUtils.getQueryParamValue( + from: continueUrl, + paramName: "ui_auid" + ) + if shouldHandleAnonymousUpgrade, anonymousUserID == currentUser?.uid { + let credential = EmailAuthProvider.credential(withEmail: email, link: link) + try await handleAutoUpgradeAnonymousUser(credentials: credential) + } else { + let result = try await auth.signIn(withEmail: email, link: link) + } updateAuthenticationState() emailLink = nil } @@ -315,6 +361,33 @@ public extension AuthService { throw error } } + + private func updateActionCodeSettings() throws -> ActionCodeSettings { + let actionCodeSettings = try safeActionCodeSettings() + guard var urlComponents = URLComponents(string: actionCodeSettings.url!.absoluteString) else { + throw AuthServiceError + .notConfiguredActionCodeSettings( + "ActionCodeSettings.url has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`" + ) + } + + var queryItems: [URLQueryItem] = [] + + if shouldHandleAnonymousUpgrade { + if let currentUser = currentUser { + let anonymousUID = currentUser.uid + let auidItem = URLQueryItem(name: "ui_auid", value: anonymousUID) + queryItems.append(auidItem) + } + } + + urlComponents.queryItems = queryItems + if let finalURL = urlComponents.url { + actionCodeSettings.url = finalURL + } + + return actionCodeSettings + } } // MARK: - Google Sign In diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/CommonUtils.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/CommonUtils.swift index 7be8e14f8c..4fbc15d137 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/CommonUtils.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/CommonUtils.swift @@ -47,6 +47,14 @@ public class CommonUtils { } return hash.map { String(format: "%02x", $0) }.joined() } + + public static func getQueryParamValue(from urlString: String, paramName: String) -> String? { + guard let urlComponents = URLComponents(string: urlString) else { + return nil + } + + return urlComponents.queryItems?.first(where: { $0.name == paramName })?.value + } } public extension FirebaseOptions { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 1e7c3c3caa..fa8b614235 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -126,7 +126,7 @@ extension EmailAuthView: View { .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) Button(action: { - authService.authView = .passwordRecovery + authService.authView = .emailLink }) { Text("Prefer Email link sign-in?") }