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

Mehdi JaiMehdi Jai
12 min read

What we are building

Introduction

ExpressJS Web API with JWT full Auth System

In this article, we will walk through the full process of building an ExpressJS API with JWT Authentication system, and E2E testing. We will be building this API with:

  • NodeJS

  • ExpressJS

  • TypeScript

  • PrismaJS

  • PostgresSQL (Your choice)

  • Jest

  • NodeMailer

  • ZOD We will also use other packages to achieve certain goals.

Architecture

Before we dive in code, This is what we will be building. The 5 main process of an Auth System:

Login Process: Simple JWT powered login process. the Basic process, jsonwebtoken package handles the process of JWT behind the scene for us. Except the Refresh Token, We generate it and store it.

Refresh Token Process: After the token is expired, the client side should call this API Endpoint with the refresh token provided at login.

Register Process: This process have configured process, we have the process of requiring Email Verification, and without. In the API we will be building, I added this configuration to a config file. This process is quite simple too. We send the new users payload from client, The API checks the request body schema with ZOD and checks duplicated emails. If all is good, We create the new User in DB and return the UserObject. If the Email Verification if toggled on we generate a token and store it, then send an Email to the client with the token attached to the client's URL responsible for that process. After the user clicks the URL with the token, the client side sends the token in verify-user endpoint. The API Then validates the token, update the User verifiedEmail property, and delete the token from DB. And return a status response if everything is good. Also, This token has an Expiration Date (Currently set to 1 hour).

Reset Password Process: This process has two steps; The Forget Password triggering and the verification of Email, Then the reset password process. In the Forget Password step, The API receives the Email and Type (This type is business related property, For example we have many types of Users (ADMIN, User, Client...)). In this step, we only send the token in Email. This token is generated locally and stored in DB to validate it later. The Reset Process is triggered when the user clicks the link, goes to client side page and fill the Resetting password form. The new password is attached with the token and sent to API. After receiving the payload, we validate the token, update the password and finally delete the token from DB.

Update Password Process: This process is the only one in Auth System that requires the JWT Access Token provided in Login. We have two ways to update password (Configurable in the API as well). We have the simple one, Where we update directly the password, and the guarded one, where we only update after the user confirms the action through mail token. The simple way is simple as its name indicates. We just update the password, the access token is a way to confirm the user's identity (But not enough). Guarded way has another step for security check. Where we store the new password (Hashed of course), and add a token to that table. We send the token through email. After getting the token, The client sends it through confirm-update-password endpoint. We validate the token, if there is a match and it's not expired, we update the user's password with the stored one. Then delete the token record and return a success status.

Now we understand what we are building and how we build it. This Project is simple one, But the process of designing and engineering is required process to have a good, scalable, and clean product. You can apply the same methodology (It will vary depending on the product/system) to build more robust products and also train your brain to see Development in Design and Diagrams more than code in screen.

Requirements

To follow along smoothly this article you will need:

  • NodeJS

  • Knowledge of NodeJS, ExpressJS and PrismaJS

  • Knowledge of TypeScript

  • Postman (For testing -- optional)

Setup

You will find the full code source in GitHub

We will first install the basic requirements to setup ExpressJS and PrismaJS. Create the project's directory and initialize NPM:

mkdir nodexp-auth
cd nodexp-auth
npm init -y

In the directory run the next NPM install scripts:

npm i ts-node express tsconfig-paths uuid body-parser cors dotenv @prisma/client prisma

Also the types and devDependencies:

npm i -D typescript @types/node @types/express @types/body-parser @types/cors @types/uuid nodemon

Extra ExpressJS Third-Party Middlewares and clean code:

npm i cookie-parser express-rate-limit helmet x-xss-protection
npm i -D @types/cookie-parser @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint prettier

All in one:

npm i ts-node express tsconfig-paths uuid body-parser cors dotenv @prisma/client prisma cookie-parser express-rate-limit helmet x-xss-protection
npm i -D typescript @types/node @types/express @types/body-parser @types/cors @types/uuid nodemon @types/cookie-parser @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint prettier

Code Structure

The app will roughly follow this approach. We separate the code by domain; Router handlers, Server Handlers, Repository Handlers and Services Dependencies.

Now, initialize PrismaJS:

npx prisma init

Create tsconfig.json file and paste this code:

{
  "ts-node": {
    "files": true // To enable nodemon to read the .d.ts definitions on dev mode
  },
  "compilerOptions": {
    "target": "es6",
    "module": "CommonJS",
    "sourceMap": false,
    "strict": false,
    "outDir": "./dist",
    "esModuleInterop": true,
    "moduleResolution": "node",
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "typeRoots": ["./node_modules/@types"]
  },
  "types": ["src/index.d.ts"],
  "include": ["src/**/**/*.ts"],
  "exclude": ["node_modules", "public"]
}

This script will create prisma directory and the schema. Plus, .env file with DATABASE_URL variable.

The folder structure we gonna create now:

