DEV Community

Ez Pz Developement
Ez Pz Developement

Posted on • Originally published at ezpzdev.Medium on

Building a Serverless Application with NestJS and the Serverless Framework: Authentication and a…

Building a Serverless Application with NestJS and the Serverless Framework: Authentication and a custom lambda Authorizer.


Image for the blog building a Serverless Application with NestJS and the Serverless Framework: Authentication and a custom lambda Authorizer

Table of contents:

  • Intro
  • Handling authentication using lambda functions
  • Enabling CORS

In the previous post, I talked about the following :

after the last blog here is what our file structure looks like

apps
  users
    src
      app.controller.ts
      app.module.ts
      app.service.ts
      main.ts
      serverless.yaml
    tsconfig.app.json
  items
    src
      app.controller.ts
      app.module.ts
      app.service.ts
      main.ts
      serverless.yaml
    tsconfig.app.json
config
  serverless.yaml
nest-cli.json
serverless-compose.yaml
package.json
tsconfig.json
.eslintrc.jsya
Enter fullscreen mode Exit fullscreen mode

in this blog post I will write about how I handled JWT authentication using a lambda function

Handling authentication with a lambda function

The first thing I did was to search in the serverless framework authentication to find if there is a way to handle authentication faster with the serverless framework and this led to this blog post: https://www.serverless.com/blog/strategies-implementing-user-authentication-serverless-applications/ which contains details about how to implement authentication using a lambda custom authorizer so my plane was to do the following

  • check aws documentation that talks about authorization.
  • write a lambda function that handles login and signup.
  • write a lambda function that handles refreshing tokens.
  • and writing a function that handles authorization where I will check if a request of the client contains a valid token.

after checking the aws documentation i decided to implement a simple version of the authorizer function.

the first thing that i did was to go inside my src folder and created a new folder with the name auth and added the following lines to my new yaml file

service: auth

plugins:
  - serverless-offline

provider:
  name: aws
  region: eu-west-3
  runtime: nodejs16.x
  stage: dev
  environment:
    DYNAMODB_TABLE: authors-${opt:stage, self:provider.stage}
    JWT_ACCESS_TOKEN_SECRET_KEY: __secret_key__
    JWT_REFRESH_TOKEN_SECRET_KEY: __refresh_token_secret_key__
    JWT_ACCESS_EXPIRES_IN_MINUTES: 30
    JWT_REFRESH_EXPIRES_IN_MINUTES: 10080
    PASSWORD_SALT_ROUNDS: 10
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan  
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
      Resource: 
        Fn::Join:
          - ''
          - - "arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/"
            - ${self:provider.environment.DYNAMODB_TABLE}
  apiGateway:
    restApiId:
      'Fn::ImportValue': MyApiGateway-restApiId
    restApiRootResourceId:
      'Fn::ImportValue': MyApiGateway-rootResourceId

custom:
    serverless-offline:
        httpPort: 3000
        websocketPort: 3001
        lambdaPort: 3002

functions:
  signin:
    handler: dist/main.signin
    events:
      - http:
          method: POST
          path: /signin

  signup:
    handler: dist/main.signup
    events:
      - http:
          method: POST
          path: /signup
  refreshToken: 
    handler: dist/main.refreshToken
    events:
      - http:
          method: POST
          path: /refresh-token
  authorizer:
    handler: dist/main.authorizer

resources:
  Resources:
    Authorizer:
      Type: AWS::ApiGateway::Authorizer
      Properties: 
        Name: ${self:provider.stage}-Authorizer
        RestApiId: 
          'Fn::ImportValue': MyApiGateway-restApiId
        Type: TOKEN
        IdentitySource: method.request.header.Authorization
        AuthorizerResultTtlInSeconds: 300
        AuthorizerUri:
          Fn::Join:
            - ''
            - 
              - 'arn:aws:apigateway:'
              - Ref: "AWS::Region"
              - ':lambda:path/2015-03-31/functions/'
              - Fn::GetAtt: "AuthorizerLambdaFunction.Arn"
              - "/invocations"
  Outputs:
   AuthorizerId:
     Value:
       Ref: Authorizer
     Export:
       Name: authorizerId
