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.