Building a Custom UI for Amazon Cognito with AWS Amplify

In my last post, I introduced the basic form of authentication, hooked onto a button and using the default UI. The default UI does not include any branding. When you are creating your own app, you will want to use your backgrounds, colors, fonts and logos. You will want to create your own Authenticator component that deals with this for you. Fortunately, AWS Amplify makes this simple.

Authentication follows a state diagram. You start unauthenticated and sitting at the sign-in screen. As you perform authentication operations (sign-in, submit-mfa, sign-up, confirm-signup, etc.), you transition from one state to another, gathering and mutating the authentication data and communicating with Amazon Cognito along the way. The Authenticator component is a state management system that displays the right component based on the state and allows the underlying component to sign-in:

import React from 'react';
import ForgotPassword from './ForgotPassword';
import SignUp from './SignUp';
import SignIn from './SignIn';

class Authenticator extends React.Component {
    static defaultProps = {
        initialState: 'default',
        initialData: {},
        onAuthenticated: (authData) => { console.log(`onAuthenticated(${JSON.stringify(authData, null, 2)}`); }
    };

    constructor(props) {
        super(props);

        this.state = {
            authState: this.props.initialState,
            authData: this.props.initialData,
        };
    }

    onAuthStateChange(newState, newData) {
        const data = Object.assign({}, this.state.authData, newData);
        this.setState({ authState: newState, authData: data });
        if (newState === 'authenticated') {
            this.props.onAuthenticated(data);
        }
    }

    render() {
        const props = {
            authData: this.state.authData,
            authState: this.state.authState,
            onAuthStateChange: (s,d) => this.onAuthStateChange(s,d)
        };

        switch (this.state.authState) {
            case 'forgotPassword':
                return <ForgotPassword {...props}/>;
            case 'signUp':
                return <SignUp {...props} />;
            case 'signIn':
            default:
                return <SignIn {...props} />;
        };
    }
}

export default Authenticator;

In this particular flow matrix (which models the requirements of user pools in Amazon Cognito), we need to model three different screens – SignIn, SignUp and ForgotPassword. Each one is passed the current state and the current authentication data blob (which is made up of return values from Amazon Cognito).

The SignUp Component

Each flow has a main screen where it asks for information and then submits it to the backend, and a potential confirmation – a token that is sent via email or text message, which I will gather with a modal. Let’s look at the event handlers for the SignUp component:

class SignUp extends React.Component {
    static defaultProps = {
        authData: {},
        authState: 'signUp',
        onAuthStateChange: (next, data) => { console.log(`SignUp:onAuthStateChange(${next}, ${JSON.stringify(data, null, 2)})`); }
    };

    constructor(props) {
        super(props);
        this.state = {
            authData: this.props.authData,
            authState: this.props.authState,
            modalShowing: false,
            error: null,
            loading: false,
            username: '',
            emailaddress: '',
            phone: '',
            password: ''
        };
    }

    async onSignUp() {
        try {
            this.setState({ loading: true });
            const response = await Auth.signUp(this.state.username, this.state.password, this.state.emailaddress, this.state.phone);
            console.log(`SignUp::onSignUp(): Response#1 = ${JSON.stringify(response, null, 2)}`);
            if (response.userConfirmed === false) {
                this.setState({ authData: response, modalShowing: true, loading: false });
            } else {
                this.onAuthStateChange('default', { username: response.username });
            }
        } catch (err) {
            console.log(`SignUp::onSignUp(): Error ${JSON.stringify(err, null, 2)}`);
            this.setState({ error: err.message, loading: false });
        }
    }

    async onConfirmSubmitted(token) {
        try {
            this.setState({ loading: true });
            const response = await Auth.confirmSignUp(this.state.username, token);
            console.log(`SignUp::onConfirmSubmitted(): Response#2 = ${JSON.stringify(response, null, 2)}`);
            this.setState({ loading: false });
            if (response === 'SUCCESS') {
                this.props.onAuthStateChange('default', { username: this.state.username });
            }
        } catch (err) {
            console.log(`SignUp::onConfirmSubmitted(): Error ${JSON.stringify(err, null, 2)}`);
            this.setState({ error: err.message, loading: false });
        }
    }

The event handlers handle submission of each form – one on the main page and one on the modal for gathering the confirmation token. The magic in this is actually one line – line 68 does all the submission to Amazon Cognito and returns (asynchronously) the result from Amazon Cognito. It handles all the request signing and validation necessary to perform the action. The request signing is done in native code on React Native. Similarly, line 84 submits the confirmation token to Amazon Cognito and returns success or failure.

This leaves the UI:

