You are currently viewing How to handle User Registration and Authentication with AWS Cognito Serverless

How to handle User Registration and Authentication with AWS Cognito Serverless

Welcome to the very first release of our real-time chat application development journey! In this inaugural phase, we’re diving headfirst into one of the most critical aspects of our application’s functionality: User Registration and Authentication.
By the end of this release, you’ll have a deep understanding of how to build a user registration and authentication system using Serverless Framework with TypeScript and Amazon Cognito.

What we’ll cover

How Cognito works ?

aws cognito serverless - cognito structure

AWS Cognito is a service provided by Amazon Web Services that offers identity and access control for your applications. It’s often used for user authentication and authorization in web and mobile applications.
Here’s a breakdown of how Cognito works:

  • User Pools: Manages user directories for sign-up, sign-in, and user profile management.
  • Identity Pools: Allows users to access AWS services using identity providers like Google, Facebook, or Amazon.
  • Authentication Flow: Users sign up and sign in. On successful authentication, Cognito returns tokens (ID, access, and refresh tokens) for accessing application and AWS services.
  • Integration: Cognito integrates with various AWS services and offers SDKs for easy implementation in web and mobile apps.
  • Customization: Supports UI customization and custom workflows with AWS Lambda triggers.
  • Federation: Allows sign-in through external identity providers and links these identities with Cognito.
  • Security and Scalability: Provides features like multi-factor authentication and scales for millions of users. AWS manages the infrastructure.

Now that we understand how Amazon cognito works, let’s start by exploring our project setup. 

Project setup

Our project will look like this: 

aws cognito serverless - project setup

As you can see, we are storing all our lambda function files inside of the folder src/functions.

We do have two sub folders user that have all the lambda functions for user authentication and registration and the hello folder that have the private lambda function.

All the shared library code that is used across the project are located in the libs folder. We do have as well a services folder that contains the utility function to interact with cognito api library. Other than that there is a serverless.ts file which is a core file for any serverless-based project.

You can find the code source of the project here

Serverless.ts file

The configuration file includes information about the name of your serverless application, the AWS provider and resources, the functions that are part of your application, and the packages and plugins required for your application to run smoothly. 
Let’s take a look at our serverless.ts file and try to understand it.

import type { AWS } from '@serverless/typescript';

import hello from '@functions/hello';
import { signup, login, verification } from '@functions/user'

const serverlessConfiguration: AWS = {
  service: 'serverless-chat-app',
  frameworkVersion: '3',
  plugins: ['serverless-esbuild'],
  provider: {
    ...
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
      NODE_OPTIONS: '--enable-source-maps --stack-trace-limit=1000',
      USER_CLIENT_ID: { 'Ref': 'UserClient'},
      USER_POOL_ID: { 'Ref': 'UserPool' }
    },
  },
  // import the function via paths
  functions: { 
    hello,
    signup,
    login,
    verification
  },
  ...
  resources: {
    Resources: {
      UserPool: {
        Type: 'AWS::Cognito::UserPool',
        Properties: {
          UserPoolName: 'Serverless-Chat',
          Schema: [
            {
              Name: 'email',
              Required: true,
              Mutable: true
            }
          ],
          Policies: {
            PasswordPolicy: {
              MinimumLength: 6
            }
          },
          AutoVerifiedAttributes: ["email"]
        }
      },
      UserClient: {
        Type: 'AWS::Cognito::UserPoolClient',
        Properties: {
          ClientName: 'user-pool-ui',
          GenerateSecret: false,
          UserPoolId: { 'Ref': 'UserPool' },
          AccessTokenValidity: 5,
          IdTokenValidity: 5,
          ExplicitAuthFlows: ["ADMIN_NO_SRP_AUTH", "USER_PASSWORD_AUTH"]
        }
      },
      LambdaRole: {
        Type: 'AWS::IAM::Role',
        Properties: {
          RoleName: 'LambdaRole',
          AssumeRolePolicyDocument: {
            Version: '2012-10-17',
            Statement: [
              {
                Effect: 'Allow',
                Principal: {
                  Service: 'lambda.amazonaws.com',
                },
                Action: 'sts:AssumeRole'
              }
            ]
          },
          Policies: [
            {
              PolicyName: 'LambdaPolicy',
              PolicyDocument: {
                Version: '2012-10-17',
                Statement: [
                  {
                    Effect: "Allow",
                    Action: [
                      "logs:CreateLogGroup",
                      "logs:CreateLogStream",
                      "logs:PutLogEvents"
                    ],
                    Resource: ["arn:aws:logs:*:*:*"]
                  },
                  {
                    Effect: "Allow",
                    Action: [
                      "cognito-idp:AdminInitiateAuth"
                    ],
                    Resource: ["*"]
                  }
                ]
              }
            }
          ]
        }
      }
    }
  }
};

