DEV Community

Cover image for NestJS Authentication with OAuth2.0: Configuration and Operations
Afonso Barracha
Afonso Barracha

Posted on • Edited on

NestJS Authentication with OAuth2.0: Configuration and Operations

Series Intro

This series will cover the full implementation of OAuth2.0 Authentication in NestJS for the following types of APIs:

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 first part of this series.

Tutorial Intro

In this tutorial I will cover all the common operations necessary for implementing any type of OAuth system:

  • User CRUD;
  • User versioning for single user token revocation;
  • JWT token generation;
  • Auth module with token blacklisting.

TLDR: if you do not have 45 minutes to read the article, the code can be found on this repo

Overview

Local OAuth system, is an authentication system comprised on authentication through JSON Web Tokens (JWTs), where we use an access and refresh token pair:

  • Access Token: the token we use to authenticate the current user by sending it on the Authorization header as a Bearer token. It has a small lifespan of 5 to 15 minutes;
  • Refresh Token: this token is normally sent on a signed HTTP only cookie and is used to refresh the access tokens, this is achieved since the refresh token has a higher lifespan from 20 minutes to 7 days.

Set up

Start by creating a new NestJS app and open it on VSCode:

$ npm i -g @nestjs/cli
$ nest new nest-local-oauth -s
$ code nest-local-oauth
Enter fullscreen mode Exit fullscreen mode

Create a new yarn config file (.yarnrc.yml):

nodeLinker: node-modules
Enter fullscreen mode Exit fullscreen mode

Install the latest version of yarn:

$ yarn set version stable
$ yarn plugin import interactive-tools
Enter fullscreen mode Exit fullscreen mode

Before installing the packages add yarn cache to .gitignore:

### Yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
Enter fullscreen mode Exit fullscreen mode

On the tsconfig.json add "esModuleInterop":

