bug with SOFTWARE_TOKEN_MFA if you use `CUSTOM_AUTH` flow

See original GitHub issue

Before opening, please confirm:

JavaScript Framework

React

Amplify APIs

Authentication

Amplify Categories

auth

Environment information

  System:
    OS: macOS 12.0.1
    CPU: (8) x64 Apple M1
    Memory: 25.52 MB / 8.00 GB
    Shell: 5.8 - /bin/zsh
  Binaries:
    Node: 14.18.0 - ~/.nvm/versions/node/v14.18.0/bin/node
    Yarn: 1.22.11 - /usr/local/bin/yarn
    npm: 6.14.15 - ~/.nvm/versions/node/v14.18.0/bin/npm
  Browsers:
    Chrome: 98.0.4758.80
    Safari: 15.1
  npmPackages:
    @aws-amplify/auth: ^4.4.1 => 4.4.1 
    @babel/core: 7.15.5 => 7.15.5 (7.16.0, 7.12.3, 7.13.8)
    @babel/node: ^7.0.0 => 7.16.0 
    @babel/plugin-proposal-class-properties: 7.14.5 => 7.14.5 (7.16.0, 7.12.1)
    @babel/preset-env: 7.15.6 => 7.15.6 (7.12.1)
    @babel/preset-react: ^7.0.0 => 7.16.0 (7.12.1)
    @fortawesome/fontawesome-svg-core: ^1.2.0-14 => 1.2.36 
    @fortawesome/free-solid-svg-icons: ^5.12.0 => 5.15.4 
    @fortawesome/react-fontawesome: 0.1.15 => 0.1.15 
    @freshes/jsdoc-template: ^1.0.0 => 1.0.0 
    @hookform/resolvers: ^2.8.5 => 2.8.5 
    @lingui/babel-preset-react: ^2.9.2 => 2.9.2 
    @lingui/cli: ^2.9.0 => 2.9.2 
    @lingui/core: ^2.9.0 => 2.9.2 
    @lingui/react: ^2.9.0 => 2.9.2 
    @testing-library/react: ^12.1.0 => 12.1.2 
    @wojtekmaj/enzyme-adapter-react-17: ^0.6.3 => 0.6.5 
    amazon-cognito-identity-js: ^5.1.0 => 5.2.3 (5.2.6)
    apollo-link: ^1.2.4 => 1.2.14 
    apollo-link-http: ^1.5.7 => 1.5.17 
    babel-core: ^7.0.0-bridge.0 => 7.0.0-bridge.0 
    babel-eslint: ^10.1.0 => 10.1.0 
    babel-jest: ^27.1.1 => 27.3.1 
    babel-loader: ^8.1.0 => 8.2.3 
    babel-plugin-dynamic-import-node: ^2.3.3 => 2.3.3 
    babel-plugin-lodash: ^3.3.2 => 3.3.4 
    babel-plugin-named-asset-import: ^0.3.7 => 0.3.7 
    babel-plugin-styled-components: ^1.11.1 => 1.13.3 
    babel-plugin-syntax-dynamic-import: 6.18.0 => 6.18.0 
    babel-preset-react-app: ^10.0.0 => 10.0.0 
    bfj: ^7.0.2 => 7.0.2 
    big.js: ^6.1.1 => 6.1.1 (5.2.2, 3.2.0)
    bip39: ^3.0.4 => 3.0.4 
    camelize: ^1.0.0 => 1.0.0 
    case-sensitive-paths-webpack-plugin: ^2.1.2 => 2.4.0 
    chokidar: ^3.5.2 => 3.5.2 (2.1.8)
    class-validator:  1.0.0 
    clipboard: ^2.0.1 => 2.0.8 
    computed-types:  1.0.0 
    copy-webpack-plugin: ^6.4.1 => 6.4.1 
    cross-env: ^7.0.3 => 7.0.3 
    cypress: ^8.3.1 => 8.7.0 
    decamelize: ^4.0.0 => 4.0.0 (1.2.0)
    docdash: 1.2.0 => 1.2.0 
    docs:  0.0.0 
    dotenv: ^10.0.0 => 10.0.0 
    dotenv-expand: ^5.1.0 => 5.1.0 
    effector: ^22.1.1 => 22.1.2 
    effector-logger: ^0.10.0 => 0.10.0 
    effector-react: ^22.0.4 => 22.0.4 
    enzyme: ^3.11.0 => 3.11.0 
    eslint: ^7.32.0 => 7.32.0 
    eslint-config-airbnb: ^18.2.1 => 18.2.1 
    eslint-config-prettier: ^8.3.0 => 8.3.0 
    eslint-config-react-app: ^6.0.0 => 6.0.0 
    eslint-config-standard: ^16.0.3 => 16.0.3 
    eslint-import-resolver-webpack: 0.13.1 => 0.13.1 
    eslint-loader: ^4.0.2 => 4.0.2 
    eslint-plugin-flowtype: ^5.9.2 => 5.10.0 
    eslint-plugin-import: ^2.14.0 => 2.25.3 
    eslint-plugin-jsx-a11y: ^6.1.1 => 6.5.1 
    eslint-plugin-node: ^11.1.0 => 11.1.0 
    eslint-plugin-prettier: ^4.0.0 => 4.0.0 
    eslint-plugin-promise: ^5.1.0 => 5.1.1 
    eslint-plugin-react: ^7.11.1 => 7.27.0 
    eslint-plugin-react-hooks: ^1.6.1 => 1.7.0 
    eslint-plugin-standard: ^5.0.0 => 5.0.0 
    file-loader: ^6.1.1 => 6.2.0 
    fs-extra: ^10.0.0 => 10.0.0 (9.1.0, 8.1.0, 7.0.1)
    graphql: ^15.5.3 => 15.7.2 
    graphql-tag: ^2.10.0 => 2.12.6 
    history: ^5.0.1 => 5.1.0 (4.10.1)
    html-webpack-plugin: ^4.5.2 => 4.5.2 (3.2.0)
    husky: ^3.1.0 => 3.1.0 
    io-ts:  1.0.0 
    isomorphic-unfetch: ^3.0.0 => 3.1.0 
    jest: ^27.1.1 => 27.3.1 
    jest-canvas-mock: ^2.2.0 => 2.3.1 
    joi:  1.0.0 
    jsdoc: ^3.5.5 => 3.6.7 
    jsdoc-babel: 0.5.0 => 0.5.0 
    jsdom: ^17.0.0 => 17.0.0 (16.7.0)
    lint-staged: ^11.1.2 => 11.2.6 
    live-server: ^1.2.0 => 1.2.1 
    lodash: ^4.17.21 => 4.17.21 
    markdown-jsx-loader: ^3.0.2 => 3.0.2 
    moment: ^2.21.0 => 2.29.1 
    nope:  1.0.0 
    numeral: ^2.0.6 => 2.0.6 
    opn: ^6.0.0 => 6.0.0 (5.5.0)
    path: 0.12.7 => 0.12.7 
    plop: ^2.7.4 => 2.7.6 
    plop-example:  undefined ()
    prettier: ^2.2.1 => 2.4.1 
    prop-types: ^15.6.0 => 15.7.2 
    qrcode.react: 1.0.1 => 1.0.1 
    query-string: ^7.0.1 => 7.0.1 (6.14.1, 5.1.1)
    rc-tooltip: ^5.1.1 => 5.1.1 
    react: ^17.0.2 => 17.0.2 
    react-dev-utils: ^11.0.3 => 11.0.4 
    react-document-title: ^2.0.3 => 2.0.3 
    react-dom: ^17.0.2 => 17.0.2 
    react-flip-move: ^3.0.4 => 3.0.4 
    react-google-recaptcha-v3: 1.9.7 => 1.9.7 
    react-hook-form: ^7.23.0 => 7.23.0 
    react-hot-loader: ^4.3.3 => 4.13.0 
    react-modal: ^3.4.5 => 3.14.4 
    react-on-screen: ^2.1.0 => 2.1.1 
    react-redux: ^7.2.5 => 7.2.6 
    react-router-dom: ^5.3.0 => 5.3.0 
    react-svg-loader: 3.0.3 => 3.0.3 
    react-transition-group: ^4.4.2 => 4.4.2 (2.9.0)
    react-transition-group/CSSTransition:  undefined ()
    react-transition-group/ReplaceTransition:  undefined ()
    react-transition-group/SwitchTransition:  undefined ()
    react-transition-group/Transition:  undefined ()
    react-transition-group/TransitionGroup:  undefined ()
    react-transition-group/TransitionGroupContext:  undefined ()
    react-transition-group/config:  undefined ()
    redux: ^4.1.1 => 4.1.2 
    redux-mock-store: ^1.5.1 => 1.5.4 
    redux-thunk: ^2.2.0 => 2.4.0 
    reselect: ^4.0.0 => 4.1.4 
    style-loader: ^3.2.1 => 3.3.1 
    styled-components: ^5.3.1 => 5.3.3 
    styled-components/macro:  undefined ()
    styled-components/native:  undefined ()
    styled-components/primitives:  undefined ()
    styled-normalize: ^8.0.7 => 8.0.7 
    stylelint: ^13.13.1 => 13.13.1 
    stylelint-config-standard: ^22.0.0 => 22.0.0 
    stylelint-config-styled-components: 0.1.1 => 0.1.1 
    stylelint-processor-styled-components: ^1.10.0 => 1.10.0 
    superstruct:  1.0.0 
    terser-webpack-plugin: ^4.2.3 => 4.2.3 (1.4.5)
    tinycolor2: ^1.4.1 => 1.4.2 
    tweetnacl: ^1.0.0 => 1.0.3 (0.14.5)
    tweetnacl-util: 0.15.1 => 0.15.1 
    typanion:  1.0.0 
    ua-parser-js: 0.7.28 => 0.7.28 
    uglifyjs-webpack-plugin: ^2.2.0 => 2.2.0 
    url-loader: ^4.1.1 => 4.1.1 
    url-polyfill: ^1.0.13 => 1.1.12 
    vest:  1.0.0 
    webpack: ^4.44.2 => 4.46.0 
    webpack-bundle-analyzer: ^4.4.2 => 4.5.0 
    webpack-cli: 4.2.0 => 4.2.0 
    webpack-dev-server: ^3.11.1 => 3.11.3 
    webpack-manifest-plugin: ^2.2.0 => 2.2.0 
    yup: ^0.32.11 => 0.32.11 (1.0.0)
    zod:  1.0.0 
  npmGlobalPackages:
    npm: 6.14.15

