Building an Authentication Service with JSON Web Tokens and Express

18oct2019

Nowadays, microservices are the preferred architecture due to its flexibility and scalability over a traditional monolithic application. However, when an application is split across multiple services, managing authentication becomes difficult. Traditionally, authentication is handled through a server-side session that tracks the user’s state. However, microservices are expected to be stateless and have a single responsibility. Therefore, a modern stateless approach was created, known as client tokens. Rather than storing sessions on a server, client tokens are held by the user. With each request, the user sends the token to the server in order to authenticate the user and determine authorization.

In this guide we will implement a token-based authentication service using JSON web tokens (JWT) in Express. For the sake of this tutorial, the service will only handle registering users and issuing tokens. However, it can be easily extended to include other common functionality, such as managing user accounts and permissions.

The Initial Setup

The authentication service with be implemented in TypeScript. Therefore, we must first set up our dev environment. First, create your root directory and run npm init to create the initial package.json file.

First, we install our main dependencies.

npm i -S express argon2 cookie-parser jsonwebtoken mongoose

 

Below is a list of each library used:

  • express: A web framework for node.js
  • argon2: A password hashing library
  • cookie-parser: An express middleware to parse cookies
  • jsonwebtoken: The JWT library
  • mongoose: An object document mapper (ODM) for MongoDB

Next, we install the dev dependencies, specifically TypeScript:

npm i -D typescript tslint ts-node

 

Once installed, create a tsconfig.json in the root directory containing the following settings:




{

  "compilerOptions": {

      "module": "commonjs",

      "esModuleInterop": true,

      "allowSyntheticDefaultImports": true,

      "target": "es5",

      "noImplicitAny": false,

      "sourceMap": true,

      "outDir": "build"

  },

  "exclude": [

      "node_modules",

      "**/*.spec.ts",

      "**/*.test.ts"

  ],

  "include": [

      "**/*.ts"

  ],

}

 

Additionally, create a tslint.json file to set up the linter. Below are the rules I prefer, however, feel free to use what you see fit!

{

    "defaultSeverity": "error",

    "extends": [

        "tslint:recommended"

    ],

    "jsRules": {},

    "rules": {

        "ordered-imports": false,

        "object-literal-sort-keys": false,

        "class-name": true,

        "comment-format": [

            true,

            "check-space"

        ],

        "indent": [

            true,

            "spaces"

        ],

        "one-line": [

            true,

            "check-open-brace",

            "check-whitespace"

        ],

        "no-var-keyword": true,

        "quotemark": [

            true,

            "single",

            "jsx-double"

        ],

        "semicolon": [

            true,

            "always",

            "ignore-bound-class-methods"

        ],

        "whitespace": [

            true,

            "check-branch",

            "check-decl",

            "check-operator",

            "check-module",

            "check-separator",

            "check-type"

        ],

        "typedef-whitespace": [

            true,

            {

                "call-signature": "nospace",

                "index-signature": "nospace",

                "parameter": "nospace",

                "property-declaration": "nospace",

                "variable-declaration": "nospace"

            },

            {

                "call-signature": "onespace",

                "index-signature": "onespace",

                "parameter": "onespace",

                "property-declaration": "onespace",

                "variable-declaration": "onespace"

            }

        ],

        "interface-name" : [true, "never-prefix"],

        "no-internal-module": true,

        "no-trailing-whitespace": true,

        "no-null-keyword": true,

        "prefer-const": true,

        "jsdoc-format": true

    },

    "rulesDirectory": []

}

 

Next, the types for each library must be installed and saved as a dev dependency:

npm i -D @types/argon2 @types/cookie-parser @types/express @types/jsonwebtoken @types/mongoose

 

Finally, we can set up hot loading for development. This will save us from having to transpile our ts code to js each time we make a change. For this, we will use nodemon:

npm i -D nodemon

To get nodemon working nicely with TypeScript, we must create the following nodemon.json config:

{

    "ignore": ["**/*.test.ts", "**/*.spec.ts", ".git", "node_modules"],

    "watch": ["."],

    "exec": "npm start",

    "ext": "ts"

}

 

Next, the following scripts are added to the package.json.

"scripts": {

"dev": "./node_modules/nodemon/bin/nodemon.js",

"deploy": "tsc && node build/bin/server.js",

}

 

Running npm run dev will spin up nodemon, which will observe any changes to our code and hot load while npm run deploy will compile the ts code, save the result in build directory (don’t forget to make this directory) then run it.

Setting Up Express

The first step is creating the http server for the app. Create a directory titled bin and within it the file server.js containing the following (Note: this file is copied directly from bin/www within the official express generator):

// /bin/server.js


import * as http from 'http';

import app from '../app';


/**

* Get port from environment and store in Express.

*/


const port = normalizePort(process.env.PORT || '3000');

app.set('port', port);


/**

* Create HTTP server.

*/


const server = http.createServer(app);


/**

* Listen on provided port, on all network interfaces.

*/


server.listen(port);

server.on('error', onError);

server.on('listening', onListening);