module.exports = serverlessConfiguration;

Let’s break down the most important part in this file: 

  • Functions: Lists the Lambda functions (hello, signup, login, verification) that are part of this application, likely handling different API endpoints or functionalities.
  • Resources:

    • UserPool: Defines an AWS Cognito User Pool resource, configuring attributes like the pool name, schema (requiring an email), password policy, and auto-verification of email.
    • UserClient: Sets up a User Pool Client in Cognito, which is likely used by the application for user authentication. It specifies properties like client name, token validity, and authentication flows.
    • LambdaRole: Creates an IAM Role for Lambda functions, allowing them to assume the role (sts:AssumeRole), create and manage logs, and interact with the Cognito service (cognito-idp:AdminInitiateAuth).

How to handle User Registration with AWS Cognito Serverless

The purpose of this lambda function is to handle user registration using aws cognito serverless. Let’s take a look at the index.ts file located inside of src/functions/user

//index.ts
import signupSchema from './schema/signupSchema';
import { handlerPath } from '@libs/handler-resolver';
...
export const signup = {
  handler: `${handlerPath(__dirname)}/handler.signup`,
  events: [
    {
      http: {
        method: 'post',
        path: 'user/signup',
        cors: true,
        request: {
          schemas: {
            'application/json': signupSchema,
          },
        },
      },
    },
  ],
  role: 'LambdaRole'
};

Overall, this code defines an AWS Lambda function that will be triggered by HTTP POST requests to the specified path: user/signup. The function’s logic is located in a separate file and function, and it’s likely intended to handle user registration via cognito serverless. 
Let’s look at the function implementation, let’s open the handler.ts located at src/functions/user.

import { ValidatedEventAPIGatewayProxyEvent, formatJSONResponse } from '@libs/api-gateway';
import { middyfy } from '@libs/lambda';
import signupSchema from './schema/signupSchema';
import { signUp, initiateAuth, confirmSignUp } from 'src/services/UserAuthService';
...

const signupHandler : ValidatedEventAPIGatewayProxyEvent<typeof signupSchema> = async (event) => {
  try {
    const Username = event.body?.username;
    const Password = event.body?.password;
    const email = event.body?.email;
    console.log('Event Body ', event.body);
    const userSignedUp = await signUp({ Username, Password, email });
    console.log('User Signed Up', userSignedUp);

    return formatJSONResponse({
      message: 'User registered successfully...'
    }, 201);

  } catch (error) {
    console.log('Error occured :',error.message);
    return formatJSONResponse({
      message: 'Registration failed',
      error: error.message
    }, 500)
  }
};

Let’s break down the signupHandler function : 

  • Type Definition: It’s defined as a ValidatedEventAPIGatewayProxyEvent with the type of signupSchema. This suggests it’s an AWS Lambda function triggered by an API Gateway event, with input validation against the signupSchema.
  • Parameters: The function takes an event parameter, which contains the HTTP request data.
  • Body Parsing: It extracts username, password, and email from the request body.
  • User Registration: The signUp service is called with the extracted data to register the user.
  • Response:
    • On successful user registration, it returns a JSON response with a success message and HTTP status code 201 (Created).
    • In case of an error, it catches the exception, logs the error, and returns a JSON response with an error message and HTTP status code 500 (Internal Server Error).

Let’s take a look at this UserAuthService.ts file located at src/services folder to see how the signUp function is implemented.

import {
    SignUpCommand,
    CognitoIdentityProviderClient,
    ConfirmSignUpCommand,
    AuthFlowType,
    InitiateAuthCommand,
    AdminInitiateAuthCommand
} from "@aws-sdk/client-cognito-identity-provider";

const ClientId = process.env.USER_CLIENT_ID;
const UserPoolId = process.env.USER_POOL_ID;
  
export const signUp = async ({ Username, Password, email }) => {
    try {
      const client = new CognitoIdentityProviderClient({});
  
      const command = new SignUpCommand({
        ClientId,
        Username,
        Password,
        UserAttributes: [{ Name: "email", Value: email }],
      });
      return await client.send(command);
    } catch (error) {
      throw new Error(error.message)
    }
};