Describe the bug

The main problem is in Custom Auth Challenge Lambda Triggers

On the project we use Amplify.js (Auth library) on frontend (react) and AWS Cognito User Pool (as a part of infrastructure) on the server side.

As you can see here at docs, AWS Cognito provides different ways how to use its authentication functional:

Auth Flows Configurations:

  • ALLOW_ADMIN_USER_PASSWORD_AUTH
  • ALLOW_CUSTOM_AUTH
  • ALLOW_USER_PASSWORD_AUTH
  • ALLOW_USER_SRP_AUTH
  • ALLOW_REFRESH_TOKEN_AUTH

There was ALLOW_USER_SRP_AUTH by default, which provide users to use SOFTWARE MFA as the 2th authentication factor.

later we ran into a problem with bruteforcing our users accounts.

So at first we’d like to try setup Firewall for our Cognito User Pool (but we discovered that in our case it’s impossible to setup custom domain for Cognito User Pool with CloudFlare (for example), or somehow override setup Cognito’s default Firewall, cause we relize that default Cognito’s Firewall is quite silly and fails to do its job). So, the idea with firewall seems not work.

Ok, next one what we decided to try was to add Google recaptcha v3. Frontend requests google for a recaptcha and then puts it to a metadata at Auth.signIn .On the server side recaptcha is processed at PreAuth Lambda trigger. It only worked for a couple of months, cause we got situation: when attackers try to bruteforce our app with thousands of request from hundreds IPs, recaptcha v3 goes crazy and returns very low score (from 0.1 to 0.3) for all requests (good users also get low score and can’t login)

