Introduction
In this blog post, we will create a reusable @Auth() decorator and guard in NestJS for streamlined role-based authorization. This will allow us to easily manage user roles and permissions in our application in a type-safe and maintainable way.
Authentication and authorization are crucial aspects of any web application. While NestJS provides built-in authentication mechanisms, implementing custom guards gives us more flexibility and control over how we handle user permissions. By the end of this tutorial, you'll have a robust authorization system that can be easily integrated into any NestJS application.
Prerequisites
- Node.js 16.x or higher
- NestJS 8.x or higher
- Basic understanding of TypeScript and decorators
- Familiarity with NestJS fundamentals
Understanding the Architecture
Before we dive into the implementation, let's understand the key components we'll be building:
@Auth()decorator - A custom decorator that combines role checking and session validation- Guards - Implementation of the authorization logic
- Strategies - Core business logic for different types of authentication
- Supporting interfaces and constants
Setup
First, let's create out Authentication decorator. We'll use the applyDecorators function to apply the @UseGuards decorator to the class. This will allow us to use the SessionGuard and RolesStrategy guards in our controllers.
then, let's create a auth/decorators/auth.decorator.ts file:
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { AuthDecoratorOptions } from '../auth.interface';
import { ROLES_KEY } from '../auth.constant';
import { SessionGuard } from '../guards/user-session.guard';
import { RolesGuard } from '../guards/roles.guard';
export function Auth(options?: AuthDecoratorOptions) {
const { roles } = options;
return applyDecorators(
SetMetadata(ROLES_KEY, roles),
UseGuards(SessionGuard, RolesGuard),
);
}This decorator combines multiple guards and metadata, making it easy to protect routes with a single annotation.
Then, let's create a auth/auth.constant.ts file to store our role constants:
export const ROLES_KEY = 'roles';Now, let's create a auth/auth.interface.ts file to define our options:
export type Roles = Array<string>;
export interface AuthDecoratorOptions {
roles?: Roles;
}Let's create the strategies in auth/strategies. These will contain the core logic for our authorization system.
First create a auth.strategy.ts file:
import { CanActivate, ExecutionContext } from '@nestjs/common';
export abstract class AuthStrategy implements CanActivate {
public abstract canActivate(context: ExecutionContext): Promise<boolean>;
protected req: Request | null = null;
protected getRequest(context: ExecutionContext): Request {
if (context.getType() === 'http') {
this.req = context.switchToHttp().getRequest();
}
if (!this.req) {
throw new Error('Request is not available');
}
return this.req;
}
}if you have graphql APIs, you can use the following code:
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
export abstract class AuthStrategy implements CanActivate {
public abstract canActivate(context: ExecutionContext): Promise<boolean>;
protected req: Request | null = null;
protected getRequest(context: ExecutionContext): Request {
if (context.getType() === 'http') {
this.req = context.switchToHttp().getRequest();
} else if (context.getType<GqlContextType>() === 'graphql') {
this.req = GqlExecutionContext.create(context).getContext().req;
}
// You can add other types here ex: websocket...
if (!this.req) {
throw new Error('Request is not available');
}
return this.req;
}
}The base strategy class provides common functionality for all our authentication strategies.
Next, create a roles.strategy.ts file for role-based authorization:
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from '../auth.interface';
import { ROLES_KEY } from '../auth.constant';
import { AuthStrategy } from './auth.strategy';
@Injectable()
export class RolesStrategy extends AuthStrategy {
constructor(private readonly reflector: Reflector) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = this.getRequest(context);
const user = request['user']; // get user from the request object or from a different source e.g from session `req.session.user`
const roles = this.reflector.get<Roles>(ROLES_KEY, context.getHandler());
if (!roles?.length) {
return true;
}
const userRoles = user?.roles;
if (!userRoles) {
// if user has no roles
throw new UnauthorizedException('User has no roles assigned');
}
for (const role of roles) {
// check if user has any of the roles
if (!userRoles.includes(role)) {
// if user doesn't have any of the roles
throw new UnauthorizedException(`User lacks required role: ${role}`);
}
}
return true;
}
}Create a session.strategy.ts file for session validation:
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthStrategy } from './auth.strategy';
@Injectable()
export class SessionStrategy extends AuthStrategy {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = this.getRequest(context);
const user = request['user'];
if (!user) {
throw new UnauthorizedException('No active session found');
}
return true;
}
}Now, we need to create guards for each of our strategies. Let's create a auth/guards/roles.guard.ts file:
import { Injectable } from '@nestjs/common';
import { RolesStrategy } from '../strategies/roles.strategy';
@Injectable()
export class RolesGuard extends RolesStrategy {}Create a auth/guards/session.guard.ts file:
import { Injectable } from '@nestjs/common';
import { SessionStrategy } from '../strategies/session.strategy';
@Injectable()
export class SessionGuard extends SessionStrategy {}Finally, let's create a auth/auth.module.ts file to configure our module:
import { Global, Module } from '@nestjs/common';
import { RolesStrategy } from './strategies/roles.strategy';
import { SessionStrategy } from './strategies/session.strategy';
const Strategies = [
RolesStrategy,
SessionStrategy,
// add your strategies here...
];
@Global()
@Module({
exports: [...Strategies],
providers: [...Strategies],
})
export class AuthModule {}Using the Auth Decorator
Before we can use our @Auth() decorator, we need to configure our authentication and authorization strategies. We can do this by adding the AuthModule to our application module:
@Module({
imports: [AuthModule],
})
export class AppModule {}Now that we have everything set up, we can use our @Auth() decorator in controllers like this:
@Controller('users')
export class UsersController {
@Get()
@Auth({ roles: ['ADMIN'] })
async getUsers() {
// Only accessible by admins
return this.usersService.findAll();
}
@Post()
@Auth({ roles: ['USER', 'ADMIN'] })
async createUser() {
// Accessible by users and admins
return this.usersService.create();
}
}Error Handling
Our implementation includes proper error handling with descriptive messages:
- Unauthorized access attempts throw
UnauthorizedException - Missing roles or invalid sessions are properly handled
- Custom error messages help identify the specific authorization failure
Conclusion
In this tutorial, we've created a comprehensive authentication and authorization system using NestJS guards and decorators. This implementation provides:
- Type-safe role-based authorization
- Session validation
- Reusable decorators for protecting routes
- Extensible architecture for adding new authorization strategies
- GraphQL support (commented code)
The solution is production-ready and can be extended to support additional authentication mechanisms as needed.