Search code examples
javascriptreactjsqr-codetotpamazon-cognito-identity-js

Setting up TOTP MFA with a QR Code using amazon-cognito-identity-js


Issue: I am having difficulty implementing use case 27 from the amazon-cognito-identity-js library, specifically in trying to modify it to use a QR Code. I am able to receive the secret code from "associateSoftwareToken", translate it into a QR Code, and get a TOTP code from an authenticator app. However, I'm have difficulty passing the TOTP code as a challenge answer to "verifySoftwareToken" afterwards.

Goal: In the example the library provides, they pause the workflow using a "prompt" (which I've tried and it works) but I would like the user to be able to type the TOTP into a form/input field on the page itself rather than a popup window. Upon hitting "submit" on the form, I'd like to submit the TOTP input to "verifySoftwareToken", allowing the user to complete registration and be directed to the homepage.

I have tried breaking up the authentication flow in this manner, but it doesn't seem to work:


  const [totpInput, setTotpInput] = useState(""); // controlled by an input field underneath the QR

  // Initial login event handler, which contains the main auth flow
  const onSubmit = (event) => {

    const authDetails = new AuthenticationDetails({
      Username: email,
      Password: password
    });

    const user = new CognitoUser({
      Username: email,
      Pool: UserPool
    });

    user.authenticateUser(authDetails, {

      ... success and error handling, as well as other callbacks

      mfaSetup: (challengeName) => {
        user.associateSoftwareToken({
          onFailure: (err) => {
            console.error(err);
          },
          associateSecretCode: (secretCode) => {
            setQRCodeSecret(secretCode);
            setQRCode(true); // renders the QR component on the page
            // the example uses a prompt here and continues to verifySoftwareToken immediately
            // after, which I'm trying to modify
          }
        });
      }
  }


// Runs when the QR form is submitted
const onSubmitTotp = (totpInput) => {

   user = new CognitoUser({
      Username: email,
      Pool: UserPool
    })

    user.verifySoftwareToken(totpInput, 'cry5', {
      onSuccess: (result) => {
        console.log(result);
      },
      onFailure: (err) => {
        console.log(err);
      }
    });
  }


Errors I've encountered:

  1. The method above throws a network error when the form is submitted
  2. I've tried declaring "user" and "authDetails" in useState hooks so their values are maintained across renders, but that doesn't seem to work:
...globally:
const [user, setUser] = useState();
const [authDetails, setAuthDetails] = useState();


...at the start of onSubmit function:
  setUser(new CognitoUser({
      Username: email,
      Pool: UserPool
    }));

  setAuthDetails(new AuthenticationDetails({
      Username: email,
      Password: password
    }));


Solution

  • After much toiling, I was finally able to find a solution. Note that this is a pretty specific solution for using amazon-cognito-identity-js with a login flow built entirely in React (no real vanilla JS).

    General Steps

    • Store the Cognito user object in a global variable, ideally with a useState hook
    • Break the authentication flow after calling associateSoftwareToken and getting the secret code to generate a QR Code
    • On either an onClick or onSubmit event (be sure to use event.preventDefault() at the top of the handler function for the latter) grab the global user object, call verifySoftwareToken, and use the user's TOTP input to complete verification

    Storing the User

    The first issue is that the cognito SDK alters the user object over the course of its authentication flow.

    const user = new CognitoUser({
          Username: username,
          Pool: UserPool
        });
    

    The specific information stored within that user object is necessary for any subsequent steps. E.g., if your flow uses the associateSoftwareToken and verifySoftwareToken callback functions, you need the information stored in user from associateSoftwareToken/associateSecretCode in order to successfully fulfill verifySoftwareToken. If you attempt to create a new user object to initialize verifySoftwareToken (even if you've acquired the correct TOTP code) your authentication will fail.

    // WILL NOT WORK
    const user = new CognitoUser({
          Username: username,
          Pool: UserPool
        });
    
    user.verifySoftareToken(correctTotp, {
      onSuccess: (result) => {
        // code
      },
      onFailure: (error) => {
        // code
      }
    }
    

    The solution then, is to store the user in a global variable over the course of authentication. That way, you can stop the flow at any point and return to it by referencing the variable. In React, you should store the user in a hook so it doesn't get wiped on re-renders. You can also store the authentication details if necessary:

    const [user, SetUser] = useState()
    const [authDetails, setAuthDetails] = useState()
    

    Finally, set the user variable to a new CognitoUser at the very beginning of your authentication flow.

    NB: After a function sets the state of a hook, it does not have access to the changed value within the function itself. Example:

    const [hook, setHook] = useState()
    
    const testFunc = () => {
      setHook('a');
      console.log(hook) // this will print 'undefined'
    }
    

    Because of this, I needed to write a secondary function that would both change the state of the user variable, and also return a reference to the user object to the authenticating function for use during the initial authentication flow. Complete example:

    const [user, SetUser] = useState()
    
    const returnUser = () => {
      let new_user = new CognitoUser({
        Username: username,
        Pool: UserPool
      })
    
      setUser(new_user) // storing a reference to the user object
      return new_user // returning another reference to the same object
    }
    
    const onLoginStart = (event) => {
      event.preventDefault() // Necessary, stops the SDK from losing connection to AWS when the initial login form submits
      
      let user = returnUser()
    
      user.authenticateUser(authDetails, {
      ...etc.
      }
    }
    

    Get the Secret Code

    From there, continue the authentication flow until you hit the mfaSetup callback function. Complete this flow until you get the secret code, and use that secret code to create and display the QR Code:

    ...previous callbacks
        mfaSetup: (challengeName) => {
            user.associateSoftwareToken({
              associateSecretCode: (secretCode) => {
                setQRCodeSecret(secretCode);
                displayQRCode(true);
              }
            });
          }
    

    Pass TOTP Input to verifySoftwareToken

    Once the user has entered the TOTP, use it to complete verifySoftwareToken. In my case, users would enter their TOTP into an input field and click a button. I would then pass their TOTP to an onSubmit handler function, that would reference the global user to complete verifySoftwareToken:

    const onTotpFormSubmit = (event, totp) => {
      event.preventDefault();
      user.verifySoftwareToken(totp, {
        onSuccess: (result) => {
          // code
        },
        onFailure: (err) => {
          // code
        }
      });
    }
    

    The user is now verified, and can be redirected to the desired page.