Skip to content

feat: anonymous account upgrade with error handling #1247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
29 changes: 23 additions & 6 deletions FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
107 changes: 90 additions & 17 deletions FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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()
Expand All @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should handle the currentUser null state in a separate conditional, otherwise this do-catch has the possibility of executing nothing and returning no error. From a convenience perspective it may also be nice to pass the unwrapped user through to the AccoutMergeConflictContext so the consumer doesn't have to again unwrap currentUser.

In theory currentUser should never be null here, so throwing an error is appropriate.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@morganchen12 - updated. I didn't add User to the exception as it produced an Xcode compiler error: Stored property 'user' of 'Sendable'-conforming struct 'AccountMergeConflictContext' has non-sendable type 'User'

It seems User is non-sendable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fix for that is in the pipeline in upstream Firebase, so maybe leave a TODO and just send the user's UID (String) for now?

} 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
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?")
}
Expand Down