Skip to content

fix(auth): link a colliding provider credential through the account that owns the address - #2564

Open
Mastersam07 wants to merge 5 commits into
version-10.0.1from
fix/account-linking-owning-provider
Open

Mastersam07 wants to merge 5 commits into
version-10.0.1from
fix/account-linking-owning-provider

Conversation

@Mastersam07

@Mastersam07 Mastersam07 commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

#2551 stopped the crash, but a provider sign-in that hits an existing account still can't finish. If Facebook sign-in finds an account Google created with the same email, the dialog either offers nothing (no Email provider) or sends the user to the email sign-in step, where an account with no password can't sign in. In 8.x, WelcomeBackIdpPrompt signed the user in to the provider that owns the account and then linked the pending credential.

The dialog message was also hard-coded English, so it never used the translated accountLinkingRequiredRecoveryMessage.

Change

Recovery goes to the account's owner. The AccountLinkingRequiredException recover action now sends the user to a provider that can sign in to the existing account:

  • If legacyFetchSignInWithEmail is on and the lookup names a configured method, that one is opened. Unlike the existing different-method lookup, a password account counts here, because the email step is the way back in.
  • Otherwise, with email enumeration protection on, nothing says which provider owns the address. If only one other provider can hold the account, it's opened directly. If there are several, the user stays on the method picker and picks one.
  • Phone and anonymous sign-in are never candidates, and neither is the provider that just collided. If nothing is left, or the collision didn't name an email to check against, the dialog offers no action.
  • While the opt-in lookup runs, a loading dialog blocks the screen, so a second sign-in can't start.
  • Anonymous upgrade and signed-in linking keep the existing email-step recovery, because there the collision is with the user already signed in.

Linking happens inside the provider sign-in. Google, Facebook (both through signInAndLinkWithCredential), OAuth, the email step and email-link sign-in link the pending credential before they report success. That means the host never gets the unlinked result, and a failed link has an error path.

The new AccountLinking.kt makes the result all or nothing:

  • Only a sign-in through a provider the recovery offered finishes the link. Any other sign-in, such as phone from the method picker, is the user going another way: the pending link is dropped and that sign-in goes ahead normally.
  • The account signed in to must have the email the collision named, as 8.x checked. Credential Manager and the OAuth flows let the user pick any account. A mismatch signs back out and raises the linking error again with the "Those credentials belong to a different account." message, keeping the credential pending so the user can pick the right account. If that sign-in only just created the account, it's deleted rather than left behind. The undo finishes even if the flow is cancelled. While a link is pending, Google auto-select is off, so the chooser always shows instead of returning the same wrong account.
  • The email sign-in and email-link steps check the same before signing in or sending a link. The recovery prefills the account's address, but the field stays editable, so a different address is refused with the same message. A credential a host passes to EmailAuthScreen itself is not held to this.
  • If the link fails or is cancelled, the user is signed back out and the failure is reported. A failure Firebase reports as a collision becomes an unknown error, so the recovery doesn't loop.
  • The pending credential lives on AuthFlowScope (PendingAccountLink), shared between the screen and the provider flows, and is cleared wherever the flow ends. It's saved with the screen's state, so it survives the Activity being recreated while the owner's sign-in UI is open. A later linking error with no credential of its own (for example "Sign up" tried on the recovery's email step) keeps it.
  • A Facebook or Google collision still saves its credential for a later email-link sign-in, as before. Once the recovery's pending link ends, whether it was linked another way or abandoned, that saved copy is cleared where the link resolves, so it doesn't depend on the screen still being shown. If the user dismisses the dialog without starting the recovery, the copy is kept, as it was before this PR.

Translated messages. The six places that raised AccountLinkingRequiredException with English text now use accountLinkingRequiredRecoveryMessage ("Account needs to be linked. Please try a different sign-in method."). That string is already translated in every locale, so no new strings are added. The mismatch message reuses the existing fui_error_user_mismatch.

Behaviour changes

  • AccountLinkingRequiredException.message no longer contains the email or the provider name. Hosts that read it in onSignInFailure get the translated generic copy. The email is still on exception.email.

Known limitations (follow-ups)

  • If the owning account has MFA, the sign-in goes to the MFA challenge, and the credential isn't linked after the challenge. The user ends up signed in without the link.
  • Backing out of one owner's sign-in (the email step, the Google chooser, Facebook) ends the recovery, as the previous pending credential did. Signing in with another provider afterwards succeeds without the link. Dismissing the Google chooser currently cancels the whole flow, so keeping the link through it needs that reworked first.

Testing

New AccountLinkingTest (27 tests):

  • linking on a matching email, including a different case
  • mismatch signs back out and re-raises with the credential still pending
  • failed link signs back out; a collision-shaped failure is not re-offered
  • nothing pending, and reauthentication, never link
  • a sign-in through a provider the recovery didn't offer drops the link and goes ahead
  • a mismatched account the sign-in just created is deleted; an existing one is only signed out of
  • a cancelled link signs back out, and so does a mismatch cancelled while deleting the new account
  • a finished link clears the credential saved for email-link sign-in
  • an email-link sign-in finishes the recovery's link
  • Google auto-select is off while a link is pending
  • Google and OAuth sign-ins during the recovery report Success with the linked result
  • owner lookup: off by default, counts a password account, names a configured provider, skips what can't be opened
  • the email step refuses another address before signing in, accepts the account's in any case, leaves a host's own credential alone, and signs back out when the link fails
  • the email-link step refuses to send to another address
  • a provider collision carries the translated message

