Implementing Basic Authentication using Express and Typescript

Do you use express for your backend and curious about building a basic auth using express and Typescript or exploring to adapt Typescript for your application backend? Then, you're in the right place. I often see most of the tutorial recommends frameworks like NestJS. But, An important thing to keep in mind is to learn the basics and structure before jumping into the framework. So, we are going to build express typescript basic auth

Here, we will start with the essential Typescript concepts and patterns to build our backend system. Of course, it can be opinionated too. But let's compare them and understand when we need those concepts.

Before getting into the content, the whole tutorial is split into series to explain it in detail.

  1. Project Structure, Database Connection, and Error Handling - Part 1
  2. Authentication System using Express and Typescript - Part 2

Setting up Express and Typescript is simple and straightforward. There are a lot of articles and documentation are available for initial setup. Check this out.

Project Structure

Once you set up the basic skeleton for the application. It's time to understand the project structure. Here, we are going to follow a modular system.

Let's say that our application has User and project API. So we are going to create a User and Project directory and group them based on it.

We can group all the helpers and common functionalities under the Common directory. It helps us to separate the logic, which comes in handy when the application grows.

Here's the finalized version of our project structure.

express typescript basic auth

Each module will contain different files that perform functionality. They are,

Route Configuration - It handles all the routes for the particular module. For example, if we have a user module. It handles all the GET, POST, PUT for the user module.

Controller - It manages the business logic for each endpoint. It can be fetching the users, updating the user details, etc.

Services - It handles the connection with database models and performing queries, and return that data to Controller.

Model - Here, we define our mongoose schema, which is an ODM for MongoDB. We can also use sequelize to handle Postgres, MySQL.

Interface - it helps us to define custom data types.

Route Configuration

We can configure routes in many different ways. However, since we use Express and Typescript, we will go with Typescript concepts for a more structural codebase.

Every module will have routes for its functionality. So, the structure will be the same for all the modules. To restrict the same standard across our codebase, we will use an abstraction pattern with Typescript.

Implement an abstract class with an abstract method for route configuration,

1import express, { Application } from "express"
2
3export abstract class RouteConfig {
4 app: Application
5 name: string
6 constructor(app: Application, name: string) {
7 this.app = app
8 this.name = name
9 this.configureRoutes()
10 }
11
12 getName() {
13 return this.name
14 }
15
16 abstract configureRoutes(): Application
17}

Here, we have properties such as app , name, and an abstract method.

app - it contains the Express Application Instance

name - it defined the route config name. For example, User Route Config.

configureRoutes - this is where all the classes will implement the router for a module.

Every class that inherits this abstract class must implement configureRoutes method. In that way, we will know it follows a standard.

Let's create a route config for the User module,

1import { RouteConfig } from "../Common/common.route.config"
2import express, { Application, Request, Response } from "express"
3import UserController from "./user.controller"
4
5export class UserRoutes extends RouteConfig {
6 constructor(app: Application) {
7 super(app, "UserRoutes")
8 }
9
10 configureRoutes() {
11 this.app.route(`/users`).get([UserController.getUsers])
12
13 return this.app
14 }
15}

An important thing to note here is configureRoutes. Here, we have our route implementation. it may be different for developers who used app.get() and app.post(). But, We use app.route().

It provides an instance of a single route with which we can handle HTTP verbs with middleware. It mainly helps to group the route HTTP's based on the name. For example, we can group all the GET, POST, PUT requests of route users. We can also pass middleware to that particular route.

We define GET request for route,

1this.app.route(`/users`).get([UserController.getUsers])

Controller for the User is simple and straight forward. It will be something like,

1import { Request, Response, NextFunction } from "express"
2class UserController {
3 constructor() {}
4
5 async getUsers(req: any, res: Response, next: NextFunction) {
6 return res.status(200).json({
7 success: true,
8 data: [
9 {
10 name: "John",
11 },
12 {
13 name: "Steve",
14 },
15 ],
16 })
17 }
18}
19
20export default new UserController()

Once you create routes and Controllers, you can add them in index.ts. Next, we instantiate each module route and serve them in the entry file.