The code defines an asynchronous function signUp for user registration using AWS Cognito.  Let break down the signUp Function:

  • Purpose: The function is designed to asynchronously register a new user in AWS Cognito.
  • Parameters: It accepts an object containing Username, Password, and email.
  • Cognito Client: The function initializes a new CognitoIdentityProviderClient instance. This client provides an interface to communicate with AWS Cognito.
  • SignUpCommand: It creates a SignUpCommand with the provided username, password, and email. The email is passed as a user attribute, which is standard for AWS Cognito user pools.
  • Execution and Return: The command is sent to AWS Cognito using the send method of the client. The function then returns the result of this operation.
  • Error Handling: If an error occurs during this process, it catches the error and throws a new error with the message from the original error. This is a common pattern for error handling in asynchronous operations.

How to handle User Authentication using AWS Cognito Serverless

The purpose of this lambda function is to handle user authentication using aws cognito serverless. Let’s take a look at the index.ts file located at src/functions/user. 

// index.ts 
...
export const login = {
  handler: `${handlerPath(__dirname)}/handler.login`,
  events: [
    {
      http: {
        method: 'post',
        path: 'user/login',
        cors: true,
        request: {
          schemas: {
            'application/json': loginSchema,
          },
        },
      },
    },
  ],
  role: 'LambdaRole'
};
...

This code defines an AWS Lambda function that will be triggered by HTTP POST requests to the specified path: user/login. The function’s logic is located in a separate file and function, and it’s likely intended to handle user authentication via aws cognito serverless. 
Let’s look at the function implementation, let’s open the handler.ts located at src/functions/user.

const loginHandler : ValidatedEventAPIGatewayProxyEvent<typeof loginSchema> = async (event) => {
  try {
    const Username = event.body?.username;
    const Password = event.body?.password;

    const userSignedIn = await initiateAuth({ Username, Password });

    console.log('User Signed In', JSON.stringify(userSignedIn));

    return formatJSONResponse({
      message: 'User logged in successfully ...',
      token: userSignedIn.AuthenticationResult.IdToken
    });

  } catch (error) {
    console.log('Error occured :',error.message);
    return formatJSONResponse({
      message: 'Authentication failed',
      error: error.message
    }, 404)
  }
};

This TypeScript code defines an asynchronous function loginHandler for a serverless application, typically using AWS Lambda and API Gateway. It processes a login request by:

  • Extracting username and password from the request body.
  • Attempting to authenticate the user with these credentials using initiateAuth.
  • If successful, it logs the event and returns a response with a success message and a token.
  • In case of an error (like failed authentication), it logs the error and returns an error message with a 404 status code.

The function is structured to handle user logins in a serverless environment.

Let’s look at the UserAuthService.ts file to see how the initiateAuth function is implemented.

...
export const initiateAuth = async ({ Username, Password }) => {
    try {
      const client = new CognitoIdentityProviderClient({});
  
      const command = new AdminInitiateAuthCommand({
        AuthFlow: AuthFlowType.ADMIN_USER_PASSWORD_AUTH,
        AuthParameters: {
          USERNAME: Username,
          PASSWORD: Password,
        },
        ClientId,
        UserPoolId
      });
      return await client.send(command); 
    } catch (error) {
      throw new Error(error.message);
    }
};

Let’s breakdown the initiateAuth function similar to your example: 

  • Purpose: The function is designed for user authentication using AWS Cognito in serverless environment.
  • Parameters: It takes an object with Username and Password
  • Cognito Client: The function initializes a CognitoIdentityProviderClient instance. This client allows interaction with AWS Cognito for various user management tasks.
  • Authentication Command: It creates an AdminInitiateAuthCommand with the username and password. This command is configured for an admin-based user authentication flow, which is part of AWS Cognito’s capabilities.
  • Execution and Return: The function sends this command to AWS Cognito using the client’s send method. If the authentication is successful, it returns the response from AWS Cognito, typically including authentication tokens.
  • Error Handling: In case of an error during the authentication process, the function catches the error and throws a new error with the caught error’s message. This approach is essential for proper error management in asynchronous operations.

How to handle User Verification with AWS Cognito Serverless

The purpose of this lambda function is to handle user verification using aws cognito serverless. 

...
export const verification = {
  handler: `${handlerPath(__dirname)}/handler.verification`,
  events: [
    {
      http: {
        method: 'post',
        path: 'user/verification',
        cors: true,
        request: {
          schemas: {
            'application/json': comfirmUserSchema,
          },
        },
      },
    },
  ],
  role: 'LambdaRole'
};