New tests in FirebaseAuthScreenEmailRecoveryTest (8):

  • a lone other provider (Google) is opened, the Facebook credential is linked end to end, and the credential saved for email-link sign-in is cleared
  • several providers: the user stays on the picker, and signing in with Email from there links the credential
  • an anonymous upgrade still goes to the email step
  • no action when the colliding provider is the only one that can hold the account, or when the collision names no email
  • a linking error with no credential keeps the pending link
  • the pending link survives an Activity recreation
  • the owner lookup blocks the screen until it answers

With the link step disabled, 8 of the new tests fail. Each later fix (email-step check, owner set, new-account delete, cancel sign-out, email-step all-or-nothing, email-link check, saved-credential clear, saveable link, lookup loading, auto-select) was also disabled on its own, and its test failed each time.

  • ./gradlew :auth:testDebugUnitTest (JDK 21): 1140 tests, 0 failures
  • ./gradlew lintAll: pass

Maintainer note: Fixes internal CPRN-646

…hat owns the address

- The account-linking recovery opens the provider that owns the address:
  the opt-in legacy lookup names it when it can, a lone remaining provider
  is opened directly, and several are offered on the method picker.
- Google, Facebook and OAuth sign-ins link the pending credential before
  reporting success, and only to an account with the same address; a
  mismatch or a failed link signs back out instead of half-finishing.
- Account-linking errors carry the translated recovery message instead of
  hard-coded English.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a robust account-linking recovery mechanism that links a pending credential to an existing account directly inside the provider's sign-in flow. It adds the PendingAccountLink data class, the linkPendingAccountCredential helper, and updates the FirebaseAuthScreen to coordinate the recovery process, backed by comprehensive unit tests. The review feedback highlights two key improvements: using rememberSaveable instead of remember for pendingAccountLink to ensure the state survives configuration changes, and replacing referential equality (===) with structural equality (==) when comparing credentials to handle reconstructed instances.

val hostStateHolder = rememberUpdatedState(rawAuthState)
// Shared with the provider flows, which link it once the user signs in to the account that
// owns the address. Cleared wherever the flow ends.
val pendingAccountLink = remember { mutableStateOf<PendingAccountLink?>(null) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The pendingAccountLink state is currently stored using remember, which means it will be lost during configuration changes (such as screen rotation) or process recreation. To ensure the pending credential survives Activity recreation and can be successfully linked when the user completes the sign-in flow, use rememberSaveable with a custom Saver to persist the PendingAccountLink data in a Bundle.

    val pendingAccountLink = rememberSaveable(
        saver = androidx.compose.runtime.saveable.Saver(
            save = { state ->
                state.value?.let { pending ->
                    android.os.Bundle().apply {
                        putParcelable("credential", pending.credential)
                        putString("email", pending.email)
                    }
                }
            },
            restore = { bundle ->
                val credential = bundle.getParcelable("credential") as? com.google.firebase.auth.AuthCredential
                val email = bundle.getString("email")
                mutableStateOf(credential?.let { PendingAccountLink(it, email) })
            }
        )
    ) { mutableStateOf(null) }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 93de3c5: pendingAccountLink is now rememberSaveable with a listSaver that keeps the credential, the email and ownerProviderIds (the suggestion above predates that field). the pending link survives an Activity recreation covers it with a real FacebookAuthCredential, since the state is actually parcelled.

Comment on lines +82 to +83
internal fun AuthFlowScope.isPendingLinkMismatch(e: AuthException.AccountLinkingRequiredException) =
e.credential != null && e.credential === pendingAccountLink.value?.credential

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using referential equality (===) to compare credentials can fail if the credential instance is reconstructed or restored from a saved state (e.g., after a configuration change or process recreation). Use structural equality (==) instead to ensure robust comparison of AuthCredential instances.

Suggested change
internal fun AuthFlowScope.isPendingLinkMismatch(e: AuthException.AccountLinkingRequiredException) =
e.credential != null && e.credential === pendingAccountLink.value?.credential
internal fun AuthFlowScope.isPendingLinkMismatch(e: AuthException.AccountLinkingRequiredException) =
e.credential != null && e.credential == pendingAccountLink.value?.credential

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping === here. None of the Firebase credential classes (AuthCredential, FacebookAuthCredential, GoogleAuthCredential, OAuthCredential, EmailAuthCredential) override equals, so == would do the same identity check. Identity is also the intent: the question is whether this is the exact credential the recovery is holding. After a recreation, the restored pending link is the single instance every comparison reads: the mismatch error is built from it and the email step gets its credential from it. The recreation test exercises that path.

…nk is pending

The account-linking recovery prefills the account's address, but the field
stays editable. Signing in elsewhere would link the credential to someone
else, so the email step now refuses before signing in, as the provider
flows do after.
…path

- Only a sign-in through a provider the recovery offered finishes the
  link; any other, such as phone, drops it instead of being refused.
- A mismatched account the sign-in only just created is deleted rather
  than left behind; a cancelled link signs back out.
- The email step links the recovery's credential all or nothing, and the
  email-link step is held to the account's address too.
- The provider credential a collision saved for a later email-link
  sign-in is forgotten once the recovery's link ends.
- A mismatch undone while cancelled still signs out.
- An email-link sign-in counts as the Email provider, so it finishes
  the recovery's link instead of dropping it.
- A linking error with no credential keeps the link already pending.
- The credential saved for email-link sign-in is forgotten where the
  link resolves, not only from the screen.
- No provider recovery when the collision names no address.
- The email step merges the linked provider's profile again.
…ups and auto-select

- The pending link is saveable, so an Activity recreated while the
  owner's sign-in UI is open still finishes the link.
- The opt-in owner lookup shows loading, so no second sign-in can
  start while it runs.
- Google auto-select is off while a link is pending, so a wrong pick
  is not handed back again.
@Mastersam07
Mastersam07 marked this pull request as ready for review November 6, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant