NestJS Authentication with OAuth2.0: Fastify Local OAuth REST API
Afonso Barracha
Posted on January 27, 2023
Series Intro
This series will cover the full implementation of OAuth2.0 Authentication in NestJS for the following types of APIs:
- Express REST API;
- Fastify REST API;
- Apollo GraphQL API.
And it is divided in 5 parts:
- Configuration and operations;
- Express Local OAuth REST API;
- Fastify Local OAuth REST API;
- Apollo Local OAuth GraphQL API;
- Adding External OAuth Providers to our API;
Lets start the third part of this series.
Tutorial Intro
On this tutorial we will change the adapter from our previous REST API, from Express to Fastify.
TLDR: if you do not have 25 minutes to read the article, the code can be found on this repo
Set Up
Start by removing express and its dependencies:
$ yarn remove @types/express @types/express-serve-static-core @nestjs/platform-express cookie-parser helmet @types/cookie-parser
And install the fastify ones:
$ yarn add @nestjs/platform-fastify fastify @fastify/cookie @fastify/cors @fastify/csrf-protection @fastify/helmet
Auth Module
Guards
We need to remove express.d.ts
and add fastify.d.ts
:
import { FastifyRequest as Request } from 'fastify';
declare module 'fastify' {
interface FastifyRequest extends Request {
user?: number;
}
}
Auth Guard
Only one of the types change, from Request
to FastifyRequest
:
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { isJWT } from 'class-validator';
import { FastifyRequest } from 'fastify';
import { isNull, isUndefined } from '../../common/utils/validation.util';
import { TokenTypeEnum } from '../../jwt/enums/token-type.enum';
import { JwtService } from '../../jwt/jwt.service';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
const activate = await this.setHttpHeader(
context.switchToHttp().getRequest<FastifyRequest>(),
isPublic,
);
if (!activate) {
throw new UnauthorizedException();
}
return activate;
}
/**
* Sets HTTP Header
*
* Checks if the header has a valid Bearer token, validates it and sets the User ID as the user.
*/
private async setHttpHeader(
req: FastifyRequest,
isPublic: boolean,
): Promise<boolean> {
const auth = req.headers?.authorization;
if (isUndefined(auth) || isNull(auth) || auth.length === 0) {
return isPublic;
}
const authArr = auth.split(' ');
const bearer = authArr[0];
const token = authArr[1];
if (isUndefined(bearer) || isNull(bearer) || bearer !== 'Bearer') {
return isPublic;
}
if (isUndefined(token) || isNull(token) || !isJWT(token)) {
return isPublic;
}
try {
const { id } = await this.jwtService.verifyToken(
token,
TokenTypeEnum.ACCESS,
);
req.user = id;
return true;
} catch (_) {
return isPublic;
}
}
}
(Optional) Throttler Guard
We can no longer use the NestJS default ThrottlerGuard
as that one is implemented for express, so create a custom one:
// fastify-throttler.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class FastifyThrottlerGuard extends ThrottlerGuard {
public getRequestResponse(context: ExecutionContext) {
const http = context.switchToHttp();
return {
req: http.getRequest<FastifyRequest>(),
res: http.getResponse<FastifyReply>(),
};
}
}
And add it to the auth controller:
// ...
import { FastifyThrottlerGuard } from './guards/fastify-throttler.guard';
// ...
@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
// ...
}
Controller
Private Methods
There is a slight change on the refresTokenFromReq
method:
import {
// ...
Controller,
// ...
UseGuards,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
// ...
ApiTags,
// ...
} from '@nestjs/swagger';
import { FastifyReply, FastifyRequest } from 'fastify';
// ...
import { isNull, isUndefined } from '../common/utils/validation.util';
// ...
@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
// ...
private refreshTokenFromReq(req: FastifyRequest): string {
const token: string | undefined = req.cookies[this.cookieName];
if (isUndefined(token) || isNull(token)) {
throw new UnauthorizedException();
}
const { valid, value } = req.unsignCookie(token);
if (!valid) {
throw new UnauthorizedException();
}
return value;
}
// ...
}
And since fastify does not have the json
method we need to set the Content-Type
header to application/json
:
// ...
@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
// ...
private saveRefreshCookie(
res: FastifyReply,
refreshToken: string,
): FastifyReply {
return res
.cookie(this.cookieName, refreshToken, {
secure: !this.testing,
httpOnly: true,
signed: true,
path: this.cookiePath,
expires: new Date(Date.now() + this.refreshTime * 1000),
})
.header('Content-Type', 'application/json');
}
}
Enpoints
There aren't that many changes that we need to implement on our previous express API, we just need to change from:
-
Request
toFastifyRequest
on theReq
decorator; -
Response
toFastifyReply
on theRes
decorator; -
json
tosend
method.
// ...
@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
// ...
@Public()
@Post('/sign-up')
@ApiCreatedResponse({
type: MessageMapper,
description: 'The user has been created and is waiting confirmation',
})
@ApiConflictResponse({
description: 'Email already in use',
})
@ApiBadRequestResponse({
description: 'Something is invalid on the request body',
})
public async signUp(
@Origin() origin: string | undefined,
@Body() signUpDto: SignUpDto,
): Promise<IMessage> {
return await this.authService.signUp(signUpDto, origin);
}
@Public()
@Post('/sign-in')
@ApiOkResponse({
type: AuthResponseMapper,
description: 'Logs in the user and returns the access token',
})
@ApiBadRequestResponse({
description: 'Something is invalid on the request body',
})
@ApiUnauthorizedResponse({
description: 'Invalid credentials or User is not confirmed',
})
public async signIn(
@Res() res: FastifyReply,
@Origin() origin: string | undefined,
@Body() singInDto: SignInDto,
): Promise<void> {
const result = await this.authService.signIn(singInDto, origin);
this.saveRefreshCookie(res, result.refreshToken)
.status(HttpStatus.OK)
.send(AuthResponseMapper.map(result));
}
@Public()
@Post('/refresh-access')
@ApiOkResponse({
type: AuthResponseMapper,
description: 'Refreshes and returns the access token',
})
@ApiUnauthorizedResponse({
description: 'Invalid token',
})
@ApiBadRequestResponse({
description:
'Something is invalid on the request body, or Token is invalid or expired',
})
public async refreshAccess(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
): Promise<void> {
const token = this.refreshTokenFromReq(req);
const result = await this.authService.refreshTokenAccess(
token,
req.headers.origin,
);
this.saveRefreshCookie(res, result.refreshToken)
.status(HttpStatus.OK)
.send(AuthResponseMapper.map(result));
}
@Post('/logout')
@ApiOkResponse({
type: MessageMapper,
description: 'The user is logged out',
})
@ApiBadRequestResponse({
description: 'Something is invalid on the request body',
})
@ApiUnauthorizedResponse({
description: 'Invalid token',
})
public async logout(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
): Promise<void> {
const token = this.refreshTokenFromReq(req);
const message = await this.authService.logout(token);
res
.clearCookie(this.cookieName, { path: this.cookiePath })
.header('Content-Type', 'application/json')
.status(HttpStatus.OK)
.send(message);
}
@Public()
@Post('/confirm-email')
@ApiOkResponse({
type: AuthResponseMapper,
description: 'Confirms the user email and returns the access token',
})
@ApiUnauthorizedResponse({
description: 'Invalid token',
})
@ApiBadRequestResponse({
description:
'Something is invalid on the request body, or Token is invalid or expired',
})
public async confirmEmail(
@Origin() origin: string | undefined,
@Body() confirmEmailDto: ConfirmEmailDto,
@Res() res: FastifyReply,
): Promise<void> {
const result = await this.authService.confirmEmail(confirmEmailDto);
this.saveRefreshCookie(res, result.refreshToken)
.status(HttpStatus.OK)
.send(AuthResponseMapper.map(result));
}
@Public()
@Post('/forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
type: MessageMapper,
description:
'An email has been sent to the user with the reset password link',
})
public async forgotPassword(
@Origin() origin: string | undefined,
@Body() emailDto: EmailDto,
): Promise<IMessage> {
return this.authService.resetPasswordEmail(emailDto, origin);
}
@Public()
@Post('/reset-password')
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
type: MessageMapper,
description: 'The password has been reset',
})
@ApiBadRequestResponse({
description:
'Something is invalid on the request body, or Token is invalid or expired',
})
public async resetPassword(
@Body() resetPasswordDto: ResetPasswordDto,
): Promise<IMessage> {
return this.authService.resetPassword(resetPasswordDto);
}
@Patch('/update-password')
@ApiOkResponse({
type: AuthResponseMapper,
description: 'The password has been updated',
})
@ApiUnauthorizedResponse({
description: 'The user is not logged in.',
})
public async updatePassword(
@CurrentUser() userId: number,
@Origin() origin: string | undefined,
@Body() changePasswordDto: ChangePasswordDto,
@Res() res: FastifyReply,
): Promise<void> {
const result = await this.authService.updatePassword(
userId,
changePasswordDto,
origin,
);
this.saveRefreshCookie(res, result.refreshToken)
.status(HttpStatus.OK)
.send(AuthResponseMapper.map(result));
}
@Get('/me')
@ApiOkResponse({
type: AuthResponseUserMapper,
description: 'The user is found and returned.',
})
@ApiUnauthorizedResponse({
description: 'The user is not logged in.',
})
public async getMe(@CurrentUser() id: number): Promise<IAuthResponseUser> {
const user = await this.usersService.findOneById(id);
return AuthResponseUserMapper.map(user);
}
// ...
}
User Module
Controller
There is just one change on the return type of the Res
decorator (from Response
to FastifyReply
) on the delete endpoint:
// ...
import { FastifyReply } from 'fastify';
// ...
@ApiTags('Users')
@Controller('api/users')
export class UsersController {
// ...
@Delete()
@ApiNoContentResponse({
description: 'The user is deleted.',
})
@ApiBadRequestResponse({
description: 'Something is invalid on the request body, or wrong password.',
})
@ApiUnauthorizedResponse({
description: 'The user is not logged in.',
})
public async deleteUser(
@CurrentUser() id: number,
@Body() dto: PasswordDto,
@Res() res: FastifyReply,
): Promise<void> {
await this.usersService.delete(id, dto);
res
.clearCookie(this.cookieName, { path: this.cookiePath })
.status(HttpStatus.NO_CONTENT)
.send();
}
}
Main
Finally change the main
file to a NestFastifyApplication
, and register all the plugins we installed earlier:
import fastifyCookie from '@fastify/cookie';
import fastifyCors from '@fastify/cors';
import fastifyCsrfProtection from '@fastify/csrf-protection';
import fastifyHelmet from '@fastify/helmet';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
const configService = app.get(ConfigService);
app.register(fastifyCookie, {
secret: configService.get<string>('COOKIE_SECRET'),
});
app.register(fastifyHelmet);
app.register(fastifyCsrfProtection, { cookieOpts: { signed: true } });
app.register(fastifyCors, {
credentials: true,
origin: `https://${configService.get<string>('domain')}`,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
const swaggerConfig = new DocumentBuilder()
.setTitle('NestJS Authentication API')
.setDescription('An OAuth2.0 authentication API made with NestJS')
.setVersion('0.0.1')
.addBearerAuth()
.addTag('Authentication API')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
await app.listen(
configService.get<number>('port'),
configService.get<boolean>('testing') ? '127.0.0.1' : '0.0.0.0',
);
}
bootstrap();
Conclusion
I hope that I have shown just how easy it is to change an API from Express to Fastify with NestJS.
The github for this tutorial can be found here.
About the Author
Hey there! I am Afonso Barracha, your go-to econometrician who found his way into the world of back-end development with a soft spot for GraphQL. If you enjoyed reading this article, why not show some love by buying me a coffee?
Lately, I have been diving deep into more advanced subjects. As a result, I have switched from sharing my thoughts every week to posting once or twice a month. This way, I can make sure to bring you the highest quality content possible.
Do not miss out on any of my latest articles – follow me here on dev and LinkedIn to stay updated. I would be thrilled to welcome you to our ever-growing community! See you around!
Posted on January 27, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.