So, we decide to try to implement Custom Auth Challenge Lambda Triggers, cause this allows us to move recaptcha validation to a next steps and to implement our custom DDOS protection mechanism at PreAuth trigger.

So, the SignIn request flow will be like this:

  1. Frontend call Auth.signIn and send first request to Cognito with action AWSCognitoIdentityProviderService.InitiateAuth. Cognito PreAuth trigger validates this request with our custom DDOS protection mechanism, if everything is ok, request processing will be continue, otherwise auth flow will be stopped with error.

  2. After PreAuth trigger this request will be processed at DefineAuthChallenge trigger, which will recognize that this is an initial request (cause it has only challengeName === "SRP_A") and answer with a resposne:

response = {
  issueTokens: false,
  failAuthentication: false,
  challengeName: "PASSWORD_VERIFIER",
};
  1. Frontend will process this response under the hood with help of Amplify.auth and send next request to Cognito with action AWSCognitoIdentityProviderService.RespondToAuthChallenge with challengeName: "PASSWORD_VERIFIER". This request will be processed at DefineAuthChallenge trigger again, but will response with:
response = {
  issueTokens: false,
  failAuthentication: false,
  challengeName: "CUSTOM_CHALLENGE",
};

And then (cause we return challengeName: "CUSTOM_CHALLENGE" ) CreateAuthChallenge trigger will intercept this response to create our custom challenge.

  1. Frontend will process this response with challengeName: "CUSTOM_CHALLENGE , use recaptcha as an answer and send request to Cognito with action AWSCognitoIdentityProviderService.RespondToAuthChallenge with challengeName: "CUSTOM_CHALLENGE.

  2. This request will be processed at VerifyAuthChallengeResponse trigger (just recaptcha v3 server side validation based on score)

