How to Implement Role-Based Access Control in NestJS with MongoDB - Part 4

How to Implement Role-Based Access Control in NestJS with MongoDB - Part 4

Learn how to add public routes and create a custom Active User Decorator in NestJS using MongoDB

Introduction

This post is the fourth in a series titled "Implement Role-Based Access Control in NestJS using MongoDB." In this series, we'll create an RBAC (Role-Based Access Control) app from scratch using NestJS and MongoDB. Whether you're a beginner or looking to implement role-based access control in your app, follow along and build the app with me!

Previous Post

During the last post, we worked on the following things.

  • Implementing JSON Web Tokens (JWTs)

  • Creating a custom Active User Decorator

Check out the blog post here

What's Next?

In this post, we will dive into authentication and cover the following:

  • Adding Public Routes

  • Creating a custom Active User Decorator

Adding Public Routes

There are always certain end-points that always need to be public e.g. in our case the sign-in and sign-up routes. If we do not make them public no one will be able to sign in and sign-up for the application.

Right now in our application, every route is protected using AccessTokenGuard, since we have registered it globally via APP_GUARD.

To make some known routes public, let's create a new decorator that lets us flag specific endpoints as "unprotected".

In our iam folder create a new directory enums and within it create a new file auth-type.enum.ts and add the below code.

// auth-type.enum.ts
export enum AuthType {
  Bearer,
  None,
}

Next step we will create a new decorator @Auth.

Within our iam folder create a new directory decorators and within it create a new file auth.decorator.ts by using the below command.

nest g d iam/decorators/auth --flat

In the above file created, let's first declare the metadata key.

export const AUTH_KEY_TYPE = 'authType';

Next, we adjust our code for the custom @Auth() decorator as follows:

import { SetMetadata } from '@nestjs/common';
import { AuthType } from '../enums/auth-type.enum';

export const AUTH_KEY_TYPE = 'authType';

export const Auth = (...args: AuthType[]) => SetMetadata(AUTH_KEY_TYPE, args);

Next, we create a new Auth guard by executing the following command in our terminal using NestJs CLI

nest g guard iam/guards/auth

In this guard, we need to inject two dependencies into our constructor(), first the Reflector provider which gives us access to the underlying metadata and the second is our AccessTokenGuard.

// auth.guard.ts
  constructor(
    private readonly reflector: Reflector,
    private readonly accessTokenGuard: AccessTokenGuard,
  ) {}

As long as we register our guards as Providers in our modules provider array, injecting guards within guards is feasible.

Next, we declare two properties in our class, one being the static default AuthType property, indicating which strategy is the default one. In our case it is Bearer.

// auth.guard.ts  
private static readonly defaultAuthType = AuthType.Bearer;

The other one is non-static which represents the mapping between auth types and their actual corresponding guards.

// auth.guard.ts  
private readonly authTypeGuardMap: Record<
    AuthType,
    CanActivate | CanActivate[]
  > = {
    [AuthType.Bearer]: this.accessTokenGuard,
    [AuthType.None]: { canActivate: () => true },
  };

The authTypeGuardMap object is being used to map each AuthType to a corresponding CanActivate guard or array of guards.

The key [AuthType.Bearer] is mapping the Bearer authentication type to the accessTokenGuard guard, which is a guard that checks whether the user has a valid access token.

The key [AuthType.None] is mapping the None authentication type to a guard that always returns true, allowing the route to be activated without any authentication.

Now, let's move on to the method CanActivate() in our guard and add getAllOverride() method on the reflector object.

//auth.guard.ts
const authTypes = this.reflector.getAllAndOverride<AuthType[]>(
      AUTH_KEY_TYPE,
      [context.getHandler(), context.getClass()],
    ) ?? [AuthGuard.defaultAuthType];

The above block of code is retrieving the AuthType metadata associated with the current route handler and class, or falling back to a default value if no metadata is found. By using the reflector object, the code can inspect metadata associated with the current route and use it to determine the appropriate authentication type.

Next, we add the guards constant where the CanActivate guards or arrays of guards are associated with the current route's AuthType values and flatten them into a single array of guards.

// auth.guard.ts
const guards = authTypes.map((type) => this.authTypeGuardMap[type]).flat();

By using the authTypeGuardMap object, the code can retrieve the appropriate guards for the current route's authentication type.

and lastly, we iterate through our guard's array and call their respective canActivate() methods.

    for (const instance of guards) {
      const canActivate = await Promise.resolve(
        instance.canActivate(context),
      ).catch((err) => {
        error = err;
      });

      if (canActivate) {
        return true;
      }
    }
    throw error;

If any allowed authentication types pass we return true else error is returned.

Next, we replace our AccessTokenGuard with our newly created AuthGuard in the iam.module.ts and make sure to register the AccessTokenGuard as provider now so it becomes injectable for the AuthGuard.

If you test your sign-in API endpoint you will still see the 401 error status code which shows our API endpoints are protected by authentication type as Bearer