This code defines an AWS Lambda function that will be triggered by HTTP POST requests to the specified path: user/verification. The function’s logic is located in a separate file and function, and it’s likely intended to handle user verification via aws cognito serverless. 
Let’s look at the function implementation, let’s open the handler.ts located at src/functions/user.

...
const userVerificationHandler : ValidatedEventAPIGatewayProxyEvent<typeof comfirmUserSchema> = async (event) => {
  try {
    const username = event.body?.username;
    const code = event.body?.code;

    const comfirm = await confirmSignUp({ username, code });

    console.log('Confirmation Response :', JSON.stringify(comfirm));

    return formatJSONResponse({
      message: 'User verified successfully ...',
    });

  } catch (error) {
    console.log('Error occured :',error.message);
    return formatJSONResponse({
      message: 'User verification failed',
      error: error.message
    }, 404)
  }
};

This TypeScript code defines an asynchronous function userVerificationHandler for handling user verification. Let’s break it down:

  • Function Purpose: It is designed to verify a user’s account, typically as part of a registration process using AWS Cognito.
  • Input Parameters: The function expects an event object that should contain a username and a verification code, retrieved from event.body.
  • Verification Process: The function calls confirmSignUp with the username and code. This is presumably a custom or AWS SDK function that interacts with AWS Cognito to verify the user.
  • Successful Verification:
    If the verification succeeds, the function logs the response and returns a success message (“User verified successfully …”) using formatJSONResponse.
  • Error Handling: If an error occurs during the verification process, the function catches the error, logs it, and returns an error message (“User verification failed”) with a 404 status code using formatJSONResponse.
  • Response Format: formatJSONResponse is likely a helper function to format the JSON response that will be returned to the client.

Let’s see how the confirmSignUp function is implemented inside of the UserAuthService.ts file

...
export const confirmSignUp = async ({ username, code }) => {
  try {
    
    const client = new CognitoIdentityProviderClient({});

    const command = new ConfirmSignUpCommand({
      ClientId,
      Username: username,
      ConfirmationCode: code,
    });

    return await client.send(command);
  } catch (error) {
    throw new Error(error.message);
  }
};

The confirmSignUp function is an asynchronous function that confirms a user’s registration in AWS Cognito using a username and a confirmation code. It creates a Cognito client, sends a ConfirmSignUpCommand with the user’s details, and returns the result. If an error occurs, it throws an error with the error message.

Private Lambda route

This function is considered private, meaning it’s not openly accessible to everyone. Access is controlled through AWS Cognito User Pools. Let’s open the handler.ts located at src/functions/hello.

import { handlerPath } from '@libs/handler-resolver';

export default {
  handler: `${handlerPath(__dirname)}/handler.main`,
  events: [
    {
      http: {
        method: 'get',
        path: 'user/hello',
        cors: true,
        authorizer: {
          name: 'PrivateAuthorizer',
          type: 'COGNITO_USER_POOLS',
          arn: {
            'Fn::GetAtt': ['UserPool', 'Arn']
          },
          claims: ["email"]
        }
      },
    },
  ],
};

This configuration sets up a Lambda function that responds to HTTP GET requests at the path /user/hello. The request is authorized using an Amazon Cognito user pool, and CORS is enabled for cross-domain requests.

Let’s look at the private function implementation located at src/functions/hello/handler.ts.

import { formatJSONResponse } from '@libs/api-gateway';
import { middyfy } from '@libs/lambda';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';


export const main = middyfy(
  async (event: APIGatewayProxyEvent) : Promise<APIGatewayProxyResult> => {
    console.log('Event :',event);
    return formatJSONResponse({
      message: `Email ${event.requestContext.authorizer.claims.email} has been authorized`
    });
  }
);

The main handler function expects an incoming HTTP requests to be authorized and contain user email information. When invoked, it responds with a JSON message confirming the email address of the authorized user. If a user is not authenticated, it will not reach the private route. 

Conclusion

We do have now a full user registration and authentication with Cognito and Serverless Framework in TypeScript. Congrats! You can always improve the code with custom logic, make sure to check out the Github code.

We didn’t cover how to deploy and test the full backend api built, but you can checkout The comprehensive Guide to building a serverless REST API with Typescript to learn how to deploy and test a full backend api.

What’s Next?

With our user regitration and authentication system handled with Cognito and Serverless Framework using Typescript, let’s jump next to part where we will talk about Room management features using Dynamo DB. 

Feel free to share your thoughts, questions in the comments below as we keeping building our Serverless chat application backend. 

Leave a Reply