Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
January 14, 2023 05:39 pm GMT

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

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

nodeLinker: node-modules

Install the latest version of yarn:

$ yarn set version stable$ yarn plugin import interactive-tools

Before installing the packages add yarn cache to .gitignore:

### Yarn.pnp.*.yarn/*!.yarn/patches!.yarn/releases!.yarn/plugins!.yarn/sdks!.yarn/versions

On the tsconfig.json add "esModuleInterop":

{  "compilerOptions": {    "...": "...",    "esModuleInterop": true  }}

Finally install the packages and upgrade to the latest version:

$ yarn install$ yarn upgrade-interactive

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

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 tokensJWT_ACCESS_TIME=600JWT_CONFIRMATION_SECRET='random_string'JWT_CONFIRMATION_TIME=3600JWT_RESET_PASSWORD_SECRET='random_string'JWT_RESET_PASSWORD_TIME=1800JWT_REFRESH_SECRET='random_string'JWT_REFRESH_TIME=604800

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 tokenREFRESH_COOKIE='cookie_name'COOKIE_SECRET='random_string'

Email config

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

# Email configEMAIL_HOST='smtp.gmail.com'EMAIL_PORT=587EMAIL_SECURE=falseEMAIL_USER='[email protected]'EMAIL_PASSWORD='your_email_password'

Database config

For the database we just need the PostgreSQL URL:

# Database configDATABASE_URL='postgresql://postgres:postgres@localhost:5432/auth'

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=4000DOMAIN='localhost:3000'

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.tsexport 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;}

Email config interface:

// email-config.interface.tsinterface IEmailAuth {  user: string;  pass: string;}export interface IEmailConfig {  host: string;  port: number;  secure: boolean;  auth: IEmailAuth;}

Config interface:

// config.interface.tsimport { 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;}

Create the config function:

// index.tsimport { 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,    }),  };}

Install the config module:

$ yarn add @nestjs/config joi

Create a validation schema:

// config.schema.tsimport 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(),});

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 {}

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.tsimport { 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;

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"    ]  }}

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

// mikro-orm.config.tsimport {  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');  }}

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 {}

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

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();

Create the module and service:

$ nest g mo common$ nest g s common

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 {}

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 characterexport const PASSWORD_REGEX =  /((?=.*\d)|(?=.*\W+))(?![.
])(?=
.*[A-Z])(?=.*[a-z]).*$/;// checks if a string has only letters, numbers, spaces, apostrophes, dots and dashesexport const NAME_REGEX = /(^[\p{L}\d'\.\s\-]*$)/u;// checks if a string is a valid slug, useful for usernamesexport const SLUG_REGEX = /^[a-z\d]+(?:(\.|-|_)[a-z\d]+)*$/;// validates if passwords are valid bcrypt hashesexport const BCRYPT_HASH = /\$2[abxy]?\$\d{1,2}\$[A-Za-z\d\./]{53}/;

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

// validation.util.tsexport const isUndefined = (value: unknown): value is undefined =>  typeof value === 'undefined';export const isNull = (value: unknown): value is null => value === null;

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);  }}

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(',
')); } }}

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);    }  }}

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));  }}

String manipulation

Start by installing slugify:

$ yarn add slugify

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(/
/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 }); }}

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.tsexport interface IMessage {  id: string;  message: string;}

And create a method for it:

// ...import { v4 } from 'uuid';// ...@Injectable()export class CommonService {  // ...  public generateMessage(message: string): IMessage {    return { id: v4(), message };  }} 

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

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;}

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

// user.entity.tsimport { 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();}

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 {}

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.tsexport interface ICredentials {  version: number;  lastPassword: string;  passwordUpdatedAt: number;  updatedAt: number;}

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;    this.passwordUpdatedAt = dayjs().unix();    this.updatedAt = dayjs().unix();  }  public updateVersion(): void {    this.version++;    this.updatedAt = dayjs().unix();  }}

And update our users interface and entity:

// user.interface.tsimport { ICredentials } from './credentials.interface';export interface IUser {  // ...  credentials: ICredentials;  // ...}// user.entity.tsimport { 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();  // ...}

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,  ) {}}

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;  }  // ...}

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.tsimport { 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;}

And one for changing the username:

// username.dto.tsimport { IsString, Length, Matches } from 'class-validator';import { SLUG_REGEX } from '../../common/consts/regex.const';export abstract class UsernameDto {  @IsString()  @Length(3, 106)  @Matches(SLUG_REGEX, {    message: 'Username must be a valid slugs',  })  public username: string;}

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

  1. Username Update:

    // ...import { UsernameDto } from './dtos/username.dto';@Injectable()export class UsersService {  // ...  public async updateUsername(    userId: number,    dto: UsernameDTO,  ): Promise<UserEntity> {    const user = await this.userById(userId);    const formattedUsername = dto.username.toLowerCase();    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;  }  // ...}

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

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 {}

Token Types

Enum

Create a enums folder and add the following enum:

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

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.tsexport interface ITokenBase {  iat: number;  exp: number;  iss: string;  aud: string;  sub: string;}

Access Token

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