Enter fullscreen mode Exit fullscreen mode

let’s explain more about this file, our serverless framework configuration file sets up an AWS Lambda service, called auth, intended for handling authentication in the application.

The environment subsection includes environment variables such as the JWT secret keys for both access and refresh tokens, the duration of the tokens, and the DynamoDB table name, which is dynamic based on the stage (dev in this case).

In the iamRoleStatements, it sets permissions for the Lambda functions to perform various actions on DynamoDB. The Resource subsection constructs the ARN (Amazon Resource Name) for the DynamoDB table.

The functions the section includes different AWS Lambda functions with respective HTTP events, such as signin, signup, refreshToken, and authorizer.

The authorizer the function doesn't have an events section or a dedicated path because it's not intended to be accessed directly through an HTTP request. Instead, the authorizer function is used as a custom authorizer for your API Gateway.

An authorizer is a Lambda function that performs authentication and authorization on requests before they reach the actual service endpoints.

When an incoming request triggers an AWS API Gateway event, the authorizer function is invoked first. This function examines the authorization token included in the request’s Authorization header and determines whether the request is allowed.

The resources section of the YAML file is where you set up the Authorizer as a custom authorizer for the API Gateway. It doesn't have an HTTP endpoint because it's not meant to be invoked directly by HTTP requests but rather as an intermediate layer by the API Gateway. The AuthorizerUri property specifies the Lambda function that API Gateway calls for the custom authorization.

The authorizerId output can be imported into other Serverless services users and items as the identifier of this authorizer.

Now it is time to write code for our functions: signin, signup, refreshToken, and authorizer .

Signup:

