Leveraging AWS Cognito with Serverless for User Authentication and Data Management
Tags: awscognito, lambda, dynamodb, cloudformation
Published: 2023-10-13 06:06:30

In this comprehensive guide, we will demonstrate how to integrate AWS Cognito with a Serverless application to handle user registration, authentication, and data management. This powerful combination allows you to build secure and scalable serverless APIs with user authentication and data storage features. We'll break down the process step by step and provide code snippets for each task.

**Overview:**
Our objective is to create a Serverless API using Node.js Lambda functions that enable user registration and list countries from DynamoDB for authenticated users. We'll leverage the Serverless Framework for this purpose.

**Acceptance Criteria:**
we aim to achieve the following :
1- Register users through the Serverless API and verify their email addresses.
2- Enable users to list countries using a REST client by providing a token obtained during registration.
4- Store user data securely in DynamoDB.

**Architecture:**

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4125a4910morohkx99vf.png)

**Step-by-Step Implementation:**

**Step 1: Install Serverless Framework**

The Serverless Framework simplifies the deployment and management of serverless applications. To get started, you can install it globally using npm (Node Package Manager).

```
npm install serverless -g
```
This command installs the Serverless Framework globally on your machine, allowing you to create and manage serverless projects with ease.

**Step 2: Create a Serverless Project**

The Serverless Framework provides a convenient way to create a new serverless project. You can use predefined templates to scaffold your project.

```
serverless create --template aws-nodejs --path my-service
```
This command creates a new serverless project in a directory called my-service. Inside this directory, you'll find essential project files such as serverless.yml and handler.js.

**Step 3: Create a DynamoDB Table for Countries**

In your serverless.yml file shown below, you need to define the DynamoDB table that will store country data. DynamoDB is a fully managed NoSQL database service provided by AWS.

