Implementing Google and Apple login hooks with Expo 43 and Firebase v9

How to implement an auth system with using neat hooks and a credential-based social login system.

I'm starting on a new mobile project this weekend and decided to implement my auth system properly this time, using neat hooks and a credential-based social login system.

Our tech stack for this project will include React Native, Expo 43 (which came out yesterday) and Firebase v9 - the new modular version of the library that's ~73% smaller through the power of tree-shaking.

I'll assume that you've setup your local Firebase instance and start off by implementing Google and Apple sign in systems.

Sign in with Apple

Expo has a built in Apple Authentication package called expo-apple-authentication. There's a whole lot of setup to do and an even more intricate implementation, but I have a finished code snippet for you so don't worry.

A bit of preamble - before actually showing the login button on the UI, Expo give us a promise called Apple.isAvailableAsync that checks whether Login with Apple is available on this device. By combining with with a quick Platform.OS check (to be safe), we can ensure pressing the button won't blow up the user's Android phone.

The login function is a bit more complex. And by a bit more I mean quite a lot. Before we can even show the sign-in screen, we have to create a state and a nonce. The state is an arbitrary string that is returned unmodified after auth to verify that the response was from the request you made. The nonce is used for... exactly the same thing as far as I'm aware. I think one is used for OpenID and the other is for the OAuth 2 protocol, but besides being different lengths, we generate them the same way - using Expo's handy expo-crypto library!

Anyway once we have those ready, we can hit up the Apple Auth library's signInAsync method with those values and our requested scopes (in my case, full name and email). That gives us back an AppleCredential, complete with my requested data and (hopefully) an identityToken.

Assuming we have this token, we can create a new OAuthProvider for firebase with the provider ID of 'apple.com' and use that to create a credential. Then, it's just a matter of returning this credential to the login UI.

CODE GO:

import {
  isAvailableAsync,
  AppleAuthenticationScope,
  signInAsync,
} from 'expo-apple-authentication';
import { digestStringAsync, CryptoDigestAlgorithm } from 'expo-crypto';
import { OAuthProvider } from 'firebase/auth';
import { useState, useEffect } from 'react';
import { Alert, Platform } from 'react-native';
 
async function login() {
  console.log('Signing in with Apple...');
  const state = Math.random().toString(36).substring(2, 15);
  const rawNonce = Math.random().toString(36).substring(2, 10);
  const requestedScopes = [
    AppleAuthenticationScope.FULL_NAME,
    AppleAuthenticationScope.EMAIL,
  ];
 
  try {
    const nonce = await digestStringAsync(
      CryptoDigestAlgorithm.SHA256,
      rawNonce
    );
 
    const appleCredential = await signInAsync({
      requestedScopes,
      state,
      nonce,
    });
 
    const { identityToken, email, fullName } = appleCredential;
 
    if (!identityToken) {
      throw new Error('No identity token provided.');
    }
 
    const provider = new OAuthProvider('apple.com');
 
    provider.addScope('email');
    provider.addScope('fullName');
 
    const credential = provider.credential({
      idToken: identityToken,
      rawNonce,
    });
 
    const displayName = fullName
      ? `${fullName.givenName} ${fullName.familyName}`
      : undefined;
    const data = { email, displayName };
 
    return [credential, data] as const;
  } catch (error: any) {
    throw error;
  }
}
 
export default function useAppleAuthentication() {
  const [authenticationLoaded, setAuthenticationLoaded] =
    useState<boolean>(false);
 
  useEffect(() => {
    async function checkAvailability() {
      try {
        const available = await isAvailableAsync();
 
        setAuthenticationLoaded(available);
      } catch (error: any) {
        Alert.alert('Error', error?.message);
      }
    }
 
    if (Platform.OS === 'ios' && !authenticationLoaded) {
      checkAvailability();
    }
  }, []);
 
  return [authenticationLoaded, login] as const;
}

Now, let's add this hook to our login screen. Expo's Apple Authentication library is nice enough to provide us with the physical sign in button as dictated by Apple's design team, along with enums for the button type and style.

