Overview
This is a fairly large section, highlighting the process by which I’ve created the starting point for the auth and user registration for the new iteration of my bootcamp final project. I wanted to do my best to follow current best practices and build out a solid starting point for user registration and authentication. A primary reference for this work was this excellent video by Michael Guay. I like how he organizes the NestJS project and his approach seems to follow pretty solid, modern best practices, though these things seem to change all the time. He utilizes MongoDB/mongoose and not Postgres/Drizzle so for my app I had to do a fair bit of customization; couldn’t just use his code line by line. I’ll outline what I did in this article.
Here are some of the key concepts:
- Initially the user will login with email + password and be granted a short lived Access Token JWT
- At the same time as initial auth, the user will be granted a longer-lived Refresh Token JWT. This will make it so that people can stay logged in for a while until they log out or the RT expires. We can also invalidate the RT if we need to terminate a user’s access.
- Both JWTs will be programmatically inaccessible on the client because they are sent as http-only cookies.
- The user table will live on our database and store hashed versions of both the password and refresh token.
- Logout clears Access Token and Refresh Token cookies, then clears the Refresh Token from the User table.
- The refresh token is only sent with requests to the
/auth/refresh
endpoint.
This is by no means the end of the work on this topic. As I’ve already alluded to, I’m not 100% confident I’ve got my bases covered, security wise. I will need to keep doing research to protect against attacks. That said, security will not be as important for this application as it would be for say, an online banking app. There will be limited information about the users that could be exposed aside from an email and password. Things could change in the future but for now I think this works.
As for things missing from this approach… I’d also eventually like to enable social auth, aka “Login through Google” etc. Then there also needs to be some kind of Role Based Access Control (RBAC) to guard certain endpoints. Those will be part of future iterations. This is about an MVP.
Dependencies
Before you start: Need an existing NestJS project with Drizzle set up with database module.
Install these dependencies:
Validation:
npm i class-validator class-transformer
Passport.js:
npm i @nestjs/passport passport passport-local passport-jwt
npm i @nestjs/jwt
npm i @types/passport-jwt @types/passport-local
Cookie Parsing:
npm i cookie-parser
npm i -D @types/cookie-parser
Users Module
We first need to set up a table in the database where the user information will be persisted.
- Run the following to generate required files for User model:
nest g module users
nest g controller users
nest g service users
- Create schema file and configure columns:
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').unique().notNull(),
password: text('password').notNull(),
refreshToken: text('refresh-token'),
});
- Configure
users.module.ts
:
@Module({
imports: [DatabaseModule],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
- Configure
users.service.ts
:
import * as schema from './schema';
@Injectable()
export class UsersService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly database: NodePgDatabase<typeof schema>
) {}
}
- Add the following line to
main.ts
to enable validators in the DTO:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe()); // THIS IS NEW
await app.listen(process.env.PORT ?? 3000);
}
- Create request DTO for
create-user.request.ts
with validators :
import { IsEmail, IsStrongPassword } from 'class-validator';
export class CreateUserRequest {
@IsEmail()
email: string;
@IsStrongPassword()
password: string;
}
- Add “Post” method to
users.controller.ts
:
@Post()
async createUser(@Body() request: CreateUserRequest) {
return this.usersService.createUser(request);
}
- Add “createUser()” method to
users.service.ts
and hash the password (don’t store as plain text):- Install bcrypt:
npm i bcryptjs @types/bcryptjs
- Install bcrypt:
async createUser(user: typeof schema.users.$inferInsert) {
const hashedPassword = await hash(user.password || '', 10);
await this.database.insert(schema.users).values({ ...user, password: hashedPassword });
}
Setting up Passport.js
- Create Auth Module:
nest g module auth
nest g controller auth
nest g service auth
- Configure
auth.module.ts
:
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [UsersModule, PassportModule, JwtModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
- Add “UsersService” to exports of the
users.module.ts
- Create
auth/strategies
directory - Create a new file
local.strategy.ts
and export an injectable class as follows:
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
usernameField: 'email',
});
}
async validate(email: string, password: string) {
// WE'LL ABSTRACT THIS INTO auth.service.ts
}
}
- Create
getUser()
method forusers.service.ts
:
async getUser(email: string) {
const user = await this.database.query.users.findMany({
where: eq(schema.users.email, email),
}); // Returns an array of matches
if (user.length < 1) {
throw new NotFoundException('User not found');
}
return user[0];
}
- Inject UsersService and add logic to verify user in
auth.service.ts
:
export class AuthService {
constructor(private readonly usersService: UsersService) {}
async verifyUser(email: string, password: string) {
try {
const user = await this.usersService.getUser(email);
const authenticated = await compare(password, user.password);
if (!authenticated) {
throw new UnauthorizedException();
}
return user;
} catch (error) {
throw new UnauthorizedException(error);
}
}
}
- Go back to
local.strategy.ts
and add call to verifyUser(). Note: Everything we return here will be added to the request object under the user property allowing us to access the user from the request object in any service layer.
async validate(email: string, password: string) {
return this.authService.verifyUser(email, password);
}
- Add
LocalStrategy
to providers array insideauth.module.ts
- Create
auth/guards
directory and createlocal-auth.guard.ts
. Each strategy will get a guard file.
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
- Add
@Post
route toauth.controller.ts
:
import { Controller, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './guards/local-auth.guard';
@Controller('auth')
export class AuthController {
@Post('login')
@UseGuards(LocalAuthGuard)
async login() {
// This is where we'll create a JWT token and return it to the client. It will then use JWT to authenticate itself in future requests.
}
}
NOTE: Its important that the JWT is not accessible via client-side JS so we’re going to have it saved onto a HTTP only cookie that accompanies every request.
Creating the login route and JWT
- First we’ll abstract the ability to extract the “user” information from the the request object. Create
auth/current-user.decorator.ts
:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '../users/dto/create-user.request';
const getCurrentUserByContext = (context: ExecutionContext): User =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
context.switchToHttp().getRequest().user;
export const CurrentUser = createParamDecorator<User>(
(_data: unknown, context: ExecutionContext) =>
getCurrentUserByContext(context)
);
- Add new env variable for
JWT_ACCESS_TOKEN_SECRET
. Create a secure value here. Use the “CodeIgniter Encryption Keys” option. - Create env variable for
JWT_ACCESS_TOKEN_EXPIRATION_MS
and set to 3600000 - Inject
ConfigService
andJwtService
into AuthService class. - Create interface for TokenPayload:
export interface TokenPayload {
userId: string;
}
- Add login() method to AuthService:
async login(user: User, response: Response) {
const expiresTime = new Date();
expiresTime.setMilliseconds(
expiresTime.getTime() +
parseInt(this.configService.getOrThrow<string>('JWT_ACCESS_TOKEN_EXPIRATION_MS'),
),
);
const tokenPayload: TokenPayload = {
userId: user.id.toString(),
};
const accessToken = this.jwtService.sign(tokenPayload, {
secret: this.configService.getOrThrow<string>('JWT_ACCESS_TOKEN_SECRET'),
expiresIn: `${this.configService.getOrThrow('JWT_ACCESS_TOKEN_EXPIRATION_MS')}ms`,
});
response.cookie('Authentication', accessToken, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
expires: expiresTime,
});
}
- Add
UsersService
to exports array forusers.module.ts
and providers array forapp.module.ts
Using JWT to guard endpoints
- Add “cookie-parser” as middleware in
main.ts
. This will parse the cookie for every request and append it to the “request” object:
import * as cookieParser from 'cookie-parser'; // The "* as" is important
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
app.use(cookieParser()); // Here's the new line
await app.listen(process.env.PORT ?? 3000);
}
- Create
auth/strategies/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private readonly usersService: UsersService
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => request.cookies?.Authentication,
]),
secretOrKey: configService.getOrThrow('JWT_ACCESS_TOKEN_SECRET'),
});
}
async validate(payload: TokenPayload) {
return this.usersService.getUser({ email: null, id: payload.userId });
}
}
- I had to reconfigure
usersService.getUser()
to be able to query a user with either an email or an id. - Add
JwtStrategy
to “providers” array in side ofauth.module.ts
- Create
auth/guards/jwt-auth.guard.ts
:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
- The following is an example of an end point that utilizes the guard we just created as well as the
@CurrentUser()
decorator.
// users.service.ts - This method retrieves the menus associated with the currently logged in user:
async getUserMenus(user: User) {
return this.database.query.users.findFirst({
where: eq(schema.users.email, user.email),
with: {
menus: true,
},
});
}
// users.controller.ts - @CurrentUser() extracts the user object from the request object
// the JwtAuthGuard validates the JWT attached to the request and either throws an error or allows the request to proceed.
@Get('menus')
@UseGuards(JwtAuthGuard)
async getUserMenus(@CurrentUser() user: User) {
return this.usersService.getUserMenus(user);
}
Refresh Token Implementation:
- We need to return a refresh token from our
auth/login
route. Since the process is the same for both the access token and refresh token, I elected to abstract the token creation into a private function inside ofauth.service.ts
:
private createToken(user: User, secretKey: string, expKey: string) {
const expiresTime = new Date(Date.now() + parseInt(this.configService.getOrThrow<string>(expiresKey))),;
const tokenPayload: TokenPayload = {
userId: user.id.toString(),
};
const token = this.jwtService.sign(tokenPayload, {
secret: this.configService.getOrThrow<string>(secretKey),
expiresIn: `${this.configService.getOrThrow(expKey)}ms`,
});
return { expiresTime, token };
}
- I also extracted some of the constants into a new
constants.ts
file:
export const accessTokenSecret = 'JWT_ACCESS_TOKEN_SECRET';
export const accessTokenExpiration = 'JWT_ACCESS_TOKEN_EXPIRATION_MS';
export const refreshTokenSecret = 'JWT_REFRESH_TOKEN_SECRET';
export const refreshTokenExpiration = 'JWT_REFRESH_TOKEN_EXPIRATION_MS';
- Now we refactor the access token creation and also create a refresh token. We’re not done yet with the login route because we need to put some constraints on the refresh token.
async login(user: User, response: Response) {
const { expiresTime: expiresTimeAccess, token: accessToken } =
this.createToken(user, accessTokenSecret, accessTokenExpiration);
const { expiresTime: expiresTimeRefresh, token: refreshToken } =
this.createToken(user, refreshTokenSecret, refreshTokenExpiration);
// now update the user's refresh token. You'll need a method in users service to do this.
await this.usersService.updateUserRefreshToken(user, refreshToken);
response.cookie('Authentication', accessToken, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
expires: expiresTimeAccess,
});
// add refresh token as cookie:
response.cookie('Refresh', refreshToken, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
expires: expiresTimeRefresh,
// Need to specify path so that refresh token is only attached to calls to the refresh endpoint
path: "/auth/refresh"
});
}
- Create new method in
auth.service.ts
that verifies refresh token:
async verifyUserRefreshToken(refreshToken: string, userId: string) {
try {
const user = await this.usersService.getUser({ email: null, id: userId });
const authenticated = user.refreshToken ?
await compare(refreshToken, user.refreshToken): false;
if (!authenticated) {
throw new UnauthorizedException();
}
return user;
} catch {
throw new UnauthorizedException('Refresh token is not valid');
}
}
- Create a new strategy that accepts a refresh token and delivers a new access token. Create file
auth/strategies/jwt-refresh.strategy.ts
:
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(
Strategy,
'jwt-refresh'
) {
constructor(
configService: ConfigService,
private readonly authService: AuthService
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => request.cookies?.Refresh,
]),
secretOrKey: configService.getOrThrow(refreshTokenSecret),
passReqToCallback: true,
});
}
async validate(request: Request, payload: TokenPayload) {
return this.authService.verifyUserRefreshToken(
request.cookies?.Refresh,
payload.userId
);
}
}
- Create corresponding auth guard:
@Injectable()
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}
- Add
JwtRefreshStrategy
to providers array inauth.module.ts
- Add a refresh route to
auth.controller.ts
that’s guarded by our new Refresh strategy:
@Post('refresh')
@UseGuards(JwtRefreshAuthGuard)
async refresh(
@CurrentUser() user: User,
@Res({ passthrough: true }) response: Response,
) {
await this.authService.login(user, response);
}
- Finally we just need a logout route and corresponding service:
// auth.service.ts:
async logout(user: User, response: Response) {
await this.usersService.updateUserRefreshToken(user, null);
response.clearCookie('Authentication');
response.clearCookie('Refresh');
}
// auth.controller.ts:
@Post('logout')
@UseGuards(JwtRefreshAuthGuard)
async logout(
@CurrentUser() user: User,
@Res({ passthrough: true }) response: Response,
) {
await this.authService.logout(user, response);
}
Sources
- NestJS - Passport Recipe
- Secure JWT Authentication
- NestJS Authentication + Refresh Token With Passport.js
- Drizzle Docs