Passwordless with React Native and Auth0

I’m looking for feedback and validation for this approach we’ve used and then turn this into a blog post later on probably.

React Native Passwordless login with Auth0

With this guide you’ll be able to create magic links for passwordless login on a mobile app built with React Native using Auth0 as identity provider.

This is based on the oAuth 2.0 PKCE flow, following the Auth0 implementation

https://oauth.net/2/pkce/

Setup Auth0

First of all enable Passwordless with Email in the connections side menu

Next you need to create a new Applications and make sure its type is native.

Screenshot 2019-11-20 at 22 15 56

Add the Allowed Callback URLs and Allowed Logout URLs in this format and change it to have your BUNDLE_ID and your Auth0 domain.

com.your.app.prod://your-domain.eu.auth0.com/ios/com.your.app.prod/callback, com.your.app.prod://your-domain.eu.auth0.com/android/com.your.app.prod/callback

Make sure that Passwordless is enabled in the connections tab.

You also need to add a new API so that we will receive it as audience of our tokens and enable offline access in order to retrieve a refresh_token

Screen Shot 2019-11-12 at 10 25 06 AM

Setup for RN >= 0.60

React Native JS doesn’t run in Node so the crypto module is not available.
For this reason you need to install the crypto-js module.
The other 2 dependencies you need are react-native-keychain for saving the tokens safely and url-parse as an utility to parse the querystring and extract the code from the redirect.

npm i --save crypto-js react-native-keychain url-parse

Don’t forget to update the pods, rebuild the app and restart the packager.

cd ios
pod update
cd ..
npx react-native run-ios
npm start -- --reset-cache

iOS deep linking config

Add this to your info.plist in order to handle the deep link.

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleTypeRole</key>
    <string>None</string>
    <key>CFBundleURLName</key>
    <string>auth0</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    </array>
  </dict>
</array>

Android deep linking config

Add this to your inside android/app/src/main/AndroidManifest.xml and make sure the .MainActivity has launchMode as singleTask.

<activity
  android:name=".MainActivity"
  android:launchMode="singleTask"
  android:label="@string/app_name"
  android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
  android:windowSoftInputMode="adjustResize">
  <intent-filter>
      <action android:name="android.intent.action.MAIN" />
      <category android:name="android.intent.category.LAUNCHER" />
  </intent-filter>
  <!-- ADD THIS -->
  <intent-filter>
      <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.DEFAULT" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data
          android:host="your-domain.eu.auth0.com"
          android:pathPrefix="/android/${applicationId}/callback"
          android:scheme="${applicationId}" />
  </intent-filter>
</activity>

auth.js and useAuth hook

This will be your file containing the hook handling the interaction with Auth0 API and saving the relevant tokens plus all the functions needed to generate the code_challenge and code_verifier.

// auth.js
import React, { useState, useEffect, useContext } from 'react';
import { Platform } from 'react-native';
import * as Keychain from 'react-native-keychain';

const { SHA256, enc, AES, lib } = require('crypto-js');

const { OS } = Platform;
export const BUNDLE_ID = 'com.your.app';
export const AUTH0_CLIENT_ID = 'xxxxxxxxxxx';
export const AUTH0_DOMAIN = 'your-domain.eu.auth0.com'
export const AUTH0_REDIRECT_URI = `${BUNDLE_ID}://${AUTH0_DOMAIN}/${OS}/${BUNDLE_ID}/callback`;

export const AuthContext = React.createContext({});
export const useAuth = () => useContext(AuthContext);

Make sure to change BUNDLE_ID with the one specified in your app and AUTH0_DOMAIN, AUTH0_CLIENT_ID from your Auth0 configuration.

You will need these utility functions:

function generateCodeVerifier() {
  const random = lib.WordArray.random(32);
  return enc.Base64.stringify(random)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/[=]/g, '');
}

function generateCodeChallenge(verifier) {
  const sha = SHA256(verifier).toString(enc.Base64);
  return sha
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/[=]/g, '');
}

Let’s define the APIs that we will call

1) Request magic link