#apps/auth/src/main.ts
export const signup = async (
  event: any,
  _context: Context,
  _callback: Callback,
) => {
  const appContext = await NestFactory.createApplicationContext(AppModule);
  const appService = appContext.get(AppService);

  const { email, password, firstName, lastName } = JSON.parse(event.body);

  try {
    const result = await appService.signup(
      email,
      password,
      firstName,
      lastName,
    );
    return {
      statusCode: 201,
      body: JSON.stringify({
        success: true,
        data: result,
      }),
    };
  } catch (error) {
    console.log(error);
    return {
      statusCode: 500,
      body: JSON.stringify(error.response ?? error.message),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

This function receives an event, context, and callback, but also extracts the firstName and lastName fields from the request body. These are used along with the email and password to call the signup method of appService. If the sign-up process is successful, it returns a 201 status code along with the result of the operation. If an error occurs, it responds with a 500 status code and an error message.

Now let’s take a look to our signup service which handles the logic that does the signup.

  async signup(
    email: string,
    password: string,
    firstName: string,
    lastName: string,
  ) {
    let user = await this.getUserByEmail(email);

    if (user) {
      throw new BadRequestException('Email already in use');
    }

    const hash = await this.hash(password);
    user = await this.createUser({
      email,
      password: hash,
      firstName,
      lastName,
    });
    console.log(user);
    if (!user) {
      throw new InternalServerErrorException();
    }

    const payload = {
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      sub: user.id,
    };

    const accessToken = await this.jwtService.signAsync(payload);
    const refreshToken = await this.jwtService.signAsync(payload, {
      secret: jwtConstants.refreshTokenSecret,
      expiresIn: `${jwtConstants.refreshExpiresIn} min`,
    });

    this.updateRefreshToken(user.id, refreshToken);
    return {
      access_token: accessToken,
      refresh_token: refreshToken,
    };
  }
Enter fullscreen mode Exit fullscreen mode

signup(): This method is used to create a new user account.

  • It first checks whether a user with the given email already exists by calling (the email must be unique for each user) getUserByEmail().
  • If the user already exists, it throws an error. If not, it hashes the provided password using hash(), creates a new user with the hashed password, and provided details using createUser(), and creates an access token and a refresh token for this user using jwtService.signAsync().
  • It then associates the refresh token with the user by calling updateRefreshToken(). Finally, it returns the access token and refresh token.

Below is the code and the explanation for the 4 helper functions used in the signup method, in these methods JWT (JSON Web Token) from nestjs is used to generate tokens, bcrypt is used for hashing passwords, andAWS DynamoDB from aws-sdk is used for storing user information.


  private async updateRefreshToken(id: string, refreshToken: string) {
    const params = {
      TableName: process.env.DYNAMODB_TABLE,
      Key: { id },
      UpdateExpression: 'set refreshToken = :refreshToken',
      ExpressionAttributeValues: {
        ':refreshToken': refreshToken,
      },
    };
    const res = await this.db.update(params).promise();
    if (res.$response.error) {
      throw new InternalServerErrorException(res.$response.error.message);
    }
  }

  private async hash(password: string) {
    const salt = await bcrypt.genSalt(jwtConstants.saltRounds);
    return await bcrypt.hash(password, salt);
  }
  private async getUserByEmail(email: string) {
    const params = {
      TableName: process.env.DYNAMODB_TABLE,
      FilterExpression: 'email = :email',
      ExpressionAttributeValues: {
        ':email': email,
      },
      ProjectionExpression: 'id, email, firstName, lastName, password',
    };

    const res = await this.db.scan(params).promise();
    if (res.$response.error) {
      throw new InternalServerErrorException(res.$response.error);
    }
    console.log(res.Items[0]);
    return res.Items[0];
  }

  private async createUser(user: any) {
    const { email, firstName, lastName, password } = user;
    const id = crypto.randomUUID();
    const data = {
      TableName: process.env.DYNAMODB_TABLE,
      Item: {
        id: id,
        email,
        firstName,
        lastName,
        password,
      },
    };

    const res = await this.db
      .put({
        ...data,
      })
      .promise();
    if (res.$response.error) {
      throw new InternalServerErrorException(res.$response.error.message);
    }

    return { id, email, firstName, lastName };
  }
Enter fullscreen mode Exit fullscreen mode
  1. getUserByEmail(): This private method queries the DynamoDB table to find a user with the given email. It returns the first user that matches the email, if any. If there is an error with the scan operation, it throws an error.
  2. createUser(): This private method adds a new user to the DynamoDB table. The user’s id is randomly generated, and the provided email, first name, last name, and password are stored in the table. If there is an error with the put operation, it throws an error.
  3. updateRefreshToken(): This private method updates a user’s refresh token in the DynamoDB table. If there is an error with the update operation, it throws an error.
  4. hash(): This private method hashes a password using bcrypt. It first generates a salt using bcrypt.genSalt() with the number of salt rounds defined in jwtConstants, then hashes the password with this salt using bcrypt.hash().

Signin:

Now as we are done with the signup method and all the methods that we use in it, it is time to move to signin.

#apps/auth/src/main.ts

export const signin = async (
  event: any,
  _context: Context,
  _callback: Callback,
) => {
  const appContext = await NestFactory.createApplicationContext(AppModule);
  const appService = appContext.get(AppService);

  const { email, password } = JSON.parse(event.body);

  try {
    const result = await appService.signIn(email, password);
    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        data: result,
      }),
    };
  } catch (error) {
    console.log(error);
    return {
      statusCode: 401,
      body: JSON.stringify(error.response ?? error.message),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

This signin function accepts an event, context, and callback. It extracts the email and password from the request body. These are utilized when invoking the signIn method of appService. If the sign-in procedure is successful, a 200 status code and the result of the operation are returned. Should an error occur, it responds with a 401 status code and an error message.

Now let’s take a look at our signin service which handle the logic that does the signin.

  async signIn(email: string, pass: string) {
    const user = await this.getUserByEmail(email);

    if (!user) {
      throw new BadRequestException('Wrong credentials');
    }

    const match = await bcrypt.compare(pass, user?.password);

    if (!match) {
      throw new BadRequestException('Wrong credentials');
    }

    const payload = {
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      sub: user.id,
    };

    const accessToken = await this.jwtService.signAsync(payload);
    const refreshToken = await this.jwtService.signAsync(payload, {
      secret: jwtConstants.refreshTokenSecret,
      expiresIn: `${jwtConstants.refreshExpiresIn} min`,
    });

    this.updateRefreshToken(user.id, refreshToken);
    return {
      access_token: accessToken,
      refresh_token: refreshToken,
    };
  }
Enter fullscreen mode Exit fullscreen mode

The function receives the email and password (pass) as parameters. and do the following

  1. It retrieves the user’s details from the database using the getUserByEmail method.
  2. If there’s no user found with the provided email, it throws a BadRequestException error with the message 'Wrong credentials'.
  3. Next, it checks whether the provided password matches the user’s password stored in the database. The bcrypt.compare method is used to compare the hashed version of the input password with the hashed version stored in the database.
  4. If the passwords don’t match, it throws an BadRequestException error with the message 'Wrong credentials'.
  5. If the user is found and the passwords match, it prepares a payload with the user’s details.
  6. The method then creates a JWT access token and a refresh token using the jwtService.signAsync method. The payload is used as the data for these tokens. The refresh token has a different secret and expires after a defined amount of time.
  7. The updateRefreshToken the method is called to associate the new refresh token with the user in the database.
  8. Finally, the method returns both the access token and the refresh token. These can be used by the client application to make authenticated requests and renew the access token when it expires, respectively.

Refresh Token:

Now let’s write the lambda function that takes of refreshing token when expired.

export const refreshToken = async (
  event: any,
  _context: Context,
  _callback: Callback,
) => {
  const appContext = await NestFactory.createApplicationContext(AppModule);
  const appService = appContext.get(AppService);

  const { refreshToken } = JSON.parse(event.body);

  try {
    const result = await appService.refreshToken(refreshToken);
    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        data: result,
      }),
    };
  } catch (error) {
    console.log(error);
    return {
      statusCode: 403,
      body: JSON.stringify(error.response ?? error.message),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

This refreshToken function takes an event, context, and callback as inputs. From the request body, it extracts the refreshToken. This is then used when calling the refreshToken method of appService. If the refresh operation is successful, a 200 status code and the result of the process are returned. This would typically be a new access token. However, if an error occurs, the function responds with a 403 status code and the respective error message. The error could occur for various reasons, such as the provided refreshToken is invalid or expired.

Now let’s take a look at our refreshToken service which handle the logic that refreshes the token.

  async refreshToken(refreshToken: string) {
    let user = undefined;
    try {
      const payload = await this.jwtService.verifyAsync(refreshToken, {
        secret: jwtConstants.refreshTokenSecret,
      });
      user = payload;
    } catch (e) {
      console.log(e);
      throw new InternalServerErrorException(
        'Error while validating refresh token',
      );
    }

    const user = await this.getUserByEmail(user.email);
    if (!user) {
      throw new BadRequestException('Invalid refresh token');
    }

    if (user.refreshToken && user.refreshToken !== refreshToken) {
      console.log('refresh token does not match.');
      throw new BadRequestException('Invalid refresh token');
    }

    const payload = {
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      sub: user.id,
    };

    return {
      access_token: await this.jwtService.signAsync(payload),
    };
  }
Enter fullscreen mode Exit fullscreen mode

The refreshToken function accepts the refreshToken as a parameter and proceeds as follows:

1- It attempts to verify the refreshToken using the jwtService.verifyAsync method. If there's an issue with the verification, an error is logged, and it throws an InternalServerErrorException with the message 'Error while validating refresh token'.

2- Once the refreshToken is verified, the payload (which contains the user's information) is extracted from the refreshToken and used to retrieve the user's data from the database using the getUserByEmail method.

3- If no user is found, or the refreshToken stored in the database for the user does not match the provided refreshToken, it throws a BadRequestException with the message 'Invalid refresh token'.

4- If the user exists and the refreshToken is valid, it prepares a new payload with the user's details. The payload includes the user's email, first name, last name, and id.

5- Finally, it generates a new JWT access token using the jwtService.signAsync method, with the payload as the data for this token and returns this new access token. The client application can use this new access token for further authenticated requests.

This process helps ensure that the user is still valid and has the right to access the resources, even when the access token is expired but the refresh token is still valid. This reduces the need for the user to provide their credentials again, improving the user experience.

Authorizer:

With the main subject that we are going to talk about in this post which is writing an authorizer that going to be used in the other services and in our API gateway to check if a request is valid or not.

as a reminder, we already created the required configuration in our serverless.yaml file , below is the most important parts that your file have to make the authorizer work with the API gateway and with other services and lambda function in the different serverless.yaml files.

service: auth

provider:
  ...
  apiGateway:
    restApiId:
      'Fn::ImportValue': MyApiGateway-restApiId
    restApiRootResourceId:
      'Fn::ImportValue': MyApiGateway-rootResourceId

...

functions:
  ...
  authorizer:
    handler: dist/main.authorizer

resources:
  Resources:
    Authorizer:
      Type: AWS::ApiGateway::Authorizer
      Properties: 
        Name: ${self:provider.stage}-Authorizer
        RestApiId: 
          'Fn::ImportValue': MyApiGateway-restApiId
        Type: TOKEN
        IdentitySource: method.request.header.Authorization
        AuthorizerResultTtlInSeconds: 300
        AuthorizerUri:
          Fn::Join:
            - ''
            - 
              - 'arn:aws:apigateway:'
              - Ref: "AWS::Region"
              - ':lambda:path/2015-03-31/functions/'
              - Fn::GetAtt: "AuthorizerLambdaFunction.Arn"
              - "/invocations"
  Outputs:
   AuthorizerId:
     Value:
       Ref: Authorizer
     Export:
       Name: authorizerId
Enter fullscreen mode Exit fullscreen mode

We can go back to our auth/main.ts and write some code for our authorizer, the provided code is an AWS Lambda function that serves as an “authorizer” in the context of AWS API Gateway. It will be invoked before your actual business logic function to verify that the incoming request has the necessary permissions to perform the intended action, let’s explain more about the function

  1. The function then retrieves the accessToken from the event.authorizationToken property by removing the 'Bearer' prefix from it.
  2. It also extracts the methodArn from the event.methodArn. methodArn is the Amazon Resource Name (ARN) of the incoming request. This is an identifier that AWS uses to identify individual resources. It represents the requested resource (like an API method) to be accessed.
  3. It then calls the authorizer method from AppService bypassing accessToken and methodArn. This function should return a policy document that tells API Gateway what resources this token is allowed to access.
  4. If the authorizer function is successful and the user is authorized, it returns a policy document by calling the callback function with null as the first parameter and authorized as the second parameter.
  5. If an error occurs, it calls the callback function with null as the first parameter and the error response as the second.

now we can explain why are we using methodArn , and callback in the authorizer :

  • The reason for extracting the methodArn is to generate a policy document specific to the API method being accessed. A user may have different permissions for different API methods. The methodArn helps us identify which API method the user is trying to access so that we can generate the correct policy document.
  • The callback function is part of the AWS Lambda handler. In an AWS Lambda function, the callback function is used to signal the end of the function’s execution and return a response to the service that invoked the Lambda function. It’s essential to call the callback function once you’re done with your processing, or AWS Lambda will continue to wait until the function execution times out.

Now let’s dive deeper and discover our authorizer service and explain more about how the authorizer logic work, below is the authorizer service and the other two helper functions used in it.

  async authorizer(accessToken: string, methodArn: any) {
    if (!accessToken || !methodArn)
      return this.generateAuthResponse('None', 'Deny', 'None');

    // verifies token
    const decoded = await this.verifyToken(accessToken);
    if (decoded && decoded.sub) {
      return this.generateAuthResponse(decoded.sub, 'Allow', methodArn);
    } else {
      return this.generateAuthResponse('None', 'Deny', methodArn);
    }
  }

  async verifyToken(accessToken: string) {
    console.log('verify token', accessToken);
    try {
      return await this.jwtService.verifyAsync(accessToken);
    } catch (e) {
      throw new BadRequestException('Invalid access token');
    }
  }

  private generateAuthResponse(principalId, effect, methodArn) {
    const policyDocument = this.generatePolicyDocument(effect, methodArn);

    return {
      principalId,
      policyDocument,
    };
  }

  private generatePolicyDocument(effect, methodArn) {
    if (!effect || !methodArn) return null;

    const policyDocument = {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke',
          Effect: effect,
          Resource: methodArn,
        },
      ],
    };

    return policyDocument;
  }
Enter fullscreen mode Exit fullscreen mode
  1. authorizer(accessToken: string, methodArn: any): This function is the authorizer for the API. It checks if an accessToken and methodArn (a unique identifier for the method to be authorized) are provided. If either is missing, it generates an authorization response denying access. If both are provided, it verifies the token. If the token is valid and contains a sub (subject) field, it generates an authorization response allowing access to the methodArn. If the token is not valid, it generates a response denying access.
  2. generateAuthResponse(principalId, effect, methodArn): This function generates an authorization response. It creates a policy document (using generatePolicyDocument(effect, methodArn)) and combines it with the principalId to form the response. The principalId is the identifier of the user or role for which the policy is being created.
  3. generatePolicyDocument(effect, methodArn): This function generates a policy document. A policy document is a structured policy that AWS uses to evaluate whether to allow or deny access to a specific AWS resource. This function accepts an effect (either 'Allow' or 'Deny') and a methodArn and constructs a policy document from them
  4. verifyToken(accessToken: string): This function verifies if the provided accessToken is valid. It uses the jwtService.verifyAsync method to decode the token and confirm its legitimacy. If the token is invalid, it throws a BadRequestException error with the message 'Invalid access token'.

For more details about method arn, callback, and generating policy documents check the links below :

Enabling CORS

In order for our authorizer to work we need to import it in our serverless.yaml file and enable CORS for the function that accepts PUT and POST requests.

export const updateItem: Handler = async (
  event: any,
  _context: Context,
  _callback: Callback,
) => {
  const appContext = await NestFactory.createApplicationContext(ItemsModule);
  const appService = appContext.get(ItemsService);
  const token = appService.extractTokenFromHeader(event);
  const { id } = event.pathParameters;
  const { name, description } = JSON.parse(event.body);

  try {
    const res = await appService.updateItem(
      id,
      {
        name,
        description,
      },
      token,
    );
    return {
      statusCode: HttpStatus.OK,
      body: JSON.stringify(res),
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': '*',
        'Access-Control-Allow-Credentials': true,
      },
    };
  } catch (error) {
    console.log(error);
    return {
      statusCode: HttpStatus.BAD_REQUEST,
      body: JSON.stringify(error.response ?? error.message),
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': '*',
        'Access-Control-Allow-Credentials': true,
      },
    };
  }
};