/**

* Normalize a port into a number, string, or false.

*/


function normalizePort(val) {

  const portNum = parseInt(val, 10);


  if (isNaN(portNum)) {

    // named pipe

    return val;

  }


  if (portNum >= 0) {

    // port number

    return portNum;

  }


  return false;

}


/**

* Event listener for HTTP server "error" event.

*/


function onError(error) {

  if (error.syscall !== 'listen') {

    throw error;

  }


  const bind = typeof port === 'string'

    ? 'Pipe ' + port

    : 'Port ' + port;


  // handle specific listen errors with friendly messages

  switch (error.code) {

    case 'EACCES':

      // tslint:disable-next-line:no-console

      console.error(bind + ' requires elevated privileges');

      process.exit(1);

      break;

    case 'EADDRINUSE':

      // tslint:disable-next-line:no-console

      console.error(bind + ' is already in use');

      process.exit(1);

      break;

    default:

      throw error;

  }

}


/**

* Event listener for HTTP server "listening" event.

*/


function onListening() {

  const addr = server.address();

  const bind = typeof addr === 'string'

    ? 'pipe ' + addr

    : 'port ' + addr.port;

  // tslint:disable-next-line:no-console

  console.log('Listening on ' + bind);

}

 

With the http server in place, we can create the app.ts file in the root directory:

// app.ts


import cookieParser from 'cookie-parser';

import express from 'express';

import mongoose from 'mongoose';

import logger from 'morgan';


// Path to your mongodb instance 

const mongoURL = 'mongodb://localhost/jwtAPI';


// Connecting mongoose to the mongo instance

mongoose.connect(mongoURL).then(() => {}).catch((err) => {

    console.log('MongoDB connection error: ' + err);

});


const app = express();


app.use(express.json());

app.use(express.urlencoded({ extended: false }));

app.use(cookieParser());



export default app;

 

 

At this point, the project should have the following structure:

project/

    /bin

        server.js

    app.ts

    nodemon.json

    tsconfig.json

    tslint.json

    package.json

    package-lock.json

Creating the User

For the authentication app, we want to create a user object. First, create a directory user and within this directory, create the file user.model.ts . This file will define the mongoose schema for a user:

// user/user.model.ts


import { Document, model, Schema } from 'mongoose';


export interface UserInterface extends Document {

    username: string;

    email: string;

    password: string;

  }


const UserSchema = new Schema({

    password: {

        required: true,

        type: String,

    },

    email: {

        dropDups: true,

        index: true,

        required: true,

        type: String,

        unique: true,

    },

    username: {

        dropDups: true,

        index: true,

        required: true,

        type: String,

        unique: true,

    },

});


export default model<UserInterface>('User', UserSchema);

 

Signing up a user

With a user model in place, we can create the logic for creating a user and signing in. To do this, we will create a file called user.api.ts.

First, we will set up the logic for signing up, which will take the username, email and password from the post request body, hash the password with argon2, and create the new user.

// user/user.api.ts


import * as argon2 from 'argon2';

import { Request, Response } from 'express';

import User, { UserInterface } from './user.model';


export default {

    signUp: async (req: Request, res: Response) => {

        try {

            // fetch user info from request body

            const { username, email, password }: UserInterface = req.body;

            // Hash the password, you don't want to store this as plain text :P

            const pwdHash = await argon2.hash(password);

            // Create the new User and save it in the DB

            const userDoc = new User({ username, email, password: pwdHash });

            await userDoc.save();


            res.status(200).json({

                message: `logged in as ${username}`,

            });

        } catch (err) {

            res.status(400).json({

                err,

                message: 'request body should take the form { username, email, password }',

            });

        }

    },

}

 

Signing in a User and Retrieving a JWT

When a user signs in, we want to generate a JWT token. Token generation consists of three steps:

  1. Constructing the payload: the information that is stored in the token, such as the user id or role. Note: the payload should never contain sensitive information because while JSON are signed they are not encrypted!
  2. Set an expiration.
  3. Sign the token.

This is done via the following function:

import * as jwt from 'jsonwebtoken';


const generateToken = (user: UserInterface): string => {

    const data = {

        id: user._id,

        username: user.username,

        email: user.email,

    };

    const signature = 'supercool_secret'; // This is your secret key used to sign the token. Make sure it's stored in a safe place!


    const expiresIn = '6h';


    return jwt.sign(

        { data },

        signature,

        { expiresIn },

    );

};

 

Next, we can add the sign in logic. This will grab username and password from the response body, verify it is valid, and if so, return a signed JWT. Putting everything together up until this point the user.api.ts file contains the following:

// user/user.api.ts


import * as argon2 from 'argon2';

import { Request, Response } from 'express';

import User, { UserInterface } from './user.model';

import * as jwt from 'jsonwebtoken';


const generateToken = (user: UserInterface): string => {

    const data = {

        id: user._id,

        username: user.username,

        email: user.email,

    };

    const signature = 'supercool_secret';

    const expiresIn = '6h';


    return jwt.sign(

        { data },

        signature,

        { expiresIn },

    );

};


