Webview native authentication in React Native

Posted on:

Edited on:

TL;DR:

Theory

Before you start reading and getting into this article, you must be aware that only the minority of mobile developers get into this use case and due to that, I decided to write this article to guide you through on how to implement authentication within a native app that has a webview part included.

You may be wondering why going through this while you could have just converted the web app into a fully native app or just go fully with the webview.

Well to answer the first question, sometimes your client wants a quick & cheap MVP to deploy to TestFlight or the Beta track on the Play Store for their customers to test and share feedback.

The reason we want to have at least the authentication part being fully native is because your submitted app on the App Store unlike Google gets tested by humans, and they reject the app if it uses the webview only.

Before we move into to the practical part in this guide, let me explain how we will deal with authentication first:

  1. User has to register or sign in
  2. A request is sent to our REST or GraphQL API returning a JWT token
  3. Token gets stored within the device storage
  4. User gets redirected to the webview screen being authenticated as we pass the token to the web app using a great library called react-native-webview-invoke, that lets us pass values and functions to be executed within the web app.
  5. When the user signs out within the webview screen, a function will be invoked from the web app that logs out the user on the native app as well

    This way, when the user opens up the app once again, they will start from the authentication process

  6. We will be getting the stored token and verifying that it is still valid, if it is, the API will return user's data, else user has to login once again.

Practice

So let us begin by initializing a new React Native project using npx react-native init authApp

⚠️ I'll be using React Native 0.61.5

Let us install all the libraries we will be using in this example:

  • Navigation: react-native-navigation
  • HTTP requests: axios
  • Webview: react-native-webview
  • Storage: @react-native-community/async-storage
  • Forms & validation: formik + yup
  • Styling: styled-components

Configuring RNN

As I'm using React Native 0.61.5, it's way easier to configure react-native-navigation now, you can follow these steps to get it configured:

for iOS

  1. cd ios
  2. open the Podfile
  3. add this line to your Podfile
pod 'ReactNativeNavigation', :podspec => '../node_modules/react-native-navigation/ReactNativeNavigation.podspec'
  1. open your xcworkspace project in Xcode
  2. In Xcode, you will need to edit this file: AppDelegate.m
  3. Its content should look like this
 #import "AppDelegate.h"

 #import <React/RCTBundleURLProvider.h>
 #import <React/RCTRootView.h>
 #import <ReactNativeNavigation/ReactNativeNavigation.h>

 @implementation AppDelegate

 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
 {
     NSURL *jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
     [ReactNativeNavigation bootstrap:jsCodeLocation launchOptions:launchOptions];

     return YES;
 }

 @end
  1. Open AppDelegate.h and make sure its content looks like below
#import <UIKit/UIKit.h>

@interface AppDelegate : UIResponder <UIApplicationDelegate>

@property (nonatomic, strong) UIWindow *window;

@end

for Android

You might want to check the official guide

⚠️ Make sure to create react-native.config.js file on the root of your app and put this content:

module.exports = {
  dependencies: {
    '@react-native-community/async-storage': {
      platforms: {
        android: null,
      },
    },
    'react-native-webview': {
      platforms: {
        android: null,
      },
    },
  },
}

We basically want to avoid auto linking those two libraries on Android.

Registering our screens

Let's start by opening up our index.js file and removing its content, then we will import Navigation from react-native-navigation, along with our registered screens under src/config/index.js and initialize our app using the registerAppLaunchedListener callback.

index.js

import { Navigation } from 'react-native-navigation'
import { registerScreens } from './src/config'

registerScreens()

Navigation.events().registerAppLaunchedListener(() => {
  Navigation.setRoot({
    root: {
      component: {
        name: 'Initializing',
      },
    },
  })
})

We set Initializing as the first screen to render.

Let's now register the rest of the screens

We have:

  • Initializing screen, which has been explained above ☝️
  • Home screen which will contain the webview of our web app
  • Login/Register screens are self descriptive

src/config/index.js

