Reputation: 2240
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:
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.
Upvotes: 35
Views: 25590
Reputation: 66355
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());
}
}
UPDATED VERSION 31.01.2024 from @gshock
import { initializeApp } from "firebase/app";
import {
getAuth,
FacebookAuthProvider,
GoogleAuthProvider,
signInWithPopup,
signInWithRedirect,
fetchSignInMethodsForEmail,
FacebookAuthProvider,
getRedirectResult,
} from "firebase/auth"
const app = initializeApp(...firebaseConfig...);
const auth = getAuth(app);
const supportedPopupSignInMethods = [
GoogleAuthProvider.PROVIDER_ID,
FacebookAuthProvider.PROVIDER_ID,
];
const getProvider = (providerId) => {
switch (providerId) {
case GoogleAuthProvider.PROVIDER_ID:
return new GoogleAuthProvider();
case FacebookAuthProvider.PROVIDER_ID:
return new FacebookAuthProvider();
default:
throw new Error(`No provider implemented for ${providerId}`);
}
};
const FacebookLoginButton = () => {
useEffect(() => {
getRedirectResult(auth)
.then((result) => {
console.log("Facebook login redirect: ", result.user.email);
})
.catch((error) => {
console.log(error);
});
}, []);
const facebookLoginHandler = async () => {
const provider = new FacebookAuthProvider();
signInWithPopup(auth, provider)
.then((result) => {
console.log("Facebook login: ", result.user.email);
})
.catch((err) => {
if (err.code === "auth/account-exists-with-different-credential") {
fetchSignInMethodsForEmail(auth, err.customData.email).then(
(providers) => {
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 });
signInWithRedirect(auth, linkedProvider);
}
);
}
console.error(err);
});
};
return <button onClick={facebookLoginHandler}>Login with facebook</button>
}
Upvotes: 11
Reputation: 19585
@Bojeil's answer is the right one, however I wanted to add some color to it.
First, why the heck doesn't Firebase just deal with this and not involve me? IOW, why are we getting the auth/account-exists-with-different-credential
error in the first place?
Google believes (rightly or wrongly) that Facebook (and other) logins may be using non-verified email addresses (see this closed issue among others). The security issue with this flow for non-verified emails is as follows:
Therefore, at step 4, Firebase refuses to do this, and instead returns the auth/account-exists-with-different-credential
failure to your application. Your application now requires Alice to login with her Google account (which Eve cannot do) in order to link the Facebook account to the same identity as the Google account.
If Alice authenticates with Facebook first using her @gmail.com account, she is able to access her account with Facebook. If she then authenticates using the same @gmail.com account later via Google, Firebase automatically links the Google account and removes the previous Facebook login from the account identity.
This is to avoid another attack vector: if Eve can create an application account via her Facebook account and Alice's email address before Alice logs in, Eve has gained access to Alice's account in advance. In other words, Alice may happily create an account / log in as [email protected], not realizing that Eve's Facebook account was already attached to that login, and that Eve can therefore log in to the account. Since Firebase simply removes the non-canonical Facebook login, Eve no longer has access to it, and that attack vector is eliminated.
Again, @bojeil's answer is correct assuming you want to keep the Firebase "Link accounts that use the same email" setting. However, note that in browser environments obtaining the canonical credentials via popup will generally not work, because the second popup will not be a direct result of a user action, and the browser will block it.
One solution to this is to perform the canonical login via redirect. Another solution is to surface the credentials issue to the user with a message like "You already have a gmail account associated with this email, click to login with that account and confirm ownership of this Facebook account". The would then click another button explicitly in order to login with the canonical credentials for the account. Since the second login is now a direct result of a user action, the popup will not be blocked.
Upvotes: 9
Reputation: 567
It's 2022. Well, in my case. I can just go to Firebase Console -> Authentication -> User Account Link (look at the screenshot attached)
then simply choose the Create Multiple accounts on each identity provider.
choosing Link accounts that use the same email will occur this error.
Upvotes: 5
Reputation: 1109
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();
});
Upvotes: 2
Reputation: 19
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;
}
}
);
}
Upvotes: 1
Reputation: 2889
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 [email protected], 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 [email protected] 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.
Upvotes: 4
Reputation: 30798
What is happening is that Firebase enforces a same account for all emails. As you already have a Google account for the same email, you need to link that Facebook account to the Google account so the user can access the same data and next time be able to sign in to the same account with either Google or Facebook.
The issue in your snippet is that you are signing and linking with the same credential. Modify as follows. When you get the error 'auth/account-exists-with-different-credential', the error will contain error.email and error.credential (Facebook OAuth credential). You need to first lookup the error.email to get the existing provider.
firebase.auth().fetchProvidersForEmail(error.email)
.then(providers => {
//providers returns this array -> ["google.com"]
// You need to sign in the user to that google account
// with the same email.
// In a browser you can call:
// var provider = new firebase.auth.GoogleAuthProvider();
// provider.setCustomParameters({login_hint: error.email});
// firebase.auth().signInWithPopup(provider)
// If you have your own mechanism to get that token, you get it
// for that Google email user and sign in
firebase.auth().signInWithCredential(googleCred)
.then(user => {
// You can now link the pending credential from the first
// error.
user.linkWithCredential(error.credential)
})
.catch(error => log(error))
Upvotes: 28
Reputation: 8584
Since google is the trusted provider for @gmail.com addresses it gets higher priority than other accounts using a gmail as their email. This is why if you sign in with Facebook then Gmail an error isn't thrown, but if you try going Gmail to Facebook then it does throw one.
See this question.
If you want to allow multiple accounts with the same email then go to the Firebase console and under Authentication -> Sign-in methods, there should be an option at the bottom to toggle this.
Upvotes: 9