Adaptive MFA can be achieved by utilizing Authsignal’s rules engine. For example, you can create risk-based authentication flows so that MFA is only required when certain conditions are met e.g. if a user is authenticating on a new device. For more bespoke scenarios, you can also integrate with your business-specific data points.

This guide will cover how to modify your AWS Cognito integration code to use rules.

Prerequisites

This guide assumes you have an existing Authsignal + AWS Cognito integration. If you don’t, you can follow one of the following guides:

Adaptive MFA on login

A common use case for adaptive MFA is on login. For example, we might want to reduce user friction by not requiring MFA for users who are authenticating from a known device.

Authsignal rule set up

  1. Go to the Actions page and find the Cognito Auth action.
  2. Click on the Cognito Auth action go to the Rules tab.
  3. Click on the Create Rule button and create a new rule. For example, a new device rule

The Cognito Auth action will only appear if you have tested your existing Authsignal + AWS Cognito integration. If you don’t see it, you can use the Configure a new action button to create it. Make sure to name it cognitoAuth.

Define Auth Challenge lambda

In this case we assume the Define Auth Challenge lambda is implemented to require multiple authentication steps.

import { DefineAuthChallengeTriggerHandler } from "aws-lambda";

export const handler: DefineAuthChallengeTriggerHandler = async (event) => {
  const { session } = event.request;

  if (session.length === 1 && session[0].challengeName === "SRP_A") {
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = "PASSWORD_VERIFIER";
  } else if (session.length === 2 && session[1].challengeName === "PASSWORD_VERIFIER") {
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = "CUSTOM_CHALLENGE";
  } else if (
    session.length === 3 &&
    session[2].challengeName === "CUSTOM_CHALLENGE" &&
    session[2].challengeResult === true
  ) {
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  } else {
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  }

  return event;
};

The "CUSTOM_CHALLENGE" step is delegated to Authsignal.

Create Auth Challenge lambda

When tracking our action in the Create Auth Challenge lambda we can check the state field in the response to see whether our rule has determined that a challenge is required for the action.

In addition to the url for the pre-built UI, we will also pass this state value along with a token back to our app as public challenge parameters.

import { Authsignal } from "@authsignal/node";
import { CreateAuthChallengeTriggerHandler } from "aws-lambda";

const authsignal = new Authsignal({
  apiSecretKey: process.env.AUTHSIGNAL_SECRET,
  apiUrl: process.env.AUTHSIGNAL_URL,
});

export const handler: CreateAuthChallengeTriggerHandler = async (event) => {
  const userId = event.request.userAttributes.sub;
  const email = event.request.userAttributes.email;

  const { state, token, url } = await authsignal.track({
    action: "cognitoAuth",
    userId,
    attributes: {
      email,
    },
  });

  event.response.publicChallengeParameters = { state, token, url };

  return event;
};

Make sure to include any additional data points required by the rule you set up on your Cognito Auth action.

The app code

Now we can adapt our app code to use the state param to determine whether MFA is required.

// Sign in with username and password using Amplify
const { nextStep } = await signIn({
  username: email,
  password,
  options: {
    authFlowType: "CUSTOM_WITH_SRP",
  },
});

const { state, token, url } = nextStep.additionalInfo;

if (state === "ALLOW") {
  // No MFA is required
  // Pass the initial token to confirm sign-in
  await confirmSignIn({ challengeResponse: token });
} else {
  // MFA is required
  // Launch the pre-built UI to obtain a validation token for an Authsignal challenge
  const response = await authsignal.launch(url, { mode: "popup" });

  await confirmSignIn({ challengeResponse: response.token });
}

Verify Auth Challenge Response lambda

Finally, we need to update our Verify Auth Challenge Response lambda to handle if the user is allowed to bypass MFA.

The only change here is to set event.response.answerCorrect to true if the state of the action is either "CHALLENGE_SUCCEEDED" (because the user successfully completed an MFA step) or "ALLOW" (because the user wasn’t required to complete MFA).

import { Authsignal, UserActionState } from "@authsignal/node";
import { VerifyAuthChallengeResponseTriggerHandler } from "aws-lambda";

const authsignal = new Authsignal({
  apiSecretKey: process.env.AUTHSIGNAL_SECRET,
  apiUrl: process.env.AUTHSIGNAL_URL,
});

export const handler: VerifyAuthChallengeResponseTriggerHandler = async (event) => {
  const userId = event.request.userAttributes.sub;
  const token = event.request.challengeAnswer;

  const { state } = await authsignal.validateChallenge({
    action: "cognitoAuth",
    userId,
    token,
  });

  event.response.answerCorrect =
    state === UserActionState.CHALLENGE_SUCCEEDED || state === UserActionState.ALLOW;

  return event;
};

Next steps