Now to make sure our sign-in and sign-up routes are authenticated as None let's apply the @Auth() decorator to the entire AuthController class passing as AuthType.None as a parameter.

@Auth(AuthType.None)
@Controller('auth')
export class AuthController {
...
}

Now if you re-test your sign-in or sign-up routes you will not get a 401 error response code if the correct request is made.

Our sign-in and sign-up routes are publically available. With this custom @Auth() decorator, we can make any endpoints protected or unprotected based on app requirements.

Creating a custom Active User Decorator

In the AccessTokenGuard guard we validated the incoming access token, auto-assign the decoded payload, and put it in the requests "user" property.

// access-token.guard.ts

const payload = await this.jwtService.verifyAsync(
        token,
        this.jwtConfiguration,
      );
request[REQUEST_USER_KEY] = payload;

Now to access the "user" property in our protected routes. One way to retrieve the active user in our route is to use the Req() decorator as a parameter in our controller function like below.

  findAll(@Req() request: any) {
    console.log(request.user);
    return this.usersService.findAll();
  }

When we check the GET users endpoint, the request.user i.e. the decoded payload gets logged in the terminal.

{
  sub: '65293e9d0f28fbab5fb4ca59',
  email: 'creator@example.com',
  iat: 1697484460,
  exp: 1697488060,
  aud: 'localhost:3000',
  iss: 'localhost:3000'
}

Though this approach works well it is not an ideal approach as we have to add a @Req() decorator in all the API endpoints wherever we require request.user. Let's fix this by creating a new param decorator.

Implement the following code at your terminal

nest g d iam/decorators/active-user --flat

Inside the file, let's declare a new active user decorator with the help of createParamDecorator(), replace the contents of the file with the following code

import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import { REQUEST_USER_KEY } from '../constants/iam.constants';

export const ActiveUser = createParamDecorator(
  (field: string | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request[REQUEST_USER_KEY];
    return field ? user?.[field] : user;
  },
);

We are including the request object from the execution context using switchToHttp() and calling the getRequest() method. Next from that request, we select the decoded user payload and assign it to the user variable. Lastly, if a field parameter was passed into the decorator we access it using the field value of the user object otherwise we pass the full user object.

Now if we want to use this custom decorator to get the request.user value we can simply do this by calling as below.

@Get()
findAll(@ActiveUser() user) {
    console.log(user)
    return this.usersService.findAll();
}

If you retest your GET users endpoint it will log the decoded payload of the request.user object on the terminal.

By using this custom param @ActiveUser() decorator, we do not need to inject the request object every time.

Now let's improve our code by defining the shape of our JWT payload. let's create a new interface file active-user.data.ts in a new interfaces folder within the iam folder.

nest g itf iam/interfaces/active-user.data --flat

Inside this file, let's declare two properties sub and email in the ActiveUserData interface.

export interface ActiveUserData {
  sub: number;
  email: string;
}

The sub property is the user ID that granted this token. JWT payload contains a few other properties too as we have seen when we logged the user object from the request object. The newly defined interface is then added to our custom param decorator i.e. ActiveUser

// active-user.decorator.ts

export const ActiveUser = createParamDecorator(
  (field: keyof ActiveUserData | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user: ActiveUserData = request[REQUEST_USER_KEY];
    return field ? user?.[field] : user;
  },
);

Next, we update the type of ActiveUser() user parameter to ActiveUserData as shown below.

// users.controller.ts

@Get()
  findAll(@ActiveUser() user: ActiveUserData) {
    console.log(user);
    return this.usersService.findAll();
  }

Lastly, we also update the type of payload of the JWT token when signing the token.

// auth.service.ts

const accessToken = await this.jwtService.signAsync(
        {
          sub: user.id,
          email: user.email,
        } as ActiveUserData,
        {
          audience: this.jwtConfiguration.audience,
          issuer: this.jwtConfiguration.issuer,
          secret: this.jwtConfiguration.secret,
          expiresIn: this.jwtConfiguration.accessTokenTtl,
        },
      );

By making these changes to our code, we have achieved full type safety and simplified our code as well. Our code is now more robust and modular.

Conclusion

That's it for today. We covered a lot, so let's summarize:

  • Adding Public Routes

  • Creating a custom Active User Decorator

Next, we will work on the implementation of Refresh Tokens, and Invalidating Tokens.

Next Post

Stay tuned for the next post, where we will deep dive more into the Implementation of Refresh Tokens, and Invalidating Tokens. The new blog post will be published by 13 July 2024.

References

  1. https://blog.amanpreet.dev/how-to-implement-role-based-access-control-in-nestjs-with-mongodb-part-1

  2. https://blog.amanpreet.dev/how-to-implement-role-based-access-control-in-nestjs-with-mongodb-part-2

  3. https://blog.amanpreet.dev/how-to-implement-role-based-access-control-in-nestjs-with-mongodb-part-3