SO, the whole flow works fine if users use only one factor for authentication, in our case its login with password. BUT if user has a second authentication factor (in our case it’s optional for users) such as SOFTWARE_TOKEN_MFA, the flow will be broken on step, when the client receives a response from Cognito! (even if the MFA validation was successful) with Error Cannot read properties of undefined (reading 'NewDeviceMetadata').

BTW: Some time ago, we received an advice from AWS Support about how to protect from bruteforcing: just use our Adding advanced security feature with additional charges and everything will be fine!

BUT:

  • it looks like magic pandora box
  • we tested it on staging for a half of year and it doesn’t work so well as they said

Also there is a question on stackoverflow.

Expected behavior

Just to add that we’re seeing exactly the same issue here.

  • Configure the User Pool Client auth flow configuration to use ALLOW_CUSTOM_AUTH
  • Configure the User Pool to remember users’ devices
  • Create user.
  • Enable MFA (Software Token)
  • Logout
  • Clear local browser cache
  • Auth.signIn (with ChallengeName: "PASSWORD_VERIFIER")
  • Auth.confirmSignIn (with ChallengeName: "SOFTWARE_TOKEN_MFA")
  • Auth.sendCustomChallengeAnswer (with ChallengeName: "SOFTWARE_TOKEN_MFA")
  • Succes login, receive id, access and refresh tokens

Reproduction steps

  • Configure the User Pool Client auth flow configuration to use ALLOW_CUSTOM_AUTH
  • Configure the User Pool to remember users’ devices
  • Create user.
  • Enable MFA (Software Token)
  • Logout
  • Clear local browser cache
  • Auth.signIn (with ChallengeName: "PASSWORD_VERIFIER")
  • Auth.confirmSignIn (with ChallengeName: "SOFTWARE_TOKEN_MFA")
  • Receive error: Cannot read properties of undefined (reading 'NewDeviceMetadata')
CognitoUser.js:808 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'NewDeviceMetadata')
    at CognitoUser.js:808:49
    at Client.js:140:31

Code Snippet

DefineAuthChallenge trigger:

