Authentication Guard in NestJS with AWS Cognito

Authentication Guard in NestJS with AWS Cognito

ยท

5 min read

Introduction

Hi, good to see you here. This article is kind of a continuation of what I wrote in my other blog post "The basics of integrating your backend with AWS Cognito".

What I'm going to share with you right now is how to protect routes and create an Authentication Guard for your NestJS application, using AWS Cognito as the Auth Service. You can extend the logic and apply it to your route middleware in express.

Dependencies

NestJS @ 10.0.0 (both core and common). AWS provides a JS library to verify JWTs signed by Cognito called aws-jwt-verify, which I am running the version 4.0.0 of it.

What are NestJS Guards?

Quoting the official docs:

They determine whether a given request will be handled by the route handler or not. [...] Guards have access to the ExecutionContext instance, and thus know exactly what's going to be executed next.

Guards are often used to deal with authentication and authorization. However, there are some other use cases where you can implement a Guard and put it on top of the routes of your application.

By nature, Guards implement the interface CanActivate and the output should be a Boolean value.

export interface CanActivate {
    canActivate(
        context: ExecutionContext
    ): boolean | Promise<boolean> | Observable<boolean>;
}

This means, if we return true, the route handler will be activated, otherwise, it will not and the guard can deal with HTTP responses as well.

Jump into the code

In this post, we will build one to handle authentication, therefore, validating the token and getting data from the authenticated user directly from AWS Cognito.

When we sign in with AWS Cognito, it generates an Access Token. This token is of type Bearer, and the CognitoGuard expects it to be present at the header by the key authorization.

๐Ÿ’ก
You can modify this logic the way you want.
@Injectable()
export class CognitoGuard implements CanActivate {
  // D.I is handled by NestJS framework.
  constructor(private authService: AuthService) {}

  async canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest<Request>();

    const authHeader = request.headers['authorization'];

    if (!authHeader) {
      throw new UnauthorizedException();
    }

    const [, token] = authHeader.split(' ');

    if (!token) {
      throw new UnauthorizedException();
    }

    const isAuthorized = await this.authService.verifyToken(token);

    if (!isAuthorized) {
      throw new UnauthorizedException();
    }

    await this.authService.getUserData(token);

    const user = {
      username: isAuthorized.username,
    };

    request['user'] = user;

    return Promise.resolve(true);
  }
}

Scenarios we're handling:

  • authorization header is not present - throws 401

  • the token is incorrect (handle cases like Bearer ) - throws 401

  • if the token is invalid - throws 401

If the token is VALID - then we can extract data out of it to later, inject the information of the user in the request later, which can and will be used to perform actions in the name of this user.

Adding information to the request is very simple but we also might need to modify the Request interface of express. Because if you try to access the request, you might get a typescript error like this one:

const yourRouteHandler = (req: Express.Request) => {
    console.log(req['user']); 
    // ERROR: Property 'user' does not exist on type 'Request'
}

Here's how you can extend the Request interface add your typing and get rid of this error.

The services

The AuthService that is being injected in the CognitoGuard has two functions that deal with the verification of the token and fetch data from the owner of the access token provided in the header.

Verifying the JWT token signed by Cognito

Simple snippet to verify the authenticity of the access token. However, there is room for some improvements like dealing with invalid JWT claims and many others.

import { CognitoJwtVerifier } from 'aws-jwt-verify';

async verifyToken(token: string): Promise<{ username: string } | null> {
    const verifier = CognitoJwtVerifier.create({
      userPoolId: this.userPoolId, // Cognito UserPool ID
      tokenUse: 'access',
      clientId: this.clientId, // Cognito App Client ID
    });

    try {
      const payload = await verifier.verify(token);
      return { username: payload.username };
    } catch {
      return null;
    }
}

Errors like

  • JwtExpiredError

  • JwtInvalidClaimError

  • and many others can be found here.

Add it to your catch and return a friendly message the way you might need it.

Getting user data through the validated access token

The Cognito Identity Provider SDK for Node.js allows us to get the user data from a valid access token. Therefore, after we validated it, we called the AuthService.GetUserData to get data from the user, such as email, username, and others.

Since we're calling this function AFTER verifying the token, we can assume we're not going to face a lot of weird errors right here, just for the sake of sharing knowledge with you all.

const identityProvider = new CognitoIdentityProvider({
    region: process.env.AWS_REGION,
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    },
});

async getUserData(accessToken: string) {
    const params: GetUserCommandInput = {
      AccessToken: accessToken,
    };

    const response = await identityProvider.getUser(params);

    const userData = response.UserAttributes;

    console.log(userData);
}

and this is what you might have inside the response object:

{
  '$metadata': {
    httpStatusCode: 200,
    requestId: '0f366483-f5e9-4ca0-8b1d-786274970a89',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  UserAttributes: [
    { Name: 'sub', Value: 'f94c5266-695f-4098-9956-51cde85d5b86' },
    { Name: 'email_verified', Value: 'true' },
    { Name: 'email', Value: 'ceyicef794@dixiser.com' }
  ],
  Username: 'f94c5266-695f-4098-9956-51cde85d5b86'
}

Since I configured this user pool to only ask for the email of the user as a required property, I don't have a lot of UserAttributes, but if I had, it would be in the UserAttributes array, which I could've mapped it out and add to the request.

Conclusion

Having an Authentication Guard helps us not repeat ourselves and keep this logic in just one place and reuse it within our routes, thus creating Protected Routes, something very common in apps nowadays.

I'll be sharing more information about Cognito, feel free to follow my blog! I am happy to share this knowledge with you.