serverless.yml
```
# Service name has to be unique for your account.
service: my-service

# framework version range supported by this service.
frameworkVersion: '2'

# Configuration of the cloud provider. As we are using AWS so we defined AWS corresponding configuration.
provider:
  name: aws
  runtime: nodejs14.x
  #lambdaHashingVersion: 20201221
  stage: dev
  region: us-east-2
  
  # Create an ENV variable to be able to use it in my JS code. *** Check line 4 in get-country-by-name JS file ***
  environment:
    countriestableName: ${self:custom.countriestableName}
    userstableName: ${self:custom.userstableName}
  
  # To Give a permission to each lambda function to access DynamoDB table
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:*
        # - dynamodb:Query
        # - dynamodb:Scan
        # - dynamodb:GetItem
        # - dynamodb:PutItem
      Resource: "*"


custom:
  countriestableName: countriees  
  userstableName: useres
  userPoolCallback: http://localhost

# As shown below, when the HTTP POST or GET request is made, the handler should be invoked.
functions:
  #(1) Lambda function to initially fill DynamoDB
  FillDynamoDB:
    handler: lambdas/common/countries_loadData.fill
    description: fill DynamoDB table with set of countries.
    events:
      - http: 
          path: fill-dynamoDB
          method: POST
          cors: true

  #(2) Lambda function to list all the countries 
  GetAllCountries:
    handler: lambdas/lambda-endpoints/list-countries.list
    description: get all the countries information.
    events:
      - http: 
          path: list-countries
          method: GET
          cors: true 
          integration: lambda
          authorizer:
            #name: ${self:resources.Resources.ApiGatewayAuthorizer.Properties.Name}
            name: CognitoUserPoolAuthorizer
            # The type of authorizer. COGNITO_USER_POOLS: An authorizer that uses Amazon Cognito user pools.
            type: COGNITO_USER_POOLS
            scopes: email
            # The source of the identity in an incoming request.
            identitySource: method.request.header.Authorization
            # The ID of the RestApi resource that API Gateway creates the authorizer in.
            RestApiId: ApiGatewayRestApi
            arn:
              Fn::GetAtt:
                - UserPool
                - Arn
  
  #(3) Lambda function to add the user data in users dynamoDB table 
  RegisterNewUser:
    handler: lambdas/lambda-endpoints/addusertoDB.putNewUser
    description: get all the countries information.
    events:
      - http: 
          path: add-new-user
          method: POST
          cors: true

#Resources are AWS infrastructure components which your Functions use. 
#The Serverless Framework deploys an AWS components your Functions depend upon.
resources:
  ${file(./CF.yaml)}
```
CF.yaml
```
 Resources:
    countriesDynamoDbTable:
      Type: 'AWS::DynamoDB::Table'
      #DeletionPolicy: Retain
      Properties:       
        TableName: ${self:custom.countriestableName}

        AttributeDefinitions:
          -
            AttributeName: "NAME"
            AttributeType: "S" 


        KeySchema:
          -
            AttributeName: "NAME"
            KeyType: "HASH"

        BillingMode: PAY_PER_REQUEST
    
    usersDynamoDbTable:
      Type: 'AWS::DynamoDB::Table'
      #DeletionPolicy: Retain
      Properties:       
        TableName: ${self:custom.userstableName}

        AttributeDefinitions:
          -
            AttributeName: "NAME"
            AttributeType: "S" 


        KeySchema:
          -
            AttributeName: "NAME"
            KeyType: "HASH"

        BillingMode: PAY_PER_REQUEST

    # Custom resource to invoke lambda function to fill the countries DynamoDB table
    TriggerFillDynamoDBFunction:
     Type: AWS::CloudFormation::CustomResource
     #DependsOn: !Ref FillDynamoDB
     Properties:
       #ServiceToken: arn:aws:lambda:us-east-2:944163165741:function:my-service-dev-FillDynamoDB
       ServiceToken: !GetAtt 'FillDynamoDBLambdaFunction.Arn'

      # User Pool Resources
    
    # Cognito User Pool Resource
    UserPool:
      Type: AWS::Cognito::UserPool
      Properties:
        AdminCreateUserConfig:
        UserPoolName: ahmedsalem-UserPool
        Schema:
          - Name: email
            AttributeDataType: String
            Mutable: false
            Required: true
        AutoVerifiedAttributes:
          - email
        UsernameConfiguration:
          CaseSensitive: false
        AccountRecoverySetting:
          RecoveryMechanisms:
            - Priority: 1
              Name: "verified_email"
        LambdaConfig:
          PostConfirmation: !GetAtt RegisterNewUserLambdaFunction.Arn
  
    UserPoolToRegisterNewUserLambdaPermission:
      Type: AWS::Lambda::Permission
      Properties:
        FunctionName: !GetAtt RegisterNewUserLambdaFunction.Arn
        Principal: cognito-idp.amazonaws.com
        Action: lambda:InvokeFunction
        SourceArn: !GetAtt UserPool.Arn

    UserPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
        ClientName: ahmedsalem-UserPoolClient
        UserPoolId: !Ref UserPool
        GenerateSecret: false
        AllowedOAuthFlows:
          - implicit
        AllowedOAuthFlowsUserPoolClient: true
        AllowedOAuthScopes:
          - phone
          - email
          - openid
          - profile
          - aws.cognito.signin.user.admin
        CallbackURLs:
          - ${self:custom.userPoolCallback}
        SupportedIdentityProviders:
          - COGNITO
    
    UserPoolDomain:
      Type: AWS::Cognito::UserPoolDomain
      Properties:
        Domain: ahmedsalem-app
        UserPoolId: !Ref UserPool
 Outputs:
  TokenURL:
    Description: Url for users signing in/up
    Value: !Sub "https://${UserPoolDomain}.auth.us-east-2.amazoncognito.com/oauth2/authorize?response_type=token&client_id=${UserPoolClient}&redirect_uri=${self:custom.userPoolCallback}"

```

**Step 4: Create Lambda function to initially fill countries DynamoDB table in serverless.yaml file and its backend nodejs code.**

API_Responses.js

