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.

  1. Run the following to generate required files for User model:
    • nest g module users
    • nest g controller users
    • nest g service users
  2. 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'),
});
  1. Configure users.module.ts:
@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
  1. Configure users.service.ts:
import * as schema from './schema';
@Injectable()
export class UsersService {
  constructor(
    @Inject(DATABASE_CONNECTION)
    private readonly database: NodePgDatabase<typeof schema>
  ) {}
}
  1. 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);
}
  1. 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;
}
  1. Add “Post” method to users.controller.ts:
@Post()
async  createUser(@Body() request:  CreateUserRequest) {
	return  this.usersService.createUser(request);
}
  1. Add “createUser()” method to users.service.ts and hash the password (don’t store as plain text):
    • Install bcrypt: npm i bcryptjs @types/bcryptjs
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

  1. Create Auth Module:
  • nest g module auth
  • nest g controller auth
  • nest g service auth
  1. 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 {}
  1. Add “UsersService” to exports of the users.module.ts
  2. Create auth/strategies directory
  3. 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
  }
}
  1. Create getUser() method for users.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];
}
  1. 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);
    }
  }
}
  1. 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);
}
  1. Add LocalStrategy to providers array inside auth.module.ts
  2. Create auth/guards directory and create local-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') {}
  1. Add @Post route to auth.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

  1. 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)
);
  1. Add new env variable for JWT_ACCESS_TOKEN_SECRET. Create a secure value here. Use the “CodeIgniter Encryption Keys” option.
  2. Create env variable for JWT_ACCESS_TOKEN_EXPIRATION_MS and set to 3600000
  3. Inject ConfigService and JwtService into AuthService class.
  4. Create interface for TokenPayload:
export interface TokenPayload {
  userId: string;
}
  1. 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,
	});
}
  1. Add UsersService to exports array for users.module.ts and providers array for app.module.ts

Using JWT to guard endpoints

  1. 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);
}
  1. 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 });
  }
}
  1. I had to reconfigure usersService.getUser() to be able to query a user with either an email or an id.
  2. Add JwtStrategy to “providers” array in side of auth.module.ts
  3. Create auth/guards/jwt-auth.guard.ts:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
  1. 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:

  1. 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 of auth.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 };
}
  1. 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';
  1. 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"
	});
}
  1. 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');
	}
}
  1. 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
    );
  }
}
  1. Create corresponding auth guard:
@Injectable()
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}
  1. Add JwtRefreshStrategy to providers array in auth.module.ts
  2. 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);
}
  1. 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