    render() {
        let settings = {
            cancelButton: {
                title: 'Cancel',
                backgroundColor: '#cccccc',
                fontSize: 14,
                enabled: !this.state.loading,
                onPress: () => this.props.onAuthStateChange('default', {})
            },
            confirmPrompt: {
                isVisible: this.state.modalShowing,
                title: 'Confirmation Required',
                description: 'Enter the six digit token you were just sent',
                onSubmit: (token) => this.onConfirmSubmitted(token)
            },
            usernameInput: {
                iconColor: 'white',
                iconName: 'user',
                iconSize: 24,
                autoCorrect: false,
                autoCapitalize: 'none',
                autoFocus: true,
                returnKeyType: 'next',
                placeholder: 'Username',
                placeholderTextColor: '#404040',
                value: this.state.username,
                onChangeText: (text) => this.setState({ username: text.toLowerCase() })
            },
            passwordInput: {
                iconColor: 'white',
                iconName: 'lock',
                iconSize: 24,
                autoCorrect: false,
                autoCapitalize: 'none',
                returnKeyType: 'done',
                secureTextEntry: true,
                placeholder: 'Password',
                placeholderTextColor: '#404040',
                value: this.state.password,
                onChangeText: (text) => this.setState({ password: text })
            },
            emailInput: {
                iconColor: 'white',
                iconName: 'envelope',
                iconSize: 24,
                autoCorrect: false,
                autoCapitalize: 'none',
                returnKeyType: 'next',
                placeholder: 'Email Address',
                placeholderTextColor: '#404040',
                value: this.state.emailaddress,
                onChangeText: (text) => this.setState({ emailaddress: text.toLowerCase() })
            },
            phoneInput: {
                iconColor: 'white',
                iconName: 'phone',
                iconSize: 24,
                autoCorrect: false,
                autoCapitalize: 'none',
                keyboardType: 'phone-pad',
                returnKeyType: 'next',
                placeholder: 'Phone',
                placeholderTextColor: '#404040',
                value: this.state.phone,
                onChangeText: (text) => this.setState({ phone: text })
            },
            submitButton: {
                title: 'Register',
                backgroundColor: '#397af8',
                onPress: () => this.onSignUp()
            },
            submitButtonLoading: {
                icon: { color: 'white', name: 'refresh', size: 24, type: 'font-awesome' },
                backgroundColor: '#cccccc',
                title: 'Processing'
            }
        };

        const errorComponent = this.state.error !== null
            ? {this.state.error}
            : false;

        return (
            <Wrapper>
                {this.state.error !== null && errorComponent}
                <View style={styles.signUpForm}>
                    <View style={styles.formContainer}>
                        <IconTextInput {...settings.usernameInput}/>
                        <IconTextInput {...settings.emailInput}/>
                        <IconTextInput {...settings.phoneInput}/>
                        <IconTextInput {...settings.passwordInput}/>
                        <View style={styles.submissionContainer}>
                            <Button {...(this.state.loading ? settings.submitButtonLoading : settings.submitButton)}/>
                        </View>
                    </View>
                </View>
                <View style={styles.flexGrow}/>
                <View style={styles.buttonsContainer}>
                    <Button {...settings.cancelButton}/>
                </View>
                <ModalTokenInput {...settings.confirmPrompt}/>
            </Wrapper>
        );
    }

This is a form that asks the user to enter the data. I’ve done a little bit of clean-up by forcing lower case on the username, but nothing major. There is certainly a lot pre-submission validation and clean-up you could do. For instance, the phone number must be in “international” format. If I am in the US (which I am), I don’t generally enter phone numbers that way. I could use the google-libphonenumber package to validate the phone number and provide type-ahead formatting so that it is easier to enter for the user. There are components that wrap that library to make it easier.

I have used some special components. The Wrapper component places the UI over a background image with the logo top and center. IconTextInput is a TextInput component with an icon (using react-native-vector-icons) by the side, and ModalTokenInput is a modal dialog (using react-native-modal) wrapping the form. The Button is from react-native-elements. It’s almost always a good idea to composite components based on existing work – you don’t have to invent everything yourself.

When the user is shown the SignUp component, he/she will fill in the form and click the submit button, which then calls the onSignUp event handler. That submits the data to Amazon Cognito, then brings up the modal to gather the token input. When the token input is successfully submitted, the component uses the passed in onAuthStateChange property to change the auth state back to sign-in so the user can log in with the new credentials.

The SignIn Component

The SignIn component, UI-wise, is very similar, so I’m only going to concentrate on the event handlers:

class SignIn extends React.Component {
    static defaultProps = {
        authData: {},
        authState: 'signIn',
        onAuthStateChange: (next, data) => { console.log(`SignIn:onAuthStateChange(${next}, ${JSON.stringify(data, null, 2)})`); }
    };