. 
├── prisma/
│ └── schema.prisma
├── src/
│ ├── config/
│ ├── middlewares/
│ ├── repositories/
│ ├── routes/
│ │ └── v1/
│ ├── schemas/
│ ├── services/
│ ├── utils/
│ ├── app.ts
│ └── server.ts
├── .env
├── .eslintignore 
├── .eslintrc.json
├── .prettierignore
├── .prettierrc
├── package.json
├── package-lock.json
└── tsconfig.json

The prisma folder will be generated automatically. You will find the ESLint and Prettier configs in the GitHub Repo. Just paste them.

In package.json, Add these scripts:

{
// ...
    "scripts": {
        "dev": "nodemon -r tsconfig-paths/register src/server.ts",
        "start": "node dist/server.js",
        "format": "prettier --write .",
        "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
        "typecheck": "tsc --noEmit -p tsconfig.json --composite false",
        "build": "typecheck && tsc"
    },
// ...
}

Setting up ExpressJS

The app.ts file will handle all ExpressJS setup and code. And server.ts will only call it and listen to it. In app.ts, we will follow this structure:

  • Initialize Express App

  • Setup Middlewares

  • Handle Routes

  • Handle Not Found Route

  • Disconnect Prisma instance (will be handled later after setting up prisma service)

  • Export the Express App

import bodyParser from 'body-parser';
import express, { NextFunction, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import xss from 'x-xss-protection';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import v1Routes from './routes/v1';
import { parseAPIVersion } from './config/app.config';
import HttpStatusCode from './utils/HTTPStatusCodes';
import prisma from '@/services/prisma.service';
import { ApiResponseBody } from '@/utils/responseHandler';

const app = express();

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(
  cors({
    origin: '*',
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  })
);
app.use(helmet());
app.use(xss());
app.use(cookieParser());

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
});
app.use(limiter);

// Handle Routes
app.use(parseAPIVersion(1), v1Routes);

app.all('*', (_, res: Response, next: NextFunction) => {
  const resBody = new ApiResponseBody<any>();
  resBody.error = {
    code: HttpStatusCode.NOT_FOUND,
    message: 'Route Not Found',
  };
  res.status(HttpStatusCode.NOT_FOUND).json(resBody);
  next();
});

// Graceful shutdown
process.on('SIGINT', async () => {
  await prisma.$disconnect();
  process.exit(0);
});

export default app;

This code uses some functions and dependencies we haven't created yet. Let's create them.

App Config

This file will handle the configs of the API and import the environment variables. Create a file named app.config.ts in src/config/ directory.

// src/config/app.config.ts

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

// Configs
const appConfig = {
  apiURI: '/api/$v',
  requireVerifyEmail: true, // As mentioned before in the Architecture section, This process is configurable.
  updatePasswordRequireVerification: true, // As mentioned before in the Architecture section, This process is configurable.
  apiVersion: '1.0.0', // this version will be returned in the API check route
  apiName: 'NodeJS Express API', // this name will be returned in the API check route
  jwt: { // The variables required for JWT
    secret: process.env.JWT_SECRET_KEY!,
    refreshSecretKey: process.env.REFRESH_SECRET_KEY!,
    expiresIn: '15d',
  },
  logRootPath: '.logs', // The root directory for logging using Winston
};

export default appConfig;

// this function will parse the API URI for us, we just give the version number.
export function parseAPIVersion(version: number) {
  return appConfig.apiURI.replace('$v', `v${version}`);
}

We will need also to add those environment variables to .env file:

PORT=3000
JWT_SECRET_KEY="secret"
JWT_REFRESH_SECRET_KEY="secret"
STAGE=DEV # Will be used in testing
DATABASE_URL="postgresql://postgres:pw@localhost:port/db"
TEST_DATABASE_URL="postgresql://postgres:pw@localhost:port/db-test"

# The Client Side urls to attach the token and send through emails
RESET_PASSWORD_UI_URL=http://localhost:3001/auth/reset-password
VERIFY_EMAIL_UI_URL=http://localhost:3001/auth/verify
CONFIRM_UPDATE_PASSWORD_EMAIL_UI_URL=http://localhost:3001/auth/confirm-password

# MAIL Configuration
MAIL_FROM_EMAIL=sender@email.com
MAIL_FROM_NAME=Sender
MAIL_HOST=sandbox.smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USER=username
MAIL_PASS=password

Response Handler

The purpose of this handler is to manage and unify the API response bodies. This handler depends also on HTTPStatusCodes.ts, Which is just an enumerator of HTTP status codes.

In src/utils/ create a file named HTTPStatusCodes.ts and paste the content from GitHub repo file ATTACHED. Also in the same directory, create a file named responseHandler.ts. This file will include two classes.

import HttpStatusCode from './HTTPStatusCodes';

// The JSON response wrapper.
export class ApiResponseBody<T = undefined> {
  error?: {
    code: number;
    message: string;
  };
  data?: T;
}

// A class to handle the common error responses and return the ApiResponseBody for those errors.
export class ResponseHandler {
  static response(message: any, status: HttpStatusCode) {
    const response = new ApiResponseBody();
    response.error = {
      code: status,
      message: message,
    };
    return response;
  }

