Skip to content

Commit d9e7b79

Browse files
Merge pull request #1292 from firebase/account-exists-diff-credential
2 parents 836154b + 947f2d1 commit d9e7b79

File tree

14 files changed

+331
-177
lines changed

14 files changed

+331
-177
lines changed

FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import SwiftUI
2020
@MainActor
2121
public struct SignInWithAppleButton {
2222
@Environment(AuthService.self) private var authService
23-
@Environment(\.signInWithMergeConflictHandler) private var signInHandler
2423
let provider: AuthProviderSwift
2524
public init(provider: AuthProviderSwift) {
2625
self.provider = provider
@@ -35,13 +34,7 @@ extension SignInWithAppleButton: View {
3534
accessibilityId: "sign-in-with-apple-button"
3635
) {
3736
Task {
38-
if let handler = signInHandler {
39-
try? await handler(authService) {
40-
try await authService.signIn(provider)
41-
}
42-
} else {
43-
try? await authService.signIn(provider)
44-
}
37+
try? await authService.signIn(provider)
4538
}
4639
}
4740
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,56 @@
1515
import FirebaseAuth
1616
import SwiftUI
1717

18-
public struct AccountMergeConflictContext: LocalizedError {
18+
/// Describes the specific type of account conflict that occurred
19+
public enum AccountConflictType: Equatable {
20+
/// Account exists with a different provider (e.g., user signed up with Google, trying to use
21+
/// email)
22+
/// Solution: Sign in with existing provider, then link the new credential
23+
case accountExistsWithDifferentCredential
24+
25+
/// The credential is already linked to another account
26+
/// Solution: User must sign in with that account or unlink the credential
27+
case credentialAlreadyInUse
28+
29+
/// Email is already registered with another method
30+
/// Solution: Sign in with existing method, then link if desired
31+
case emailAlreadyInUse
32+
33+
/// Trying to link anonymous account to an existing account
34+
/// Solution: Sign out of anonymous, then sign in with the credential
35+
case anonymousUpgradeConflict
36+
}
37+
38+
public struct AccountConflictContext: LocalizedError, Identifiable, Equatable {
39+
public let id = UUID()
40+
public let conflictType: AccountConflictType
1941
public let credential: AuthCredential
2042
public let underlyingError: Error
2143
public let message: String
22-
// TODO: - should make this User type once fixed upstream in firebase-ios-sdk. See: https://github.com/firebase/FirebaseUI-iOS/pull/1247#discussion_r2085455355
23-
public let uid: String?
44+
public let email: String?
45+
46+
/// Human-readable description of the conflict type
47+
public var conflictDescription: String {
48+
switch conflictType {
49+
case .accountExistsWithDifferentCredential:
50+
return "This account is already registered with a different sign-in method."
51+
case .credentialAlreadyInUse:
52+
return "This credential is already linked to another account."
53+
case .emailAlreadyInUse:
54+
return "This email address is already in use."
55+
case .anonymousUpgradeConflict:
56+
return "Cannot link anonymous account to an existing account."
57+
}
58+
}
2459

2560
public var errorDescription: String? {
2661
return message
2762
}
63+
64+
public static func == (lhs: AccountConflictContext, rhs: AccountConflictContext) -> Bool {
65+
// Compare by id since each AccountConflictContext instance is unique
66+
lhs.id == rhs.id
67+
}
2868
}
2969

3070
public enum AuthServiceError: LocalizedError {
@@ -35,7 +75,7 @@ public enum AuthServiceError: LocalizedError {
3575
case reauthenticationRequired(String)
3676
case invalidCredentials(String)
3777
case signInFailed(underlying: Error)
38-
case accountMergeConflict(context: AccountMergeConflictContext)
78+
case accountConflict(AccountConflictContext)
3979
case providerNotFound(String)
4080
case multiFactorAuth(String)
4181
case rootViewControllerNotFound(String)
@@ -64,7 +104,7 @@ public enum AuthServiceError: LocalizedError {
64104
return description
65105
case let .signInCancelled(description):
66106
return description
67-
case let .accountMergeConflict(context):
107+
case let .accountConflict(context):
68108
return context.errorDescription
69109
case let .providerNotFound(description):
70110
return description

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 112 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,27 @@ public final class AuthService {
130130
public var currentUser: User?
131131
public var authenticationState: AuthenticationState = .unauthenticated
132132
public var authenticationFlow: AuthenticationFlow = .signIn
133-
public var currentError: AlertError?
133+
private var _currentError: AlertError?
134+
135+
/// A binding that allows SwiftUI views to observe and clear errors
136+
public var currentError: Binding<AlertError?> {
137+
Binding(
138+
get: { self._currentError },
139+
set: { newValue in
140+
if newValue == nil {
141+
self._currentError = nil
142+
}
143+
}
144+
)
145+
}
146+
134147
public let passwordPrompt: PasswordPromptCoordinator = .init()
135148
public var currentMFARequired: MFARequired?
136149
private var currentMFAResolver: MultiFactorResolver?
137150

151+
/// Current account conflict context - observe this to handle conflicts and update backend
152+
public private(set) var currentAccountConflict: AccountConflictContext?
153+
138154
// MARK: - Provider APIs
139155

140156
private var listenerManager: AuthListenerManager?
@@ -204,11 +220,12 @@ public final class AuthService {
204220
}
205221

206222
func reset() {
207-
currentError = nil
223+
_currentError = nil
224+
currentAccountConflict = nil
208225
}
209226

210227
func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) {
211-
currentError = AlertError(title: title, message: message, underlyingError: underlyingError)
228+
_currentError = AlertError(title: title, message: message, underlyingError: underlyingError)
212229
}
213230

214231
public var shouldHandleAnonymousUpgrade: Bool {
@@ -239,9 +256,12 @@ public final class AuthService {
239256
}
240257
updateAuthenticationState()
241258
} catch {
259+
// Possible conflicts from user.link():
260+
// - credentialAlreadyInUse: credential is already linked to another account
261+
// - emailAlreadyInUse: email from credential is already used by another account
262+
// - accountExistsWithDifferentCredential: account exists with different sign-in method
242263
authenticationState = .unauthenticated
243-
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
244-
throw error
264+
try handleErrorWithConflictCheck(error: error, credential: credentials)
245265
}
246266
}
247267

@@ -254,32 +274,7 @@ public final class AuthService {
254274
let result = try await currentUser?.link(with: credentials)
255275
updateAuthenticationState()
256276
return .signedIn(result)
257-
} catch let error as NSError {
258-
// Handle credentialAlreadyInUse error
259-
if error.code == AuthErrorCode.credentialAlreadyInUse.rawValue {
260-
// Extract the updated credential from the error
261-
let updatedCredential = error.userInfo["FIRAuthUpdatedCredentialKey"] as? AuthCredential
262-
?? credentials
263-
264-
let context = AccountMergeConflictContext(
265-
credential: updatedCredential,
266-
underlyingError: error,
267-
message: "Unable to merge accounts. The credential is already associated with a different account.",
268-
uid: currentUser?.uid
269-
)
270-
throw AuthServiceError.accountMergeConflict(context: context)
271-
}
272-
273-
// Handle emailAlreadyInUse error
274-
if error.code == AuthErrorCode.emailAlreadyInUse.rawValue {
275-
let context = AccountMergeConflictContext(
276-
credential: credentials,
277-
underlyingError: error,
278-
message: "Unable to merge accounts. This email is already associated with a different account.",
279-
uid: currentUser?.uid
280-
)
281-
throw AuthServiceError.accountMergeConflict(context: context)
282-
}
277+
} catch {
283278
throw error
284279
}
285280
}
@@ -296,18 +291,19 @@ public final class AuthService {
296291
}
297292
} catch let error as NSError {
298293
authenticationState = .unauthenticated
294+
299295
// Check if this is an MFA required error
300296
if error.code == AuthErrorCode.secondFactorRequired.rawValue {
301297
if let resolver = error
302298
.userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver {
303299
return handleMFARequiredError(resolver: resolver)
304300
}
305-
} else {
306-
// Don't want error modal on MFA error so we only update here
307-
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
308301
}
309302

310-
throw error
303+
// Possible conflicts from auth.signIn(with:):
304+
// - accountExistsWithDifferentCredential: account exists with different provider
305+
// - credentialAlreadyInUse: credential is already linked to another account
306+
try handleErrorWithConflictCheck(error: error, credential: credentials)
311307
}
312308
}
313309

@@ -381,20 +377,21 @@ public extension AuthService {
381377

382378
func createUser(email email: String, password: String) async throws -> SignInOutcome {
383379
authenticationState = .authenticating
380+
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
384381

385382
do {
386383
if shouldHandleAnonymousUpgrade {
387-
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
388384
return try await handleAutoUpgradeAnonymousUser(credentials: credential)
389385
} else {
390386
let result = try await auth.createUser(withEmail: email, password: password)
391387
updateAuthenticationState()
392388
return .signedIn(result)
393389
}
394390
} catch {
391+
// Possible conflicts from auth.createUser():
392+
// - emailAlreadyInUse: email is already registered with another account
395393
authenticationState = .unauthenticated
396-
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
397-
throw error
394+
try handleErrorWithConflictCheck(error: error, credential: credential)
398395
}
399396
}
400397

@@ -452,8 +449,18 @@ public extension AuthService {
452449
emailLink = nil
453450
}
454451
} catch {
455-
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
456-
throw error
452+
// Reconstruct credential for conflict handling
453+
let link = url.absoluteString
454+
guard let email = emailLink else {
455+
throw AuthServiceError
456+
.invalidEmailLink("email address is missing from app storage. Is this the same device?")
457+
}
458+
let credential = EmailAuthProvider.credential(withEmail: email, link: link)
459+
460+
// Possible conflicts from auth.signIn(withEmail:link:):
461+
// - accountExistsWithDifferentCredential: account exists with different provider
462+
// - credentialAlreadyInUse: credential is already linked to another account
463+
try handleErrorWithConflictCheck(error: error, credential: credential)
457464
}
458465
}
459466

@@ -853,6 +860,71 @@ public extension AuthService {
853860
}
854861
}
855862

863+
// MARK: - Account Conflict Helper Methods
864+
865+
private func determineConflictType(from error: NSError) -> AccountConflictType? {
866+
switch error.code {
867+
case AuthErrorCode.accountExistsWithDifferentCredential.rawValue:
868+
return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict :
869+
.accountExistsWithDifferentCredential
870+
case AuthErrorCode.credentialAlreadyInUse.rawValue:
871+
return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict : .credentialAlreadyInUse
872+
case AuthErrorCode.emailAlreadyInUse.rawValue:
873+
return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict : .emailAlreadyInUse
874+
default:
875+
return nil
876+
}
877+
}
878+
879+
private func createConflictContext(from error: NSError,
880+
conflictType: AccountConflictType,
881+
credential: AuthCredential) -> AccountConflictContext {
882+
let updatedCredential = error
883+
.userInfo[AuthErrorUserInfoUpdatedCredentialKey] as? AuthCredential ?? credential
884+
let email = error.userInfo[AuthErrorUserInfoEmailKey] as? String
885+
886+
return AccountConflictContext(
887+
conflictType: conflictType,
888+
credential: updatedCredential,
889+
underlyingError: error,
890+
message: string.localizedErrorMessage(for: error),
891+
email: email
892+
)
893+
}
894+
895+
/// Handles account conflict errors by creating context, storing it, and throwing structured error
896+
/// - Parameters:
897+
/// - error: The error to check and handle
898+
/// - credential: The credential that caused the conflict
899+
/// - Throws: AuthServiceError.accountConflict if it's a conflict error, otherwise rethrows the
900+
/// original error
901+
private func handleErrorWithConflictCheck(error: Error,
902+
credential: AuthCredential) throws -> Never {
903+
// Check for account conflict errors
904+
if let error = error as NSError?,
905+
let conflictType = determineConflictType(from: error) {
906+
let context = createConflictContext(
907+
from: error,
908+
conflictType: conflictType,
909+
credential: credential
910+
)
911+
912+
// Store it for consumers to observe
913+
currentAccountConflict = context
914+
915+
// Only set error alert if we're NOT auto-handling it
916+
if conflictType != .anonymousUpgradeConflict {
917+
updateError(message: context.message, underlyingError: error)
918+
}
919+
920+
// Throw the specific error with context
921+
throw AuthServiceError.accountConflict(context)
922+
} else {
923+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
924+
throw error
925+
}
926+
}
927+
856928
// MARK: - MFA Helper Methods
857929

858930
private func extractMFAHints(from resolver: MultiFactorResolver) -> [MFAHint] {

0 commit comments

Comments
 (0)