    constructor(props) {
        super(props);
        this.state = {
            authData: this.props.authData,
            authState: this.props.authState,
            modalShowing: false,
            loading: false,
            error: null,
            username: this.props.authData.username || '',
            password: this.props.authData.password || '',
            user: null
        };
    }

    async onSignIn() {
        this.setState({ loading: true });
        try {
            const data = await Auth.signIn(this.state.username, this.state.password);
            console.log(`onSignIn::Response#1: ${JSON.stringify(data, null, 2)}`);
            if (data.signInUserSession === null) {
                this.setState({ user: data, loading: false, modalShowing: true });
            } else {
                this.props.onAuthStateChange('authenticated', data);
            }
        } catch (err) {
            console.log(`Error: ${JSON.stringify(err, null, 2)}`);
            this.setState({ error: err.message, loading: false });
        }
    }

    async onConfirmSignin(token) {
        this.setState({ loading: true });
        try {
            console.log(`onConfirmSignIn:: ${this.state.username}, ${token}`);
            const data = await Auth.confirmSignIn(this.state.user, token);
            console.log(`onConfirmSignIn::Response#2: ${JSON.stringify(data, null, 2)}`);
            const profile = await Auth.currentUser();
            this.props.onAuthStateChange('authenticated', profile);
        } catch (err) {
            console.log('Error: ', err);
            this.setState({ error: err.message, loading: false, modalShowing: false });
        }
    }

Again, we have two important lines. One initiates the sign-in process to request a session from Amazon Cognito. The other deals with multi-factor authentication. The user object contains a lot of interesting data. Here is an example (with some stuff cut out):

{
  "username": "adrian",
  "pool": {
    "userPoolId": "**user-pool-id**",
    "clientId": "**client-id**",
    "client": { ... },
    "advancedSecurityDataCollectionFlag": true
  },
  "Session": "**my-session-id**",
  "client": { ... }
  "signInUserSession": {
    "idToken": {
      "jwtToken": "**my-id-token**",
      "payload": { ... contents-of-the-id-token ... }
    },
    "refreshToken": {
      "token": "**my-refresh-token**"
    },
    "accessToken": {
      "jwtToken": "**my-access-token**",
      "payload": { ... contents-of-the-access-token ... }
    },
    "clockDrift": 0
  },
  "authenticationFlowType": "USER_SRP_AUTH",
  "challengeName": "SMS_MFA",
  "challengeParam": {
    "CODE_DELIVERY_DELIVERY_MEDIUM": "SMS",
    "CODE_DELIVERY_DESTINATION": "+*******2039"
  }
}

In the onSignin() event handler, I’m keying off whether there is a valid user session. However, I should probably key off if there is a challengeName included in the response, as that is only present if an MFA prompt is required.

One other point is that the SignIn screen is the first screen shown in the authenticator, so you need to ensure you add links to the sign-up and forgot-password pages within the UI.

Other things you can do

With a custom Authenticator like this, there are a number of things you can do. Some of my favorites:

  • Store the username in AsyncStorage for a “remember me” functionality.
  • Add TouchID or FaceID support for storing credentials.
  • Add error checking for username, password, and phone number.
  • Ask for additional custom attributes that are submitted via Auth.signUp.
  • Link in Facebook or Google authentication.

Wherever your needs take you, you can implement it with the AWS Amplify library. Learn more about the AWS Amplify library on GitHub. You can find the example code I use here in my my GitHub repository.

4 thoughts

  1. Pingback: Dew Drop - January 18, 2018 (#2646) - Morning Dew

  2. Thank you so much for taking the time to explain all of this. Is there any chance you could release a git repo with the code applied somehow? I’m having a little trouble figuring out where all of this fits in in my current RN project. Login works perfectly with the default settings but I would very much like to at least change the UI. Do I have to do all of the session state handling and re-create the JSX just to modify the font size or the color of the buttons, for example? Again, thank you for these blog posts, they are much appreciated!

    Like

Comments are closed.