Setup Cognito OTP login with AWS CDK
AWS devops cloud

Setup Cognito OTP login with AWS CDK

Timo
Timo

In this article, I would like to introduce about Amazon CDK and  how to write AWS infrastructure-as-code by TypeScript. Let's go step by step.

What is AWS CDK (Cloud Development Kit)?

The AWS Cloud Development Kit is an open-source software development framework developed by Amazon Web Services for defining and provisioning cloud infrastructure resources using familiar programming languages.

Setup

Step 1: Open command/terminal or PowerShell window.

Step 2: Verify node.js is installed or not. (If not: please access into link to download and then setup.)

npm --version

Step 3: Ensure AWS CLI is installed.

aws --version

Step 4: Install AWS CDK CLI

npm install -g aws-cdk

Step 5: Check AWS CDK is installed or not.

cdk

Step 6: Create a CDK project with project name is agapifa

mkdir agapifa
cd agapifa

Step 7: Create package.json file

{
  "name": "agapifa",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "build": "npx tsc",
    "start": "npm run build -w",
    "cdk": "cdk",
    "diff": "npx aws-cdk diff",
    "deploy": "npx aws-cdk deploy",
    "destroy": "npx aws-cdk destroy",
    "lint": "eslint .",
    "lint:fix": "eslint --fix",
    "format": "prettier --write './**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@trivago/prettier-plugin-sort-imports": "^4.0.0",
    "@types/node": "^18.13.0",
    "@typescript-eslint/eslint-plugin": "^5.51.0",
    "aws-cdk": "^2.73.0",
    "eslint": "^8.34.0",
    "eslint-config-prettier": "^8.6.0",
    "eslint-config-standard-with-typescript": "^34.0.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-prettier": "^4.2.1",
    "prettier": "^2.8.4",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.5"
  },
  "dependencies": {
    "aws-cdk-lib": "^2.73.0",
    "constructs": "^10.1.306",
    "eslint-plugin-n": "^15.6.1",
    "eslint-plugin-promise": "^6.1.1",
    "source-map-support": "^0.5.21"
  }
}

Step 8: Create tsconfig.json

