Skip to content

[Startup MVP recipes #14] JWT Authentication with Nest.js + Passport + MikroOrm

Official doc(https://docs.nestjs.com/security/authentication#authentication) is also recommended but this tutorial is easier to read and contains much clear essentials

Background

Although recently our team migrated to MikroOrm, indeed MikroOrm is very similar to TypeOrm and you can easily replace the code pointers here with your own TypeOrm implementation.

Prior to this work, our amazing intern had a very impressive blog of her auth implementation with TypeOrm for Nest.js’ Admin.js: https://medium.com/@prinpulkes/authentication-with-adminjs-for-a-nestjs-project-bead357782cf

JWT (JSON Web Token) is widely used now in backend auth since it uses the token in API requests and the stateful trait of it can make API requests easily with mobile app consumers too. This tutorial gives a short introduction to a minimal version of e2e JWT auth service in Nest.js with Passport (JWT), MikroOrm.

About JWT

I recommend jwt.io this site for some live demo of how a jwt looks like. In short it is signed using asymmetric crypto algorithms and upon HTTP request, it is added in request header with { Authorization: ‘Bearer <jwt_token>’ }. It has an expiration period but in this tutorial we will set its expiration to be very long and ignore the refresh token mechanism for now.

The User Entity

Let’s, again, define the minimal user module (entity, services) for auth usage first. Note that we reserves a field for User Roles for future RBAC (or policy based) authorization along with current authentication.

// user.entity.ts

import { Entity, Enum, Index, Property } from '@mikro-orm/core';
import {
  Field,
  HideField,
  ObjectType,
  registerEnumType,
} from '@nestjs/graphql';
import { IsEmail } from 'class-validator';
import CustomBaseEntity from 'src/infra/base-classes/base.entity';

export enum Role {
  User = 'User', // basic, shared, default access scope
  Seller = 'Seller',
  Buyer = 'Buyer',
  Admin = 'Admin',
}

registerEnumType(Role, {
  name: 'Role',
  description: 'Different User Role types',
  valuesMap: {
    User: {
      description: 'Basic, shared, default user access scope',
    },
  },
});

@Entity()
@ObjectType()
export default class User extends CustomBaseEntity {
  // CustomBaseEntity just adds fields like uuid, createdAt, updatedAt

  @Property({ unique: true })
  @Index()
  @Field(() => String, { description: 'User Email Address' })
  @IsEmail()
  email: string;

  @Property()
  @HideField()
  encryptedPassword?: string;

  @Enum({ items: () => Role, array: true, default: [Role.User] })
  @Field(() => [Role], { description: 'User roles' })
  roles: Role[] = [Role.User];
}

In short, we just need to store user’s email and encryptedPassword.

Before we go to any DTO/interfaces in User module, let’s figure out the Auth Module first which is the most important part of this tutorial.

The Auth Module

Sign Up

In our sign up flow, we accept user input of email + password and other info. We encrypt the password with bcrypt lib. The password is not encrypted on frontend but HTTPS ensures transportation security.

DTO

// sign-up.input.ts

@InputType()
export default class SignUpInput {
  @Field(() => String, { description: `User's email address` })
  @IsEmail()
  email: string;

  @Field(() => String, { description: `User's plain-text password` })
  @IsString()
  @MinLength(8)
  @MaxLength(256)
  password: string;
}

Resolver

// auth.resolver.ts, sign up part

@Resolver()
export default class AuthResolver {
  constructor(private readonly authService: AuthService) {}

  @Mutation(() => User, { name: 'signUp' })
  async signUp(@Args('signUpInput') signUpInput: SignUpInput) {
    return this.authService.signUp(signUpInput);
  }
}

Service

// auth.service.ts, sign up part

@Injectable()
export default class AuthService {
  constructor(
    private readonly configService: ConfigService,
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async signUp(signUpInput: SignUpInput) {
    const { email, password, ...rest } = signUpInput;
    const encryptedPassword = await hash(
      password,
      this.configService.get('bcrypt').saltOrRounds || 10,
    );
		// only send encrypted password to user service to create user
    return this.usersService.create({
      email,
      encryptedPassword,
      ...rest,
    });
  }
}

Login

For minimal login implementation, we ask for email + password and return signed JWT (access token).

DTO

// login.input.ts

@InputType()
export default class LoginInput {
  @Field(() => String, { description: `User's email address` })
  @IsEmail()
  email: string;

  @Field(() => String, { description: `User's plain-text password` })
  @IsString()
  @MinLength(8)
  @MaxLength(256)
  password: string;
}
// login-response.object.ts

@ObjectType()
export default class LoginResponse {
  @Field(() => String, { description: `JWT access token` })
  accessToken: string;
}

Resolver

// auth.resolver.ts

@Mutation(() => LoginResponse, { name: 'login' })
async login(@Args('signUpInput') loginInput: LoginInput) {
  return this.authService.login(loginInput);
}

Service

// auth.service.ts

async validateUser(email: string, password: string) {
  const user = await this.usersService.findOneByEmail(email);

  if (!user) return null;

  const valid = await compare(password, user.encryptedPassword);

  if (valid) return user;

  return null;
}

async login(loginInput: LoginInput) {
  const { email, password } = loginInput;
  const user = await this.validateUser(email, password);
  if (!user) {
    throw new UnauthorizedException('User email or password incorrect');
  }
  const payload: IJwtPayload = {
    email: user.email,
    sub: user.uuid,
    roles: user.roles,
  };
  return {
    accessToken: this.jwtService.sign(payload),
  };
}

IJwtPayload interface

// jwt-payload.interface.ts

import { Role } from 'src/modules/users/entities/user.entity';

export default interface IJwtPayload {
  email: string;
  sub: string;
  roles: Role[];
}

JWT strategy override

Now, how to sign JWT token? We need to inject jwtService

We will use jwtService from a 3rd party lib. But we need to config it and provide our validate implementation to include fields we want in the jwt payload.

// strategies/jwt-auth.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

import IJwtPayload from '../interfaces/jwt-payload.interface';
import IUserContext from '../interfaces/user-context.interface';

@Injectable()
export default class JwtAuthStrategy extends PassportStrategy(
  Strategy,
  'jwtAuthStrategy',
) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.AUTH_JWT_SECRET,
    });
  }

  // Passport will decodes the JWT using the secret key, then invokes the validate method below with the decoded JSON as a parameter
  // Passport builds a user object on the return value and attaches it to the request object
  async validate(payload: IJwtPayload): Promise<IUserContext> {
    const { email, roles, sub: uuid } = payload;
    return {
      uuid,
      email,
      roles,
    };
  }
}

