Authentication Using AWS Cognito and React

By: emma.jacobs Uncategorized,

Steps:

  • Creating a react app
  • Setting up a cognito user pool/app client in the aws console
  • Getting the correct pool information to insert into your react app
  • Components for authentication
    • Login page
    • Sign up page
    • Forgot password
  • Setting up multi factor authentication with TOTP
  • User management
    • Utility functions for getting and setting user attributes

In this tutorial we will be stepping through how to set up an authentication flow with MFA using React and AWS Cognito as our identity provider. We will be using the amazon-cognito-identity-js library, which will allow us to access Cognito’s functionality without using Amplify. We will implement Multi-factor Authentication using temporary one time passwords (TOTPs) provided by Google Authenticator.

Our first step will be to create a React app (if you don’t have one already) and install both the aws-sdk and the amazon-cognito-identity-js libraries using yarn or npm. Once that is done we can go into the AWS management console, navigate to Cognito using the search bar, and begin setting up a User Pool and attaching the app client to it. In this case, I think the docs provided by AWS on how to create a user pool provide a clear explanation on how to do this. Make sure to select MFA as “optional” and select “Temporary one time password”. (The SMS approach requires further configuration and charges incurred from AWS for an origin phone number)

Next, we can begin setting up our authentication functionality in our React app. Before we get to the code, here are a few notes on how things are working inside the amazon-cognito-identity-js library:

  • We can create a Cognito User Pool object that will allow us to access the user pool we just created 
    • Example functions include userPool.getCurrentUser()
  • We can then use this user pool object to create another object called Cognito User
    • All the actions we want to perform such as authentication, accessing and updating user attributes, etc. use functions that are associated with this Cognito User object
    • This has important implications — when we authenticate a user the session gets attached to the Cognito User object so even if we make a new user object with that same user and pool information it won’t have the session attached because it is a different instance. However, after we authenticate, we can retrieve that authenticated user from local storage (where it is automatically stored by cognito) using the get current user function.

Now onto the code. One of the biggest challenges I encountered when doing this project was understanding how the different library functions work, as the documentation for this library is a little sparse and somewhat deprecated. Moreover, these functions are not set up to use promises, so we will have to change that. In light of this I will do my best to explain how everything is working. In this project we will have a few different authentication flows:

  1. Sign Up
  2. Sign In
  3. Forgot Password

Since all three of these flows will use the same authentication function, we’ll cover that first.

Authentication

The documentation for the authenticateUser function in the amazon-cognito-identity-js library is sparse and difficult to understand how to tailor to your react app, so I’ll do my best to give a clear explanation of my implementation.

We’re going to create a wrapper function for the provided authenticateUser function in the amazon-cognito-identity-js library that takes in a username, password, and a boolean specifying whether the user wants to enable MFA.

The first step will be to create an AuthenticationDetails object and a CognitoUser object that we’ll pass to cognito’s authenticate user function.

 var userData = {
   Username: username,
   Pool: userPool
 };
 var authenticationData = {
   Username: username,
   Password: password,
 };
 var authenticationDetails = new AuthenticationDetails(authenticationData);
 
 var cognitoUser = new CognitoUser(userData);