import { Navigation } from 'react-native-navigation'
import Home from 'src/screens/Home'
import Initializing from 'src/screens/Initializing'
import Login from 'src/screens/Login'
import Register from 'src/screens/Register'

export const BASE_URL = 'http://localhost:5000/api'
export const REACT_APP = 'http://localhost:3000'

export const registerScreens = () => {
  Navigation.registerComponent('Home', () => Home)
  Navigation.registerComponent('Initializing', () => Initializing)
  Navigation.registerComponent('Login', () => Login)
  Navigation.registerComponent('Register', () => Register)
}

BASE_URL is our REST API and REACT_APP is our React web app.

Now let's move on creating our screens

src/screens/Initializing.js

This screen is the one that will appear to users first while fetching and validating their tokens

import React from 'react'
import Layout from 'src/components/Layout'
import Initializiation from 'src/modules/Initializiation'

export default () => (
  <Layout>
    <Initializiation />
  </Layout>
)

Initialization is where the logic exists which lives under src/modules/Initializing

import React, { useContext } from 'react'
import { Text, View } from 'react-native'
import Container from 'src/components/Container'
import CustomButton from 'src/components/CustomButton'
import useGetUser from 'src/hooks/useGetUser'
import { Context } from 'src/providers/UserProvider'

export default () => {
  const { user, dispatch } = useContext(Context)
  const { loading, isLoggedIn } = useGetUser(user, dispatch)

  return (
    <Container>
      {loading ? (
        <Text>Loading</Text>
      ) : isLoggedIn ? (
        <View>
          <Text>Welcome back {user.data.user.username}!</Text>
          <CustomButton goHome={() => goHome(user.data.token)}>Go Home</CustomButton>
        </View>
      ) : (
        <View>
          <Text>Welcome!</Text>
          <CustomButton onPress={() => goToRegister()}>Register</CustomButton>
          <CustomButton onPress={() => goToAuth()}>Sign In</CustomButton>
        </View>
      )}
    </Container>
  )
}

Notice that I'm using a custom hook useGetUser that contains all that logic and I'm passing the user object and dispatch function from the User Context.

Layout is a wrapper component that wrapps the passed children with the User Provider as shown below

You can add the header and more components that are meant to appear on all your screens

Layout lives under src/components/Layout

import React from 'react'
import UserProvider from 'src/providers/UserProvider'

export default ({ children }) => <UserProvider>{children}</UserProvider>

And I'm using React Context API to manage my global state, here's the User Provider component and reducer

It lives under src/providers/UserProvider

import React, { createContext, useReducer } from 'react'
import UserReducer from 'src/reducers/UserReducer'

export const Context = createContext()

export default ({ children }) => {
  const [user, dispatch] = useReducer(UserReducer, [])

  return (
    <Context.Provider
      value={{
        user,
        dispatch,
      }}
    >
      {children}
    </Context.Provider>
  )
}

the user reducer lives under src/reducer/UserReducer

export default (user, action) => {
  switch (action.type) {
    case 'SAVE_USER':
      return {
        ...user,
        isLoggedIn: true,
        data: action.payload,
      }
    case 'LOGOUT':
      return {
        ...user,
        isLoggedIn: false,
        data: {},
      }
    default:
      return user
  }
}

And here's the useGetUser hook which lives under src/hooks/

import { useCallback, useEffect, useState } from 'react'
import { verifyToken } from 'src/modules/auth/actions'

export default (user, dispatch) => {
  const [loading, setLoading] = useState(true)
  const [error, _setError] = useState(null)

  const fetchUser = useCallback(() => verifyToken(dispatch, setLoading), [dispatch])

  useEffect(() => {
    if (!user.isLoggedIn) {
      fetchUser()
    }
  }, [user.isLoggedIn, fetchUser])

  return {
    error,
    loading,
    isLoggedIn: user.isLoggedIn,
  }
}

I'm importing verifyToken from the auth actions, the action simply verifies that the token hasn't expired yet, see Step 6 above on the Theory section

It lives under src/modules/auth/actions.js

import AsyncStorage from '@react-native-community/async-storage'
import axios from 'axios'
import { BASE_URL } from 'src/config'
import setAuthToken from 'src/helpers/setAuthToken'