Enter fullscreen mode Exit fullscreen mode

One key thing to note in this function is the headers object in the response. This is related to the concept of Cross-Origin Resource Sharing (CORS). CORS is a mechanism that uses additional HTTP headers to tell browsers to give a web application running at one origin, access to selected resources from a different origin.

In this code, the headers 'Access-Control-Allow-Origin': '*' and 'Access-Control-Allow-Methods': '*' are set to ' * ' which means all origins and all methods are allowed. In a production environment, it's typically recommended to restrict this to only the origins and methods that need to be used for security reasons. For example, you could restrict the origins to 'https://yourwebsite.com' and the methods to 'GET, POST' as follows:

'Access-Control-Allow-Origin': 'https://yourwebsite.com',
'Access-Control-Allow-Methods': 'GET, POST'
Enter fullscreen mode Exit fullscreen mode

That’s a quick overview of the code. As always, keep in mind to secure your applications by not exposing sensitive data in error messages and by implementing proper authentication and authorization mechanisms.

Now if you try to run npm run deploy and then check your aws api portal you will find this authorizer, trying to access the updateItem function will throw an error until you provide a valid access token.

That’s all for this post! I’ve shared my journey on learning to build a basic API using NestJS, AWS, and the Serverless Framework. I plan to write more about my experiences with these tools in future posts. I hope you find this helpful, and maybe even learn something new!

Top comments (0)