Also, you'll see a login() function below. Don't worry about that for now... we'll get to it. Just trust me.

import { AppleAuthenticationButton, AppleAuthenticationButtonType, AppleAuthenticationButtonStyle } from 'expo-apple-authentication';
import React from 'react';
import { Text, View } from 'react-native';
import useAppleAuthentication from '../hooks/useAppleAuthentication';
import { RootStackScreenProps } from '../types';
 
export default function Login({ navigation }: RootStackScreenProps<'Login'>) {
  const [appleAuthAvailable, authWithApple] = useAppleAuthentication();
 
  async function loginWithApple() {
    try {
      const [credential, data] = await authWithApple();
      await login(credential, data);
    } catch (error: any) {
      console.error(error);
      Alert.alert('Error', 'Something went wrong. Please try again later.');
    }
  }
 
  return (
    <View>
      <Text>Login</Text>
      {appleAuthAvailable && (
        <AppleAuthenticationButton
          buttonType={AppleAuthenticationButtonType.SIGN_IN}
          buttonStyle={AppleAuthenticationButtonStyle.BLACK}
          cornerRadius={5}
          style={{
            width: '100%',
            height: 48,
            marginTop: 16
          }}
          onPress={loginWithApple}
        />
      }
    </View>
  );

Sign in with Google

Google Authentication, thankfully, is a lot easier to work with. But that's because it's also a bit janker. To clarify: while Apple's authentication is native (it causes the Apple login panel to appear on iPhones), this Google Authentication method uses the browser.

I know what you're thinking - ISN'T THERE A NATIVE VERSION OF GOOGLE'S AUTH? - well yes there is! But it only works in standalone or bare React Native apps, so if you don't want to eject then the only way to test it is to build a standalone app for your device OR to publish it. LOL.

Anyway, you can use this web-based method for heaps of other providers, like Coinbase and GitHub. It actually mostly happens in the background using Expo's WebBrowser, Crypto and Random libraries.

Check it (button design sold seperately):

import { GoogleAuthProvider } from '@firebase/auth';
import { useIdTokenAuthRequest } from 'expo-auth-session/providers/google';
import Constants from 'expo-constants';
import { maybeCompleteAuthSession } from 'expo-web-browser';
 
maybeCompleteAuthSession();
 
function login(id_token: string) {
  console.log('Signing in with Google...', { id_token });
 
  try {
    const credential = GoogleAuthProvider.credential(id_token);
 
    return credential;
  } catch (error) {
    throw error;
  }
}
 
export default function useGoogleAuthentication() {
  const [request, _, promptAsync] = useIdTokenAuthRequest({
    ...Constants.manifest?.extra?.google,
  });
 
  async function prompt() {
    const response = await promptAsync();
 
    if (response?.type !== 'success') {
      throw new Error(response.type);
    }
    const credential = login(response.params.id_token);
 
    return [credential];
  }
 
  return [!!request, prompt] as const;
}

And now, you guessed it, we're rendering it to the screen. I'll add it to that Login component from earlier.

import { AppleAuthenticationButton, AppleAuthenticationButtonType, AppleAuthenticationButtonStyle } from 'expo-apple-authentication';
import React from 'react';
import { TouchableOpacity, Text, View } from 'react-native';
import useAppleAuthentication from '../hooks/useAppleAuthentication';
import useGoogleAuthentication from '../hooks/useGoogleAuthentication';
import { RootStackScreenProps } from '../types';
 
export default function Login({ navigation }: RootStackScreenProps<'Login'>) {
  const [googleAuthLoading, authWithGoogle] = useGoogleAuthentication();
  const [appleAuthAvailable, authWithApple] = useAppleAuthentication();
 
  async function loginWithGoogle() {
    try {
      const [credential] = await authWithGoogle();
      await login(credential);
    } catch (error: any) {
      console.error(error);
      Alert.alert('Error', 'Something went wrong. Please try again later.');
    }
  }
 
  async function loginWithApple() {
    try {
      const [credential, data] = await authWithApple();
      await login(credential, data);
    } catch (error: any) {
      console.error(error);
      Alert.alert('Error', 'Something went wrong. Please try again later.');
    }
  }
 
  return (
    <View>
      <Text>Login</Text>
      {appleAuthAvailable && (
        <AppleAuthenticationButton
          buttonType={AppleAuthenticationButtonType.SIGN_IN}
          buttonStyle={AppleAuthenticationButtonStyle.BLACK}
          cornerRadius={5}
          style={{
            width: '100%',
            height: 48,
            marginTop: 16
          }}
          onPress={loginWithApple}
        />
      }
      <TouchableOpacity
        onPress={loginWithGoogle}
        disabled={!googleAuthLoading}
      >
        <Text>Sign in with Google</Text>
      </TouchableOpacity>
    </View>
  );

Connecting with Firebase

Okay, now for the moment of truth. Both hooks generate an AuthCredential for us. If you want the specifics, its an object that represents the credentials returned by an AuthProvider, specify the details about each auth provider's credential requirements.

All we need to do is use this credential (and any other data we want to store on the user profile... say, the full name and email we collected from Apple) to create a Firebase user.

Let's do it:

import {
  getAuth,
  signInWithCredential,
  updateEmail,
  updateProfile,
} from '@firebase/auth';
import type { AuthCredential } from '@firebase/auth';
 
export default async function loginWithCredential(
  credential: AuthCredential,
  data?: any
) {
  console.log('Logging in with credential', credential, data);
 
  const auth = getAuth();
  const { user } = await signInWithCredential(auth, credential);
 
  console.log('Signed in with credential. Updating profile details...');
 
  if (data?.email && !user.email) {
    await updateEmail(user, data.email);
  }
 
  if (data?.displayName && !user.displayName) {
    await updateProfile(user, { displayName: data.displayName });
  }
 
  return user;
}

Now let's add this handy little function to our login screen:

import { AppleAuthenticationButton, AppleAuthenticationButtonType, AppleAuthenticationButtonStyle } from 'expo-apple-authentication';
import React from 'react';
import { TouchableOpacity, Text, View } from 'react-native';
import useAppleAuthentication from '../hooks/useAppleAuthentication';
import useGoogleAuthentication from '../hooks/useGoogleAuthentication';
import loginWithCredential from '../utils/loginWithCredential';
import { RootStackScreenProps } from '../types';
 
export default function Login({ navigation }: RootStackScreenProps<'Login'>) {
  const [googleAuthLoading, authWithGoogle] = useGoogleAuthentication();
  const [appleAuthAvailable, authWithApple] = useAppleAuthentication();
 
  async function login(credential: AuthCredential, data?: any) {
    const user = await loginWithCredential(credential, data);
 
    navigation.navigate('Root');
  }
 
  async function loginWithGoogle() {
    try {
      const [credential] = await authWithGoogle();
      await login(credential);
    } catch (error: any) {
      console.error(error);
      Alert.alert('Error', 'Something went wrong. Please try again later.');
    }
  }
 
  async function loginWithApple() {
    try {
      const [credential, data] = await authWithApple();
      await login(credential, data);
    } catch (error: any) {
      console.error(error);
      Alert.alert('Error', 'Something went wrong. Please try again later.');
    }
  }
 
  return (
    <View>
      <Text>Login</Text>
      {appleAuthAvailable && (
        <AppleAuthenticationButton
          buttonType={AppleAuthenticationButtonType.SIGN_IN}
          buttonStyle={AppleAuthenticationButtonStyle.BLACK}
          cornerRadius={5}
          style={{
            width: '100%',
            height: 48,
            marginTop: 16
          }}
          onPress={loginWithApple}
        />
      }
      <TouchableOpacity
        onPress={loginWithGoogle}
        disabled={!googleAuthLoading}
      >
        <Text>Sign in with Google</Text>
      </TouchableOpacity>
    </View>
  );

And that's it! We've successfully implemented two social login providers with gorgeous hooks that I've probably butchered in some way. Let me know!


Published on October 23, 2021 8 min read