From there we’ll return a new promise that calls cognitoUser.authenticateUser(authenticationData, callbacks). The second argument to this function will be a series of callbacks. We have the two basic callbacks, onSuccess, and onFailure, which are used for basic authentication aka. Basic username and password, no MFA setup and no MFA code required.

 return new Promise(function (resolve, reject) {
   cognitoUser.authenticateUser(authenticationDetails, callbacks...

On success we will receive an access token for the user from cognito, and a session will be attached to the cognitoUser object we created above. If the user wishes to set up MFA as specified by the boolean argument, we can call this.mfaSetup() which calls the mfaSetup callback we specify below. Otherwise, we can resolve with an object that specifies the user has been authenticated. The reasoning behind returning an object will become more obvious later.

 onSuccess: function (result) {
       const accessToken = result.getAccessToken().getJwtToken();
       console.log(accessToken);
       if (mfa) {
         this.mfaSetup();
       }
       else {
         resolve({authenticated: true});
       }
     },

On failure, we can simply reject with an error.

onFailure: function (err) {
       reject(err);
     },

Moving on to the mfaSetup callback mentioned above. This callback will be reached if the user specified in their sign up or sign in form that they wanted to enable MFA. Since we have enabled MFA using TOTP in our user pool, in this callback we are going to call the cognitoUser.associateSoftwareToken function, giving it this as a callback. This will associate a software token with our user and return a secret code that we can use to render a QR code. This is the QR code the user will scan with Google Authenticator to set up MFA.

  mfaSetup: function () {
       cognitoUser.associateSoftwareToken(this);
     },

Since we provided this as a callback to associate software token the next stage of execution is in associateSecretCode. Cognito sends back a secret code to the callback function we provided, and in that function we can use that code to generate a QR code for our user to scan. If we are successful in creating the QR code we can resolve our promise with the following object: {authenticated: false, totp: false, qrcode: result, cognitoUser: cognitoUser}. This specifies that even though the promise has resolved, our user is not yet authenticated, nor is it the appropriate time for them to enter their six digit code (otp: false), but since we have included the QR code and the user object, we can use that to display the QR code to the user on our site.

 associateSecretCode: function (secretCode) {
       console.log("SECRET CODE: ", secretCode);
       const name = '$yoursitename'
       const uri = `otpauth://totp/${decodeURI(name)}?secret=${secretCode
         }`
       console.log(uri);
 
       QR.toDataURL(uri, (err, result) => {
         if (err) {
           console.log(err.message);
         }
         else {
           resolve({authenticated: false, totp: false, qrcode: result, cognitoUser: cognitoUser});
         }
       })
     },

We can then prompt the user to enter their six digit code from Google Authenticator. On submit we can verify that code using the cognitoUser object we returned from the authenticate function and calling cognitoUser.verifySoftwareToken(inputted code, name of device, callbacks). On success Cognito will authenticate the user and return a session attached to our CognitoUser object and from there we can set softwareTokenMfa as the user’s Mfa preference.

 cognitoUser.verifySoftwareToken(challengeAnswer, "My TOTP device", {
           onSuccess: function (session) {
             console.log("SUCCESS TOTP: ", session);
             var softwareTokenMfaSettings = {
               Enabled: true,
               PreferredMfa: true
             };
    
             if (cognitoUser != null) {
               cognitoUser.getSession(function (err, session) {
                 if (err) {
                   console.log(err);
                   return;
                 }
                 console.log('session validity: ' + session.isValid());
    
                 cognitoUser.setUserMfaPreference(null, softwareTokenMfaSettings, function (err, result) {
                   if (err) {
                     console.log(err.message || JSON.stringify(err));
                   }
                   console.log('call result ' + result);
			//continue to app
                 });

Back to our authenticateUser function. Now that MFA is set up, on subsequent logins, Cognito will return to our totpRequired callback, rather than our plain and simple onSuccess callback. That callback resolves the promise with another object, this time specifying that totp is true, so we can handle the code input outside the authentication function just like we did with the QR code.

totpRequired: function (secretCode) {
       resolve({authenticated: false, totp: true, cognitoUser: cognitoUser});
     },

We can use the cognitoUser object we sent back when we resolved the promise to call cognitoUser.sendMFACode, which will verify the code, authenticate the user, and return a session attached to the cognitoUser object just like before.

     props.cognitoUser.sendMFACode(challengeAnswer, {
             onSuccess: function (session) {
               console.log("SUCCESS TOTP: ", session);
               //continue to app
             },
             onFailure: function (err) {
               console.log(err);
               setDisplayError(err);
             },
           },
             "SOFTWARE_TOKEN_MFA");

Sign Up

The sign up flow works by taking the user’s information and sending it to our cognito identity provider, where a user will be added to our user pool. Then, by default, a verification code is sent to the user’s email address, which they can enter in our app and we can send it back to cognito to confirm the user’s email address. When MFA is enabled, we set up a user account with Google Authenticator by requesting a QR code uri from Cognito which we can display as a QR code on our site that the user can scan with Google authenticator. From there, we prompt them to enter the six digit code from google authenticator into our app which we can verify with cognito and authenticate the user.

Let’s take this step by step. We’re going to create a file called utils.js where we can write our wrapper functions that will turn the library functions into promises. Let’s declare our user pool at the top of the file:

const userPool = new CognitoUserPool({
 UserPoolId: process.env.REACT_APP_USER_POOL_ID,
 ClientId: process.env.REACT_APP_CLIENT_ID,
})

The first function we’re going to use is the sign up function attached to the user pool object. Let’s write a function called cognitoSignUp that takes in the user’s username and password and any other attributes we wish to associate with our user (ex. Phone number) from our sign up form. 

We can construct new user attribute objects to pass along when we sign up our user:

  const emailAttribute = new CognitoUserAttribute({
     Name: 'email',
     Value: email,
   })
 
   const phoneAttribute = new CognitoUserAttribute({
     Name: 'phone_number',
     Value: phone,
   })
 
 
   let attributes = [emailAttribute, phoneAttribute]

Then we can return a new promise in which we call the cognito userPool.signUp(email, password, attributes, null) and in the onSuccess and onFailure callbacks we resolve and reject accordingly.

The second function we have to write we will call cognitoConfirmSignUp which takes in the username and the six digit code entered by the user when prompted. First, we create an object called userData containing the username and the userPool and use that to create a CognitoUser object we were discussing earlier. In this case, we don’t have to worry about passing around the object since we haven’t authenticated or connected a session to that object yet. We return a new promise in which we call cognitoUser.confirmRegistration(code, true) and resolve and reject the same way we did before.

In terms of app flow we will call these two functions we made like this: 

  1. On submit for our sign in form call cognitoSignUp
  2. On success display a new input field for the verification code
  3. On submit call cognitoConfirmSignUp
  4. Authenticate 

Sign In

The sign in flow can work in a few different ways. In each one the user enters their information and we send it to Cognito for authentication. If the user does not have MFA enabled, we follow the basic authenticateUser, onSuccess, onFailure flow mentioned above. If the user wants to enable MFA  they can check the box on our sign up form, setting the boolean to true, and we go through the MFA setup steps mentioned above. If the user has already enabled MFA and we go through the authenticateUser, totpRequired flow mentioned above.

In terms of app flow we will call these functions like this: 

  1. On submit for our sign in form call authenticate
  2. On success (mfa not required, nor does the user want to set it up) reroute to app
  3. If the user wants to set up MFA follow the steps above
  4. If the user already has MFA enabled:
props.cognitoUser.sendMFACode(challengeAnswer, {
             onSuccess: function (session) {
               console.log("SUCCESS TOTP: ", session);
               //continue to app
             },
             onFailure: function (err) {
               console.log(err);
             },
           },
             "SOFTWARE_TOKEN_MFA");

Forgot Password

On our sign in page we can provide an option for the user to select if they forgot their password. When the user selects this option, we can prompt them for their username, and then call our forgot password function.

export function forgotPassword(username) {
 // setup cognitoUser first
 cognitoUser = new CognitoUser({
     Username: username,
     Pool: userPool
 });
 
 // call forgotPassword on cognitoUser
 cognitoUser.forgotPassword({
     onSuccess: function(result) {
         console.log('call result: ' + result);
     },
     onFailure: function(err) {
         console.log(err);
     },
 });
}

This will prompt Cognito to send a reset password code to the user’s email. We can then show a new form where they can enter the code and their new password. We can confirm this new password using the cognitoUser.confirmPassword() function.

export function confirmPassword(username, verificationCode, newPassword) {
 cognitoUser = new CognitoUser({
     Username: username,
     Pool: userPool
 });
 
 return new Promise((resolve, reject) => {
     cognitoUser.confirmPassword(verificationCode, newPassword, {
         onFailure(err) {
             console.log(err.message);
             reject(err);
         },
         onSuccess(result) {
             console.log(result);
             resolve(result);
         },
     });
 });

And then when the promise resolves we can call the authenticateUser function with the user’s new password to authenticate the user and attach a session to their cognitoUser object.

Further Functionality

If we want to check that the user is authenticated ex. On visits to protected routes, we can call first userPool.getCurrentUser() and then cognitoUser.getSession() to verify that our user has an access token.

 function getSession(){
   return new Promise((success, reject) => {
    const cognitoUser = userPool.getCurrentUser();
    console.log(cognitoUser);
 
    if (! cognitoUser) {
     reject('Could not retrieve current user');
     return;
    }
 
    cognitoUser.getSession((err, result) => {
     if (err) {
      reject('Error retrieving user session: ', err);
      return;
     }
 
     if (result.isValid()) {
       const session = {
           credentials: {
            accessToken: result.accessToken.jwtToken,
            idToken: result.idToken.jwtToken,
            refreshToken: result.refreshToken.token
           },
           user: {
            userName: result.idToken.payload['cognito:username'],
            email: result.idToken.payload.email
           }
          }
      success(session);
     } else {
      reject('Session is not valid');
     }
    });
   });
  }

We can also get, set, update, and delete user attributes using more functions from the aws-cognito-identity-js library, examples of which are in the git repo here.