  static NoDataResponse(message: any = 'Operation successful') {
    return this.response(message, HttpStatusCode.OK);
  }
  static NotFound(message: any = 'Not found') {
    return this.response(message, HttpStatusCode.NOT_FOUND);
  }
  static InvalidBody(message: any = 'Invalid request body') {
    return this.response(message, HttpStatusCode.UNPROCESSABLE_ENTITY);
  }
  static Unauthorized(message: any = 'Unauthorized') {
    return this.response(message, HttpStatusCode.UNAUTHORIZED);
  }
  static Forbidden(message: any = 'Forbidden') {
    return this.response(message, HttpStatusCode.FORBIDDEN);
  }
  static BadRequest(message: any = 'Bad Request') {
    return this.response(message, HttpStatusCode.BAD_REQUEST);
  }
}

This ApiResponseBody will be used like this:

app.all('*', (_, res: Response, next: NextFunction) => {
  const resBody = new ApiResponseBody<any>(); // Intialize the class
  resBody.error = {
    code: HttpStatusCode.NOT_FOUND,
    message: 'Route Not Found',
  };
  res.status(HttpStatusCode.NOT_FOUND).json(resBody); // return the class object
  next();
});

This code will return:

{
    "error": {
        "code": 404,
        "message": "Route Not Found"
    }
}

We can also use the ResponseHandler class like this:

app.all('*', (_, res: Response, next: NextFunction) => {
  const resBody = ResponseHandler.NotFound("Route Not Found")
  res.status(HttpStatusCode.NOT_FOUND).json(resBody);
  next();
});

Prisma Service

At the end of Express App, we disconnect prisma. The prisma service is a singleton for prisma client, it also handles the Test DB environment.

In src/services/ create a file named prisma.service.ts.

// src/services/prisma.service.ts

import { logger } from '@/utils/winston'; // You can use just console logs if you want
import { PrismaClient } from '@prisma/client';

declare global {
  var prisma: PrismaClient | undefined;
}

let prisma: PrismaClient; 

try {
  if (process.env.NODE_ENV === 'production') {
    prisma = new PrismaClient();
  } else if (process.env.STAGE === 'TEST') { // The STAGE env var in .env changes in Testing mode. And we use another DB for testing.
    prisma = new PrismaClient({
      datasourceUrl: process.env.TEST_DATABASE_URL,
    });
  } else {
    if (!global.prisma) {
      global.prisma = new PrismaClient();
    }
    prisma = global.prisma;
  }
} catch (err) {
  logger.error(err); // You can use console.error
}

export default prisma;
// src/app.ts

// This code in app.ts will disconnect prisma in exiting
// Graceful shutdown
process.on('SIGINT', async () => {
  await prisma.$disconnect();
  process.exit(0);
});

Winston Logs

I used Winston to handle the logs and write them in log files. Winston is a good package for this. It creates multi-channels for logs and enable creating files for each level, and also print out in console. First, install it:

npm i winston

Then, create winston.ts in src/utils.

import appConfig from '@/config/app.config';
import winston, { transports } from 'winston';

export const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json(),
    winston.format.prettyPrint()
  ),
  transports: [
    // new winston.transports.Console(),
    new winston.transports.File({
      filename: appConfig.logRootPath + '/errors.log',
      level: 'error',
    }),
    new winston.transports.File({
      filename: appConfig.logRootPath + '/warnings.log',
      level: 'warn',
    }),
    new winston.transports.File({ filename: appConfig.logRootPath + '/info.log', level: 'info' }),
    new winston.transports.File({ filename: appConfig.logRootPath + '/debug.log', level: 'debug' }),
    new winston.transports.File({
      filename: appConfig.logRootPath + (process.env.STAGE === 'TEST' ? '/test.log' : '/app.log'),
      level: '',
    }),
  ],
});

logger.exceptions.handle(
  new transports.File({ filename: appConfig.logRootPath + '/exceptions.log' })
);

Routes

Now we handles the routes. And for API versioning, We will create an index.ts file in src/routes/v{version}. That file will handle the routes for that version.

app.use(parseAPIVersion(1), v1Routes); // Handle v1 routes

With that being said, Let's create the index file in src/routes/v1/index.ts The router index will call all the routes for v1.

import { Response, Router } from 'express';
import AuthRoutes from './auth.route';
import appConfig from '@/config/app.config';
import { authenticateJWT } from '@/middlewares/jwt.middleware';
import HttpStatusCode from '@/utils/HTTPStatusCodes';

const routes = Router();

routes.get('/', (_, res: Response, next) => {
  res.status(HttpStatusCode.OK).json({
    name: appConfig.apiName,
    version: appConfig.apiVersion,
    dateTime: new Date().toISOString(),
    status: 'RUNNING',
  });
  next();
});

// To test authentication
routes.get('/protected', authenticateJWT, (_, res: Response, next) => {
  res.status(HttpStatusCode.OK).json({
    name: appConfig.apiName,
    version: appConfig.apiVersion,
    dateTime: new Date().toISOString(),
    status: 'RUNNING',
    protected: true,
  });
  next();
});

routes.use('/auth', AuthRoutes);

export default routes;

This router calls the auth.route.ts and uses JWT middleware for the protected API endpoint.


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.