invertase / react-native-apple-authentication

A React Native library providing support for Apple Authentication on iOS and Android.
Other
1.39k stars 220 forks source link

Error linking Apple account on Firebase: Duplicate credential received. Please try again with a new credential #272

Closed cae-li closed 10 months ago

cae-li commented 2 years ago

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.

mikehardy commented 2 years 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

cae-li commented 2 years ago

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.

mikehardy commented 2 years ago

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

cae-li commented 2 years ago

Alright, will prepare one for you!

cae-li commented 2 years ago

Hi! Here's the App.js as requested. Do let me know if you have any questions! Thanks again.

cae-li commented 2 years ago

Hello, may I know an update on this issue? Thanks!

mikehardy commented 2 years ago

Sorry, this got pushed pretty far down the urgent/important stack and was indeed buried.

cae-li commented 2 years ago

Hi! Just checking, is there any update on this bug? Thanks!

mikehardy commented 2 years ago

: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;
Birowsky commented 1 year ago

Hey @cae-li did you manage to figure it out? Thanks.

cae-li commented 1 year ago

Hi @Birowsky , unfortunately I have not. Still encountering the same issue. Do you have any suggestions on how to resolve this?

Birowsky commented 1 year ago

@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.

cae-li commented 1 year ago

Ah I see, okay thanks anyway! @Birowsky

hesselbom commented 1 year ago

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].

FrenchMajesty commented 1 year ago

@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

proactivebit commented 8 months ago

I have the same problem as @FrenchMajesty mentioned. @FrenchMajesty Have you fixed this?

FatemaDholkawala commented 7 months ago

I am also facing the same issue. Did you find any solution to this?

mansoorshahsaid commented 4 months ago

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 of userInfo[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.
 }
...