Closed cae-li closed 10 months ago
Sorry, I can't reason through what the actual problem is, it starts out too low-level vs including "here's the goal / our requirements", "here's all the steps we follow", "here's what happens vs what we want to have happen" https://stackoverflow.com/help/how-to-ask
I will say the same as is said on the thread you linked: the user in general will stay authenticated, but for account-related tasks, in general they must re-authenticate
Thanks for your response! Sorry, let me clarify.
Goal Allow users to sign in using their Apple ID, and link it to a phone number on Firebase (one user on Firebase with two providers - Apple and phone number).
Steps followed Followed the steps on React Native Firebase's documentation on Social Authentication to initialise the Apple authentication.
Retrieving the credential:
const appleCredential = auth.AppleAuthProvider.credential(identityToken, nonce)
Signing in the user's Apple ID with the credential retrieved:
auth().signInWithCredential(appleCredential)
Re-authenticating this Apple ID:
auth().currentUser.reauthenticateWithCredential(appleCredential)
User enters phone number and confirms OTP (Current auth().currentUser is the user's phone number details):
auth().currentUser.linkWithCredential(appleCredential)
Outcome
What currently happens:
Apple account and phone number are not linked on Firebase - creating two different accounts with one provider each.
When re-authenticating and linking, the error message returned says:
[Error: [auth/unknown] Duplicate credential received. Please try again with a new credential.]
Desired outcome: Apple account and phone number are linked as one account on Firebase, with two providers.
I understand better now - this might work best as a minimal App.js you can drop in to a project for testing / troubleshooting. I have a firebase project / apple app lined up with apple and phone that I could use to reproduce if this was encapsulated into an App.js I could drop in with a couple buttons to try it
Alright, will prepare one for you!
Hi! Here's the App.js as requested. Do let me know if you have any questions! Thanks again.
Hello, may I know an update on this issue? Thanks!
Sorry, this got pushed pretty far down the urgent/important stack and was indeed buried.
Hi! Just checking, is there any update on this bug? Thanks!
:facepalm: google i/o happened and I'm still struggling to catch up with firebase releases - you've done everything I've asked and I'm sincerely sorry I just can't seem to get through daily important+urgent tasks quickly enough to start working through the stack to get to this one.
In place of non-existent time for insightful analysis, I can offer a sort of info-dump that is my entire PhoneVerify screen for an app that is actually in service and working.
it is old style class-based but it demonstrates how I connect a phone account with an existing user account. It has definitely worked for me in iOS cases with facebook+phone and google+phone, I can't recall if I tried it with apple+phone but it should work as it does not rely on any particular sign-in method coming in, it just relies that the user is signed in somehow, then this allows them to enter a phone number + OTP verify it + links it to existing account
/* eslint-disable react/jsx-one-expression-per-line */
import * as RX from 'reactxp';
import * as RN from 'react-native';
import firebase from '@react-native-firebase/app';
import { FirebaseAuthTypes } from '@react-native-firebase/auth';
import { Formik, FormikHelpers, FormikProps } from 'formik';
import DeviceInfo from 'react-native-device-info';
import Analytics from 'modules/analytics';
import Styles, { rnStyles } from '../styles/Styles';
import CurrentUserStore from '../stores/UserStore';
import FAQPopup from '../components/FAQPopup';
import I18NService from '../services/I18NService';
import DeviceInfoService from '../services/DeviceInfoService';
import NavigationService from '../services/NavigationService';
import HeaderContainer from '../components/HeaderContainer';
import { User } from '../models/IdentityModels';
interface PhoneVerifyState {
loading: boolean;
firebaseUser: FirebaseAuthTypes.User | null;
kullkiUser?: User;
verificationId: string;
codeInput: string;
userPhoneNumber: string;
deviceInfoPhoneNumber: string;
phonePrefix: string;
confirmResult: FirebaseAuthTypes.ConfirmationResult | null;
}
type PhoneVerifyFormikValues = {
phoneNumber: string;
};
type PhoneVerifyCodeFormikValues = {
verificationCode: string;
};
class PhoneVerifyPanel extends RX.Component<RX.CommonProps, PhoneVerifyState> {
authSubscription: (() => void) | null = null;
listeningForPhoneVerifyStatus = true;
constructor(props: RX.CommonProps) {
super(props);
CurrentUserStore.setIsNewUser(false);
// The user might have a phone number linked
const user = CurrentUserStore.getUser();
// We may be able to get a phone number from device info
let deviceInfoPhoneNumber = DeviceInfoService.getEssentialDeviceInfo()?.phoneNumber;
if (!deviceInfoPhoneNumber || deviceInfoPhoneNumber === 'unknown') {
deviceInfoPhoneNumber = ''; // nope, came up empty
}
let userPhoneNumber = '';
if (user?.phoneNumber) {
console.log('PhoneVerify::_buildState - we appear to have a user with phone already');
userPhoneNumber = user.phoneNumber;
} else {
userPhoneNumber = deviceInfoPhoneNumber;
}
this.state = {
loading: true,
kullkiUser: user,
firebaseUser: null,
verificationId: '',
codeInput: '',
phonePrefix: I18NService.translate('PhoneNumberPrefix'),
userPhoneNumber,
deviceInfoPhoneNumber,
confirmResult: null,
};
}
// Device phone numbers can be null, empty string, or random chars.
// Return either the empty string or just the digits if they exist
public static async getDevicePhoneNumber(): Promise<string> {
const phoneNumber = await DeviceInfo.getPhoneNumber();
console.log(`PhoneVerify::getDevicePhoneNumber - obtained: ${phoneNumber}`);
return PhoneVerifyPanel.cleanDevicePhoneNumber(phoneNumber);
}
public static cleanDevicePhoneNumber(phoneNumber: string | null | undefined): string {
let deviceInfoPhoneNumber = '';
if (phoneNumber && phoneNumber !== '' && phoneNumber !== 'unknown') {
const phoneIndex = phoneNumber.search('\\d+$');
if (phoneIndex !== -1) {
deviceInfoPhoneNumber = phoneNumber.substr(phoneIndex);
console.log(`PhoneVerify::getDevicePhoneNumber - returning: ${deviceInfoPhoneNumber}`);
}
}
return deviceInfoPhoneNumber;
}
onEnterPhoneNumber = async (
values: PhoneVerifyFormikValues,
actions: FormikHelpers<PhoneVerifyFormikValues>
): Promise<void> => {
const { phoneNumber } = values;
console.log(`PhoneVerify::onEnterPhoneNumber - values: ${phoneNumber}`);
Analytics.analyticsEvent('onEnterPhoneNumber', { phoneNumber });
this.listeningForPhoneVerifyStatus = true;
firebase
.auth()
.verifyPhoneNumber(phoneNumber)
.on(
'state_changed',
async (phoneAuthSnapshot) => {
if (!this.listeningForPhoneVerifyStatus) {
console.log('PhoneVerify::auth listener - no longer listening for phone events.');
return;
}
// How you handle these state events is entirely up to your ui flow and whether
// you need to support both ios and android. In short: not all of them need to
// be handled - it's entirely up to you, your ui and supported platforms.
// E.g you could handle android specific events only here, and let the rest fall back
// to the optionalErrorCb or optionalCompleteCb functions
switch (phoneAuthSnapshot.state) {
// ------------------------
// IOS AND ANDROID EVENTS
// ------------------------
case firebase.auth.PhoneAuthState.CODE_SENT: // or 'sent'
console.log(
`PhoneVerify::auth listener code sent - verificationId: ${phoneAuthSnapshot.verificationId}`
);
// on ios this is the final phone auth state event you'd receive
// so you'd then ask for user input of the code and build a credential from it
// as demonstrated in the `signInWithPhoneNumber` example above
this.setState({
verificationId: phoneAuthSnapshot.verificationId,
// message: 'Not verified. Code Sent.',
});
RN.Alert.alert(
I18NService.translate('phone-code-sent'),
I18NService.translate('phone-code-sent-message')
);
break;
case firebase.auth.PhoneAuthState.ERROR: // or 'error'
console.log(
'PhoneVerify::auth listener verification error, phoneAuthSnapshot: ',
phoneAuthSnapshot
);
RN.Alert.alert(
I18NService.translate('phone-verify-error'),
// FIXME unhandled promise rejection here?
I18NService.translate(phoneAuthSnapshot.error?.code ?? 'unknown', {
default: phoneAuthSnapshot.error?.message,
})
);
Analytics.analyticsEvent('errorPhoneVerify', {
phoneNumber,
emailAddress: firebase.auth().currentUser?.email ?? '',
});
break;
// ---------------------
// ANDROID ONLY EVENTS
// ---------------------
case firebase.auth.PhoneAuthState.AUTO_VERIFY_TIMEOUT: // or 'timeout'
console.log('PhoneVerify::auth listener auto verify on android timed out');
Analytics.analyticsEvent('verifyPhoneAutoTimeout', { phoneNumber });
// proceed with your manual code input flow, same as you would do in
// CODE_SENT if you were on IOS
this.setState({
verificationId: phoneAuthSnapshot.verificationId,
});
break;
case firebase.auth.PhoneAuthState.AUTO_VERIFIED: // or 'verified'
// auto verified means the code has also been automatically confirmed as correct/received
// phoneAuthSnapshot.code will contain the auto verified sms code - no need to ask the user for input.
console.log(
'PhoneVerify::auth listener auto verified on android, phoneAuthSnapshot: ',
phoneAuthSnapshot
);
// Example usage if handling here and not in optionalCompleteCb:
Analytics.analyticsEvent('successPhoneVerify', { verifyType: 'autoVerify' });
this.linkUser(phoneAuthSnapshot.verificationId, `${phoneAuthSnapshot.code}`); // coercion fixes type error
break;
default:
console.log(
`PhoneVerify::auth listener unknown phoneAuthSnapshot.state?${phoneAuthSnapshot.state}`
);
}
},
(error) => {
// optionalErrorCb would be same logic as the ERROR case above, if you've already handed
// the ERROR case in the above observer then there's no need to handle it here
console.log(
'PhoneVerify::auth listener error visible in optional error handler: ',
error
);
// verificationId is attached to error if required
},
(phoneAuthSnapshot) => {
// optionalCompleteCb would be same logic as the AUTO_VERIFIED/CODE_SENT switch cases above
// depending on the platform. If you've already handled those cases in the observer then
// there's absolutely no need to handle it here.
// Platform specific logic:
// - if this is on IOS then phoneAuthSnapshot.code will always be null
// - if ANDROID auto verified the sms code then phoneAuthSnapshot.code will contain the verified sms code
// and there'd be no need to ask for user input of the code - proceed to credential creating logic
// - if ANDROID auto verify timed out then phoneAuthSnapshot.code would be null, just like ios, you'd
// continue with user input logic.
console.log(
'PhoneVerify::auth listener optional complete handler sees value:',
phoneAuthSnapshot
);
}
);
// optionally also supports .then & .catch instead of optionalErrorCb &
// optionalCompleteCb (with the same resulting args)
actions.setSubmitting(false);
};
private async linkUser(verificationId: string, code: string) {
const sentCredential = firebase.auth.PhoneAuthProvider.credential(verificationId, code);
console.log('PhoneVerify::linkUser created credential based on sms: ', sentCredential);
try {
// If the user has a phone number already, unlink it
const { currentUser } = firebase.auth();
if (!currentUser) {
throw new Error('No current user');
}
if (this.state.kullkiUser && this.state.kullkiUser.phoneNumber) {
console.log('PhoneVerify::linkUser - user had phone already, unlinking');
await currentUser.unlink(firebase.auth.PhoneAuthProvider.PROVIDER_ID);
}
// FIXME link the user but we have to make sure it exists first
// https://rnfirebase.io/docs/v5.x.x/auth/reference/User#linkWithCredential
const receivedCredential = await currentUser.linkWithCredential(sentCredential);
RN.Alert.alert(
I18NService.translate('phone-link-success'),
I18NService.translate('phone-link-success-message')
);
console.log('PhoneVerify::linkUser receivedCredential post-link was: ', receivedCredential);
try {
Analytics.setAnalyticsUserProperties({
phone: currentUser.phoneNumber,
});
} catch (e) {
/* do nothing */
}
NavigationService.goBack();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.log('PhoneVerify::linkUser Problem linking with new credential: ', e);
const { code: errCode, message } = e;
Analytics.analyticsEvent('errorPhoneVerify', { errorCode: errCode });
RN.Alert.alert(
I18NService.translate('phone-link-error'),
I18NService.translate(errCode, { default: message })
);
}
}
private onEnterVerificationCode = async (
values: PhoneVerifyCodeFormikValues,
actions: FormikHelpers<PhoneVerifyCodeFormikValues>
) => {
const { verificationCode } = values;
console.log(`PhoneVerify::onEnterVerificationCode, values: ${verificationCode}`);
Analytics.analyticsEvent('onEnterVerificationCode', {
phoneNumber: this.state.userPhoneNumber,
});
this.linkUser(this.state.verificationId, verificationCode);
Analytics.analyticsEvent('successPhoneVerify', { verifyType: 'manualVerify' });
try {
Analytics.setAnalyticsUserProperties({
phone: firebase.auth().currentUser?.phoneNumber ?? '',
});
} catch (e) {
/* do nothing */
}
actions.setSubmitting(false);
// If we verify a phone number, it will create / link the account to the phone number
// unless Google Play Services verified it automatically. We don't really want the user to
// log in that way, so we need to unlink the phone after verify
// a code snippet for that is here https://stackoverflow.com/a/47198337
};
// This is our listener for auth events
authUnsubscriber: (() => void) | null = null;
async componentDidMount(): Promise<void> {
this.handleFocus();
}
async componentWillUnmount(): Promise<void> {
this.removeAuthListener();
}
async handleFocus(): Promise<void> {
const tempEmail = firebase.auth().currentUser?.email ?? '';
this.addAuthListener();
if (!firebase.auth().currentUser) {
console.log(
`PhoneVerify::handleFocus had user. Timed out. Login forward w/email: ${tempEmail}`
);
this.removeAuthListener();
}
}
private async addAuthListener() {
this.authUnsubscriber = firebase.auth().onAuthStateChanged(async (user) => {
console.log('PhoneVerify::authStateChanged listener called, user is:', user);
this.setState({
loading: false,
});
await CurrentUserStore.isUserTimedOut(user);
});
}
private removeAuthListener() {
console.log('PhoneVerify::removeAuthListener');
if (this.authUnsubscriber) this.authUnsubscriber();
this.listeningForPhoneVerifyStatus = false;
}
onValidatePhoneNumber = (values: PhoneVerifyFormikValues): { phoneNumber?: string } => {
if (!values.phoneNumber) {
return { phoneNumber: `(${I18NService.translate('MandatoryFieldError')})` };
}
return {};
};
onValidateVerificationCode = (
values: PhoneVerifyCodeFormikValues
): { verificationCode?: string } => {
if (!values.verificationCode) {
return { verificationCode: `(${I18NService.translate('MandatoryFieldError')})` };
}
if (!this.state.verificationId) {
return { verificationCode: `(${I18NService.translate('PhoneVerifySendCodeError')})` };
}
return {};
};
private getCurrentPhoneNumber() {
console.log('PhoneVerify::getCurrentPhoneNumber()');
if (this.state.userPhoneNumber !== '') {
return this.state.userPhoneNumber;
}
if (this.state.deviceInfoPhoneNumber !== '') {
if (this.state.deviceInfoPhoneNumber.startsWith('0'))
return this.state.phonePrefix + this.state.deviceInfoPhoneNumber;
}
return this.state.phonePrefix;
}
render(): JSX.Element {
let description = I18NService.translate('DashboardPhoneVerifyText');
if (this.state.kullkiUser?.phoneNumber) {
description = I18NService.translate('PhoneVerifySwitch');
}
return (
<HeaderContainer>
<RN.View
style={[
rnStyles.textCenterAligner,
{ maxHeight: 40, minHeight: 40, flexDirection: 'row' },
]}
>
<RN.Text style={[rnStyles.topHeaderText, { backgroundColor: '#00ACE6', flex: 1 }]}>
{I18NService.translate('PhoneVerify')}
</RN.Text>
</RN.View>
<RN.ScrollView
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
style={{ flex: 1 }}
>
<RN.View style={{ flex: 1, padding: 10 }}>
<RN.Text style={rnStyles.inputInstruction}>{description}</RN.Text>
</RN.View>
<Formik
initialValues={{ phoneNumber: this.getCurrentPhoneNumber() }}
onSubmit={(values, actions) => this.onEnterPhoneNumber(values, actions)}
validate={(values) => this.onValidatePhoneNumber(values)}
validateOnBlur={false}
validateOnChange={false}
>
{(props: FormikProps<PhoneVerifyFormikValues>) => (
<RN.View style={{ flex: 1 }}>
<RN.View style={rnStyles.middleInputBlock}>
<RN.Text style={rnStyles.inputInstruction}>
1) {I18NService.translate('PhoneNumberInstructions')}{' '}
{props.errors.phoneNumber && (
<RN.Text style={rnStyles.inputInstructionError}>
{props.errors.phoneNumber}
</RN.Text>
)}
</RN.Text>
<RN.TextInput
autoCorrect={false}
// autoComplete={email} - FIXME are these possible in ReactXP?
returnKeyType="next"
keyboardType="numeric"
style={rnStyles.inputWithDarkUnderline}
onChangeText={props.handleChange('phoneNumber')}
onBlur={props.handleBlur('phoneNumber')}
value={props.values.phoneNumber}
/>
<RN.View style={[rnStyles.middleInputBlock, { flexDirection: 'row' }]}>
<RX.Button
style={[Styles.loginIngresarButton, { width: 200 }]}
onPress={async () => {
if (!props.isSubmitting) await props.submitForm();
}}
>
<RN.Text style={rnStyles.buttonText}>
{I18NService.translate('PhoneNumberSendCode')}
</RN.Text>
</RX.Button>
<RX.Button onPress={FAQPopup.onClickFAQLink}>
<RN.Text style={rnStyles.darkBlueText}>
{I18NService.translate('NeedHelp')}
</RN.Text>
</RX.Button>
</RN.View>
</RN.View>
</RN.View>
)}
</Formik>
<RN.View style={rnStyles.middleInputBlock}>
<RN.Text style={rnStyles.inputInstruction}>
2) {I18NService.translate('PhoneNumberCodeWait')}
</RN.Text>
</RN.View>
<Formik
initialValues={{ verificationCode: '' }}
onSubmit={(values, actions) => this.onEnterVerificationCode(values, actions)}
validate={(values) => this.onValidateVerificationCode(values)}
validateOnBlur={false}
validateOnChange={false}
>
{(props: FormikProps<PhoneVerifyCodeFormikValues>) => (
<RN.View style={{ flex: 1 }}>
<RN.View style={rnStyles.middleInputBlock}>
<RN.Text style={rnStyles.inputInstruction}>
3) {I18NService.translate('PhoneNumberEnterCode')}{' '}
{props.errors.verificationCode && (
<RN.Text style={rnStyles.inputInstructionError}>
{props.errors.verificationCode}
</RN.Text>
)}
</RN.Text>
<RN.TextInput
autoCorrect={false}
// autoComplete={email} - FIXME are these possible in ReactXP? React-Native provides them
returnKeyType="go"
keyboardType="number-pad"
style={rnStyles.inputWithDarkUnderline}
onChangeText={props.handleChange('verificationCode')}
onBlur={props.handleBlur('verificationCode')}
value={props.values.verificationCode}
/>
<RX.Button
style={[Styles.loginIngresarButton, { width: 200 }]}
onPress={async () => {
if (!props.isSubmitting) await props.submitForm();
}}
>
<RN.Text style={rnStyles.buttonText}>
{I18NService.translate('PhoneNumberVerify')}
</RN.Text>
</RX.Button>
</RN.View>
</RN.View>
)}
</Formik>
</RN.ScrollView>
</HeaderContainer>
);
}
}
export = PhoneVerifyPanel;
Hey @cae-li did you manage to figure it out? Thanks.
Hi @Birowsky , unfortunately I have not. Still encountering the same issue. Do you have any suggestions on how to resolve this?
@cae-li I actually found out a mistake in my code that caused calling the API twice. So my solution is not really relevant to this context.
Ah I see, okay thanks anyway! @Birowsky
I had a similar issue, linking anonymous user with Apple user, and got it to work (inspired by https://github.com/invertase/react-native-firebase/issues/3952) like this:
return user.linkWithCredential(appleCredential)
.catch((err) => {
if (err.message?.includes('[auth/credential-already-in-use]') && err.userInfo.authCredential) {
return auth().signInWithCredential(err.userInfo.authCredential)
} else {
throw err
}
})
So userInfo.authCredential
should be the equivalent of userInfo[FIRAuthErrorUserInfoUpdatedCredentialKey]
.
@hesselbom @mikehardy Unfortunately, as of
"@invertase/react-native-apple-authentication": "^2.2.2",
"react": "18.2.0",
"react-native": "0.71.4",
This does not work. The error
object does not contain any credentials when doing Apple sign in. Here is the method I call after Apple Pop-up is successfully dismissed:
const createUserWithCredentials = async (
credential: FirebaseAuthTypes.AuthCredential,
) => {
try {
const currentUser = auth().currentUser;
if (currentUser) {
console.log('Attempt to link with anonymous user..');
await currentUser.linkWithCredential(credential);
} else {
console.log('Attempt to sign up a brand new account..');
await auth().signInWithCredential(credential);
}
} catch (error) {
const freshCredentials =
error.userInfo?.authCredential || error.credential;
console.log({
code: error.code,
userInfo: error.userInfo,
credentials: freshCredentials,
});
if (
error.code === 'auth/provider-already-linked' ||
error.code == 'auth/credential-already-in-use' ||
error.code == 'auth/email-already-in-use'
) {
console.log('User already exists so linking the accounts');
await auth().signInWithCredential(freshCredentials);
} else {
throw error;
}
}
};
This is the console output:
LOG Attempt to link with anonymous user..
LOG {"code": "auth/email-already-in-use", "credentials": null, "userInfo": {"authCredential": null, "code": "email-already-in-use", "message": "The email address is already in use by another account.", "nativeErrorMessage": "The email address is already in use by another account.", "resolver": null}}
LOG User already exists so linking the accounts
INFO [bugsnag] Sending event TypeError: Cannot read property 'providerId' of undefined
As you can see the authCredential
key of error.userInfo
is set to null
. I am testing on iOS 16.2
I have the same problem as @FrenchMajesty mentioned. @FrenchMajesty Have you fixed this?
I am also facing the same issue. Did you find any solution to this?
I had a similar issue, linking anonymous user with Apple user, and got it to work (inspired by invertase/react-native-firebase#3952) like this:
return user.linkWithCredential(appleCredential) .catch((err) => { if (err.message?.includes('[auth/credential-already-in-use]') && err.userInfo.authCredential) { return auth().signInWithCredential(err.userInfo.authCredential) } else { throw err } })
So
userInfo.authCredential
should be the equivalent ofuserInfo[FIRAuthErrorUserInfoUpdatedCredentialKey]
.
I was experiencing the same issue on iOS. This helped me fix the problem. You have to reach inside the error and get the auth credential over there. Then use that to sign in with Apple.
let errorCode = AuthErrorCode(_nsError: error)
switch errorCode.code {
case .providerAlreadyLinked, .credentialAlreadyInUse:
guard let authCredential = error.userInfo["FIRAuthErrorUserInfoUpdatedCredentialKey"] as? AuthCredential else {
print("Unable to retrieve authentication credential when account is already linked")
}
await signIn(with: authCredential) // This works now. No more duplicate credential error.
}
...
I'm developing an application using React Native, and authenticating users on Firebase.
I've managed to authenticate and link Google accounts to phone numbers on Firebase using
linkWithCredential
. However, when I try to do the same with Apple accounts (link Apple account with phone number), it's returning this error:[Error: [auth/unknown] Duplicate credential received. Please try again with a new credential.]
I understand that reusing credentials to link accounts is not allowed for Apple, and someone has mentioned this issue as well. I saw a solution in the thread - to get the updated credential key from the error message returned by calling
error.userInfo[FIRAuthErrorUserInfoUpdatedCredentialKey]
, and use it to link the Apple account with another account on Firebase. However, I've tried that, and it returned undefined, as shown below.error.userInfo: {"authCredential": null, "code": "unknown", "message": "Duplicate credential received. Please try again with a new credential.", "nativeErrorMessage": "Duplicate credential received. Please try again with a new credential."}
error.userInfo[FIRAuthErrorUserInfoUpdatedCredentialKey]: undefined
Here are the complete steps to initialise Apple authentication that I've followed, but I can't seem to find a solution to this issue.