Create Authentication system with NodeJS, ExpressJS, TypeScript and Jest E2E testing -- PART 1.
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 theUserObject
. 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 inverify-user
endpoint. The API Then validates the token, update the UserverifiedEmail
property, and delete the token from DB. And return astatus
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.
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.