```
const Responses = {
    _200(data = {}) {
        return {
            headers: {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Methods': '*',
                'Access-Control-Allow-Origin': '*',
            },
            statusCode: 200,
            body: JSON.stringify(data),
        };
    },

    _400(data = {}) {
        return {
            headers: {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Methods': '*',
                'Access-Control-Allow-Origin': '*',
            },
            statusCode: 400,
            body: JSON.stringify(data),
        };
    },
};

module.exports = Responses;
```

countries.json

```
[

    {
        "name": "Albania",
        "code": "AL"
    },

    {
        "name": "Australia",
        "code": "AU"

    },

    {
        "name": "Belgium",
        "code": "BE"

    },

    {
        "name": "Brazil",
        "code": "BR"

    },

    {
        "name": "China",
        "code": "CN"

    },

    {
        "name": "Greece",
        "code": "GR"

    }
]
```

Dynamo.js

```
//  To access AWS resources  
const AWS = require('aws-sdk');

// when you're working with dynamoDB you have to use the document client to acces the files in DynamoDB
const documentClient = new AWS.DynamoDB.DocumentClient();

// Create an obj named Dynamo that has a get method that takes an NAME and tablename
// this a Async process looking into DynamoDB, So we need to await this 
const Dynamo = {
    async get(NAME, TableName) {
        const params = {
            TableName,
            Key: {
                NAME,
            },
        };

        const data = await documentClient.get(params).promise();

        if (!data || !data.Item) {
            throw Error(`There was an error fetching the data for NAME of ${NAME} from ${TableName}`);
        }
        console.log(data);

        return data.Item;
    },

    async write(data, TableName) {
        if (!data.NAME) {
            throw Error('no NAME on the data');
        }

        const params = {
            TableName,
            Item: data,
        };

        const res = await documentClient.put(params).promise();

        if (!res) {
            throw Error(`There was an error inserting NAME of ${data.NAME} in table ${TableName}`);
        }

        return data;
    },
    async list(TableName) {
        if (!data) {
            throw Error('error in listing all countries');
        }

        const params = {
            TableName
        };

        const res = await documentClient.scan(params).promise();

        if (!res) {
            throw Error(`error in listing all countires ${TableName}`);
        }

        return data;
    },
};
module.exports = Dynamo;
```

countries_loadData.js

```
var AWS = require("aws-sdk");
var fs = require('fs');
const Responses = require('../common/API_Responses');
const Dynamo = require('../common/Dynamo');
const countriestableName = process.env.countriestableName;
var response = require('cfn-response');

var documentClient = new AWS.DynamoDB.DocumentClient();

console.log("Importing countries into DynamoDB. Please wait...");

var allcountries = JSON.parse(fs.readFileSync('lambdas/common/countries.json', 'utf8'));

console.log(allcountries);
exports.fill = (event, context) => {

    if (event.RequestType != "Create") {
        return response.send(event, context, response.SUCCESS, {})
    }

allcountries.forEach( function(country) {
    var params = {
        TableName: countriestableName,
        Item: {
            "NAME":  country.name,
            "Code": country.code
        }
    };
    documentClient.put(params, function(err, data) {
       if (err) {
           console.error("Unable to add country", country.name, ". Error JSON:", JSON.stringify(err, null, 2));
           return response.send(event, context, response.FAILED, {})

       } else {
           console.log("PutItem succeeded:", country.name);
           return response.send(event, context, response.SUCCESS, {})
       }
    });
});
};
```

**Step 5: Create a Custom Resource to fill the DynamoDB Table**
To automate the population of the DynamoDB table during deployment, you can define a custom resource in your serverless.yml file above. This custom resource triggers the Lambda function responsible for filling the countries' DynamoDB table.

**Step 6: Create Lambda Function for Listing Countries**

Create a Lambda function to list countries from the DynamoDB table. This function should be defined in Node.js and include code for querying DynamoDB.

```
// const Responses = require('../common/API_Responses');
const AWS = require('aws-sdk');
const Dynamo = require('../common/Dynamo');
const documentClient = new AWS.DynamoDB.DocumentClient();
const countriestableName = process.env.countriestableName;

const params = {
 TableName : countriestableName
}

async function listItems(){
 try {
   const data = await documentClient.scan(params).promise()
   return data
 } catch (err) {
   return err
 }
}

exports.list = async (event, context) => {
 try {
   const data = await listItems()
   return { body: JSON.stringify(data) }
 } catch (err) {
   return { error: err }
 }
}

```