export const verifyToken = async (dispatch, setLoading) => {
  try {
    const token = await AsyncStorage.getItem('token')

    if (token) {
      const { data } = await axios({
        method: 'GET',
        url: `${BASE_URL}/user/verify`,
        headers: {
          'Content-Type': 'application/json',
          'x-auth': token,
        },
      })

      setAuthToken(data.token)
      await dispatch({ type: 'SAVE_USER', payload: data })
      AsyncStorage.setItem('token', data.token)
    }
  } catch (err) {
    setError(err)
  } finally {
    setLoading(false)
  }
}

More actions will be added as we move on through this guide through.

Next, let's prepare both the SignIn and Register screens:

Login lives under src/screens/Login

import React from 'react'
import Layout from 'src/components/Layout'
import Login from 'src/modules/auth/Login'

export default () => (
  <Layout>
    <Login />
  </Layout>
)

And Login module lives under src/modules/auth/Login

import React, { useContext } from 'react'
import { Formik } from 'formik'
import { View } from 'react-native'
import Container from 'src/components/Container'
import CustomButton from 'src/components/CustomButton'
import DismissibleKeyboardView from 'src/components/DismissibleKeyboardView'
import ErrorField from 'src/components/ErrorField'
import InputField from 'src/components/InputField'
import { login } from 'src/modules/auth/actions'
import { Context } from 'src/providers/UserProvider'
import * as Yup from 'yup'
import { Label } from '../styles'

export default () => {
  const { dispatch } = useContext(Context)

  return (
    <Formik
      initialValues={{
        email: '',
        password: '',
      }}
      validationSchema={Yup.object().shape({
        email: Yup.string().email().required(),
        password: Yup.string().required(),
      })}
      onSubmit={async (values, { setSubmitting, setErrors }) => {
        try {
          login({ dispatch, setErrors, setSubmitting, values })
        } catch (err) {
          setSubmitting(false)
        }
      }}
    >
      {({ isSubmitting, handleSubmit, errors, touched, values, handleChange, handleBlur }) => (
        <Container>
          <DismissibleKeyboardView keyboardShouldPersistTaps="handled">
            <View>
              <Label>Email</Label>
              <InputField
                value={values.email}
                onChangeText={handleChange('email')}
                onBlur={handleBlur('email')}
                selectTextOnFocus
              />
              {touched.email && errors.email && <ErrorField>{errors.email}</ErrorField>}
            </View>
            <View>
              <Label>Password</Label>
              <InputField
                value={values.password}
                onChangeText={handleChange('password')}
                onBlur={handleBlur('password')}
                selectTextOnFocus
                secureTextEntry
              />
              {touched.password && errors.password && <ErrorField>{errors.password}</ErrorField>}
            </View>
            <CustomButton onPress={handleSubmit} disabled={isSubmitting}>
              Login
            </CustomButton>
          </DismissibleKeyboardView>
        </Container>
      )}
    </Formik>
  )
}

I'm using the newest version of Formik with yup for validation, there is one action called login being dispatched there when the form is submitted.

login action lives under src/modules/auth/actions, the same file where verifyToken lives

import AsyncStorage from '@react-native-community/async-storage'
import axios from 'axios'
import { BASE_URL } from 'src/config'
import { goHome } from 'src/config/navigation'
import setAuthToken from 'src/helpers/setAuthToken'

export const login = async ({ dispatch, setErrors, setSubmitting, values }) => {
  try {
    const { data } = await axios.post(`${BASE_URL}/user/login`, values)
    setAuthToken(data.token)
    await dispatch({ type: 'SAVE_USER', payload: data })
    await AsyncStorage.setItem('token', data.token)
    setSubmitting(false)
    goHome(data.token)
  } catch (err) {
    setSubmitting(false)
    setErrors({ email: err.response.data.error })
  }
}

