Create Authentication system with NodeJS, ExpressJS, TypeScript and Jest E2E testing -- PART 2.

Mehdi JaiMehdi Jai
10 min read

JWT Middleware

Let's create the middleware to handle the authentications. Create this file src/middlewares/jwt.middleware.ts.

This middleware takes the Authorization header (Bearer [token]) and get the token. jsonwebtoken packages handles the process of validating the token behind the scene.

If the token doesn't exist, the access is denied (401 Error). Else, we validated the token, the package handles the token and return an error if it's expired or invalid (in case not expired, then it's invalid). Otherwise, it's valid, and we move on to the next handler (The route handler probably).

import { Response, NextFunction } from 'express';
import jwt, { TokenExpiredError } from 'jsonwebtoken';
import { ResponseHandler } from '@/utils/responseHandler';
import appConfig from '@/config/app.config';

export function authenticateJWT(req: IRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;

  if (authHeader) {
    const token = authHeader.split(' ')[1];
    jwt.verify(token, appConfig.jwt.secret, (err, user) => {
      if (err || !user) {
        if (err instanceof TokenExpiredError) {
          const resBody = ResponseHandler.Unauthorized('Unauthenticated');
          res.status(resBody.error!.code).json(resBody);
        } else {
          const resBody = ResponseHandler.Forbidden('Access forbidden: Invalid token');
          res.status(resBody.error!.code).json(resBody);
        }
        return;
      }
      req.user = user;
      next();
    });
  } else {
    const resBody = ResponseHandler.Unauthorized('Access denied: No token provided');
    res.status(resBody.error!.code).json(resBody);
  }
}

The IRequest interface is a globally declared interface, it extends default Express Request, and add user payload (userId). We can use this userId in other route handler to get the logged in user. Create src/index.d.ts

// Refrence files, to import the types from other definition files. We will be importing them here instead of importing the types in each file.

/// <reference path="./schemas/auth/auth.schema.d.ts" />
/// <reference path="./types/auth.d.ts" />

import { Request as ExpressRequest } from 'express';

declare global {
  interface IRequest extends ExpressRequest {
    user?: any;
  }
}

Auth Router

Router

As I mentioned in the Architecture section, the code will be separated, the router will only handle routing and listening. The logic will be in repositories files. Let's create the file src/routes/v1/auth.route.ts

import { NextFunction, Request, Response, Router } from 'express';
import { validate } from '@/middlewares/validateRequest.middleware';
import { AuthRepository } from '@/repositories/auth.repo';
import { authenticateJWT } from '@/middlewares/jwt.middleware';
import HttpStatusCode from '@/utils/HTTPStatusCodes';
import { AuthZODSchema } from '@/schemas/auth.schema';

const AuthRoutes = Router();

AuthRoutes.post(
  '/login',
  validate(AuthZODSchema.authSchema),
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const body: TAuthSchema = req.body;
      const resBody = await AuthRepository.loginUser(body);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

AuthRoutes.post(
  '/refresh-token',
  validate(AuthZODSchema.refreshTokenSchema),
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const body: TRefreshTokenSchema = req.body;
      const resBody = await AuthRepository.refreshToken(body);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

AuthRoutes.post(
  '/register',
  validate(AuthZODSchema.registerSchema),
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const body: TRegisterSchema = req.body;
      const resBody = await AuthRepository.createUser(body);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

AuthRoutes.post(
  '/forget-password',
  validate(AuthZODSchema.forgetPasswordSchema),
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const body: TForgetPasswordSchema = req.body;
      const resBody = await AuthRepository.forgotPassword(body);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

AuthRoutes.post(
  '/reset-password',
  validate(AuthZODSchema.resetPasswordSchema),
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const body: TResetPasswordSchema = req.body;
      const resBody = await AuthRepository.resetPassword(body);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

AuthRoutes.post(
  '/update-password',
  authenticateJWT,
  validate(AuthZODSchema.updatePasswordSchema),
  async (req: IRequest, res: Response, next: NextFunction) => {
    try {
      const body: TUpdatePasswordSchema = req.body;
      // We get the userId from the request. It's passed by "authenticateJWT"
      const resBody = await AuthRepository.updatePassword(body, req.user.userId);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

AuthRoutes.post(
  '/confirm-update-password',
  validate(AuthZODSchema.validateUserSchema),
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const body: TValidateUserSchema = req.body;
      const resBody = await AuthRepository.confirmUpdatePassword(body);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

AuthRoutes.post(
  '/verify-user',
  validate(AuthZODSchema.validateUserSchema),
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const body: TValidateUserSchema = req.body;
      const resBody = await AuthRepository.verifyUser(body);
      res.status(resBody.error ? resBody.error.code : HttpStatusCode.OK).json(resBody);
      next();
    } catch (err) {
      next(err);
    }
  }
);

export default AuthRoutes;

This router depends on 3 other files; The types and ZOD schema, the ZOD validator middleware, and the Auth Repo.

ZOD Validator Middleware.

All middlewares are injected in src/middlewares directory. So, let's create the middleware. Create validateRequest.middleware.ts file.

import { Request, Response, NextFunction } from 'express';
import { ZodError, ZodSchema } from 'zod';
import { ResponseHandler } from '@/utils/responseHandler';

function parseZodErrors(errors: ZodError) {
  return errors.errors.map((err) => `${err.path.join(', ')}: ${err.message}`);
}

export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse(req.body);
      next();
    } catch (error: any) {
      const resBody = ResponseHandler.InvalidBody({
        message: 'Validation Error',
        errors: parseZodErrors(error),
      });
      res.status(resBody.error!.code).json(resBody);
    }
  };
}

This middleware is simple. ZOD validates the schema passed through the middleware in Router handler. If it's valid, move to next handler. Otherwise, return a 422 response with the ZOD error to know which property isn't valid and what's the error. That's the role of parseZodErrors. It parses the ZodErrors to the ApiResponseBody message property.

ZOD Schemas

I used a class with static ZOD schema object to have a clean import section. And used .d.ts to declare the types. So, we got two files; the schema objects file, and type definitions file. Create a file src/schemas/auth/auth.schema.ts

import { z } from 'zod';

export class AuthZODSchema {
  static readonly authSchema = z.object({
    email: z.string().email(),
    password: z.string().min(8),
    type: z.enum(['DOCTOR', 'PATIENT', 'ADMIN']),
  });
  static readonly registerSchema = z.object({
    name: z.string().min(2),
    phone: z.string().refine((phone) => /^\+\d{10,15}$/.test(phone), 'Invalid phone number'),
    email: z.string().email(),
    password: z.string().min(8),
    type: z.enum(['DOCTOR', 'PATIENT']),
  });
  static readonly refreshTokenSchema = z.object({
    refreshToken: z.string().uuid(),
  });
  static readonly forgetPasswordSchema = z.object({
    email: z.string().email(),
    type: z.enum(['DOCTOR', 'PATIENT', 'ADMIN']),
  });
  static readonly resetPasswordSchema = z.object({
    newPassword: z.string().min(8),
    token: z.string().uuid(),
  });
  static readonly validateUserSchema = z.object({
    token: z.string().uuid(),
  });
  static readonly updatePasswordSchema = z.object({
    oldPassword: z.string().min(8),
    newPassword: z.string().min(8),
    type: z.enum(['DOCTOR', 'PATIENT', 'ADMIN']),
  });
}

Now create the definition file src/schemas/auth/auth.schema.d.ts

import { AuthZODSchema } from './auth.schema';
import { z } from 'zod';

declare global {
  type TAuthSchema = z.infer<typeof AuthZODSchema.authSchema>;
  type TRegisterSchema = z.infer<typeof AuthZODSchema.registerSchema>;
  type TForgetPasswordSchema = z.infer<typeof AuthZODSchema.forgetPasswordSchema>;
  type TResetPasswordSchema = z.infer<typeof AuthZODSchema.resetPasswordSchema>;
  type TUpdatePasswordSchema = z.infer<typeof AuthZODSchema.updatePasswordSchema>;
  type TValidateUserSchema = z.infer<typeof AuthZODSchema.validateUserSchema>;
  type TRefreshTokenSchema = z.infer<typeof AuthZODSchema.refreshTokenSchema>;
}

This definition is already declared in index.d.ts. We won't need to import each type in each file we use.

Setting up prisma

Before we start with the repository, we need to create the prisma models and migrate them. Create the schema in prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum UserType {
  DOCTOR
  PATIENT
  ADMIN
}

model User {
  id                  String               @id @unique @default(uuid())
  name                String
  email               String               @unique
  phone               String               @unique
  password            String
  verifiedEmail       Boolean              @default(false)
  userType            UserType
  refreshToken        RefreshToken[]
  resetPasswordToken  ResetPasswordToken?
  updatePasswordToken UpdatePasswordToken?
  verifyEmailToken    VerifyEmailToken?
  createdAt           DateTime             @default(now())
  updatedAt           DateTime             @updatedAt

  @@map("users")
}

model ResetPasswordToken {
  id        String   @id @unique @default(uuid())
  token     String   @unique
  expiresAt DateTime
  userId    String   @unique
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("reset_password_tokens")
}

model UpdatePasswordToken {
  id          String   @id @unique @default(uuid())
  token       String   @unique
  newPassword String
  expiresAt   DateTime
  userId      String   @unique
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@map("update_password_tokens")
}

model VerifyEmailToken {
  id        String   @id @unique @default(uuid())
  token     String   @unique
  expiresAt DateTime
  userId    String   @unique
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("verify_email_tokens")
}

model RefreshToken {
  id        String   @id @default(uuid())
  token     String   @unique
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now())

  @@map("refresh_tokens")
}

Now we migrate our schema. Make sure you created the DB and added the URL in .env

npx prisma migrate dev --name init

And because we will handle also the TEST DB, I created a script to migrate the migrations in that DB. Create the file in prisma/scripts/migrate.ts.

import { execSync } from 'child_process';
import { config } from 'dotenv';
config()

async function runMigrations() {
  try {
    console.log('Running migrations...');
    execSync(`cross-env DATABASE_URL=${process.env.TEST_DATABASE_URL} npx prisma migrate deploy`, {
      stdio: 'inherit',
    });
    console.log('Migrations completed successfully.');
  } catch (error) {
    console.error('Error running migrations:', error);
    process.exit(1);
  }
}

runMigrations()

Install cross-env before, and make sure you installed ts-node like mentioned before.

npm i -D cross-env

After that, you can run the script

npx ts-node prisma/scripts/migrate.ts

0
Subscribe to my newsletter

Read articles from Mehdi Jai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mehdi Jai
Mehdi Jai

Result-oriented Lead Full-Stack Web Developer & UI/UX designer with 5+ years of experience in designing, developing and deploying web applications.