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

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

Learn to implement JWT authentication and protect routes with guards in a NestJS application using MongoDB

Introduction

This post is the third 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.

  • Added hashing to our passwords using Bcrypt library

  • Implemented Sign-in and Sign-up routes

  • Added a global validation for incoming requests using ValidationPipe.

Check out the blog post here

What's Next?

In this post, we will cover the following:

  • Implementing JSON Web Token or JWTs

  • Protecting routes with Guards

Implementing JSON Web Token or JWT

JSON Web Tokens or JWTs are an open standard used to share security information between two parties - a client and a server. Each JWT contains encoded JSON objects, including a set of claims. JWTs are assigned using a cryptographic algorithm to ensure that claims cannot be altered after the token is been issued.

JWTs can be signed using a secret with an HMAC algorithm or a public-private key pair using RSA or ECDSA.

JSON Web Tokens consist of three parts separated by dots, which are the header, payload, and signature. A typical JWT looks like this

xxxxx.yyyyy.zzzzz

To get more details on JWT please check the website jwt.io

In Authentication, when the user successfully logs in using their credentials, a JSON web token will be returned. Currently, in our application, it is returning a boolean. Whenever a user wants to access a protected route or a resource, the user agent should send their JWT typically in the authorization header using the bearer schema.

Authorization: Bearer <token>

Let's implement JWT authentication in our application and for that, we need to install the additional dependency which can be done as follows.

npm i @nestjs/jwt

@nestjs/jwt is a wrapper around the JSON web token package that exposes methods for encoding and decoding JWTs. Let's import the ConfigModule in our app module and configure it accordingly.

// app.module.ts

@Module({
  imports: [
    ConfigModule.forRoot(),
    UsersModule,
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        uri: configService.get<string>('MONGODB_URI'),
      }),
      inject: [ConfigService],
    }),
    IamModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

We also need to update our .env file in the root directory that will store our "secret" that will be used for encoding and decoding our JWT tokens.

Never push your .env file to your repository and make sure it is included in your .gitignore file.

Let's add the following variables to our .env file.

# JWT
JWT_SECRET= YOUR_SECRET_KEY_HERE
JWT_TOKEN_AUDIENCE= localhost:3000
JWT_TOKEN_ISSUER= localhost:3000
JWT_ACCESS_TOKEN_TTL= 3600
  • JWT_SECRET should be a long hard-to-guess string.

  • JWT_TOKEN_AUDIENCE identifies the recipients for whom the JWT is intended to be used

  • Whereas JWT_TOKEN_ISSUER identifies the principle that issues the JWT and

  • Lastly JWT_ACCESS_TOKEN_TTL is the access token "Time to Live" i.e. TTL.

Since all the above four environment variables are necessary to run our application, it is the best practice to throw an exception during application startup if any required environment variables are not provided or validated as per the needs.

Next, we will be namespacing all of these configurations under the "jwt" namespace. For that, we need to create a new config file in our iam folder.

Let's create a new folder config in our iam folder and within it create a new config file named as jwt.config.ts

# Within `iam` folder
mkdir config && cd config

# Within `config` folder
touch jwt.config.ts

Now let's configure the "jwt" namespace in the jwt.config.ts file.

// jwt.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('jwt', () => {
  return {
    secret: process.env.JWT_SECRET,
    audience: process.env.JWT_TOKEN_AUDIENCE,
    issuer: process.env.JWT_TOKEN_ISSUER,
    accessTokenTtl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600', 10),
  };
});

Next up we need to register our JwtModule in our IamModule

// iam.module.ts

  imports: [
    MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
    JwtModule.registerAsync(jwtConfig.asProvider()),
  ],

The method .asProvider() converts a factory to match the expected async modules configuration object with imports, use factory, etc.

Let's not forget to also import ConfigModule and register the JWT config factory so it can be injected by the authentication service.

// iam.module.ts
imports: [
    MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
    JwtModule.registerAsync(jwtConfig.asProvider()),
    ConfigModule.forFeature(jwtConfig),
  ],

Since our JWT configuration is completed, let's navigate to our authentication service to complete the functionality for our signIn method.

Let's start by injecting the JWT service. We also need to inject the JWT configuration which was included in the iam.module.ts module, using the inject decorator, giving us the ability to grab those JWT configurations.

//auth.service.ts
.
.
import { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    @InjectModel(User.name) private readonly userModel: Model<User>,
    private readonly hashingService: HashingService,
    private readonly jwtService: JwtService,
    @Inject(jwtConfig.KEY) 
    private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
  ) {}

.
.
.
}

Let's update our SignIn functionality with the following code

// auth.service.ts

