Implementing Mobile Authentication with React Native and Amazon Cognito

It’s been a while since I’ve blogged. I’ve been, erm, busy. I’m back with a vengeance this year. I’ve switched over to React Native, been falling in love with GraphQL and doing a lot of cloud-y stuff.

Todays blog is on mobile authentication. If there is one topic that comes up over and over, it’s how to ensure that only my users can access their data. Security and Data Privacy are hugely important topics, and rightly so. How do I do this easily?

Fortunately, AWS Mobile has been hard at work on making this simple, so let’s get right to it with setting up a backend. If you have an AWS account (they are free – you only pay for your consumption, not for having an account), then you can set up the awsmobile CLI to make configuring things easy. I’m going to assume you have already set up the React Native CLI on your system and installed the AWS CLI as well. Installing the awsmobile CLI is a snap:

$ npm install -g awsmobile-cli
$ awsmobile configure

To be fair, there is a little more to it. The awsmobile configure command will open a browser so that you can create an AWS IAM user, then you need to copy the access key and secret key into the prompts in your terminal session. This is a one-time item. You don’t need to go into the browser again (unless you want to).

I already have a React Native + Redux app – it’s a simple to-do list, and it’s all working on a local device. So, how do I add authentication to it? Well, first I need to create a mobile backend:

$ awsmobile init

The command will prompt you for your project source directory (mine is in ./src). You can accept the defaults for everything else, although you may want to name the project appopriately. Then, enable user-signin:

$ awsmobile user-signin enable

Now that I have the backend in place, I can move on to the client-side code. For this, I’m going to use the new and open-source AWS Amplify project. This has in-built support for Amazon Cognito (which is the user sign-in service that we are using on the backend). First, add the library:

$ npm install aws-amplify-react-native
$ react-native link amazon-cognito-identity-js

There are a couple of ways to implement authentication. The easiest, and the worst, method is to wrap the whole application. This is done in the App.js file. First, configure AWS Amplify:

import Amplify, { withAuthenticator } from 'aws-amplify-react-native';
import awsmobile from './src/aws-exports';

Amplify.configure(awsmobile);

The aws-exports.js file is maintained by the awsmobile command. Do not check this into source code. Add it to the .gitignore file.

The App.js file exports a component that represents the entire app. I can wrap the exported component with the authentication:

export default withAuthenticator(App);

Now, why is this bad? Well, first off, the default UI for authentication does not have your branding. In fact, it doesn’t have any branding. You definitely want to add branding to your app. There is an expectation that you are going to replace the UI with your own custom UI.

But the more serious point is this. You are putting a barrier between your user and their use of the app. You don’t want to do that. About the only thing worse than forcing your users to sign up immediately is an application crash. Let your users decide if they want to use your app first, then get them to sign up for the service you are offering.

Since this is a bad way of doing it, what’s a better way? Well, I can attach the login UI to a button. This allows me to determine if the user is authenticated or not later on and satisfies the more serious point I made earlier. However, this requires a little bit more infrastructure into the code. I need to store the authenticated state in my redux store. That requires an action creator (in my case, in ./src/redux/actions/auth.js):

import { createAction } from 'redux-actions';

export const setAuthState = createAction('@auth:set');

I also need a reducer (from ./src/redux/reducers/auth.js):

import { isFSA } from 'flux-standard-action';

const initialState = {
    authState: 'unauthenticated'
};

const reducer = (state = initialState, action) => {
    if (!isFSA(action))
        return state;

    const payload = action.payload || {};
    switch (action.type) {
        case '@auth:set':
            if ('state' in payload) {
                return Object.assign({}, state, { authState: payload.state });
            } else {
                console.warn(`@auth:set: called without state in payload`);
                return state;
            }
    }

    // If it didn't match one of the known types, then just return the state
    return state;
};

export default reducer;

I also needed to link the reducer into the redux store and ensure that the auth section is black listed from persisting to local storage. I use redux-persist for persisting the redux store to AsyncStorage, and I don’t want the auth state to get out of sync with the session state in Amazon Cognito. I can now connect the two pieces of information (the auth state and the action creator) to my screens:

const mapStateToProps = (state) => {
    return {
        items: state.tasks,
        authState: state.auth.authState
    };
};

const mapDispatchToProps = (dispatch) => {
    return {
        onDeleteTask: (item) => dispatch(actions.tasks.deleteTask(item)),
        onSaveTask: (item) => dispatch(actions.tasks.saveTask(item)),
        setAuthState: (state) => dispatch(actions.auth.setAuthState({ state }))
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(TaskListScreen);

The authState and setAuthState() are injected into my screen as props. Inside the screen (which is just a React component), I need to do three things:

  1. Write event handlers for when the user clicks on sign-in or sign-out
  2. Adjust the header so that sign-in or sign-out buttons are displayed
  3. Display the authenticator when the sign-in button is pressed

The authState starts off line as “authenticated”. When the user presses the sign-in button, it transitions to “during-auth”. This will be used to render the Authenticator component from AWS Amplify. When the Authenticator says it is authenticated, the authState transitions to “authenticated”. Here are the event handlers:

    onSigninPressed() {
        this.props.onSetAuthState('during-auth');
    }

    async onSignoutPressed() {
        try {
            await Amplify.Auth.signOut();
            this.props.onSetAuthState('unauthenticated');
        } catch (err) {
            console.log(`onSignoutPressed: err = ${err}`);
        }
    }

    onAuthStateChange(state, data) {
        if (state === 'signedIn') {
            this.props.onSetAuthState('authenticated');
        }
    }

The onSigninPressed() event handler is called from a button click to kick off the process. The onSignoutPressed() event handler is called from a button click to sign out. Note that I am using async/await on this event handler to actually sign-out from Amazon Cognito.

The onAuthStateChange() event handler is called by the Authenticator component when it transitions from one state to another. An authentication UI is represented as a state diagram, with each node in the state diagram corresponding to a UI screen.

Display the Authenticator within the render() method:

        if (this.props.authState === 'during-auth') {
            const Authenticator = Amplify.Components.Authenticator;
            return (
                <View style={[styles.container, this.props.style]}>
                    <Authenticator onStateChange={(state, data) => this.onAuthStateChange(state, data)} />
                </View>
            );
}

This wires in the onAuthStateChange() event handler. Finally, I need to wire in a button. I produce my own NavBar for my apps – I don’t like the limitations of the navigational structure that is provided in react-navigation. Thus, my header is integrated:

        const navBarOptions = {
            tintColor: colors.headerBackgroundColor,
            statusBar: {
                style: colors.statusBarColor,
                hidden: false
            },
            leftButton: (this.props.authState === 'unauthenticated')
                ? <HeaderButton name='sign-in' color={colors.statusIconColor} onPress={() => this.onSigninPressed()} />
                : <HeaderButton name='sign-out' color={colors.statusIconColor} onPress={() => this.onSignoutPressed()} />,
            rightButton: (Platform.OS === 'ios')
                ? <HeaderButton name='plus' color={colors.statusIconColor} onPress={this.props.onAddTask} />
                : <View/>,
            title: {
                title: 'Tasks',
                tintColor: colors.headerForegroundColor
            }
};

The button that is displayed is based on the authState and each button is wired up to the appropriate event handler.

The result is that I can click on the sign-in button and get the authenticator UI. Once authenticated, the sign-in button becomes a sign-out button. Pressing that signs me out and places me back into the unauthenticated state.

In the next post time I’m going to fix that by looking at replacing the UI underlying the Authenticator component to provide your own sign-in and sign-up flows.