{
  "compilerOptions": {
    "noImplicitAny": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "target": "es2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": "./",
    "rootDir": "./",
    "outDir": "./dist",
    "strictPropertyInitialization": false,
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules", "**/node_modules/*", "cdk.out"]
}

Step 9: Create cdk.json file

{
  "app": "npx ts-node --prefer-ts-exts lib/index.ts",
  "context": {
    "@aws-cdk/core:newStyleStackSynthesis": "true",
    "@aws-cdk/core:bootstrapQualifier": "hnb659fds"
  }
}

Step 10: Copy code below into lib/index.ts file

#!/usr/bin/env node
import { App } from 'aws-cdk-lib';
import { Construct } from 'constructs';

import apiStack from './stacks/api-stack';

class AgapifaApp extends Construct {
  constructor(scope: App, id: string) {
    super(scope, id);

    new AuthStack(this, `${id}-auth-stack`, {
      stackName: `${id}-auth-stack`,
    });
  }
}

const app = new App();

new AgapifaApp(app, 'Agapifa');

Step 11: Create lib/stacks/auth-stack.ts file

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';

import { CognitoResource } from '../resources';

export class AuthStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    this.createCognito(this, id);
  }

  private createCognito(stack: Stack, id: string) {
    new CognitoResource(stack, `${id}-cognito`, {})
      .setupUserPool()
      .setupAppClient()
      .setupDomain()
      .build();
  }
}

Step 12: Setup Cognito User Pool like code below into lib/resources/cognito/index.ts file

import { Duration, Stack, StackProps, aws_cognito as cognito, aws_iam as iam } from 'aws-cdk-lib';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import path from 'path';

import { BaseResource } from '../base';

export class CognitoResource extends BaseResource {
  private _cognitoUserPool: cognito.UserPool;

  constructor(scope: Stack, id: string, props: StackProps) {
    super(scope, id, props);
  }

  setupUserPool(name?: string) {
    this._cognitoUserPool = new cognito.UserPool(
      this._scope,
      `${this._scopeId}-${name ?? 'user-pool'}`,
      {
        userPoolName: `${this._scopeId}-${name ?? 'user-pool'}`,
        passwordPolicy: {
          minLength: 8,
          tempPasswordValidity: Duration.days(7),
          requireDigits: false,
          requireLowercase: false,
          requireSymbols: false,
          requireUppercase: false,
        },
        mfa: cognito.Mfa.REQUIRED,
        mfaSecondFactor: {
          otp: true,
          sms: true,
        },
        accountRecovery: cognito.AccountRecovery.PHONE_AND_EMAIL,
        autoVerify: {
          phone: false,
          email: true,
        },
        selfSignUpEnabled: true,
        standardAttributes: {
          phoneNumber: {
            required: true,
          },
        },
        signInAliases: {
          preferredUsername: true,
          email: true,
          phone: true,
          username: true,
        },
        signInCaseSensitive: false,
        snsRegion: process.env.AWS_REGION,
        lambdaTriggers: {
          createAuthChallenge: this.createNodeJsFn(
            'createAuthChallengeFn',
            'create-auth-challenge',
            new iam.Policy(this._scope, `${this._scopeId}-create-auth-challenge-sns-policy`, {
              statements: [
                new iam.PolicyStatement({
                  effect: iam.Effect.ALLOW,
                  actions: ['SNS:Publish'],
                  resources: ['*'],
                }),
              ],
            }),
          ),
          defineAuthChallenge: this.createNodeJsFn(
            'defineAuthChallengeFn',
            'define-auth-challenge',
          ),
          preSignUp: this.createNodeJsFn('preSignUpFn', 'pre-sign-up'),
          verifyAuthChallengeResponse: this.createNodeJsFn(
            'verifyAuthChallengeResponseFn',
            'verify-auth-challenge-response',
          ),
        },
      },
    );

    return this;
  }

  setupAppClient() {
    this._cognitoUserPool.addClient(`${this._scopeId}-user-pool-app-client`, {
      userPoolClientName: `${this._scopeId}-user-pool-app-client`,
      authFlows: {
        custom: true,
        userPassword: true,
        userSrp: true,
        adminUserPassword: false,
      },
      refreshTokenValidity: Duration.days(
        parseInt(process.env.REFRESH_TOKEN_DURATION_DAYS || '365', 10),
      ),
      idTokenValidity: Duration.days(parseInt(process.env.ID_TOKEN_DURATION_DAYS || '1', 10)),
      accessTokenValidity: Duration.days(
        parseInt(process.env.ACCESS_TOKEN_DURATION_DAYS || '1', 10),
      ),
      enableTokenRevocation: true,
      preventUserExistenceErrors: true,
    });

    return this;
  }

  setupDomain() {
    if (!process.env.COGNITO_DOMAIN_PREFIX) {
      return this;
    }

    this._cognitoUserPool.addDomain(`${this._scope}-user-pool-domain`, {
      cognitoDomain: {
        domainPrefix: process.env.COGNITO_DOMAIN_PREFIX,
      },
    });

    return this;
  }

  build() {
    return this._cognitoUserPool;
  }

  private createNodeJsFn(name: string, id: string, role?: iam.Policy) {
    const fn = new NodejsFunction(this._scope, name, {
      functionName: `${this._scopeId}-${id}`,
      runtime: Runtime.NODEJS_14_X,
      entry: path.join(__dirname, `lambda-function/${id}/index.ts`),
    });

    if (role) fn.role?.attachInlinePolicy(role);

    return fn;
  }
}

Step 13: Create lambda functions

  • create-auth-challenge/index.ts
import { CreateAuthChallengeTriggerEvent } from 'aws-lambda';
import AWS from 'aws-sdk';

function sendSMS(phone: string, message: string) {
  const params: AWS.SNS.PublishInput = {
    Message: message,
    PhoneNumber: phone,
  };

  return new AWS.SNS({ apiVersion: '2010-03-31' }).publish(params).promise();
}

export const handler = async (event: CreateAuthChallengeTriggerEvent) => {
  try {
    const evtReq = event.request;
    const evtReqSession = evtReq.session;
    const phoneNumber = event.request.userAttributes.phone_number;
    const otp = this.generateOtp();

    if (!evtReqSession || evtReqSession.length === 0) {
      const message = `OTP to login to WebsiteX is ${otp}`;

      await sendSMS(phoneNumber, message);

      event.response.privateChallengeParameters = {
        answer: otp,
      };
      event.response.challengeMetadata = 'CUSTOM_CHALLENGE';
    }

    return event;
  } catch (error) {
    Promise.reject(error);
  }
};

  • define-auth-challenge/index.ts
import { DefineAuthChallengeTriggerEvent } from 'aws-lambda';

export const handler = async (event: DefineAuthChallengeTriggerEvent) => {
  const evtReq = event.request;
  const evtReqSession = evtReq.session;

  // User is not registered
  if (evtReq.userNotFound) {
    event.response.issueTokens = false;
    event.response.failAuthentication = true;

    throw new Error('User does not exist', {
      cause: evtReq,
    });
  }

  // wrong OTP even After 3 sessions
  if (evtReqSession.length >= 3 && evtReqSession.slice(-1)[0].challengeResult === false) {
    event.response.issueTokens = false;
    event.response.failAuthentication = true;

    throw new Error('Invalid OTP');
  }
  // Correct OTP!
  else if (evtReqSession.length > 0 && evtReqSession.slice(-1)[0].challengeResult === true) {
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  }
  // not yet received correct OTP
  else {
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = 'CUSTOM_CHALLENGE';
  }

  return event;
};

  • pre-sign-up/index.ts
export const handler = (event, _, callback) => {
  // Confirm the user
  event.response.autoConfirmUser = true;

  // Set the email as verified if it is in the request
  if (event.request.userAttributes.hasOwnProperty('email')) {
    event.response.autoVerifyEmail = true;
  }

  // Set the phone number as verified if it is in the request
  if (event.request.userAttributes.hasOwnProperty('phone_number')) {
    event.response.autoVerifyPhone = true;
  }

  // Return to Amazon Cognito
  callback(null, event);
};

  • verify-auth-challenge-response/index.ts
import { VerifyAuthChallengeResponseTriggerEvent } from 'aws-lambda';

export const handler = async (event: VerifyAuthChallengeResponseTriggerEvent) => {
  if (event.request.privateChallengeParameters.answer === event.request.challengeAnswer) {
    event.response.answerCorrect = true;
  } else {
    event.response.answerCorrect = false;
  }

  return event;
};

Step 14: Build AWS CDK

yarn build

Step 15: Deploy AWS CDK

cdk deploy --profile agapifa

Step 16: Check resources on AWS Console

  • CloudFormation
  • Lambda functions
  • Cognito

Step 17: If you want to remove this stack. You could run command below

cdk destroy --profile agapifa

Summary

In this tutorial, you learned how to install the AWS CDK, set up and initialize an AWS CDK project, assemble it into a CloudFormation template, and deploy to AWS Cloud.

Good luck to your practice !!!