async signIn(signInDto: SignInDto) {
    try {
      .
      .
      .     
      // Access Token 
      const accessToken = await this.jwtService.signAsync(
        {
          sub: user.id,
          email: user.email,
        },
        {
          audience: this.jwtConfiguration.audience,
          issuer: this.jwtConfiguration.issuer,
          secret: this.jwtConfiguration.secret,
          expiresIn: this.jwtConfiguration.accessTokenTtl,
        },
      );
      return { accessToken };
    } catch (error) {
      return error;
    }
  }

We are calling the SignAsync method of the JWT service to generate a new JWT access token. We're also passing the payload object as the first argument and the JWT configuration as the second parameter.

The payload object should be very small and lightweight and include only those properties that are essential.

In response we are getting the access token, do not forget to check your sign-in route. With the correct credentials, it will give you an output of something like this.

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2NTI5M2U5ZDBmMjhmYmFiNWZiNGNhNTkiLCJlbWFpbCI6ImNyZWF0b3JAZXhhbXBsZS5jb20iLCJpYXQiOjE2OTc0NTEzMTIsImV4cCI6MTY5NzQ1NDkxMiwiYXVkIjoibG9jYWxob3N0OjMwMDAiLCJpc3MiOiJsb2NhbGhvc3Q6MzAwMCJ9.yIV4DltY9qZLxOAoSwMGjgvtDZeBzfr5RPVomPJPY"
}

Protecting our Routes with a Guard

Now we have our sign-in and sign-out points implemented, let's work on protecting other existing CRUD endpoints. To achieve this, we will create a guard that will check for the presence of a valid JWT as a bearer token. Any protected route handler with this guard will only be invoked if the user has been successfully validated.

Let's create our new guard named access-token by implementing the following command:

nest g guard iam/guards/access-token

Once the file is created let's update the required dependencies. First, inject the JWT service into our constructor which will be used to decode incoming JWT tokens. Together with this we also need the JWT configuration so that we pass the configurations to the decode method.

// access-token.guard.ts

constructor(
    private readonly jwtService: JwtService,
    @Inject(jwtConfig.KEY)
    private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
  ) {}

Before we can validate an incoming JWT token we have to first extract it from the request object assuming we are including the token in the authorization header.

Let's create this method. This method will accept one parameter, which is the platform-specific request object.

  private extractTokenFromHeader(request: Request): string | undefined {
    const [_, token] = request.headers.authorization?.split(' ') ?? [];
    return token;
  }
// Since the token have the bearer prefix thus using `split`

In the above code snippet, we are using a Request parameter that is to be imported from the express package. Let's grab the reference of this method and use it in our canActivate() method using execution context.

 async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    try {
      const payload = await this.jwtService.verifyAsync(
        token,
        this.jwtConfiguration,
      );
      request[REQUEST_USER_KEY] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

In the above code snippet we are initially extracting the token from the header using extractTokenFromHeader() method and checking whether the token is present or not.

In case token is not preset we throw an error of UnauthorizedException() otherwise, we verify the token using the JWT service verifyAsync() method to verify and decode it.

Once the payload is decoded we assign it to the request user property. With this approach, we can access the user object later on in our endpoints.

We can do this by creating a constant to avoid any unexpected behaviour. Let's create a constants folder within the iam folder and create a new iam.constants.ts file

// iam.constants.ts

export const REQUEST_USER_KEY = 'user';

Lastly, let's register our access-token guard globally using a dedicated APP_GUARD token.

Please note we can also use a @UseGuards() decorator and bind the access-token guard to individual routes. This can be a tedious process and not a full proof in case there are newly added endpoints.

// iam.module.ts

providers: [
    {
      provide: HashingService,
      useClass: BcryptService,
    },
    { provide: APP_GUARD, useClass: AccessTokenGuard },
    AuthService,
  ],

Now before saving all the above code, please make sure to grab the access-token by using the correct credentials for the sign-in route as it will be needed to verify whatever we have done till now.

Let's try to hit our GET users endpoint and see what response you get. You will be getting a 401 status code something like below. This is because all our routes are guarded by access-token guards.

{
  "message": "Unauthorized",
  "statusCode": 401
}

You will also notice that sign-in and sign-up routes are also responding with the same response as above.

To validate your AcccessTokenGuard guard functionality, use the access-token generated from the sign-in route and add the Authorization bearer header in your API endpoint for users route.

Response of GET  API endpoint.

You will notice that this time you will get the 200 OK response instead of 401 an unauthenticated error as seen in the above image.

Conclusion

We covered a lot, so let's summarize:

  • Implementing JSON Web Token or JWTs

  • Protecting routes with Guards

That's it for today. Next, we will work on the implementation of JWT, Protecting our routes with a guard and adding public routes.

Next Post

Stay tuned for the next post, where we will deep dive more into Adding Public Routes and Creating a custom Active User Decorator. The new blog post will be published by 12 July 2024.

References

  1. GitHub Repo

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

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