export default {

    signUp: async (req: Request, res: Response) => {

        try {

            const { username, email, password }: UserInterface = req.body;

            const pwdHash = await argon2.hash(password);

            const userDoc = new User({ username, email, password: pwdHash });

            await userDoc.save();

            res.status(200).json({

                message: `logged in as ${username}`,

            });

        } catch (err) {

            res.status(400).json({

                err,

                message: 'request body should take the form { username, email, password }',

            });

        }

    },

    signIn: async (req: Request, res: Response) => {

        const { username, password } = req.body;

        try {

            const user = await User.findOne({ username });

            if (!user) {

                throw new Error(`Invalid username or password`);

            }

            const verified = await argon2.verify(user.password, password);

            if (!verified) {

                throw new Error(`Invalid username or password`);

            }


            res.status(200).json({

                token: generateToken(user),

            })


        } catch (err) {

            res.status(400).json({

                err,

            });

        }

    }

};

 

Routes

Next, we will create the routes for sign up and sign in by creating a new file called user.router.ts:

// user/user.router.ts


import { Router } from 'express';

import userAPI from './user.api';


const router = Router();

router.post('/login', userAPI.signIn);

router.post('/signup', userAPI.signUp);



export default router;

 

Finally, in order to import the routes into app.ts, create an index.ts file in the user directory, which will export the userRouter.

// user/index.ts


export { default as userRouter } from './user.router';

 

At this point, the project should have the following:

project/

    /bin

        server.js

    /user

        index.ts

        user.model.ts

        user.api.ts

        user.router.ts

    app.ts

    nodemon.json

    tsconfig.json

    tslint.json

    package.json

    package-lock.json

 

Now, all that is left is registering the router to the app:

// app.ts


import cookieParser from 'cookie-parser';

import express from 'express';

import mongoose from 'mongoose';

import logger from 'morgan';

// import the user router

import { userRouter } from './user';


const mongoURL = 'mongodb://localhost/jwtAPI';


mongoose.connect(mongoURL).then(() => {}).catch((err) => {

    console.log('MongoDB connection error: ' + err);

});


const app = express();


app.use(express.json());

app.use(express.urlencoded({ extended: false }));

app.use(cookieParser());


// Add auth routes to the app

app.use('/auth', userRouter);



export default app;

 

Authorization middleware

Currently, the authentication service has a way to register a user and issue a JWT, but no way to authorize a user. Luckily, this can be easily done by implementing a simple middleware.

The middleware will first extract the token from the request. Typically, this information is stored as a bearer token in the authorization header. Once extracted, we simply verify the token — checking that its content was not altered or that the token has not expired.

// middlewares/isAuth.ts


import * as jwt from 'jsonwebtoken';

import { Request, Response, NextFunction } from 'express';


const extractToken = (req: Request) => {

    if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {

        return req.headers.authorization.split(' ')[1];

    } else if (req.query && req.query.token) {

        return req.query.token;

    }

    return undefined;

};


export default (req: Request, res: Response, next: NextFunction) => {

    const token = extractToken(req);

    try {

        const jwtPayload = jwt.verify(token, 'supercool_secret');


        // This will pass the payload to the next function which is useful if you want to filter data by the user id or role, for instance.

        res.locals.jwtPayload = jwtPayload;

    } catch (err) {

        res.status(401).end('Invalid jwt');

    }

    return next();

};

 

And, as before, we will create an index.ts file in the middleware directory, so we can import this in our app:

// middleware/index.ts


export { default as isAuth } from './isAuth';

Finally, we can use this middleware to secure the routes within our application. For example:

// app.ts


import { isAuth } from './middlewares';


/*

    same code as before

*/


// An authenticated route

app.get('/auth/test', isAuth, attachUser, (req, res) => res.send(

  `authenticated as ${ res.locals.currentUser.username }`,

));

 

Handling Authentication and Authorization Within Other Services

Authentication and authorization can be handled by using the middleware created above in whatever service requires it. In general, the middleware handles authentication by verifying the JWT, while the contents of its payload can be used for authorization.

To better understand how this works, let us consider a scenario in which a user logs in to the application and requests a resource from some microservice within our application that requires authorization.

  1. First the user will authenticate by posting their username and password to the authentication service sign in endpoint to retrieve a JWT.
  2. The JWT will be stored client side and will be included in the header of any requests that require authentication as a bearer token.
  3. The user then requests a resource from a microservice requiring authorization.
  4. The microservice receives the request, fetches the bearer token, and then, fires off the authentication middleware.
  5. The authentication middleware verifies the JWT and extracts the payload, which is passed to the next function (which usually the endpoint logic).
  6. Finally, authorization is handled by reading the contents of the payload. For instance, the payload may contain a role and user id that is used to filter the resource such that the response only returns the information the user is authorized to see.

Conclusion

In this article, we implement a basic JWT authentication service that allows a user to create an account and sign in to retrieve a token. In addition, we illustrate how other services can authenticate and authorize users by using a middleware which verifies the JWT and extracts the payload.