1import { RouteConfig } from "./Common/common.route.config"
2import { UserRoutes } from "./User/user.route.config"
3
4const routes: Array<RouteConfig> = []
5
6routes.push(new UserRoutes(app))

index.ts after the changes,

1import express, { Express, Application, Request, Response } from "express"
2import * as http from "http"
3import cors from "cors"
4import dotenv from "dotenv"
5import { RouteConfig } from "./Common/common.route.config"
6import { UserRoutes } from "./User/user.route.config"
7const routes: Array<RouteConfig> = []
8
9const app: Express = express()
10
11dotenv.config({})
12
13app.use(express.json())
14app.use(cors())
15
16const PORT = process.env.PORT || 8000
17
18if (process.env.DEBUG) {
19 process.on("unhandledRejection", function(reason) {
20 process.exit(1)
21 })
22} else {
23}
24
25routes.push(new UserRoutes(app))
26
27app.get("/", (req: Request, res: Response) => {
28 res.send("Welcome world")
29})
30
31const server: http.Server = http.createServer(app)
32server.listen(PORT, () => {
33 console.log(`Server is running on ${PORT}`)
34
35 routes.forEach((route: RouteConfig) => {
36 console.log(`Routes configured for ${route.getName()}`)
37 })
38})

So far, we have a basic setup for route and Controller. If you run the application, you can see the application running,

express typescript basic auth

Database Connection

Before implementing the connection to the database, we need to install the required dependencies for it.

1npm i mongoose debug
2npm i --save-dev @types/mongoose @types/debug

We all know the use of Mongoose. If you're new to Mongoose, you can learn the basics here. We also install debug package. It helps us to log all the debug in the console. It's better than console.log

Let's connect MongoDB with the application. Create directory services and mongoose.services.ts and add the following code,

1import mongoose from "mongoose"
2import debug, { IDebugger } from "debug"
3
4const log: IDebugger = debug("app:mongoose-service")
5
6class MongooseService {
7 private count = 0
8 private mongooseOptions = {
9 useNewUrlParser: true,
10 useUnifiedTopology: true,
11 useCreateIndex: true,
12 serverSelectionTimeoutMS: 5000,
13 useFindAndModify: false,
14 }
15
16 constructor() {
17 this.connectWithRetry()
18 }
19
20 getInstance() {
21 return mongoose
22 }
23
24 connectWithRetry() {
25 log("process.env.MONGODB_URI", process.env.MONGODB_URI)
26 const MONGODB_URI = process.env.MONGODB_URI || ""
27 log("Connecting to MongoDB(Retry when failed)")
28 mongoose
29 .connect(MONGODB_URI, this.mongooseOptions)
30 .then(() => {
31 log("MongoDB is connected")
32 })
33 .catch(err => {
34 const retrySeconds = 5
35 log(
36 `MongoDB connection unsuccessful (will retry #${++this
37 .count} after ${retrySeconds} seconds):`,
38 err
39 )
40 setTimeout(this.connectWithRetry, retrySeconds * 1000)
41 })
42 }
43}
44
45export default new MongooseService()

connectWithRetry is the main function that connects our application to MongoDB. It also retries the connection after 5 seconds of the failure.

We get an instance of Mongoose using the getInstance method to have a single instance across the application.

Authentication

So far, we have seen Project Structure, Route Configuration, and Database Connection. It's time to implement Authentication for the application. For Authentication, we need

  • Login.
  • Signup.
  • User route to get the logged-in user.

Since Authentication is a workflow itself, we can create a separate module and write logic there. So here's the flow to build Authentication.

  • Create User Model - It creates a model with which we can connect with MongoDB users collection.
  • Implement Authentication Route Configuration - route configuration for Auth. i.e., login and signup
  • Create Authentication Controller - Controller interacts with services for data fetch and update.
  • Build Authentication Service - Service interacts with DB for database operations.

Create user.model.ts and add the following code,