export const verifyToken = async (dispatch, setLoading) => {
  try {
    const token = await AsyncStorage.getItem('token')

    if (token) {
      const { data } = await axios({
        method: 'GET',
        url: `${BASE_URL}/user/verify`,
        headers: {
          'Content-Type': 'application/json',
          'x-auth': token,
        },
      })

      setAuthToken(data.token)
      await dispatch({ type: 'SAVE_USER', payload: data })
      AsyncStorage.setItem('token', data.token)
    }
  } catch (err) {
    setError(err)
  } finally {
    setLoading(false)
  }
}

We will add three more actions later on as we move on.

The setAuthToken function simply adds a x-auth header to all upcoming requests

It lives under src/helpers/setAuthToken

import axios from 'axios'

export default (token) => {
  if (token) {
    axios.defaults.headers.common['x-auth'] = token
  } else {
    delete axios.defaults.headers.common['x-auth']
  }
}

Register is following the same logic, you'll be able to find the source code on the repositories as everything will be open sourced, so let's move on to the important screen which is the Home screen

It lives under src/screens/Home

import React from 'react'
import Layout from 'src/components/Layout'
import Home from 'src/modules/dashboard/Home'

export default ({ token }) => (
  <Layout>
    <Home token={token} />
  </Layout>
)

the actual logic exists within src/module/dashboard/Home

let's start by creating an invoke from the native side and add the webview of our React app

import React, { Component } from 'react'
import { SafeAreaView } from 'react-native'
import { WebView } from 'react-native-webview'
import createInvoke from 'react-native-webview-invoke/native'
import { REACT_APP } from 'src/config/'

class Home extends Component {
  webview

  invoke = createInvoke(() => this.webview)

  render() {
    return (
      <SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }}>
        <WebView
          useWebKit
          ref={(webview) => (this.webview = webview)}
          onMessage={this.invoke.listener}
          source={{
            uri: `${REACT_APP}`,
          }}
          bounces={false}
        />
      </SafeAreaView>
    )
  }
}

Home.options = {
  topBar: {
    title: {
      text: 'Home',
    },
    visible: false,
  },
}

export default Home

We want to pass one function and value from React Native to the React web app:

  1. Passing the token as url param, not sure if it's a good approach to follow, feel free to enlighten me if you know any better approach to achieve this.
  2. A function that will log the user out from the React Native app, remove the token from the device storage and redirect them back to the Login screen, triggered/invoked from the React web app.

So let's add that to the Home module

import React, { Component } from 'react'
import AsyncStorage from '@react-native-community/async-storage'
import { Alert, SafeAreaView } from 'react-native'
import { WebView } from 'react-native-webview'
import createInvoke from 'react-native-webview-invoke/native'
import { REACT_APP } from 'src/config/'
import { goToAuth } from 'src/config/navigation'

class Home extends Component {
  webview

  invoke = createInvoke(() => this.webview)

  componentDidMount() {
    this.invoke.define('onLogout', this.onLogout)
  }

  onLogout = async () => {
    try {
      AsyncStorage.clear()
      goToAuth()
    } catch (err) {
      Alert.alert('Something went wrong')
    }
  }

  render() {
    const { token } = this.props
    return (
      <SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }}>
        <WebView
          useWebKit
          ref={(webview) => (this.webview = webview)}
          onMessage={this.invoke.listener}
          source={{
            uri: `${REACT_APP}/?token=${token}`,
          }}
          bounces={false}
        />
      </SafeAreaView>
    )
  }
}

export default Home

Let's now see how can we handle that from the React web app.

I'll skip right into the part where we handle the passed function to invoke it, the source code will be able available for you.

First of all, let's import invoke from react-native-webview-invoke/browser

import invoke from 'react-native-webview-invoke/browser'

All we have to do to access the function and invoke it is binding, checking if it exists and invoking it.

const onLogout = invoke.bind('onLogout')
if (onLogout) {
  onLogout().then(() => {})
}

That's basically the guide through to implement authentication within a native app that has a webview section.

If you managed to make it until the end, make sure to subscribe to the news letter down below in order to get the latest articles delivered right to your inbox!


© Copyright 2016-2024 , All Rights Reserved by Smakosh LLC