Firebase JS API auth - account-exists-with-different-credential

Viewed 13628

We're having real problems trying to resolve this and so hoping for some Firebase assistance / those that have solved the same problem.

The app is React Native (0.43.2) and using Firebase JS API (latest)

We provide Facebook and Google auth. Works fine.

BUT, if a user:

  1. signs in with Facebook (is ok)
  2. later, signs in with Google (is also fine)
  3. later, tries to sign in with Facebook - BOOM! not so fine and Firebase returns this error:

auth/account-exists-with-different-credential

From reading docs and a few posts on SO, we thought the following was correct but clearly not as we're getting the same auth error back.

...error returned by Firebase auth after trying Facebook login...

const email = error.email;
const pendingCred = error.credential;

firebase.auth().fetchProvidersForEmail(email)
.then(providers => {
   //providers returns this array -> ["google.com"]
   firebase.auth().signInWithCredential(pendingCred)
   .then(result => {
       result.user.link(pendingCred)
   })
   .catch(error => log(error))

The call to signInWithCredential is throwing the same error auth/account-exists-with-different-credential.

Can anyone help point out what we are doing wrong with this implementation? Greatly appreciated.

6 Answers

I find it strange and inconvenient that Firebase have chosen this behaviour as default, and the solution is non-trivial. Here's a full and updated solution for Firebase as of the time of writing based on @bojeil's answer.

function getProvider(providerId) {
  switch (providerId) {
    case firebase.auth.GoogleAuthProvider.PROVIDER_ID:
      return new firebase.auth.GoogleAuthProvider();
    case firebase.auth.FacebookAuthProvider.PROVIDER_ID:
      return new firebase.auth.FacebookAuthProvider();
    case firebase.auth.GithubAuthProvider.PROVIDER_ID:
      return new firebase.auth.GithubAuthProvider();
    default:
      throw new Error(`No provider implemented for ${providerId}`);
  }
}

const supportedPopupSignInMethods = [
  firebase.auth.GoogleAuthProvider.PROVIDER_ID,
  firebase.auth.FacebookAuthProvider.PROVIDER_ID,
  firebase.auth.GithubAuthProvider.PROVIDER_ID,
];

async function oauthLogin(provider) {
  try {
    await firebase.auth().signInWithPopup(provider);
  } catch (err) {
    if (err.email && err.credential && err.code === 'auth/account-exists-with-different-credential') {
      const providers = await firebase.auth().fetchSignInMethodsForEmail(err.email)
      const firstPopupProviderMethod = providers.find(p => supportedPopupSignInMethods.includes(p));

      // Test: Could this happen with email link then trying social provider?
      if (!firstPopupProviderMethod) {
        throw new Error(`Your account is linked to a provider that isn't supported.`);
      }

      const linkedProvider = getProvider(firstPopupProviderMethod);
      linkedProvider.setCustomParameters({ login_hint: err.email });

      const result = await firebase.auth().signInWithPopup(linkedProvider);
      result.user.linkWithCredential(err.credential);
    }

    // Handle errors...
    // toast.error(err.message || err.toString());
  }
}

I emailed the Firebase support and they explained more to me. In their own words:

To provide context, distinct emails have their own Identity Providers. If a user has an email of sample@gmail.com, the IDP (identity provider) for that email will be Google, as specified by the domain, @gmail.com (this won't be true to an email whose domain is @mycompany.com or @yahoo.com).

Firebase Authentication allows sign-ins to happen when it detects that the provider used is the email's IDP, regardless of whether they're using the setting 'One account per email address' and have signed in with a previous provider, such as Email/Password based authentication or any federated identity providers such as Facebook. That means that if they signed in sample@gmail.com with Email and password, then Google, (under One account per Email address setting), Firebase will allow the latter and the account's provider will be updated to Google. The reason for this is that the IDP will most likely have the most up-to-date information about the email.

On the other hand, if they sign-in with Google at first and then sign in with an Email and password account with the same associated email, we wouldn't want to update their IDP and will continue with the default behavior of notifying the user that is already an account associated with that email.

I've written about how to do this without needing to sign in for a second time here:

https://blog.wedport.co.uk/2020/05/29/react-native-firebase-auth-with-linking/

You need to store the original credential and retrieve to login silently before linking the accounts. Full code in link:

signInOrLink: async function (provider, credential, email) {
  this.saveCredential(provider, credential)
  await auth().signInWithCredential(credential).catch(
    async (error) => {
      try {
        if (error.code != "auth/account-exists-with-different-credential") {
          throw error;
        }
        let methods = await auth().fetchSignInMethodsForEmail(email);
        let oldCred = await this.getCredential(methods[0]);
        let prevUser = await auth().signInWithCredential(oldCred);
        auth().currentUser.linkWithCredential(credential);
      }
      catch (error) {
        throw error;
      }
    }
  );

}

Sometimes the firebase documentation is great and other times it leaves you wanting more. In this case, when it comes to handling the error, it gives very detailed instructions on signInWithPopup. However the instructions for signInWithRedirect in their entirety are...

Redirect mode

This error is handled in a similar way in the redirect mode, with the difference that the pending credential has to be cached between page redirects (for example, using session storage).

Based on the answers from @bojeil and @Dominic, here is how you can link a facebook account with a google account calling signInWithRedirect.

const providers = {
  google: new firebase.auth.GoogleAuthProvider(),
  facebook: new firebase.auth.FacebookAuthProvider(),
  twitter: new firebase.auth.TwitterAuthProvider(),
};

const handleAuthError = async (error) => {
  if (error.email && error.credential && error.code === 'auth/account-exists-with-different-credential') {
    // We need to retain access to the credential stored in `error.credential`
    // The docs suggest we use session storage, so we'll do that.

    sessionStorage.setItem('credential', JSON.stringify(error.credential));

    const signInMethods = await firebase.auth().fetchSignInMethodsForEmail(error.email); // -> ['google.com']
    const providerKey = signInMethods[0].split('.')[0]; // -> 'google'
    const provider = providers[providerKey]; // -> providers.google

    firebase.auth().signInWithRedirect(provider);
  }
};

const handleRedirect = async () => {
  try {
    const result = await firebase.auth().getRedirectResult();
    const savedCredential = sessionStorage.getItem('credential');

    // we found a saved credential in session storage
    if (result.user && savedCredential) {
      handleLinkAccounts(result.user, savedCredential);
    }
    return result;
  }
  catch (error) {
    handleAuthError(error);
  }
};

const handleLinkAccounts = (authUser, savedCredential) => {
  // Firebase has this little hidden gem of a method call fromJSON
  // You can use this method to parse the credential saved in session storage
  const token = firebase.auth.AuthCredential.fromJSON(savedCredential);

  const credential = firebase.auth.FacebookAuthProvider.credential(token);
  authUser.linkWithCredential(credential);

  // don't forget to remove the credential
  sessionStorage.removeItem('credential');
};

firebase.auth().onAuthStateChanged((authUser) => {
  handleRedirect();
});
Related