IUserContext interface

// user-context.interface.ts

import { Role } from 'src/modules/users/entities/user.entity';

export default interface IUserContext {
  uuid: string;
  email: string;
  roles: Role[];
}

We’ve seen the userService has been called many times by authService. It’s time to go back to userService and check its implementation.

The User revisited: interfaces and services

DTO/interfaces

// create-user-input.interface.ts

export default interface ICreateUserInput {
  email: string;
  encryptedPassword: string;
}

// update-user-input.interface.ts

export default interface IUpdatePasswordInput {
  newEncryptedPassword: string;
}

Service (MikroOrm uses implicit transaction design)

@Injectable()
export default class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: EntityRepository<User>,
  ) {}

  async findOne(uuid: string) {
    return this.userRepository.findOne(uuid);
  }

  async findOneByEmail(email: string) {
    return this.userRepository.findOne({ email });
  }

  async create(createUserInput: ICreateUserInput) {
    const user = this.userRepository.create(createUserInput);
    await this.userRepository.persistAndFlush(user);
    return user;
  }
}

Finally: make authenticated requests with JWT, Guard

Now when making authenticated requests, the client side will include the access token returned from login to request header. (Authorization Bearer Token)

On server side, we can use Guard to protect endpoints. Meanwhile, for guarded endpoints, we can create our own param decorator to inject user to the endpoint handler from its execution context.

Guard definition

// jwt-auth.guard.ts

import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export default class JwtAuthGuard extends AuthGuard('jwtAuthStrategy') {
  // Pull request off context object
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    const request = ctx.getContext().req;
    return request;
  }
}

Custom User decorator definition

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

// Custom decorator to inject user into execution context
const CurrentUser = createParamDecorator(
  (_data: never, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user;
  },
);

export default CurrentUser;

And to test its usage, let’s create a GraphQL Query that returns current user object.

// auth.resolver.ts

@UseGuards(JwtAuthGuard)
@Query(() => User, { name: 'currentUser', nullable: true })
async getCurrentUser(@CurrentUser() user: IUserContext): Promise<User> {
  return this.authService.findUserFromContext(user);
}
// auth.service.ts

async findUserFromContext(userContext: IUserContext) {
  return this.usersService.findOne(userContext.uuid);
}

Bonus: update user password (given authenticated)

Auth side DTO

// update-password.input.ts

@InputType()
export default class UpdatePasswordInput {
  @Field(() => String)
  @IsString()
  @MinLength(8)
  @MaxLength(256)
  newPassword: string;
}

Auth resolver

// auth.resolver.ts

@UseGuards(JwtAuthGuard)
@Mutation(() => User, { nullable: true })
updatePassword(
  @CurrentUser() userContext: IUserContext,
  @Args('updatePasswordInput') updatePasswordInput: UpdatePasswordInput,
) {
  return this.authService.updatePassword(
    userContext.uuid,
    updatePasswordInput,
  );
}

Auth service

// auth.service.ts

async updatePassword(uuid: string, updatePasswordInput: UpdatePasswordInput) {
  const { newPassword } = updatePasswordInput;
  const encryptedPassword = await hash(
    newPassword,
    this.configService.get('bcrypt').saltOrRounds || 10,
  );
  return this.usersService.updateOne(uuid, {
    newEncryptedPassword: encryptedPassword,
  });
}

User side interface

// update-password-input.interface.ts

export default interface IUpdatePasswordInput {
  newEncryptedPassword: string;
}

User Service

async updateOne(uuid: string, updatePasswordInput: IUpdatePasswordInput) {
  const { newEncryptedPassword } = updatePasswordInput;
  const user = await this.findOne(uuid);
  if (!user) return null;
  user.encryptedPassword = newEncryptedPassword;
  await this.userRepository.flush();
  return user;
}

Leave a Reply

Your email address will not be published. Required fields are marked *