**Step 7: Access the API**
To access your Serverless API, you can use the provided URLs. Utilize a REST client to test the API, specifically the /list-countries endpoint.

**Step 8: Create a DynamoDB Table for Users**
Similar to step 3, you need to define another DynamoDB table to store user data. This table will hold user information after registration.

**Step 9: Create a Cognito User Pool**
Amazon Cognito is an identity management service that simplifies user authentication and authorization. Here, you create a Cognito user pool, which is essentially a user directory where your users can sign up and sign in.

a) Create a User Pool

b) Create a Lambda Permission to Register New Users
Create a Lambda permission to allow Cognito to invoke your Lambda function for registering new users. This step establishes the connection between Cognito and your Lambda function.

c) Create a User Pool Client
A user pool client represents a web or mobile application that interacts with your Cognito User Pool. Define the client properties to configure how users interact with your app.

d) Create a User Pool Domain
A user pool domain provides a custom domain name for your Cognito user pool's hosted UI. It's used for user sign-up and sign-in.

**Step 10: Implement Lambda Function for User Registration**
Create a Lambda function that handles user registration. This function should be triggered when a user signs up through the Cognito user pool. Ensure that you have installed any required dependencies for your Lambda function.

```
const {
    DynamoDBClient,
    PutItemCommand,
} = require("@aws-sdk/client-dynamodb");

console.log("hello");

const dynamoDbClient = new DynamoDBClient();
const USER_TABLE = process.env.userstableName;
module.exports.putNewUser = async (event, context, callback) => {
    console.log(USER_TABLE);
    console.log(event.request.userAttributes.email)
    console.log(event.userName)

    await dynamoDbClient.send(new PutItemCommand({

        TableName: USER_TABLE,
        Item: {
            NAME: {S: event.userName},
            Email: {S: event.request.userAttributes.email}
        }
    }))
    callback(null, event)
};
```

**Step 11: Test User Registration**
To Confirm that lambda function will to be fired when new user sign up using AWS Cognito and its data will be saved in users table.

(a) Go to the created User Pool and select "App Client Settings"
(b) Click on "Launch Hosted UI"
(c) Click on sign up, add new user, and paste the confirmation code that will be sent to you through the mail
(d) Check the users DynamoDB table, it should have the new registered user data.

**Step 12: Create an API Authorizer**
API authorizers are used to control access to your API endpoints. In this step, you create an API authorizer that checks the access_token header in the request to ensure that only authorized users can access your API.

**Step 13: Test API Authorization**
To confirm that the API endpoint is restricted to authorized users, follow these steps:

1- Go to your Cognito User Pool settings.
2- Select "App Client Settings."
3- Launch the hosted UI.
4- Sign in with a user account.
5- After signing in, you'll be directed to a rollback URL; 
6- copy the URL to obtain the access_token.
7- Open a REST client and paste the API endpoint URL.
8- Set the action to "GET."
9- If you attempt to send the request without the access_token, you should receive an "unauthorized request" response.
10- Add an "Authorization" header with the access_token, and you should receive the expected response from the API endpoint.

**Step 14: Deploy Your Serverless Application**
Deploying your Serverless application is straightforward using the Serverless Framework. The sls deploy command packages and deploys your entire application stack to AWS.

```
sls deploy
```
This command uploads your Lambda functions, API Gateway, and other resources to AWS.

**Step 15: Remove Resources (Optional)**
If you ever need to remove all the functions, events, and resources created by your Serverless application from your AWS account, you can use the sls remove command.

```
sls remove
```
**Conclusion:**
In this article, we've covered the complete process of integrating AWS Cognito with a Serverless application for user registration, authentication, and data management. Leveraging the power of Serverless and AWS Cognito, you can build secure and scalable serverless APIs with user authentication.