function requestMagicLinkAuth0(email, codeChallenge) {
  return fetch(`https://${AUTH0_DOMAIN}/passwordless/start`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      client_id: AUTH0_CLIENT_ID,
      connection: 'email',
      email,
      send: 'link',
      authParams: {
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
        scope: 'openid email profile offline_access',
        response_type: 'code',
        redirect_uri: AUTH0_REDIRECT_URI,
      },
    }),
  });
}

2) Exchange the code for the tokens

function exchangeCodeAuth0(code, verifier) {
  return fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      client_id: AUTH0_CLIENT_ID,
      grant_type: 'authorization_code',
      code_verifier: verifier,
      code,
      redirect_uri: AUTH0_REDIRECT_URI,
    }),
  });
}

3) Exchange the refresh_token for a new access_token

function exchangeRefreshToken(refreshToken) {
  return fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      client_id: AUTH0_CLIENT_ID,
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    }),
  });
}

Let’s define our Context Provider and a placeholder for all the functions you will need.


export const AuthProvider = ({ children }: Props) => {
  const [isAuthenticated, setIsAuthenticated] = useState();
  const [user, setUser] = useState();
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    // check on mount if we are logged in
  }, []);

  const requestMagicLink = async (email) => {
    // first call to trigger the email
  };

  const exchangeCode = async (code) => {
    // second call to exchange the code for tokens
  };

  const logout = async () => {
    // clean saved tokens and set user not authenticated
  };

  return (
    <AuthContext.Provider
      value={{
        isAuthenticated,
        user,
        loading,
        getToken,
        requestMagicLink,
        exchangeCode,
        logout,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

The first step would be to call the /passwordless/start endpoint and generate a code_challenge to pass.
This call to Auth0 will start the passwordless login by sending a mail with a link to the user.

const requestMagicLink = async (email) => {
  const codeVerifier = await generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);
  await Keychain.setInternetCredentials(
    'auth0_code_verifier',
    'verifier',
    codeVerifier,
  );
  await requestMagicLinkAuth0(email, codeChallenge);
};

Once the user has clicked on the link they will navigate to your Auth0 domain and then there will be a redirect to your app, like we specified in the redirect_uri, containing the code to be exchaged, together with the codeVerifier for our tokens.

The final redirect_uri is using deep linking to make sure it’s gonna work reliably.

At this point you need to be able to read this URL and extract the code from the query params in order to trigger the exchange call.

useLinking.js

Let’s create this hook in order to read the initial URL containing the query param code when get redirected from Auth0 to the React Native app.

import { useState, useEffect } from 'react';
import { Linking } from 'react-native';

function useLinking() {
  const [url, setUrl] = useState(null);
  const [error, setError] = useState();

  async function initialUrl() {
    try {
      const linkingUrl = await Linking.getInitialURL();
      if (linkingUrl) {
        setUrl(linkingUrl);
      }
    } catch (ex) {
      setError(ex);
    }
  }

  useEffect(() => {
    function handleOpenUrl(ev) {
      setUrl(ev.url);
    }

    initialUrl();

    Linking.addEventListener('url', handleOpenUrl);
    return () => Linking.removeEventListener('url', handleOpenUrl);
  }, []);

  return { url, error };
}

export default useLinking;

Now you can use useLinking() for example where you declare our navigation routes.
In our case we are using react-navigation and this is what it looks like:

function Navigation() {
  const { isAuthenticated, loading } = useAuth();
  const { url } = useLinking();
  // we need this in order to trigger navigation actions outside of the navigation provider
  const navigation = useRef(null);
  
  // check for changes of isAuthenticated and url
  useEffect(() => {
    const parsedUrl = parse(url, true);
    if (
      !isAuthenticated &&
      parsedUrl.hostname === AUTH0_DOMAIN &&
      parsedUrl.pathname ===
        `/${Platform.OS}/${BUNDLE_ID}/callback`
    ) {
      navigation.current.dispatch(
        NavigationActions.navigate({
          routeName: 'ExchangeCodeScreen',
          params: {
            code: parsedUrl.query.code,
          },
        }),
      );
    }
  }, [isAuthenticated, url]);

  // routes declaration

  if (loading) {
    // or show a loading state but it should be quite instant
    return null;
  }

  const RootNavigator = createAppContainer(Routes);
  return <RootNavigator ref={navigation} />;
}

You should create an ExchangeCodeScreen where you can show a loading state while we perform the code exchange.
This might not be instant as it has to make a network call to Auth0 and you might want to improve it with some retry logic in case of network failure or expired code.
Remember by default the link sent via mail is valid for 5 minutes.

function ExchangeCodeScreen() {
  const navigation = useNavigation();
  const code = navigation.getParam('code', null);
  const { exchangeCode } = useAuth();
  useEffect(() => {
    if (code) {
      exchangeCode(code);
    }
  }, [code, exchangeCode]);

  return // UI with loading state
}

At this point going back to your auth.js containing the AuthProvider you need to add the code exchange part:

const exchangeCode = async (code: string) => {
  const { password: verifier } = await Keychain.getInternetCredentials(
    'auth0_code_verifier',
  );
  const res = await exchangeCodeAuth0(code, verifier);
  if (!res.ok) {
    throw new Error(`Error exchanging code with status ${res.status}`);
  }
  const tokens = await res.json();
  const refreshToken = {
    token: tokens.refresh_token,
  };
  await Keychain.setInternetCredentials(
    'auth0_access_token',
    'access_token',
    tokens.access_token,
  );
  await Keychain.setInternetCredentials(
    'auth0_refresh_token',
    'refresh_token',
    JSON.stringify(refreshToken),
  );
  setIsAuthenticated(true);
};

At this point we logged in the user and saved the tokens securely in the keychain.
You might notice that we saved the refresh_token as an object with a key token, this might be useful in case you wanna further protect your refresh_token by encrypting it and then verify it’s correctly decrypted (i.e. if you wanna lock your app with a pin) or in case you wanna add some information like the creation timestamp.

The best way to handle this logic from a navigation point of view is to use a SwitchNavigator.

const AuthSwitch = createSwitchNavigator(
  {
    UserLoggedOut: UserLoggedOutScreens,
    UserLoggedIn: UserLoggedInScreens,
  },
  {
    initialRouteName: isAuthenticated ? 'UserLoggedIn' : 'UserLoggedOut',
  },
);

Now you need to handle logout which will clean the keychain and set the user not authenticated.

const logout = async () => {
  await Keychain.resetInternetCredentials('auth0_access_token');
  await Keychain.resetInternetCredentials('auth0_refresh_token');
  setIsAuthenticated(false);
};

Because of the SwitchNavigator above once you trigger logout() your navigation component will re-render as isAuthenticated changed to false and send the user back to the logged out screens.

And finally since we are using the access_token that has a short TTL to call our APIs we need a function to refresh it when it’s expired using a refresh_token.

const getToken = async () => {
  const { password: accessToken } = await Keychain.getInternetCredentials(
    'auth0_access_token',
  );
  const decodedToken = jwtDecode(accessToken);
  if (decodedToken.exp * 1000 > Date.now()) {
    return accessToken;
  } else {
    // token expired, refresh it
    const {
      password: refreshToken,
    } = await Keychain.getInternetCredentials('auth0_refresh_token');
    const res = await exchangeRefreshToken(refreshToken);
    const json = await res.json();
    const newAccessToken = json.access_token;
    await Keychain.setInternetCredentials(
      'auth0_access_token',
      'auth0_access_token',
      newAccessToken,
    );
    return newAccessToken;
  }
};

From here what you can do is add some logic for error handling and retry in case of network failure or expired code for example.
If your app has to deal with very important informations you might want further to protect the refresh_token by using a pin to unlock the app (and encrypt the token) and biometric.

4 Likes

Good morning @mtt87 and welcome to the Auth0 community!

Thank you for sharing and I will be sure to get some eyes on this :slightly_smiling_face:

2 Likes

I have found and am checking now theses 2 official links :

  1. https://auth0.com/docs/quickstart/native/react-native?download=true
  2. https://github.com/auth0/react-native-auth0