1import MongooseService from "../Common/services/mongoose.service"
2import { model, Schema, Model, Document } from "mongoose"
3import { scrypt, randomBytes } from "crypto"
4import { promisify } from "util"
5import { IUser } from "./user.interface"
6import { Password } from "../Common/services/password"
7const scryptAsync = promisify(scrypt)
8export interface UserDocument extends Document {
9 email: string
10 password: string
11 username: string
12}
13
14interface UserModel extends Model<UserDocument> {
15 build(attrs: IUser): UserDocument
16}
17
18const UserSchema: Schema = new Schema(
19 {
20 email: { type: String, required: true },
21 password: { type: String, required: true },
22 username: { type: String, required: true },
23 },
24 {
25 toObject: {
26 transform: function(doc, ret) {},
27 },
28 toJSON: {
29 transform: function(doc, ret) {
30 delete ret.password
31 },
32 },
33 }
34)
35
36UserSchema.pre("save", async function(done) {
37 if (this.isModified("password")) {
38 const hashed = await Password.toHash(this.get("password"))
39 this.set("password", hashed)
40 }
41 done()
42})
43
44UserSchema.statics.build = (attrs: IUser) => {
45 return new User(attrs)
46}
47
48const User = MongooseService.getInstance().model<UserDocument, UserModel>(
49 "User",
50 UserSchema
51)
52
53export default User

It may be overwhelming and confusing at the beginning. But let me break it down and explain everything.

First and foremost, Every Mongoose model starts with a schema. So, we define a Schema here.

1const UserSchema: Schema = new Schema(
2 {
3 email: { type: String, required: true },
4 password: { type: String, required: true },
5 username: { type: String, required: true },
6 },
7 {
8 toObject: {
9 transform: function(doc, ret) {},
10 },
11 toJSON: {
12 transform: function(doc, ret) {
13 delete ret.password
14 },
15 },
16 }
17)

Every time we send the user data in response. We don't want to expose the password in response. So to remove it, we implement this toJSON here.

1{
2 toObject: {
3 transform: function (doc, ret) {},
4 },
5 toJSON: {
6 transform: function (doc, ret) {
7 delete ret.password;
8 },
9 },
10 }

While users signup, we need to insert that data into DB. In Mongoose, we can implement it in two ways.

  1. Using <Model>.create method
  2. Using new keyword to create an object. For example, new User().

We are going to use a new User to create a user here. But the problem with that approach is, Typescript doesn't check the type when we create a new User(). So it doesn't complain even if we change the argument name in it.

1new User({
2 email: "",
3 pass: "", // This should be password. but, we are passing a wrong argument and Typescript doesn't complain.
4})

It completely affects the purpose of using Typescript in the application. To fix it, we somehow need to create a method that checks the type before passing it to a new User().

create an interface user.interface.ts and add the following code

1export interface IUser {
2 email: string
3 password: string
4 username: string
5}
1UserSchema.statics.build = (attrs: IUser) => {
2 return new User(attrs)
3}

So, we create a build method that takes the type of IUser, which is an interface here, and pass it to a new User()

If we try to use User.build in our Controller. Typescript will complain something like this,

Basic%20Authentication%20using%20Express%20and%20Typescript%20de5ebf8c50e84947acdd78acd22f166e/Screenshot_2021-05-09_at_2.25.22_PM.png

Even though we created a custom method in the Schema, the model Type in Typescript doesn't know. So, we need to extend Mongoose Model and add this custom method to it.

1export interface UserDocument extends Document {
2 email: string
3 password: string
4 username: string
5}
6
7interface UserModel extends Model<UserDocument> {
8 build(attrs: IUser): UserDocument
9}

That completes the User Model. let's create Auth route, Controller and services.

auth.route.config

1import { Application, Request, Response } from "express"
2import { RouteConfig } from "../Common/common.route.config"
3import AuthController from "./auth.controller"
4export class AuthRoutes extends RouteConfig {
5 constructor(app: Application) {
6 super(app, "AuthRoutes")
7 }
8
9 configureRoutes() {
10 this.app.route("/login").post(AuthController.login)
11
12 this.app.route("/signup").post(AuthController.signup)
13
14 return this.app
15 }
16}

auth.controller.ts

