Introduction:
In a microservices architecture, securing your services and routes is of paramount importance. One way to achieve this is by implementing authentication. This blog post will guide you through the process of adding JWT-based authentication to your routes in a NestJS-based microservices setup. We'll create a common JWT authentication guard that can be reused across various microservices to protect routes, ensuring that only authenticated users can access them.
Prerequisites:
Before getting started, make sure you have the following prerequisites in place:
- A basic understanding of NestJS and microservices.
- NestJS installed.
- Docker and Docker Compose for containerization.
- Postman (or any other API testing tool) for testing your routes.
Step 1: Setting up Microservices with NestJS
To enable communication between microservices, you'll need to set up a transport layer. We'll use a TCP-based transport layer for this tutorial. Start by installing the necessary NestJS microservices package:
npm install @nestjs/microservices
Once installed, make sure to update the content of your apps/auth/src/main.ts file in your authentication service with the following content:
import { NestFactory } from '@nestjs/core';
import { AuthModule } from './auth.module';
import { ValidationPipe } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { ConfigService } from '@nestjs/config';
import * as cookieParser from 'cookie-parser';
import { Transport } from '@nestjs/microservices';
async function bootstrap() {
const app = await NestFactory.create(AuthModule);
const configService = app.get(ConfigService);
app.connectMicroservice({
transport: Transport.TCP,
options: {
host: '0.0.0.0',
port: configService.get('TCP_PORT'),
},
});
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
}),
);
app.useLogger(app.get(Logger));
await app.startAllMicroservices();
await app.listen(configService.get('HTTP_PORT'));
}
bootstrap();
Our host property 0.0.0.0, which tells the microservice to bind to all interfaces on the host, and then for the port, I extract this from the environment.
we call this environment variable TCP_PORT.
I also changed the PORT variable for our HTTP server and called this HTTP_PORT
This configuration allows your authentication service to communicate with other services via TCP.
since we are using a new environment variable and renaming the old variable PORT
to HTTP_PORT
which is not yet declared in our auth .env
file let's update our .env file and add the HTTP_PORT and TCP_PORT
JWT_SECRET=your_secret_key
JWT_EXPIRATION=3600
HTTP_PORT=3001
TCP_PORT=3002
Remember that we validated all our environment variables in our auth module so we need also to update our auth.module.ts with the following content:
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UsersModule } from './users/users.module';
import { LoggerModule } from '@app/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as Joi from 'joi';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
UsersModule,
LoggerModule,
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
JWT_SECRET: Joi.string().required(),
JWT_EXPIRATION: Joi.string().required(),
HTTP_PORT: Joi.number().required(),
TCP_PORT: Joi.number().required(),
}),
}),
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: `${configService.get<string>('JWT_EXPIRATION')}s`,
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}
Step 2: Creating a Common JWT Auth Guard
The next step is to create a common JWT authentication guard that can be used across different services. This guard will validate JWT tokens passed in requests. To do this, create a new folder in your common directory for auth-related components:
mkdir libs/common/src/auth
touch libs/common/src/auth/jwt-auth.guard.ts
touch libs/common/src/auth/index.ts
it will create an auth
directory in your common library then inside that directory it will create two files the index.ts
and the jwt-auth.guard.ts
let's also create a constants folder inside our common library in which all our services stored in constant variable will live:
mkdir libs/common/src/constants
touch libs/common/src/constants/service.ts
touch libs/common/src/constants/index.ts
populate your constants/service.ts with the following content:
export const AUTH_SERVICE = 'auth';
Also populate your constants/index.ts with the following content:
export * from './service';
since we created the necessary files and folders we need let's now populate our auth/jwt-auth.guard.ts with the following content:
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { ClientProxy } from '@nestjs/microservices';
import { Request } from 'express';
import { catchError, map, tap } from 'rxjs/operators';
import { AUTH_SERVICE } from '../constants/service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(@Inject(AUTH_SERVICE) private readonly authClient: ClientProxy) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const jwt =
context.switchToHttp().getRequest().cookies?.Authentication ||
context.switchToHttp().getRequest()?.Authentication ||
context.switchToHttp().getRequest().headers?.Authentication;
console.log('jwt', jwt);
if (!jwt) {
return false;
}
return this.authClient
.send('authenticate', {
Authentication: jwt,
})
.pipe(
tap((res) => {
context.switchToHttp().getRequest<Request>().user = res;
}),
map(() => true),
catchError(() => of(false)),
);
}
}
This JwtAuthGuard will check for the JWT token in the request's cookies. If a valid token is found, it communicates with the authentication service using the defined pattern and checks if the JWT is valid. If it's valid, it sets the user on the request object and allows the request to proceed.
In the libs/common/src/auth/index.ts file export your jwt-auth.guard.ts by populating it with this content:
//auth/index.ts
export * from './jwt-auth.guard';
also in the libs/common/src/index.ts file add export all from the auth folder and from constants folder by updating it with this content:
export * from './database';
export * from './logger';
export * from './auth';
export * from './constants';
Step 3: Adding a Message Pattern for Authentication
In your authentication service, set up a message pattern that corresponds to the authentication flow. In the apps/auth/src/auth.controller.ts of your auth service, update your content with the following code:
import { Controller, Post, Res, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './guards/local.auth-guard';
import { CurrentUser } from './current-user.decorator';
import { UserDocument } from './users/models/user.schema';
import { Response } from 'express';
import { MessagePattern } from '@nestjs/microservices';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('login')
async login(
@CurrentUser() user: UserDocument,
@Res({ passthrough: true }) response: Response,
) {
await this.authService.login(user, response);
response.send(user);
}
@UseGuards(JwtAuthGuard)
@MessagePattern('authenticate')
async authenticate(@Payload() data: any) {
return data.user;
}
}
This code sets up a message pattern named authenticate
that can be used to authenticate JWT tokens we are using are existing JWTAUthGuard to do that.
We also need to update our apps/auth/src/strategies/jwt.strategy.ts because right now we are pulling the JWT off of the request header in the cookies object. However, when the JWT is coming in from our RPC call our JwtAuthGuard, it's not going to be inside of a cookies object.
It's just going to be under the straight request object.
So we can put in a bit of logic here to say if there is no request cookie, we can then check to see if the authentication key actually exists on the request object itself:
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { UsersService } from '../users/users.service';
import { ConfigService } from '@nestjs/config';
import { Tokenpayload } from '../interface/token-payload.interface';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private readonly userService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: any) =>
request?.cookies?.Authentication ||
request?.Authentication ||
request?.headers.Authentication,
]),
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate({ userId }: Tokenpayload) {
return this.userService.getUser({ _id: userId });
}
}
Step 4: Registering the Authentication Service as a Client
In the reservations service (or any other service that needs authentication), you need to register the authentication service as a client. Modify the reservations.module.ts file as follows:
import { Module } from '@nestjs/common';
import { ReservationsService } from './reservations.service';
import { ReservationsController } from './reservations.controller';
import { AUTH_SERVICE, DatabaseModule, LoggerModule } from '@app/common';
import { ReservationsRepository } from './reservations.repository';
import {
ReservationDocument,
ReservationSchema,
} from './entities/reservation.entity';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as Joi from 'joi';
import { ClientsModule, Transport } from '@nestjs/microservices';
@Module({
imports: [
DatabaseModule,
DatabaseModule.forFeature([
{ name: ReservationDocument.name, schema: ReservationSchema },
]),
LoggerModule,
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
MONGODB_URI: Joi.string().required(),
PORT: Joi.number().required(),
}),
}),
ClientsModule.registerAsync([
{
name: AUTH_SERVICE,
useFactory: (configService: ConfigService) => ({
transport: Transport.TCP,
options: {
host: configService.get('AUTH_HOST'),
port: configService.get('AUTH_PORT'),
},
}),
inject: [ConfigService],
},
]),
],
controllers: [ReservationsController],
providers: [ReservationsService, ReservationsRepository],
})
export class ReservationsModule {}
This code registers the authentication service as a client using the TCP transport layer.
since we are using two new environment variable which is not yet declared in our reservations .env
file let's update our .env file and add the AUTH_HOST and AUTH_PORT
MONGODB_URI=mongodb://mongo:27017/reservation
PORT=3000
AUTH_HOST=auth
AUTH_PORT=3002
So the AUTH_HOST
is actually defined in the docker-compose.yaml
services by the actual names of the services we defined.
So the hostname for auth will be auth and the hostname for reservations is reservations running in Docker.
So we set the auth host here to just be auth.
And the AUTH_PORT we set to 3002, which corresponds to the TCP_PORT we define in the auth service.
Step 5: User Extraction and DTO:
user.dto.ts
We want to make the user object accessible across our application. For this, we'll define a Data Transfer Object (DTO) for the user. This will ensure that the user data is consistent when sent over the network. Here's how you can create the user DTO:
mkdir libs/common/src/dto
touch libs/common/src/dto/user.dto.ts
touch libs/common/src/dto/index.ts
it will create a dto
directory in your common library and then inside that directory, it will create two files the index.ts
and the user.dto.ts
populate your user.dto.ts
with the following content:
export interface UserDTO {
_id: string;
email: string;
password: string;
}
export your newly created UserDTO in your index.ts
by populating it with the following code:
export * from './user.dto';
current-user.decorator.ts
To access the current user in your routes, you can create a decorators folder. Create a current-user.decorator.ts
file in the libs/common/src/decorators directory:
mkdir libs/common/src/decorators
touch libs/common/src/decorators/current-user.decorator.ts
touch libs/common/src/decorators/index.ts
it will create a decorators
directory in your common library and then inside that directory, it will create two files the index.ts
and the current-user.decorator.ts
populate your current-user.decorator.ts with this content:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { UserDocument } from 'apps/auth/src/users/models/user.schema';
const getCurrentUserByContex = (ctx: ExecutionContext): UserDocument => {
return ctx.switchToHttp().getRequest().user;
};
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => getCurrentUserByContex(ctx),
);
export your newly created CurrentUser decorator in your index.ts
by populating it with the following code:
export * from './current-user.decorator';
to use our user.dto.ts and current-user.decorator of all our services we need to make sure that we export it in our libs/common/src/index.ts
file: update your index.ts file with this content:
export * from './database';
export * from './logger';
export * from './auth';
export * from './constants';
export * from './dto';
export * from './decorators';
We're ready to integrate our newly created common JwtAuthGuard into our reservations routes to lock them down to only authenticated users.
Open your reservationmain.ts
file and update with he following content to use cookie parser middleware:
import { NestFactory } from '@nestjs/core';
import { ReservationsModule } from './reservations/reservations.module';
import { ValidationPipe } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { ConfigService } from '@nestjs/config';
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(ReservationsModule);
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
}),
);
app.useLogger(app.get(Logger));
const configService = app.get(ConfigService);
await app.listen(configService.get('PORT'));
}
bootstrap();
Open your reservations service (reservations.service.ts) and replace the existing code with the following to dynamically fetch the user ID of the currently authenticated user, instead of hardcoding it.
import { Injectable } from '@nestjs/common';
import { ReservationsRepository } from './reservations.repository';
import { CreateReservationDto } from './dto/create-reservation.dto';
import { UpdateReservationDto } from './dto/update-reservation.dto';
@Injectable()
export class ReservationsService {
constructor(
private readonly reservationsRepository: ReservationsRepository,
) {}
async create(createReservationDto: CreateReservationDto, userId: string) {
return this.reservationsRepository.create({
...createReservationDto,
timestamp: new Date(),
userId,
});
}
async findAll() {
return this.reservationsRepository.find({});
}
async findOne(_id: string) {
return this.reservationsRepository.findOne({ _id });
}
async update(_id: string, updateReservationDto: UpdateReservationDto) {
return this.reservationsRepository.findOneAndUpdate(
{ _id },
{ $set: updateReservationDto },
);
}
async remove(_id: string) {
return this.reservationsRepository.findOneAndDelete({ _id });
}
}
Open your reservations controller (reservations.controller.ts) and update it with the following content to utilize our common JwtAuthGuard and retrieve the currently authenticated user data. We'll use the newly created CurrentUser decorator with the UserDto type, which is imported from our @app/common
.
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
} from '@nestjs/common';
import { ReservationsService } from './reservations.service';
import { CreateReservationDto } from './dto/create-reservation.dto';
import { UpdateReservationDto } from './dto/update-reservation.dto';
import { CurrentUser, JwtAuthGuard, UserDTO } from '@app/common';
@Controller('reservations')
export class ReservationsController {
constructor(private readonly reservationsService: ReservationsService) {}
@UseGuards(JwtAuthGuard)
@Post()
async create(
@Body() createReservationDto: CreateReservationDto,
@CurrentUser() user: UserDTO,
) {
return await this.reservationsService.create(
createReservationDto,
user._id,
);
}
@Get()
@UseGuards(JwtAuthGuard)
async findAll() {
return this.reservationsService.findAll();
}
@Get(':id')
@UseGuards(JwtAuthGuard)
async findOne(@Param('id') id: string) {
return this.reservationsService.findOne(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard)
async update(
@Param('id') id: string,
@Body() updateReservationDto: UpdateReservationDto,
) {
return this.reservationsService.update(id, updateReservationDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async remove(@Param('id') id: string) {
return this.reservationsService.remove(id);
}
}
By adding the @UseGuards(JwtAuthGuard) decorator to your route handlers, you ensure that only authenticated users can access these routes.
Conclusion:
In this tutorial, you learned how to add JWT-based authentication to your microservices architecture with NestJS. By creating a common JWT authentication guard, you can easily secure your routes across different services. This approach provides flexibility and reusability, allowing you to enforce authentication in a microservices environment efficiently.
Top comments (0)