export const handler: Handler<DefineAuthChallengeTriggerEvent> = (
  event: DefineAuthChallengeTriggerEvent,
  context: Context,
  callback: Callback<DefineAuthChallengeTriggerEvent>,
) => {
  if (
    event.request.session.length == 1 &&
    event.request.session[0].challengeName == "SRP_A"
  ) {
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = "PASSWORD_VERIFIER";
  } else if (
    event.request.session.length == 2 &&
    event.request.session[1].challengeName == "PASSWORD_VERIFIER" &&
    event.request.session[1].challengeResult == true
  ) {
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = "CUSTOM_CHALLENGE";
  } else if (
    event.request.session.length == 3 &&
    event.request.session[2].challengeName == "CUSTOM_CHALLENGE" &&
    event.request.session[2].challengeResult == true
  ) {
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
    // CUSTOM FOR MFA
  } else if (
    event.request.session.length === 3 &&
    event.request.session[2].challengeName === "SOFTWARE_TOKEN_MFA" &&
    event.request.session[2].challengeResult === true
  ) {
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = "CUSTOM_CHALLENGE";
  } else if (
    event.request.session.length == 4 &&
    event.request.session[3].challengeName == "CUSTOM_CHALLENGE" &&
    event.request.session[3].challengeResult == true
  ) {
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  } else {
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  }

  callback(null, event);
};

CreateAuthChallenge trigger:

export const handler: CreateAuthChallengeTriggerHandler = (
  event: CreateAuthChallengeTriggerEvent,
  context: Context,
  callback: Callback<CreateAuthChallengeTriggerEvent>,
) => {
  if (event.request.challengeName == "CUSTOM_CHALLENGE") {
    event.response.publicChallengeParameters = {};
    event.response.publicChallengeParameters.captchaUrl = "url/123.jpg";
    event.response.privateChallengeParameters = {};
    event.response.privateChallengeParameters.answer = "5";
    
    event.response.challengeMetadata = "RECAPTCHA_CHALLENGE";
  }

  callback(null, event);
};

VerifyAuthChallengeResponse trigger:

export const handler: VerifyAuthChallengeResponseTriggerHandler = async (
  event: VerifyAuthChallengeResponseTriggerEvent,
  context: Context,
  callback: Callback<VerifyAuthChallengeResponseTriggerEvent>,
) => {

  try {
    const { recaptchaToken: token } = JSON.parse(
      event.request.challengeAnswer,
    );
    
   if(token !== "recaptchaToken") { throw new Error(); }

    event.response.answerCorrect = true;
  } catch (error) {
    logger.error(error);
    event.response.answerCorrect = false;
  } finally {
    callback(null, event);
  }
};

Log output

// Put your logs below this line


aws-exports.js

No response

Manual configuration

No response

Additional configuration

No response

Mobile Device

No response

Mobile Operating System

No response

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

No response

Issue Analytics

  • State:open
  • Created 2 years ago
  • Comments:13 (3 by maintainers)

github_iconTop GitHub Comments

2reactions
Fomin2402commented, Feb 21, 2022

@sammartinez any news?

1reaction
nickarochocommented, Jun 8, 2022

Hi @Fomin2402 , thank you for your patience. We are currently working on reproducing the issue on our side to fully understand the scope of the problem and pinpoint the root cause.

I’ll reach back out when I have a working sample representing the exact flow you’ve described here. Thanks!

Read more comments on GitHub >

github_iconTop Results From Across the Web

SOFTWARE_TOKEN_MFA fails if try to use Custom Auth ...
Is it even possible to use SOFTWARE_TOKEN_MFA with Custom authentication challenge Lambda triggers and if yes, how to do this correct?
Read more >
Cognito Custom authentication flow - getting user input mid-flow
I am creating a custom authentication flow using AWS Cognito to send a MFA code via email through the cognito triggers. I am...
Read more >
Where Do I report an AzureB2C Bug invloving SignIn User ...
Steps To Repoduce Bug: Create SignIn user flow using the recommended version. Enable Multi Factor Authentication using Email 152393-mfa.png.
Read more >
AWS Cognito MFA TOTP using Google Authenticator + React ...
Multi-Factor Authentication (or MFA/2FA) adds an extra layer of security to your application. TOTP methods such as the Google Authenticator ...
Read more >
TOTP software token MFA - Amazon Cognito
When you set up TOTP software token MFA in your user pool, your user signs in ... To implement TOTP MFA in a...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found