1import { NextFunction, Request, Response } from "express"
2import AuthService from "./auth.service"
3import jwt from "jsonwebtoken"
4import debug, { IDebugger } from "debug"
5import { Password } from "../Common/services/password"
6const jwtSecret: string = process.env.JWT_SECRET || "123456"
7const tokenExpirationInSeconds = 36000
8
9const log: IDebugger = debug("auth:controller")
10
11class AuthController {
12 constructor() {}
13
14 async login(req: Request, res: Response, next: NextFunction) {
15 try {
16 const email = req.body.email
17 const password = req.body.password
18
19 const user = await AuthService.findUserByEmail(email)
20 log("user", user)
21 if (user) {
22 const isPasswordMatch = await Password.compare(user.password, password)
23
24 if (!isPasswordMatch) {
25 throw new Error("Invalid Password")
26 } else {
27 log("jwt Secret", jwtSecret)
28 const token = jwt.sign(req.body, jwtSecret, {
29 expiresIn: tokenExpirationInSeconds,
30 })
31
32 return res.status(200).json({
33 success: true,
34 data: user,
35 token,
36 })
37 }
38 } else {
39 log("User Not Found")
40 throw new Error("User Not Found")
41 }
42 } catch (e) {
43 next(e)
44 }
45 }
46
47 async signup(req: Request, res: Response, next: NextFunction) {
48 try {
49 const username = req.body.username
50 const email = req.body.email
51 const password = req.body.password
52
53 const user = await AuthService.findUserByEmail(email)
54 log("user", user)
55 if (user) {
56 throw new Error("User Already Exists")
57 } else {
58 try {
59 const newUser = await AuthService.createUser({
60 username,
61 email,
62 password,
63 })
64
65 const token = jwt.sign({ username, password }, jwtSecret, {
66 expiresIn: tokenExpirationInSeconds,
67 })
68
69 return res.status(200).json({
70 success: true,
71 data: newUser,
72 token,
73 })
74 } catch (e) {
75 log("Controller capturing error", e)
76 throw new Error("Error while register")
77 }
78 }
79 } catch (e) {
80 next(e)
81 }
82 }
83}
84
85export default new AuthController()

auth.service.ts

1import User from "../User/user.model"
2import { IUser } from "../User/user.interface"
3class AuthService {
4 async createUser(data: IUser) {
5 try {
6 const user = User.build(data)
7 await user.save()
8 } catch (e) {
9 throw new Error(e)
10 }
11 }
12
13 async findUserByEmail(email: string) {
14 return User.findOne({
15 email: email,
16 }).exec()
17 }
18}
19
20export default new AuthService()

Middleware

To validate the user based on the jwt token, create a middleware JWT.ts and add the following code,

1import jwt from "jsonwebtoken"
2import { Request, Response, NextFunction } from "express"
3const JWT_KEY = process.env.JWT_SECRET || "123456"
4import debug, { IDebugger } from "debug"
5
6const log: IDebugger = debug("middleware:JWT")
7
8class JWT {
9 authenticateJWT(req: Request, res: Response, next: NextFunction) {
10 const authHeader = req.headers.authorization
11 if (authHeader && authHeader !== "null") {
12 // const token = authHeader.split(" ")[1];
13 log("auth Header", JWT_KEY)
14 jwt.verify(authHeader, JWT_KEY, (err: any, user: any) => {
15 if (err) {
16 log("Error", err)
17 return res
18 .status(403)
19 .send({ success: false, message: "Token Expired" })
20 }
21 req.user = user
22 next()
23 })
24 } else {
25 res.status(403).json({ success: false, message: "UnAuthorized" })
26 }
27 }
28}
29
30export default new JWT()

We get the token from the header and verify it with just. We can use the middleware in the route like,

1this.app.route(`/user`).get([JWT.authenticateJWT, UserController.getUser])

Complete source code is available here

To Read More

Modern React Redux Toolkit - Login ...

User Authentication is one of the common workflow in web applications. In this t...

Building Nodejs Microservice - A Cl...

This Article explains everything about how to build Nodejs Microservices in clou...

I Accidentally wiped the entire dat...

One of the tragic accident in my job turned out to be good learning for me in re...

Never miss a story from us, subscribe to our newsletter