// access-token.interface.tsimport { ITokenBase } from './token-base.interface';export interface IAccessPayload {  id: number;}export interface IAccessToken extends IAccessPayload, ITokenBase {}

Email Token

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

// email-token.interface.tsimport { IAccessPayload } from './access-token.interface';import { ITokenBase } from './token-base.interface';export interface IEmailPayload extends IAccessPayload {  version: number;}export interface IEmailToken extends IEmailPayload, ITokenBase {}

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.tsimport { IEmailPayload } from './email-token.interface';import { ITokenBase } from './token-base.interface';export interface IRefreshPayload extends IEmailPayload {  tokenId: string;}export interface IRefreshToken extends IRefreshPayload, ITokenBase {}

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,  ) {}}

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);      });    });  }}

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');  }  // ...}

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,            },          ),        );    }  }}

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'],          }),        );    }  }}

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

Create a mailer module and service:

$ nest g mo mailer$ nest g s mailer

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 {}

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.tsexport interface ITemplatedData {  name: string;  link: string;}

And one for the templates we will have:

// templates.interface.tsimport { TemplateDelegate } from 'handlebars';import { ITemplatedData } from './template-data.interface';export interface ITemplates {  confirmation: TemplateDelegate<ITemplatedData>;  resetPassword: TemplateDelegate<ITemplatedData>;}

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>

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> 

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": {    "deleteOutDir": true,    "assets": [      "mailer/templates/**/*"    ],    "watchAssets": true  }}

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) {}}

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);  }}

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 });  }}

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'),    };  }  // ...}

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));  }}

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.',    );  }  // ...}

Auth Module

Create the auth module:

$ nest g mo auth$ nest g s auth

Entities

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

// blacklisted-token.interface.tsimport { IUser } from '../../users/interfaces/user.interface';export interface IBlacklistedToken {  tokenId: string;  user: IUser;  createdAt: Date;}

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

// blacklisted-token.entity.tsimport {  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];}

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 {}

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,  ) {}}

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.tsimport { 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;}

Sign-up DTO

For registration.

// sign-up.dto.tsimport { 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;}

Sign-in DTO

For login, it can take the email or username.

// sign-in.dto.tsimport { 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;}

Email DTO

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

// email.dto.tsexport abstract class EmailDto {  @IsString()  @IsEmail()  @Length(5, 255)  public email: string;}

Reset Password DTO

For reseting the password given a token.

// reset-password.dto.tsimport { IsJWT, IsString } from 'class-validator';import { PasswordsDto } from './passwords.dto';export abstract class ResetPasswordDto extends PasswordsDto {  @IsString()  @IsJWT()  public resetToken!: string;}

Change Password DTO

For updating the user password.

// change-password.dto.tsimport { IsString, MinLength } from 'class-validator';import { PasswordsDto } from './passwords.dto';export abstract class ChangePasswordDto extends PasswordsDto {  @IsString()  @MinLength(1)  public password!: string;}

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.tsimport { IUser } from '../../users/interfaces/user.interface';export interface IAuthResult {  user: IUser;  accessToken: string;  refreshToken: string;}

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,      ),    ]);  } }

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');    }  }  // ...}

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');  }  // ...}

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');    }  }  // ...}

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,    );  }  // ...}

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');  }  // ...}

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');  }  // ...}

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 };  }  // ...}

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

Configuration

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

// ...import { RedisOptions } from 'ioredis';export interface IConfig {  // ...  redis: RedisOptions;}

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.tsimport { 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),  };};

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'
// index.ts// ...import { redisUrlParser } from './utils/redis-url-parser.util';export function config(): IConfig {  // ...  return {    // ...    redis: redisUrlParser(process.env.REDIS_URL),  };}// config.schema.tsimport Joi from 'joi';export const validationSchema = Joi.object({  // ...  REDIS_URL: Joi.string().required(),});

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

// cache.config.tsimport {  CacheModuleOptions,  CacheOptionsFactory,  Injectable,} from '@nestjs/common';import { ConfigService } from '@nestjs/config';import { redisStore } from 'cache-manager-ioredis-yet';import { RedisOptions } from 'ioredis';@Injectable()export class CacheConfig implements CacheOptionsFactory {  constructor(private readonly configService: ConfigService) {}  async createCacheOptions(): Promise<CacheModuleOptions> {    return {      store: await redisStore({        ...this.configService.get<RedisOptions>('redis'),        ttl: this.configService.get<number>('jwt.refresh.time'),      }),    };  }}

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 {}

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 {}

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,    // ...  ) {}  // ...}

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;    if (ttl > 0) {      await this.commonService.throwInternalError(        this.cacheManager.set(`blacklist:${userId}:${tokenId}`, now, ttl),      );    }  }  // ...}

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');    }  }  // ...}

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 my name is Afonso Barracha, I am a Econometrician made back-end developer that has a passion for GraphQL.

I used to try to post twice a month here on Dev. I used to try to post once a week, but I am diving into more advance topics which take more time to write.

If you do not want to lose any of my posts follow me here on dev, or on LinkedIn.


Original Link: https://dev.to/tugascript/nestjs-authentication-with-oauth20-configuration-and-operations-41k

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To