{
  "compilerOptions": {
    "...": "...",
    "esModuleInterop": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally install the packages and upgrade to the latest version:

$ yarn install
$ yarn upgrade-interactive
Enter fullscreen mode Exit fullscreen mode

Technologies

For all adapters we will use the same tech-stack:

  • MikroORM: to interact with our database;
  • Bcrypt: for hashing passwords, note that for new projects you should use argon2 as it is more secure, however since bcrypt is still the norm I will explain how to build with it;
  • JSON Web Tokens: the core of this authentication system;
  • UUID: we will need unique identifiers to be able to blacklist our tokens;
  • DayJS: for date manipulations.

So start by installing all packages:

$ yarn add @mikro-orm/core @mikro-orm/postgresql @mikro-orm/nestjs bcrypt jsonwebtoken uuid dayjs
$ yarn add -D @mikro-orm/cli @types/bcrypt @types/jsonwebtoken @types/nodemailer @types/uuid
Enter fullscreen mode Exit fullscreen mode

Configuration

Before we start we need several things:

  • Secrets and lifetimes of the tokens;
  • Name and secret of cookie;
  • Email configuration;
  • Database url.

Types of token

For a complete authentication system we need 3 types of tokens:

  • Access: the access token for authorization;
  • Refresh: the refresh token for refreshing the access token;
  • Reset: used to reset an user password given an email;
  • Confirmation: use to confirm the user.

Therefore the access token will be something as follows:

# JWT tokens
JWT_ACCESS_TIME=600
JWT_CONFIRMATION_SECRET='random_string'
JWT_CONFIRMATION_TIME=3600
JWT_RESET_PASSWORD_SECRET='random_string'
JWT_RESET_PASSWORD_TIME=1800
JWT_REFRESH_SECRET='random_string'
JWT_REFRESH_TIME=604800
Enter fullscreen mode Exit fullscreen mode

Since access tokens need to be decoded by the Gateway (or other services if you do not have a Gateway), it needs to use a public and private key pair. You can generate a 2048 bits RSA key here, and add them to a keys directory on the root of your project.

Cookie config

Our refresh token will be sent on a http only signed cookie so we need a variable for the refresh cookie name and secret.

# Refresh token
REFRESH_COOKIE='cookie_name'
COOKIE_SECRET='random_string'
Enter fullscreen mode Exit fullscreen mode

Email config

To send emails we will use Nodemailer se we just need to add typical email configuration parameters:

# Email config
EMAIL_HOST='smtp.gmail.com'
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER='johndoe@gmail.com'
EMAIL_PASSWORD='your_email_password'
Enter fullscreen mode Exit fullscreen mode

Database config

For the database we just need the PostgreSQL URL:

# Database config
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/auth'
Enter fullscreen mode Exit fullscreen mode

General config

Other move geneal variables:

  • Node environment, change to production ;
  • APP ID, an UUID for the api;
  • PORT, the API port on the server (normally 5000);
  • Front-end Domain.
APP_ID='00000-00000-00000-00000-00000'
NODE_ENV='development'
PORT=4000
DOMAIN='localhost:3000'
Enter fullscreen mode Exit fullscreen mode

Config Module

For configuration we can use the nestjs ConfigModule, so start by creating a config folder on the src folder. Then inside the config folder start adding the interfaces folder with the config interfaces:

JWT interface:

// jwt.interface.ts

export interface ISingleJwt {
  secret: string;
  time: number;
}

export interface IAccessJwt {
  publicKey: string;
  privateKey: string;
  time: number;
}

export interface IJwt {
  access: IAccessJwt;
  confirmation: ISingleJwt;
  resetPassword: ISingleJwt;
  refresh: ISingleJwt;
}
Enter fullscreen mode Exit fullscreen mode

Email config interface:

// email-config.interface.ts

interface IEmailAuth {
  user: string;
  pass: string;
}

export interface IEmailConfig {
  host: string;
  port: number;
  secure: boolean;
  auth: IEmailAuth;
}
Enter fullscreen mode Exit fullscreen mode

Config interface:

// config.interface.ts

import { MikroOrmModuleOptions } from '@mikro-orm/nestjs';
import { IEmailConfig } from './email-config.interface';
import { IJwt } from './jwt.interface';

export interface IConfig {
  id: string;
  port: number;
  domain: string;
  db: MikroOrmModuleOptions;
  jwt: IJwt;
  emailService: IEmailConfig;
}
Enter fullscreen mode Exit fullscreen mode

Create the config function:

// index.ts

import { LoadStrategy } from '@mikro-orm/core';
import { defineConfig } from '@mikro-orm/postgresql';
import { readFileSync } from 'fs';
import { join } from 'path';
import { IConfig } from './interfaces/config.interface';

export function config(): IConfig {
  const publicKey = readFileSync(
    join(__dirname, '..', '..', 'keys/public.key'),
    'utf-8',
  );
  const privateKey = readFileSync(
    join(__dirname, '..', '..', 'keys/private.key'),
    'utf-8',
  );

  return {
    id: process.env.APP_ID,
    port: parseInt(process.env.PORT, 10),
    domain: process.env.DOMAIN,
    jwt: {
      access: {
        privateKey,
        publicKey,
        time: parseInt(process.env.JWT_ACCESS_TIME, 10),
      },
      confirmation: {
        secret: process.env.JWT_CONFIRMATION_SECRET,
        time: parseInt(process.env.JWT_CONFIRMATION_TIME, 10),
      },
      resetPassword: {
        secret: process.env.JWT_RESET_PASSWORD_SECRET,
        time: parseInt(process.env.JWT_RESET_PASSWORD_TIME, 10),
      },
      refresh: {
        secret: process.env.JWT_REFRESH_SECRET,
        time: parseInt(process.env.JWT_REFRESH_TIME, 10),
      },
    },
    emailService: {
      host: process.env.EMAIL_HOST,
      port: parseInt(process.env.EMAIL_PORT, 10),
      secure: process.env.EMAIL_SECURE === 'true',
      auth: {
        user: process.env.EMAIL_USER,
        pass: process.env.EMAIL_PASSWORD,
      },
    },
    db: defineConfig({
      clientUrl: process.env.DATABASE_URL,
      entities: ['dist/**/*.entity.js', 'dist/**/*.embeddable.js'],
      entitiesTs: ['src/**/*.entity.ts', 'src/**/*.embeddable.ts'],
      loadStrategy: LoadStrategy.JOINED,
      allowGlobalContext: true,
    }),
  };
}
Enter fullscreen mode Exit fullscreen mode

Install the config module:

$ yarn add @nestjs/config joi
Enter fullscreen mode Exit fullscreen mode

Create a validation schema:

// config.schema.ts

import Joi from 'joi';

export const validationSchema = Joi.object({
  APP_ID: Joi.string().uuid({ version: 'uuidv4' }).required(),
  NODE_ENV: Joi.string().required(),
  PORT: Joi.number().required(),
  URL: Joi.string().required(),
  DATABASE_URL: Joi.string().required(),
  JWT_ACCESS_TIME: Joi.number().required(),
  JWT_CONFIRMATION_SECRET: Joi.string().required(),
  JWT_CONFIRMATION_TIME: Joi.number().required(),
  JWT_RESET_PASSWORD_SECRET: Joi.string().required(),
  JWT_RESET_PASSWORD_TIME: Joi.number().required(),
  JWT_REFRESH_SECRET: Joi.string().required(),
  JWT_REFRESH_TIME: Joi.number().required(),
  REFRESH_COOKIE: Joi.string().required(),
  COOKIE_SECRET: Joi.string().required(),
  EMAIL_HOST: Joi.string().required(),
  EMAIL_PORT: Joi.number().required(),
  EMAIL_SECURE: Joi.bool().required(),
  EMAIL_USER: Joi.string().email().required(),
  EMAIL_PASSWORD: Joi.string().required(),
});
Enter fullscreen mode Exit fullscreen mode

And finally import it to the app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { config } from './config';
import { validationSchema } from './config/config.schema';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema,
      load: [config],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Mikro-ORM Config

Mikro-ORM needs a config file added to our package.json so on the src folder create the file:

// mikro-orm.config.ts

import { LoadStrategy, Options } from '@mikro-orm/core';
import { defineConfig } from '@mikro-orm/postgresql';

const config: Options = defineConfig({
  clientUrl: process.env.DATABASE_URL,
  entities: ['dist/**/*.entity.js', 'dist/**/*.embeddable.js'],
  entitiesTs: ['src/**/*.entity.ts', 'src/**/*.embeddable.ts'],
  loadStrategy: LoadStrategy.JOINED,
  allowGlobalContext: true,
});

export default config;
Enter fullscreen mode Exit fullscreen mode

And on our package.json file add the config file:

{
  "...": "...",
  "mikro-orm": {
    "useTsNode": true,
    "configPaths": [
      "./src/mikro-orm.config.ts",
      "./dist/mikro-orm.config.js"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

To use it on our config folder we need to add a class for the MikroOrmModule:

// mikro-orm.config.ts

import {
  MikroOrmModuleOptions,
  MikroOrmOptionsFactory,
} from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MikroOrmConfig implements MikroOrmOptionsFactory {
  constructor(private readonly configService: ConfigService) {}

  public createMikroOrmOptions(): MikroOrmModuleOptions {
    return this.configService.get<MikroOrmModuleOptions>('db');
  }
}
Enter fullscreen mode Exit fullscreen mode

And asynchronous register the module on our app.module.ts file:

import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
// ...
import { MikroOrmConfig } from './config/mikroorm.config';

@Module({
  imports: [
    // ...
    MikroOrmModule.forRootAsync({
      imports: [ConfigModule],
      useClass: MikroOrmConfig,
    }),
  ],
  // ...
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Common Module

I like to have a common global module for entity validation, error handling and string manipulation.

For entity and input validation we will use class validator, as explained in the docs:

$ yarn add class-transformer class-validator
Enter fullscreen mode Exit fullscreen mode

And add it to the main file:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}

bootstrap();
Enter fullscreen mode Exit fullscreen mode

Create the module and service:

$ nest g mo common
$ nest g s common
Enter fullscreen mode Exit fullscreen mode

On the common folder add the Global decorator to the module and export the service:

import { Global, Module } from '@nestjs/common';
import { CommonService } from './common.service';

@Global()
@Module({
  providers: [CommonService],
  exports: [CommonService],
})
export class CommonModule {}
Enter fullscreen mode Exit fullscreen mode

Personally I like to have all my Regular Expressions inside common, so create a consts folder with a regex.const.ts file:

// checks if a password has at least one uppercase letter and a number or special character
export const PASSWORD_REGEX =
  /((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/;

// checks if a string has only letters, numbers, spaces, apostrophes, dots and dashes
export const NAME_REGEX = /(^[\p{L}\d'\.\s\-]*$)/u;

// checks if a string is a valid slug, useful for usernames
export const SLUG_REGEX = /^[a-z\d]+(?:(\.|-|_)[a-z\d]+)*$/;

// validates if passwords are valid bcrypt hashes
export const BCRYPT_HASH = /\$2[abxy]?\$\d{1,2}\$[A-Za-z\d\./]{53}/;
Enter fullscreen mode Exit fullscreen mode

Another thing is utility functions for common checks, create a utils function with a validation file:

// validation.util.ts

export const isUndefined = (value: unknown): value is undefined =>
  typeof value === 'undefined';

export const isNull = (value: unknown): value is null => value === null;
Enter fullscreen mode Exit fullscreen mode

Common Service

Start by adding a LoggerService:

import { Dictionary, EntityRepository } from '@mikro-orm/core';
import { Injectable, Logger, LoggerService } from '@nestjs/common';

@Injectable()
export class CommonService {
  private readonly loggerService: LoggerService;

  constructor() {
    this.loggerService = new Logger(CommonService.name);
  }
}
Enter fullscreen mode Exit fullscreen mode

We need the following methods:

Validator for entities

import { Dictionary } from '@mikro-orm/core';
import { 
  BadRequestException, 
  NotFoundException,
  // ...
} from '@nestjs/common';
import { validate } from 'class-validator';

@Injectable()
export class CommonService {
  // ...

  /**
   * Validate Entity
   *
   * Validates an entities with the class-validator library
   */
  public async validateEntity(entity: Dictionary): Promise<void> {
    const errors = await validate(entity);
    const messages: string[] = [];

    for (const error of errors) {
      messages.push(...Object.values(error.constraints));
    }

    if (errors.length > 0) {
      throw new BadRequestException(messages.join(',\n'));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Promise error wrappers

import { Dictionary, EntityRepository } from '@mikro-orm/core';
import {
  BadRequestException,
  ConflictException,
  InternalServerErrorException,
  // ...
} from '@nestjs/common';
// ...

@Injectable()
export class CommonService {
  // ...

  /**
   * Throw Duplicate Error
   *
   * Checks is an error is of the code 23505, PostgreSQL's duplicate value error,
   * and throws a conflict exception
   */
  public async throwDuplicateError<T>(promise: Promise<T>, message?: string) {
    try {
      return await promise;
    } catch (error) {
      this.loggerService.error(error);

      if (error.code === '23505') {
        throw new ConflictException(message ?? 'Duplicated value in database');
      }

      throw new BadRequestException(error.message);
    }
  }

  /**
   * Throw Internal Error
   *
   * Function to abstract throwing internal server exception
   */
  public async throwInternalError<T>(promise: Promise<T>): Promise<T> {
    try {
      return await promise;
    } catch (error) {
      this.loggerService.error(error);
      throw new InternalServerErrorException(error);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Entity Actions

import { Dictionary, EntityRepository } from '@mikro-orm/core';
// ...

@Injectable()
export class CommonService {
  // ...

  /**
   * Check Entity Existence
   *
   * Checks if a findOne query didn't return null or undefined
   */
  public checkEntityExistence<T extends Dictionary>(
    entity: T | null | undefined,
    name: string,
  ): void {
    if (isNull(entity) || isUndefined(entity)) {
      throw new NotFoundException(`${name} not found`);
    }
  }

  /**
   * Save Entity
   *
   * Validates, saves and flushes entities into the DB
   */
  public async saveEntity<T extends Dictionary>(
    repo: EntityRepository<T>,
    entity: T,
    isNew = false,
  ): Promise<void> {
    await this.validateEntity(entity);

    if (isNew) {
      repo.persist(entity);
    }

    await this.throwDuplicateError(repo.flush());
  }

  /**
   * Remove Entity
   *
   * Removes an entities from the DB.
   */
  public async removeEntity<T extends Dictionary>(
    repo: EntityRepository<T>,
    entity: T,
  ): Promise<void> {
    await this.throwInternalError(repo.removeAndFlush(entity));
  }

}
Enter fullscreen mode Exit fullscreen mode

String manipulation

Start by installing slugify:

$ yarn add slugify
Enter fullscreen mode Exit fullscreen mode

Now add the methods:

// ...
import slugify from 'slugify';
// ...

@Injectable()
export class CommonService {
  // ...

  /**
   * Format Name
   *
   * Takes a string trims it and capitalizes every word
   */
  public formatName(title: string): string {
    return title
      .trim()
      .replace(/\n/g, ' ')
      .replace(/\s\s+/g, ' ')
      .replace(/\w\S*/g, (w) => w.replace(/^\w/, (l) => l.toUpperCase()));
  }

  /**
   * Generate Point Slug
   *
   * Takes a string and generates a slug with dtos as word separators
   */
  public generatePointSlug(str: string): string {
    return slugify(str, { lower: true, replacement: '.', remove: /['_\.\-]/g });
  }
}
Enter fullscreen mode Exit fullscreen mode

Message Generation

There are endpoints that have to return a single message string, for those type of endpoints I like to make a message interface with an id, so it is easier to filter on the front-end, create an interfaces folder and add the following file:

// message.interface.ts

export interface IMessage {
  id: string;
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

And create a method for it:

// ...
import { v4 } from 'uuid';
// ...

@Injectable()
export class CommonService {
  // ...

  public generateMessage(message: string): IMessage {
    return { id: v4(), message };
  }
} 
Enter fullscreen mode Exit fullscreen mode

Users Module

Before creating the auth module we need a way to do CRUD operations on our users, so create a new users module and service:

$ nest g mo users
$ nest g s users
Enter fullscreen mode Exit fullscreen mode

User Entity

Before creating the entity we need an interface with what we want in our user, create an interfaces folder and the user.interface.ts file:

export interface IUser {
  id: number;
  name: string;
  username: string;
  email: string;
  password: string;
  confirmed: boolean;
  createdAt: Date;
  updatedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

Now implement that on an entity, start by creating an entities folder:

// user.entity.ts

import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { IsBoolean, IsEmail, IsString, Length, Matches } from 'class-validator';
import {
  BCRYPT_HASH,
  NAME_REGEX,
  SLUG_REGEX,
} from '../../common/consts/regex.const';
import { IUser } from '../interfaces/users.interface';

@Entity({ tableName: 'users' })
export class UserEntity implements IUser {
  @PrimaryKey()
  public id: number;

  @Property({ columnType: 'varchar', length: 100 })
  @IsString()
  @Length(3, 100)
  @Matches(NAME_REGEX, {
    message: 'Name must not have special characters',
  })
  public name: string;

  @Property({ columnType: 'varchar', length: 106 })
  @IsString()
  @Length(3, 106)
  @Matches(SLUG_REGEX, {
    message: 'Username must be a valid slugs',
  })
  public username: string;

  @Property({ columnType: 'varchar', length: 255 })
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email: string;

  @Property({ columnType: 'boolean', default: false })
  @IsBoolean()
  public confirmed: true | false = false; // since it is saved on the db as binary

  @Property({ columnType: 'varchar', length: 60 })
  @IsString()
  @Length(59, 60)
  @Matches(BCRYPT_HASH)
  public password: string;

  @Property({ onCreate: () => new Date() })
  public createdAt: Date = new Date();

  @Property({ onUpdate: () => new Date() })
  public updatedAt: Date = new Date();
}
Enter fullscreen mode Exit fullscreen mode

Add the entity to users.module.ts and export the UserService:

import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { UserEntity } from './entities/user.entity';
import { UsersService } from './users.service';

@Module({
  imports: [MikroOrmModule.forFeature([UserEntity])],
  providers: [UsersService],
  exports: [UserService]
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

User versioning

For security purpose we need to be able to version our users credentials (password changes for example), so in case they change any credential we can revoke all refresh tokens.

We do this by creating a Credentials JSON parameter on our users. Start by creating its interface:

// credentials.interface.ts

export interface ICredentials {
  version: number;
  lastPassword: string;
  passwordUpdatedAt: number;
  updatedAt: number;
}
Enter fullscreen mode Exit fullscreen mode

On a new embeddables folder for our JSON types add a credentials embeddable:

import { Embeddable, Property } from '@mikro-orm/core';
import dayjs from 'dayjs';
import { ICredentials } from '../interfaces/credentials.interface';

@Embeddable()
export class CredentialsEmbeddable implements ICredentials {
  @Property({ default: 0 })
  public version = 0;

  @Property({ default: '' })
  public lastPassword = '';

  @Property({ default: dayjs().unix() })
  public passwordUpdatedAt: number = dayjs().unix();

  @Property({ default: dayjs().unix() })
  public updatedAt: number = dayjs().unix();

  public updatePassword(password: string): void {
    this.version++;
    this.lastPassword = password;
    const now = dayjs().unix();
    this.passwordUpdatedAt = now;
    this.updatedAt = now;
  }

  public updateVersion(): void {
    this.version++;
    this.updatedAt = dayjs().unix();
  }
}
Enter fullscreen mode Exit fullscreen mode

And update our users interface and entity:

// user.interface.ts

import { ICredentials } from './credentials.interface';

export interface IUser {
  // ...
  credentials: ICredentials;
  // ...
}

// user.entity.ts

import { Embedded, Entity, PrimaryKey, Property } from '@mikro-orm/core';
// ...
import { IUser } from '../interfaces/users.interface';

@Entity({ tableName: 'users' })
export class UserEntity implements IUser {
  // ...

  @Embedded(() => CredentialsEmbeddable)
  public credentials: CredentialsEmbeddable = new CredentialsEmbeddable();

  // ...
}
Enter fullscreen mode Exit fullscreen mode

User Service

User service will mosly cover our User CRUD operations, inject the usersRepository with @InjectRepository decorator and the CommonService:

import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable } from '@nestjs/common';
import { CommonService } from '../common/common.service';
import { UserEntity } from './entities/user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly usersRepository: EntityRepository<UserEntity>,
    private readonly commonService: CommonService,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

CRUD Operations

User Creations

To create a user we need three params:

  • name: the name of the user;
  • email: an all lowercased unique email;
  • password: the user password (note this field will be optional when we add external providers).
// ...

@Injectable()
export class UsersService {
  // ...

  public async create(
    email: string,
    name: string,
    password: string,
  ): Promise<UserEntity> {
    const formattedEmail = email.toLowerCase();
    await this.checkEmailUniqueness(formattedEmail);
    const formattedName = this.commonService.formatName(name);
    const user = this.usersRepository.create({
      email: formattedEmail,
      name: formattedName,
      username: await this.generateUsername(formattedName),
      password: await hash(password, 10),
    });
    await this.commonService.saveEntity(this.usersRepository, user, true);
    return user;
  }

  // ...

  private async checkEmailUniqueness(email: string): Promise<void> {
    const count = await this.usersRepository.count({ email });

    if (count > 0) {
      throw new ConflictException('Email already in use');
    }
  }

  /**
   * Generate Username
   *
   * Generates a unique username using a point slug based on the name
   * and if it's already in use, it adds the usernames count to the end
   */
  private async generateUsername(name: string): Promise<string> {
    const pointSlug = this.commonService.generatePointSlug(name);
    const count = await this.usersRepository.count({
      username: {
        $like: `${pointSlug}%`,
      },
    });

    if (count > 0) {
      return `${pointSlug}${count}`;
    }

    return pointSlug;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

User Reads

We need three read methods:

  1. ID: the main read methods that fetches a user by ID

    // ...
    
    @Injectable()
    export class UsersService {
      // ...
    
      public async findOneById(id: number): Promise<UserEntity> {
        const user = await this.usersRepository.findOne({ id });
        this.commonService.checkEntityExistence(user, 'User');
        return user;
      }
    
      // ...
    }
    
  2. Email: mostly for authentication fetches a user by email

    import {
      // ...
      UnauthorizedException,
    } from '@nestjs/common';
    // ...
    import { isNull, isUndefined } from '../common/utils/validation.util';
    
    @Injectable()
    export class UsersService {
      // ...
    
      public async findOneByEmail(email: string): Promise<UserEntity> {
        const user = await this.usersRepository.findOne({
          email: email.toLowerCase(),
        });
        this.throwUnauthorizedException(user);
        return user;
      }
    
      // necessary for password reset
      public async uncheckedUserByEmail(email: string): Promise<UserEntity> {
        return this.usersRepository.findOne({
          email: email.toLowerCase(),
        });
      }
    
      // ...
    
      private throwUnauthorizedException(
        user: undefined | null | UserEntity,
      ): void {
        if (isUndefined(user) || isNull(user)) {
          throw new UnauthorizedException('Invalid credentials');
        }
      }
    
      // ...
    }
    
  3. Credentials: for token generation and verification

    // ...
    
    @Injectable()
    export class UsersService {
      // ...
    
      public async findOneByCredentials(
        id: number,
        version: number,
      ): Promise<UserEntity> {
        const user = await this.usersRepository.findOne({ id });
        this.throwUnauthorizedException(user);
    
        if (user.credentials.version !== version) {
          throw new UnauthorizedException('Invalid credentials');
        }
    
        return user;
      }
    
      // ...
    }
    
  4. Username: for both fetching the user and for authentication

    // ...
    
    @Injectable()
    export class UsersService {
      // ...
    
      public async findOneByUsername(
        username: string,
        forAuth = false,
      ): Promise<UserEntity> {
        const user = await this.usersRepository.findOne({
          username: username.toLowerCase(),
        });
    
        if (forAuth) {
          this.throwUnauthorizedException(user);
        } else {
          this.commonService.checkEntityExistence(user, 'User');
        }
    
        return user;
      }
    
      // ...
    }
    

User Update

Before creating the updates we need to create some dtos (Data Transfer Objects). One for changing the email:

// change-email.dto.ts

import { IsEmail, IsString, Length, MinLength } from 'class-validator';

export abstract class ChangeEmailDto {
  @IsString()
  @MinLength(1)
  public password!: string;

  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email: string;
}
Enter fullscreen mode Exit fullscreen mode

And one for changing the rest of the user:

// update-user.dto.ts

import { IsString, Length, Matches, ValidateIf } from 'class-validator';
import { NAME_REGEX, SLUG_REGEX } from '../../common/consts/regex.const';
import { isNull, isUndefined } from '../../common/utils/validation.util';

export abstract class UpdateUserDto {
  @IsString()
  @Length(3, 106)
  @Matches(SLUG_REGEX, {
    message: 'Username must be a valid slugs',
  })
  @ValidateIf(
    (o: UpdateUserDto) =>
      !isUndefined(o.username) || isUndefined(o.name) || isNull(o.name),
  )
  public username?: string;

  @IsString()
  @Length(3, 100)
  @Matches(NAME_REGEX, {
    message: 'Name must not have special characters',
  })
  @ValidateIf(
    (o: UpdateUserDto) =>
      !isUndefined(o.name) || isUndefined(o.username) || isNull(o.username),
  )
  public name?: string;
}
Enter fullscreen mode Exit fullscreen mode

Since user is a crucial entity on our API, I like to divide the updates in serveral methods, hence endpoints/mutations:

  1. User Update:

    // ...
    import { UsernameDto } from './dtos/username.dto';
    
    @Injectable()
    export class UsersService {
      // ...
    
      public async update(userId: number, dto: UpdateUserDto): Promise<UserEntity> {
        const user = await this.findOneById(userId);
        const { name, username } = dto;
    
        if (!isUndefined(name) && !isNull(name)) {
          if (name === user.name) {
            throw new BadRequestException('Name must be different');
          }
    
          user.name = this.commonService.formatName(name);
        }
        if (!isUndefined(username) && !isNull(username)) {
          const formattedUsername = dto.username.toLowerCase();
    
          if (user.username === formattedUsername) {
            throw new BadRequestException('Username should be different');
          }
    
          await this.checkUsernameUniqueness(formattedUsername);
          user.username = formattedUsername;
        }
    
        await this.commonService.saveEntity(this.usersRepository, user);
        return user;
      }
    
      // ...
    
      private async checkUsernameUniqueness(username: string): Promise<void> {
        const count = await this.usersRepository.count({ username });
    
        if (count > 0) {
          throw new ConflictException('Username already in use');
        }
      }
    
      // ...
    }
    
  2. Email Update:

    // ...
    import { ChangeEmailDto } from './dtos/change-email.dto';
    
    @Injectable()
    export class UsersService {
      // ...
    
      public async updateEmail(
        userId: number, 
        dto: ChangeEmailDto,
      ): Promise<UserEntity> {
        const user = await this.userById(userId);
        const { email, password } = dto;
    
        if (!(await compare(password, user.password))) {
          throw new BadRequestException('Invalid password');
        }
    
        const formattedEmail = email.toLowerCase();
        await this.checkEmailUniqueness(formattedEmail);
        user.credentials.updateVersion();
        user.email = formattedEmail;
        await this.commonService.saveEntity(this.usersRepository, user);
        return user;
      }
    
      // ...
    }
    
  3. Password Update and Reset:

    // ...
    import { compare, hash } from 'bcrypt';
    // ...
    
    @Injectable()
    export class UsersService {
      // ...
    
      public async updatePassword(
        userId: number,
        password: string,
        newPassword: string,
      ): Promise<UserEntity> {
        const user = await this.userById(userId);
    
        if (!(await compare(password, user.password))) {
          throw new BadRequestException('Wrong password');
        }
        if (await compare(newPassword, user.password)) {
          throw new BadRequestException('New password must be different');
        }
    
        user.credentials.updatePassword(user.password);
        user.password = await hash(newPassword, 10);
        await this.commonService.saveEntity(this.usersRepository, user);
        return user;
      }
    
      public async resetPassword(
        userId: number,
        version: number,
        password: string,
      ): Promise<UserEntity> {
        const user = await this.findOneByCredentials(userId, version);
        user.credentials.updatePassword(user.password);
        user.password = await hash(password, 10);
        await this.commonService.saveEntity(this.usersRepository, user);
        return user;
      }
    
      // ...
    }
    

User Removal

Note that I still return the user with the function, in case we ever need to implement a notification system, feel free to return void:

// ...

@Injectable()
export class UsersService {
  // ...

  public async remove(userId: number): Promise<UserEntity> {
    const user = await this.findOneById(userId);
    await this.commonService.removeEntity(this.usersRepository, user);
    return user;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

JWT module

Although nestjs has its own JwtService, we need a custom one for the various types of tokens we have:

$ nest g mo jwt
$ nest g s jwt
Enter fullscreen mode Exit fullscreen mode

And export the service from the module:

import { Module } from '@nestjs/common';
import { JwtService } from './jwt.service';

@Module({
  providers: [JwtService],
  exports: [JwtService],
})
export class JwtModule {}
Enter fullscreen mode Exit fullscreen mode

Token Types

Enum

Create a enums folder and add the following enum:

export enum TokenTypeEnum {
  ACCESS = 'access',
  REFRESH = 'refresh',
  CONFIRMATION = 'confirmation',
  RESET_PASSWORD = 'resetPassword',
}
Enter fullscreen mode Exit fullscreen mode

Interfaces

Each token extends from the previous, create an interfaces folder and add one interface for each type of token.

Base Token

All tokens will have an iat (issued at), exp (expiration), iss (issuer), aud (audience) andsub (subject) field so we need a base for all our tokens:

// token-base.interface.ts

export interface ITokenBase {
  iat: number;
  exp: number;
  iss: string;
  aud: string;
  sub: string;
}
Enter fullscreen mode Exit fullscreen mode

Access Token

The access token will only contain the id of an user:

// access-token.interface.ts

import { ITokenBase } from './token-base.interface';

export interface IAccessPayload {
  id: number;
}

export interface IAccessToken extends IAccessPayload, ITokenBase {}

Enter fullscreen mode Exit fullscreen mode

Email Token

The email token will contain the id and the version of an user:

// email-token.interface.ts

import { IAccessPayload } from './access-token.interface';
import { ITokenBase } from './token-base.interface';

export interface IEmailPayload extends IAccessPayload {
  version: number;
}

export interface IEmailToken extends IEmailPayload, ITokenBase {}
Enter fullscreen mode Exit fullscreen mode

Refresh Token

The refresh token will contain the id and the version of an user, as well as a uuid as the identifier of the token:

// refresh-token.interface.ts

import { IEmailPayload } from './email-token.interface';
import { ITokenBase } from './token-base.interface';

export interface IRefreshPayload extends IEmailPayload {
  tokenId: string;
}

export interface IRefreshToken extends IRefreshPayload, ITokenBase {}
Enter fullscreen mode Exit fullscreen mode

Service

Start by injecting the ConfigService and CommonService:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommonService } from '../common/common.service';

@Injectable()
export class JwtService {
  private readonly jwtConfig: IJwt;
  private readonly issuer: string;
  private readonly domain: string;

  constructor(
    private readonly configService: ConfigService,
    private readonly commonService: CommonService,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Since the jsonwebtoken library still uses callback for asynchronous behaviour lets create asynchronous sign and verify functions:

// ...
import * as jwt from 'jsonwebtoken';
import { IAccessPayload } from './interfaces/access-token.interface';
import { IEmailPayload } from './interfaces/email-token.interface';
import { IRefreshToken } from './interfaces/refresh-token.interface';

@Injectable()
export class JwtService {
  // ...

  private static async generateTokenAsync(
    payload: IAccessPayload | IEmailPayload | IRefreshPayload,
    secret: string,
    options: jwt.SignOptions,
  ): Promise<string> {
    return new Promise((resolve, rejects) => {
      jwt.sign(payload, secret, options, (error, token) => {
        if (error) {
          rejects(error);
          return;
        }
        resolve(token);
      });
    });
  }

  private static async verifyTokenAsync<T>(
    token: string,
    secret: string,
    options: jwt.VerifyOptions,
  ): Promise<T> {
    return new Promise((resolve, rejects) => {
      jwt.verify(token, secret, options, (error, payload: T) => {
        if (error) {
          rejects(error);
          return;
        }
        resolve(payload);
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Start setting up the jwt configuration and domain:

// ...
import { IJwt } from '../config/interfaces/jwt.interface';
// ...

@Injectable()
export class JwtService {
  private readonly jwtConfig: IJwt;
  private readonly issuer: string;
  private readonly domain: string;

  constructor(
    private readonly configService: ConfigService,
    private readonly commonService: CommonService,
  ) {
    this.jwtConfig = this.configService.get<IJwt>('jwt');
    this.issuer = this.configService.get<string>('id');
    this.domain = this.configService.get<string>('domain');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Create a method for generating tokens using IUser and TokenTypesEnum as params:

// ...
import { v4 } from 'uuid';
import { IJwt } from '../config/interfaces/jwt.interface';
import { IUser } from '../users/interfaces/user.interface';
// ...

@Injectable()
export class JwtService {
  // ...

  public async generateToken(
    user: IUser,
    tokenType: TokenTypeEnum,
    domain?: string | null,
    tokenId?: string,
  ): Promise<string> {
    const jwtOptions: jwt.SignOptions = {
      issuer: this.issuer,
      subject: user.email,
      audience: domain ?? this.domain,
      algorithm: 'HS256', // only needs a secret
    };

    switch (tokenType) {
      case TokenTypeEnum.ACCESS:
        const { privateKey, time: accessTime } = this.jwtConfig.access;
        return this.commonService.throwInternalError(
          JwtService.generateTokenAsync({ id: user.id }, privateKey, {
            ...jwtOptions,
            expiresIn: accessTime,
            algorithm: 'RS256', // to use public and private key
          }),
        );
      case TokenTypeEnum.REFRESH:
        const { secret: refreshSecret, time: refreshTime } =
          this.jwtConfig.refresh;
        return this.commonService.throwInternalError(
          JwtService.generateTokenAsync(
            {
              id: user.id,
              version: user.credentials.version,
              tokenId: tokenId ?? v4(),
            },
            refreshSecret,
            {
              ...jwtOptions,
              expiresIn: refreshTime,
            },
          ),
        );
      case TokenTypeEnum.CONFIRMATION:
      case TokenTypeEnum.RESET_PASSWORD:
        const { secret, time } = this.jwtConfig[tokenType];
        return this.commonService.throwInternalError(
          JwtService.generateTokenAsync(
            { id: user.id, version: user.credentials.version },
            secret,
            {
              ...jwtOptions,
              expiresIn: time,
            },
          ),
        );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And then create a method to verify and decode our tokens:

import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
} from '@nestjs/common';
// ...
import {
  // ...
  IAccessToken,
} from './interfaces/access-token.interface';
import { IEmailPayload, IEmailToken } from './interfaces/email-token.interface';
import {
  // ...
  IRefreshToken,
} from './interfaces/refresh-token.interface';
// ...

@Injectable()
export class JwtService {
  // ...

  private static async throwBadRequest<
    T extends IAccessToken | IRefreshToken | IEmailToken,
  >(promise: Promise<T>): Promise<T> {
    try {
      return await promise;
    } catch (error) {
      if (error instanceof jwt.TokenExpiredError) {
        throw new BadRequestException('Token expired');
      }
      if (error instanceof jwt.JsonWebTokenError) {
        throw new BadRequestException('Invalid token');
      }
      throw new InternalServerErrorException(error);
    }
  }

  // ...

  public async verifyToken<
    T extends IAccessToken | IRefreshToken | IEmailToken,
  >(token: string, tokenType: TokenTypeEnum): Promise<T> {
    const jwtOptions: jwt.VerifyOptions = {
      issuer: this.issuer,
      audience: new RegExp(this.domain),
    };

    switch (tokenType) {
      case TokenTypeEnum.ACCESS:
        const { publicKey, time: accessTime } = this.jwtConfig.access;
        return JwtService.throwBadRequest(
          JwtService.verifyTokenAsync(token, publicKey, {
            ...jwtOptions,
            maxAge: accessTime,
            algorithms: ['RS256'],
          }),
        );
      case TokenTypeEnum.REFRESH:
      case TokenTypeEnum.CONFIRMATION:
      case TokenTypeEnum.RESET_PASSWORD:
        const { secret, time } = this.jwtConfig[tokenType];
        return JwtService.throwBadRequest(
          JwtService.verifyTokenAsync(token, secret, {
            ...jwtOptions,
            maxAge: time,
            algorithms: ['HS256'],
          }),
        );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Mailer Module

To confirm our users identity, and to be able to reset passwords we need to send emails. Start by installing nodemailer and handlebars:

$ yarn add nodemailer handlebars
$ yarn add @types/nodemailer @types/handlebars
Enter fullscreen mode Exit fullscreen mode

Create a mailer module and service:

$ nest g mo mailer
$ nest g s mailer
Enter fullscreen mode Exit fullscreen mode

And export the service from the module:

import { Module } from '@nestjs/common';
import { MailerService } from './mailer.service';

@Module({
  providers: [MailerService],
  exports: [MailerService],
})
export class MailerModule {}
Enter fullscreen mode Exit fullscreen mode

Templates

We will use handlebars to create templates for confirmation and password reseting.

Interfaces

Create an interfaces folder and add an interface for the template data:

// template-data.interface.ts

export interface ITemplatedData {
  name: string;
  link: string;
}
Enter fullscreen mode Exit fullscreen mode

And one for the templates we will have:

// templates.interface.ts

import { TemplateDelegate } from 'handlebars';
import { ITemplatedData } from './template-data.interface';

export interface ITemplates {
  confirmation: TemplateDelegate<ITemplatedData>;
  resetPassword: TemplateDelegate<ITemplatedData>;
}
Enter fullscreen mode Exit fullscreen mode

HTML (hbs)

Create an email template for confirmation:

<!-- confirmation.hbs -->
<html lang='en'>
  <body>
    <p>Hello {{name}},</p>
    <br />
    <p>Welcome to [Your app],</p>
    <p>
      Click
      <b><a href='{{link}}' target='_blank'>here</a></b>
      to activate your acount or go to this link:
      {{link}}
    </p>
    <p><small>This link will expire in an hour.</small></p>
    <br />
    <p>Best of luck,</p>
    <p>[Your app] Team</p>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

And one for password reseting:

<!-- reset-password.hbs -->
<html lang='en'>
  <body>
    <p>Hello {{name}},</p>
    <br />
    <p>Your password reset link:
      <b><a href='{{link}}' target='_blank'>here</a></b></p>
    <p>Or go to this link: ${{link}}</p>
    <p><small>This link will expire in 30 minutes.</small></p>
    <br />
    <p>Best regards,</p>
    <p>[Your app] Team</p>
  </body>
</html> 
Enter fullscreen mode Exit fullscreen mode

To compile the templates you need to add an assets on nest-cli.json:

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      "mailer/templates/**/*"
    ],
    "watchAssets": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Service

Start by importing the ConfigService:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MailerService {
  constructor(private readonly configService: ConfigService) {}
}
Enter fullscreen mode Exit fullscreen mode

And add the email client configuration, as well as a logger:

import { Injectable, Logger, LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createTransport, Transporter } from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';

@Injectable()
export class MailerService {
  private readonly loggerService: LoggerService;
  private readonly transport: Transporter<SMTPTransport.SentMessageInfo>;
  private readonly email: string;
  private readonly domain: string;

  constructor(private readonly configService: ConfigService) {
    const emailConfig = this.configService.get<IEmailConfig>('emailService');
    this.transport = createTransport(emailConfig);
    this.email = `"My App" <${emailConfig.auth.user}>`;
    this.domain = this.configService.get<string>('domain');
    this.loggerService = new Logger(MailerService.name);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see we have not added our templates yet, start by creating a parser method:

// ...
import { readFileSync } from 'fs';
import Handlebars from 'handlebars';
// ...
import { ITemplatedData } from './interfaces/template-data.interface';

@Injectable()
export class MailerService {
  // ...

  private static parseTemplate(
    templateName: string,
  ): Handlebars.TemplateDelegate<ITemplatedData> {
    const templateText = readFileSync(
      join(__dirname, 'templates', templateName),
      'utf-8',
    );
    return Handlebars.compile<ITemplatedData>(templateText, { strict: true });
  }
}
Enter fullscreen mode Exit fullscreen mode

And add our templates to the configuration:

// ...
import { ITemplates } from './interfaces/templates.interface';

@Injectable()
export class MailerService {
  // ...
  private readonly templates: ITemplates;

  constructor(private readonly configService: ConfigService) {
    this.templates = {
      confirmation: MailerService.parseTemplate('confirmation.hbs'),
      resetPassword: MailerService.parseTemplate('reset-password.hbs'),
    };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Emails should be sent asynchronously so create a public method that uses the .then notation:

// ...

@Injectable()
export class MailerService {
  // ...

  public sendEmail(
    to: string,
    subject: string,
    html: string,
    log?: string,
  ): void {
    this.transport
      .sendMail({
        from: this.email,
        to,
        subject,
        html,
      })
      .then(() => this.loggerService.log(log ?? 'A new email was sent.'))
      .catch((error) => this.loggerService.error(error));
  }
}
Enter fullscreen mode Exit fullscreen mode

And a method for each of our two templates:

// ...
import { IUser } from '../users/interfaces/user.interface';


@Injectable()
export class MailerService {
  // ...

  public sendConfirmationEmail(user: IUser, token: string): void {
    const { email, name } = user;
    const subject = 'Confirm your email';
    const html = this.templates.confirmation({
      name,
      link: `https://${this.domain}/auth/confirm/${token}`,
    });
    this.sendEmail(email, subject, html, 'A new confirmation email was sent.');
  }

  public sendResetPasswordEmail(user: IUser, token: string): void {
    const { email, name } = user;
    const subject = 'Reset your password';
    const html = this.templates.resetPassword({
      name,
      link: `https://${this.domain}/auth/reset-password/${token}`,
    });
    this.sendEmail(
      email,
      subject,
      html,
      'A new reset password email was sent.',
    );
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Auth Module

Create the auth module:

$ nest g mo auth
$ nest g s auth
Enter fullscreen mode Exit fullscreen mode

Entities

The auth module will only have one entity, the blacklisted tokens, create its interface on the interfaces folder:

// blacklisted-token.interface.ts

import { IUser } from '../../users/interfaces/user.interface';

export interface IBlacklistedToken {
  tokenId: string;
  user: IUser;
  createdAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

Now just implement it on a entity on the entities folder:

// blacklisted-token.entity.ts

import {
  Entity,
  ManyToOne,
  PrimaryKeyType,
  Property,
  Unique,
} from '@mikro-orm/core';
import { UserEntity } from '../../users/entities/user.entity';
import { IBlacklistedToken } from '../interfaces/blacklisted-token.interface';

@Entity({ tableName: 'blacklisted_tokens' })
@Unique({ properties: ['tokenId', 'user'] })
export class BlacklistedTokenEntity implements IBlacklistedToken {
  @Property({
    primary: true,
    columnType: 'uuid',
  })
  public tokenId: string;

  @ManyToOne({
    entity: () => UserEntity,
    onDelete: 'cascade',
    primary: true,
  })
  public user: UserEntity;

  @Property({ onCreate: () => new Date() })
  public createdAt: Date;

  [PrimaryKeyType]: [string, number];
}
Enter fullscreen mode Exit fullscreen mode

It has a composite key of the tokenId and the user ID.

Add the entity to the module, as well as the UserModel, JwtModule and MailerModule:

import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { JwtModule } from '../jwt/jwt.module';
import { MailerModule } from '../mailer/mailer.module';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { BlacklistedTokenEntity } from './entity/blacklisted-token.entity';

@Module({
  imports: [
    MikroOrmModule.forFeature([BlacklistedTokenEntity]),
    UsersModule,
    JwtModule,
    MailerModule,
  ],
  providers: [AuthService],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

Service

Start by injecting the blacklistedTokensRepository, CommonService, UsersService, JwtService andMailerService:

import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable } from '@nestjs/common';
import { CommonService } from '../common/common.service';
import { JwtService } from '../jwt/jwt.service';
import { MailerService } from '../mailer/mailer.service';
import { UsersService } from '../users/users.service';
import { BlacklistedTokenEntity } from './entity/blacklisted-token.entity';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(BlacklistedTokenEntity)
    private readonly blacklistedTokensRepository: EntityRepository<BlacklistedTokenEntity>,
    private readonly commonService: CommonService,
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
    private readonly mailerService: MailerService,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

DTOs

We need several dtos, so create a dtos directory, with the following files:

Passwords DTO

We need two passwords paramenters one as the main password and a confirmation password for registration and password updates.

// passwords.dto.ts

import { IsString, Length, Matches, MinLength } from 'class-validator';
import { PASSWORD_REGEX } from '../../common/consts/regex.const';

export abstract class PasswordsDto {
  @IsString()
  @Length(8, 35)
  @Matches(PASSWORD_REGEX, {
    message:
      'Password requires a lowercase letter, an uppercase letter, and a number or symbol',
  })
      public password1!: string;

      @IsString()
      @MinLength(1)
      public password2!: string;
}
Enter fullscreen mode Exit fullscreen mode

Sign-up DTO

For registration.

// sign-up.dto.ts

import { IsEmail, IsString, Length, Matches } from 'class-validator';
import { NAME_REGEX } from '../../common/consts/regex.const';
import { PasswordsDto } from './passwords.dto';

export abstract class SignUpDto extends PasswordsDto {
  @IsString()
  @Length(3, 100, {
    message: 'Name has to be between 3 and 50 characters.',
  })
  @Matches(NAME_REGEX, {
    message: 'Name can only contain letters, dtos, numbers and spaces.',
  })
  public name!: string;

  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email!: string;
}
Enter fullscreen mode Exit fullscreen mode

Sign-in DTO

For login, it can take the email or username.

// sign-in.dto.ts

import { IsEmail, IsString, Length, Matches } from 'class-validator';
import { NAME_REGEX } from '../../common/consts/regex.const';
import { PasswordsDto } from './passwords.dto';

export abstract class SignUpDto extends PasswordsDto {
  @IsString()
  @Length(3, 100, {
    message: 'Name has to be between 3 and 50 characters.',
  })
  @Matches(NAME_REGEX, {
    message: 'Name can only contain letters, dtos, numbers and spaces.',
  })
  public name!: string;

  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email!: string;
}
Enter fullscreen mode Exit fullscreen mode

Email DTO

A dto with only the email for sending password reset emails.

// email.dto.ts

export abstract class EmailDto {
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email: string;
}
Enter fullscreen mode Exit fullscreen mode

Reset Password DTO

For reseting the password given a token.

// reset-password.dto.ts

import { IsJWT, IsString } from 'class-validator';
import { PasswordsDto } from './passwords.dto';

export abstract class ResetPasswordDto extends PasswordsDto {
  @IsString()
  @IsJWT()
  public resetToken!: string;
}
Enter fullscreen mode Exit fullscreen mode

Change Password DTO

For updating the user password.

// change-password.dto.ts

import { IsString, MinLength } from 'class-validator';
import { PasswordsDto } from './passwords.dto';

export abstract class ChangePasswordDto extends PasswordsDto {
  @IsString()
  @MinLength(1)
  public password!: string;
}
Enter fullscreen mode Exit fullscreen mode

Interfaces

Most authentication service methods will return the same three fields:

  • User;
  • Access Token;
  • Refresh Token.

So create an interface for that called IAuthResult:

// auth-result.interface.ts

import { IUser } from '../../users/interfaces/user.interface';

export interface IAuthResult {
  user: IUser;
  accessToken: string;
  refreshToken: string;
}
Enter fullscreen mode Exit fullscreen mode

Methods

We will start by creating a private method for faster generation of the access and refresh token by leveraging Promise.all:

// ...

@Injectable()
export class AuthService {
  // ...

  private async generateAuthTokens(
    user: UserEntity,
    domain?: string,
    tokenId?: string,
  ): Promise<[string, string]> {
    return Promise.all([
      this.jwtService.generateToken(
        user,
        TokenTypeEnum.ACCESS,
        domain,
        tokenId,
      ),
      this.jwtService.generateToken(
        user,
        TokenTypeEnum.REFRESH,
        domain,
        tokenId,
      ),
    ]);
  } 
}
Enter fullscreen mode Exit fullscreen mode

Sign Up Method

// ...
import { SignUpDto } from './dtos/sign-up.dto';
// ...

@Injectable()
export class AuthService {
  // ...

  public async signUp(dto: SignUpDto, domain?: string): Promise<IMessage> {
    const { name, email, password1, password2 } = dto;
    this.comparePasswords(password1, password2);
    const user = await this.usersService.create(email, name, password1);
    const confirmationToken = await this.jwtService.generateToken(
      user,
      TokenTypeEnum.CONFIRMATION,
      domain,
    );
    this.mailerService.sendConfirmationEmail(user, confirmationToken);
    return this.commonService.generateMessage('Registration successful');
  }

  private comparePasswords(password1: string, password2: string): void {
    if (password1 !== password2) {
      throw new BadRequestException('Passwords do not match');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Sign In Method

// ...
import {
  // ...
  UnauthorizedException,
} from '@nestjs/common';
import { compare } from 'bcrypt';
import { isEmail } from 'class-validator';
import { SLUG_REGEX } from '../common/consts/regex.const';
// ...
import { IAuthResult } from './interfaces/auth-result.interface';

@Injectable()
export class AuthService {
  // ...

  public async singIn(dto: SignInDto, domain?: string): Promise<IAuthResult> {
    const { emailOrUsername, password } = dto;
    const user = await this.userByEmailOrUsername(emailOrUsername);

    if (!(await compare(password, user.password))) {
      await this.checkLastPassword(user.credentials, password);
    }
    if (!user.confirmed) {
      const confirmationToken = await this.jwtService.generateToken(
        user,
        TokenTypeEnum.CONFIRMATION,
        domain,
      );
      this.mailerService.sendConfirmationEmail(user, confirmationToken);
      throw new UnauthorizedException(
        'Please confirm your email, a new email has been sent',
      );
    }

    const [accessToken, refreshToken] = await this.generateAuthTokens(
      user,
      domain,
    );
    return { user, accessToken, refreshToken };
  }

  // validates the input and fetches the user by email or username
  private async userByEmailOrUsername(
    emailOrUsername: string,
  ): Promise<UserEntity> {
    if (emailOrUsername.includes('@')) {
      if (!isEmail(emailOrUsername)) {
        throw new BadRequestException('Invalid email');
      }

      return this.usersService.userByEmail(emailOrUsername);
    }

    if (
       emailOrUsername.length < 3 ||
       emailOrUsername.length > 106 ||
       !SLUG_REGEX.test(emailOrUsername)
     ) {
      throw new BadRequestException('Invalid username');
    }

    return this.usersService.userByUsername(emailOrUsername, true);
  }

  // checks if your using your last password
  private async checkLastPassword(
    credentials: ICredentials,
    password: string,
  ): Promise<void> {
    const { lastPassword, passwordUpdatedAt } = credentials;

    if (lastPassword.length === 0 || !(await compare(password, lastPassword))) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const now = dayjs();
    const time = dayjs.unix(passwordUpdatedAt);
    const months = now.diff(time, 'month');
    const message = 'You changed your password ';

    if (months > 0) {
      throw new UnauthorizedException(
        message + months + (months > 1 ? ' months ago' : ' month ago'),
      );
    }

    const days = now.diff(time, 'day');

    if (days > 0) {
      throw new UnauthorizedException(
        message + days + (days > 1 ? ' days ago' : ' day ago'),
      );
    }

    const hours = now.diff(time, 'hour');

    if (hours > 0) {
      throw new UnauthorizedException(
        message + hours + (hours > 1 ? ' hours ago' : ' hour ago'),
      );
    }

    throw new UnauthorizedException(message + 'recently');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Refresh Token Access

// ...

@Injectable()
export class AuthService {
  // ...

  public async refreshTokenAccess(
    refreshToken: string,
    domain?: string,
  ): Promise<IAuthResult> {
    const { id, version, tokenId } =
      await this.jwtService.verifyToken<IRefreshToken>(
        refreshToken,
        TokenTypeEnum.REFRESH,
      );
    await this.checkIfTokenIsBlacklisted(id, tokenId);
    const user = await this.usersService.userByCredentials(id, version);
    const [accessToken, newRefreshToken] = await this.generateAuthTokens(
      user,
      domain,
      tokenId,
    );
    return { user, accessToken, refreshToken: newRefreshToken };
  }

  // checks if a token given the ID of the user and ID of token exists on the database
  private async checkIfTokenIsBlacklisted(
    userId: number,
    tokenId: string,
  ): Promise<void> {
    const count = await this.blacklistedTokensRepository.count({
      user: userId,
      tokenId,
    });

    if (count > 0) {
      throw new UnauthorizedException('Token is invalid');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Logout

// ...

@Injectable()
export class AuthService {
  // ...

  public async logout(refreshToken: string): Promise<IMessage> {
    const { id, tokenId } = await this.jwtService.verifyToken<IRefreshToken>(
      refreshToken,
      TokenTypeEnum.REFRESH,
    );
    await this.blacklistToken(id, tokenId);
    return this.commonService.generateMessage('Logout successful');
  }

  // creates a new blacklisted token in the database with the
  // ID of the refresh token that was removed with the logout
  private async blacklistToken(userId: number, tokenId: string): Promise<void> {
    const blacklistedToken = this.blacklistedTokensRepository.create({
      user: userId,
      tokenId,
    });
    await this.commonService.saveEntity(
      this.blacklistedTokensRepository,
      blacklistedToken,
      true,
    );
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Reset Password Email

// ...
import { isNull, isUndefined } from '../common/utils/validation.util';

@Injectable()
export class AuthService {
  // ...

  public async resetPasswordEmail(
    dto: EmailDto,
    domain?: string,
  ): Promise<IMessage> {
    const user = await this.usersService.uncheckedUserByEmail(dto.email);

    if (!isUndefined(user) && !isNull(user)) {
      const resetToken = await this.jwtService.generateToken(
        user,
        TokenTypeEnum.RESET_PASSWORD,
        domain,
      );
      this.mailerService.sendResetPasswordEmail(user, resetToken);
    }

    return this.commonService.generateMessage('Reset password email sent');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Reset Password

// ...

@Injectable()
export class AuthService {
  // ...

  public async resetPassword(dto: ResetPasswordDto): Promise<IMessage> {
    const { password1, password2, resetToken } = dto;
    const { id, version } = await this.jwtService.verifyToken<IEmailToken>(
      resetToken,
      TokenTypeEnum.RESET_PASSWORD,
    );
    this.comparePasswords(password1, password2);
    await this.usersService.resetPassword(id, version, password1);
    return this.commonService.generateMessage('Password reset successful');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Change Password

// ...

@Injectable()
export class AuthService {
  // ...

  public async changePassword(
    userId: number,
    dto: ChangePasswordDto,
  ): Promise<IAuthResult> {
    const { password1, password2, password } = dto;
    this.comparePasswords(password1, password2);
    const user = await this.usersService.updatePassword(
      userId,
      password,
      password1,
    );
    const [accessToken, refreshToken] = await this.generateAuthTokens(user);
    return { user, accessToken, refreshToken };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Optional Section

Optimizations with Redis Cache

Since tokens expire saving them on disk permanently can be quite wasteful, so it is better to save them on cache. NestJS comes with its own CacheModule so start by installing the following packages:

$ yarn add cache-manager ioredis cache-manager-redis-yet
Enter fullscreen mode Exit fullscreen mode

Configuration

On the config.interface.ts add the ioredis options:

// ...
import { RedisOptions } from 'ioredis';

export interface IConfig {
  // ...
  redis: RedisOptions;
}
Enter fullscreen mode Exit fullscreen mode

Now most managed redis services (AWS ElastiCache, Digital Ocean Redis DB, etc) will actually give you an URL and not the options so we need to build a parser, on a new utils folder add:

// redis-url-parser.util.ts

import { RedisOptions } from 'ioredis';

export const redisUrlParser = (url: string): RedisOptions => {
  if (url.includes('://:')) {
    const arr = url.split('://:')[1].split('@');
    const secondArr = arr[1].split(':');

    return {
      password: arr[0],
      host: secondArr[0],
      port: parseInt(secondArr[1], 10),
    };
  }

  const connectionString = url.split('://')[1];
  const arr = connectionString.split(':');
  return {
    host: arr[0],
    port: parseInt(arr[1], 10),
  };
};
Enter fullscreen mode Exit fullscreen mode

Add the redis URL to the .env file (and on docker-compose if you are using it), the schema and the index file:

REDIS_URL='redis://localhost:6379'
Enter fullscreen mode Exit fullscreen mode
// index.ts

// ...
import { redisUrlParser } from './utils/redis-url-parser.util';

export function config(): IConfig {
  // ...

  return {
    // ...
    redis: redisUrlParser(process.env.REDIS_URL),
  };
}

// config.schema.ts

import Joi from 'joi';

export const validationSchema = Joi.object({
  // ...
  REDIS_URL: Joi.string().required(),
});
Enter fullscreen mode Exit fullscreen mode

Finaly to be able to use the CacheModule we need to create a config class for the cache:

// cache.config.ts

import {
  CacheModuleOptions,
  CacheOptionsFactory,
  Injectable,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-ioredis-yet';

@Injectable()
export class CacheConfig implements CacheOptionsFactory {
  constructor(private readonly configService: ConfigService) {}

  async createCacheOptions(): Promise<CacheModuleOptions> {
    return {
      store: await redisStore({
        ...this.configService.get('redis'),
        ttl: this.configService.get<number>('jwt.refresh.time') * 1000,
      }),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

And add it to the app.module.ts:

// ...
import { CacheModule, Module } from '@nestjs/common';
// ...

@Module({
  imports: [
    // ...
    CacheModule.registerAsync({
      isGlobal: true,
      imports: [ConfigModule],
      useClass: CacheConfig,
    }),
    // ...
  ],
  // ...
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Auth Module

Start by deleting the entities directory, the blacklisted-token.interface.ts file, and remove it from the module:

import { Module } from '@nestjs/common';
import { JwtModule } from '../jwt/jwt.module';
import { MailerModule } from '../mailer/mailer.module';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';

@Module({
  imports: [UsersModule, JwtModule, MailerModule],
  providers: [AuthService],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

Auth Service

Start by changing the blacklistedTokensRepository for the cache manager:

import {
  // ...
  CACHE_MANAGER,
  // ...
} from '@nestjs/common';
import { Cache } from 'cache-manager';
// ...

@Injectable()
export class AuthService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,
    // ...
  ) {}

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now update the blacklistToken method and logout as it is dependent on it:

// ...
import dayjs from 'dayjs';
// ...

@Injectable()
export class AuthService {
  // ...

  public async logout(refreshToken: string): Promise<IMessage> {
    const { id, tokenId, exp } =
      await this.jwtService.verifyToken<IRefreshToken>(
        refreshToken,
        TokenTypeEnum.REFRESH,
      );
    await this.blacklistToken(id, tokenId, exp);
    return this.commonService.generateMessage('Logout successful');
  }

  // ...

  // checks if a blacklist token given a redis key exist on cache
  private async blacklistToken(
    userId: number,
    tokenId: string,
    exp: number,
  ): Promise<void> {
    const now = dayjs().unix();
    const ttl = (exp - now) * 1000;

    if (ttl > 0) {
      await this.commonService.throwInternalError(
        this.cacheManager.set(`blacklist:${userId}:${tokenId}`, now, ttl),
      );
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we use a typical redis key divided in three parts by a colon:

  • Title of key: "blacklist"
  • User ID: the id of the user that the token belongs;
  • Token ID: the id of the token that is blacklisted.

I save the date of when it was created, but you cant just save 0 or 1 (binary) as true or false.

Also, the way we check for if the token exists changes as well:

// ...

@Injectable()
export class AuthService {
  // ...

  private async checkIfTokenIsBlacklisted(
    userId: number,
    tokenId: string,
  ): Promise<void> {
    const time = await this.cacheManager.get<number>(
      `blacklist:${userId}:${tokenId}`,
    );

    if (!isUndefined(time) && !isNull(time)) {
      throw new UnauthorizedException('Invalid token');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

With this you can create the base for a full local authentication type of API.

The full code of this tutorial can be find on this repo.

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!

Top comments (10)

Collapse
 
mubasharstack profile image
Mubashar Ahmed

This Article is God's work.
it its really informative and innovative keep us posted with new updates. its was really valuable. thanks a lot

Collapse
 
kurokikaze profile image
Serge Shirokov

Also, if you just do yarn add @mikro-orm/core, it will install the v6 version, where the persist, flush and removeAndFlush methods are moved to the EntityManager, so the repo.flush() will be highlighted as Property 'flush' does not exist on type 'EntityRepository<T> type error.

Collapse
 
tugascript profile image
Afonso Barracha

Yeah, on the repo master branch this is fixed, but I never came back and fixed the article. Thanks for pointing it out.

Collapse
 
tugascript profile image
Afonso Barracha • Edited

So there have been some updates to cache-manager that were not in the docs, now it uses milliseconds, so I updated it to use them.

There is an ongoing discussion on the NestJS discord about this change, and it will be reflected in the docs shortly

Collapse
 
manncodes2014 profile image
Abdur Rehman • Edited

Hi, very helpful article. I have a query, Why public and private keys are used for access token, instead of secret?

Collapse
 
tugascript profile image
Afonso Barracha

So this is meant to be an auth microservice, you need the public key to verify the token, either on the api-gateway or on the other services withing the same aplication.

Collapse
 
vnzinki profile image
Phạm Quang Trung

By design, the authentication server is separated from the backend server. The authentication server uses a private key to generate a JWT token, which any backend server can verify using a public key without compromising on performance or security.

Collapse
 
kurokikaze profile image
Serge Shirokov

This is very useful. Probably should add that the options at the beginning of the article go into the .env file (for people not familiar with this way).

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
tugascript profile image
Afonso Barracha

Hum... comparePasswords is not to compare hashes but if the two passwords provided on a registration or update are the same.