Passwordless Authentication Flow in Cognito User Pool

Adithya Hebbar's avatar

Adithya Hebbar

Introduction

When using a custom authentication flow with Cognito, several steps occur behind the scenes. Understanding the entire flow can be very beneficial.

The process starts with a user request to initiate authentication. Cognito responds by creating a new session token, which expires after 3 minutes, and sends it to the defineChallenge Lambda function. This function acts as a state machine for the entire authentication flow. Since this is the first invocation in the session, we return one of the predefined challenge types provided by AWS. The ChallengeNameType enum in the AWS SDK repository lists all possible challenge types.

Next, Cognito triggers the createChallenge Lambda function with the challenge type. Here, we implement the handler logic to define how the custom authentication works. Once the challenge parameters are set, we return the public parameters to the client along with the session token.

The user then enters the challenge response, which, along with the username and session token, is sent to the respondToAuthChallenge command. Cognito takes this response and triggers the verifyChallenge Lambda function. Here, we verify if the provided challenge response matches the correct answer set in the createChallenge Lambda function.

If the response is correct, Cognito invokes the defineAuth Lambda function again, which responds with authentication tokens. Finally, Cognito sends the tokens to the client, completing the authentication process.

Define Challenge Lambda

// lambdas/define-challenge/index.js
 
exports.handler = async (event) => {
  if (event.request.session.length === 0) {
    // First challenge
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = 'CUSTOM_CHALLENGE';
  } else if (
    event.request.session.length === 1 &&
    event.request.session[0].challengeName === 'CUSTOM_CHALLENGE' &&
    event.request.session[0].challengeResult === true
  ) {
    // User has successfully completed the challenge
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  } else {
    // Challenge failed
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  }
};

The defineChallenge Lambda function is the most complex as it acts as a state machine for the entire authentication flow. Cognito provides the current session in the request, which is an array of all challenge answers.

To create the appropriate response for the user pool, we use three properties on the response object: issueTokens, failAuthentication, and challengeName.

If the session array is empty, we issue the challenge since it's the first time the Lambda has been triggered. Otherwise, we validate the challenge. If the challenge name matches our configuration, we issue tokens if challengeResult is true and fail authentication if it is false.

Create Challenge Lambda

// lambdas/create-challenge/index.js
 
const emailClient = require('@sendgrid/mail');
const sendgridApiKey = process.env.SENDGRID_API_KEY;
emailClient.setApiKey(sendgridApiKey);
 
exports.handler = async (event) => {
  const code = Math.floor(100000 + Math.random() * 900000).toString(); // Generate a random 6-digit number
  const user = event.request.userAttributes;
  const email = {
    to: `${event.userName} <${user.email}>`,
    from: 'example.com <no-reply@example.com>',
    subject: `[${event.triggerSource}] Your login token`,
    text: `Use the link below to log in to example.com\n http://localhost:4000/verify-login?code=${code}&username=${event.userName} \n this link will expire in three minutes`,
  };
  await emailClient.send(email);
  event.response.publicChallengeParameters = { challenge: 'CUSTOM_CHALLENGE' };
  event.response.privateChallengeParameters = { code };
};

The createChallenge Lambda function generates the temporary password for logging in. It also notifies the user of the correct password via email, text message, or push notification. In this example, an email with a login link is sent. The link includes the code and username as query parameters, which the frontend application will use to call the backend API and respond to the challenge.

We set public and private challenge parameters. Public parameters are sent back to the client and should not include the challenge answer, while private parameters are only accessible within the authentication flow.

Verify Challenge Lambda

// lambdas/verify-challenge/index.js
 
exports.handler = async (event) => {
  const expectedOtp = event.request.privateChallengeParameters.code;
  const userOtp = event.request.challengeAnswer;
  if (userOtp === expectedOtp) {
    event.response.answerCorrect = true;
  } else {
    event.response.answerCorrect = false;
  }
  return event;
};

The verifyChallenge Lambda function is straightforward. It compares the user input with the correct answer stored in privateChallengeParameters in the createChallenge Lambda function and assigns the result to the answerCorrect property on the response object.

Login Service

// auth/auth.service.js
 
async function login({ username }) {
  const input = {
    ClientId: this.awsConfigService.userPoolClientId,
    AuthFlow: 'CUSTOM_AUTH',
    AuthParameters: {
      USERNAME: username,
    },
  };
  const command = new InitiateAuthCommand(input);
  return this.client.send(command);
}

In the login method, the AuthFlow is set to CUSTOM_AUTH, indicating that Cognito should trigger our defineChallenge handler and not expect a PASSWORD field in the AuthParameters.

Verify Login

// auth/auth.service.js
 
async function verifyLogin({ code, username, session }) {
  const input = {
    ClientId: '<User-Pool-Client-Id>',
    ChallengeName: 'CUSTOM_CHALLENGE',
    Session: session,
    ChallengeResponses: {
      ANSWER: code,
      USERNAME: username,
    },
  };
  const command = new RespondToAuthChallengeCommand(input);
  const response = await this.client.send(command);
  return response.AuthenticationResult;
}

The verifyLogin method splits the authentication process, requiring username and challenge answer separately. The session token from the initialAuth command in the login method is required. The challenge name should match the one in the defineChallenge Lambda, and the username and challenge answer are provided. If successful, the command responds with authentication tokens.

Direct Passwordless Authentication without Challenges

An alternative approach to achieving passwordless authentication is to directly issue tokens in the first invocation of the defineChallenge Lambda function without creating challenges. This method bypasses the intermediate challenge steps.

As a result, when you call the login service, which initiates the authentication process, the response will already include the authentication tokens. This eliminates the need for a separate verifyLogin step since the tokens are issued directly in the login service.

Define Challenge Lambda

// lambdas/define-challenge/index.js
 
exports.handler = async (event) => {
  event.response.issueTokens = true;
  event.response.failAuthentication = false;
  return event;
};

Login Service

// auth/auth.service.js
 
async function login({ username }) {
  const input = {
    ClientId: this.awsConfigService.userPoolClientId,
    AuthFlow: 'CUSTOM_AUTH',
    AuthParameters: {
      USERNAME: username,
    },
  };
  const command = new InitiateAuthCommand(input);
  return this.client.send(command);
}

The login service will return the authentication tokens directly, as Cognito has been instructed to issue them immediately. This method offers a more streamlined and seamless login experience, where users receive their tokens right after initiating the authentication process.

Conclusion

This guide has provided a comprehensive walkthrough of implementing a passwordless authentication flow using AWS Cognito and custom Lambda functions. By following these steps, you can create a secure and seamless login experience for your users without the need for passwords. Whether you opt for a challenge-based flow or direct authentication, AWS Cognito allows you to tailor the authentication process to meet your application's needs. Happy coding!

Resources