Invalidating JSON Web Tokens (JWT):  NodeJS + Express

Invalidating JSON Web Tokens (JWT): NodeJS + Express

Heredia, Costa Rica, 2022-12-10

Series: JWT Diaries, Article 2

Hello and welcome. This article will demonstrate how to invalidate JWT's based on the iat claim. If you haven't done so already, you can find the explanation about this invalidation method in the previous article of this series.

Right, so let's get started. This article will cover the complete (but basic) implementation of a simple website and will provide an alternative for API-only servers.

NOTES

  • Being a big fan of proper software architecture, the sample provided here will follow layering techniques.

  • The folder structure and package.json file contents are shown near the end of the article.

  • You can clone this GitHub repository to quickly obtain this example.

The JWT Service

The JWT service will both issue new JWT's and will validate said JWT's. This service will need the jsonwebtoken NPM package as well as some configuration values. For configuration, we'll use the amazing wj-config NPM package. If you are unfamiliar with this configuration package, I highly recommend you read all about it here, or in a more tutorial-based way here.

Ok, without further ado, here is the code for the token-service.js file:

import jwt from 'jsonwebtoken';
import config from '../config.js';
import jwtInvalidationService from './jwt-invalidation-service.js';

export default function (jwtInvSvc) {
    jwtInvSvc = jwtInvSvc ?? jwtInvalidationService();
    return {
        issueToken: function (payload) {
            return jwt.sign(payload, config.jwt.secret, {
                expiresIn: config.jwt.tokenTtl
            });
        },
        validateToken: async function (token) {
            let verifiedToken = null;
            try {
                verifiedToken = jwt.verify(token, config.jwt.secret);
            }
            catch (e) {
                // This logging is probably unneeded in production environments.
                console.error('Error verifying token: %o', e);
                return {
                    valid: false
                };
            }
            // Standard validation succeeded.  Let's see about the iat:
            const globalInv = await jwtInvSvc.globalInvalidation();
            const userInv = await jwtInvSvc.userInvalidation(verifiedToken.name);
            // Using the most recent date of the two is enough.
            let minimumIat = Math.max(globalInv, userInv);
            if (minimumIat) {
                minimumIat = new Date(minimumIat);
                console.debug('Token subject to minimum issued at verification: %s', minimumIat);
                const issuedAt = new Date(verifiedToken.iat * 1000);
                if (issuedAt < minimumIat) {
                    // This logging is probably unneeded in production environments.
                    console.warn("Token issued at %s for user %s is not acceptable.", issuedAt, verifiedToken.name);
                    return {
                        valid: false
                    };
                }
            }
            return {
                valid: true,
                token: verifiedToken
            };
        }
    }
};

Let's go through this logic unless, of course, you got all this, in which case please proceed to the next level-2 section.

Exporting a Function

The first thing to note in the code is that we are exporting a constructor-like function (not to be confused with a JavaScript constructor). This allows us to unit-test this service by injecting a mock implementation of the real token invalidation service, which is another service we will be creating. This constructor-like function then proceeds to create an object with two functions: One to create JWT's and another one for validating them. The second one is the object of interest in this article.

Validation of JWT's

The validation starts as per usual, calling the built-in functionality of the jsonwebtoken NPM package. If this standard validation passes, we go into our special invalidation code.

We obtain 2 possible minimum issued at date values from the (yet mysterious) token invalidation service: The global invalidation date, if it exists, and the per-user invalidation date, if it exists. Since a more recent date covers older dates, we use Math.max() to pick up the more recent of the two dates.

Once we have confirmed we need to do issued at invalidation, it is just a matter of a simple if statement using the verified token's iat claim value. Since our basic math school teachers told us we should always compare apples to apples and oranges to oranges, we convert the value of the iat claim to a JavaScript date by multiplying its value by 1000 and passing it to the Date's constructor.

In the end, the validation function will return its caller an object with one or two properties, depending on the result. If the token is valid, then the returned object will look like this: { "valid": true, "token": { "name": "jose", "iat": 1234567, ... } }. If the token is invalid, the response is simplified to: { "valid": false }.

The Token Invalidation Service

This service is in charge of obtaining and saving global invalidation and per-user invalidation dates. It uses the constructor pattern we saw above to allow unit testing. This service, as seen by its description, requires some form of storage. The common storage options for this are Database, Redis or Memcached, or a mixture of these. You pick your poison. This example uses a MySQL database as the storage solution.

Here's the code for jwt-invalidation-service.js:

import jwtInvalidationRepository from '../repositories/jwt-invalidation-repository.js';
import mysqlTransactionFactory from '../repositories/mysql-transaction-factory.js';

const globalId = '__global';

export default function (jwtInvRepository, txnFactory) {
    jwtInvRepository = jwtInvRepository ?? jwtInvalidationRepository;
    txnFactory = txnFactory ?? mysqlTransactionFactory;
    return {
        globalInvalidation: async function (newTime) {
            if (newTime) {
                // Set a new global invalidation record.
                const txn = await txnFactory.create();
                try {
                    await txn.start();
                    // Clear all invalidation records.
                    await jwtInvRepository.clear(txn);
                    // Insert the global invalidation record.
                    await jwtInvRepository.add(globalId, newTime, txn);
                    // Commit the transaction.
                    await txn.commit();
                }
                catch (err) {
                    console.error('Error caught while trying to add global invalidation.\n%o', err);
                    await txn.rollback();
                    throw err;
                }
                return newTime;
            }
            const r = await jwtInvRepository.get(globalId);
            if (r?.length) {
                return r[0].MinimumIat;
            }
            return null;
        },
        userInvalidation: async function (userId, newTime) {
            if (newTime) {
                await jwtInvRepository.add(userId, newTime);
                return newTime;
            }
            const r = await jwtInvRepository.get(userId);
            if (r?.length) {
                return r[0].MinimumIat;
            }
            return null;
        }
    };
};

As seen before, the exported object is a function that receives repository and transaction factory objects. This is done to support mocking the repository and transaction factory while unit testing. Let's discuss the rest.

Per-User Invalidation Logic

This logic is found inside the userInvalidation() function. It is very simple: If a time is provided, then it is assumed the caller wants to save an invalidation date for the provided user ID. In this case, the service calls the repository's add() function to fulfill the request.

If no time is provided, then it is assumed the caller wants to retrieve the current invalidation record for the provided user ID. The service turns once more its eyes towards the repository and calls its get() function. After obtaining the record, the MinimumIat value of the returned record is returned (or null is returned if no record was returned).

Global Invalidation Logic

If no time is provided when calling the globalInvalidation() function, then the same logic found in the userInvalidation() function is followed. The only difference is the user ID. I, as a very personal choice, decided to create a special user ID, __global, to refer to the global invalidation record. I did not want to create the MySQL table with a nullable UserId column. That's all.

The saving part, however, differs greatly from the userInvalidation() counterpart.

As stated in the previous article in the series, whenever global invalidation occurs, all other records can be safely deleted because, chronologically speaking, the global record will include them all. Wiping the table clean is a performance enhancement.

So whenever the service is requested to invalidate globally, a database transaction is started, all the table records are deleted, and then the global invalidation record is added. At this point, the transaction is committed.


So far I have shown the code that covers everything this article advertises:

  • How to create global and per-user invalidation records.

  • How to retrieve global and per-user invalidation records.

  • How to use the invalidation records in a token-verifying function.

The rest of this article will talk about the rest of the files that make up for the entire sample project, so you can run this test for yourself. You may skip the rest of the article if you don't need it, but note that the final section explains how to make use of the token validation service in two cases: A web server that serves content, and an API-only web server.

Repository

Let's see the details about the MySQL repository I used for this article, but let's start with the SQL that defines the table, for your reference.

create table JwtBlacklist
(
    Id int not null auto_increment,
    UserId varchar(50) not null,
    MinimumIat datetime not null,
    constraint PKC_JwtBlacklist primary key (Id),
    constraint UX_JwtBlacklist_UserId unique (UserId)
);

MySQL2 NPM Package

The project uses the mysql2 package to read and write MySQL records. The API in it, however, is callback-based. I hate callback-based API. Because of this, I made three helpers to create a promise-based (awaitable) API.

The first one is a Query object. Here are the contents of the mysql-query.js file:

export default function Query(dbConn) {
    this._dbConn = dbConn;
    this.query = function (sql, sqlParams) {
        return new Promise((rslv, rjct) => {
            this._dbConn.query(sql, sqlParams, (err, results, fields) => {
                if (err) {
                    rjct(err);
                    return;
                }
                rslv({
                    results: results,
                    fields: fields
                });
            });
        });
    }
}

This is just a wrapper object that provides an awaitable query() function. It returns a single object with two properties: results and fields.

The second one is a transaction factory function. It creates Transaction objects that are wrappers to the beginTransaction(), commit() and rollback() functions in mysql2.

This is mysql-transaction-factory.js:

import dbConnectionFactory from "./mysql-connection-factory.js";

function Transaction(dbConn) {
    this.connection = dbConn;
    this.started = false;
    this.disposed = false;
    this.start = function () {
        if (this.disposed) {
            throw new Error("A transaction object cannot be reused and this one has already been used.  Create a new Transaction object.");
        }
        if (this.started) {
            throw new Error("This transaction has already been started and cannot undergo the start process again.");
        }
        return new Promise((rslv, rjct) => {
            this.connection.beginTransaction(err => {
                if (err) {
                    rjct(err);
                    return;
                }
                this.started = true;
                rslv();
            });
        });
    };
    this.commit = function () {
        if (!this.started) {
            throw new Error("Cannot commit a transaction that hasn't started.");
        }
        if (this.disposed) {
            throw new Error("Cannot commit a disposed transaction.");
        }
        return new Promise((rslv, rjct) => {
            this.connection.commit(err => {
                if (err) {
                    rjct(err);
                    return;
                }
                this.started = false;
                this.disposed = true;
                rslv();
            });
        });
    };
    this.rollback = function () {
        if (!this.started) {
            throw new Error("Cannot roll back a transaction that hasn't started.");
        }
        if (this.disposed) {
            throw new Error("Cannot roll back a disposed transaction.");
        }
        return new Promise((rslv, rjct) => {
            this.connection.rollback(err => {
                if (err) {
                    rjct(err);
                    return;
                }
                this.started = false;
                this.disposed = true;
                rslv();
            });
        });
    }
}

export default {
    create: async function () {
        const dbConn = await dbConnectionFactory();
        return new Transaction(dbConn);
    }
};

Then comes the connection factory as helper number 3. This is mysql-connection-factory.js:

import mysql from 'mysql2';
import config from '../config.js';

export default function () {
    const conn = mysql.createConnection(config.db);
    return new Promise((rslv, rjct) => {
        conn.connect(err => {
            if (err) {
                rjct(err);
                return;
            }
            rslv(conn);
        });
    });
};

Now, with an awaitable API, I proceed to show jwt-invalidation-repository.js:

import dbConnectionFactory from './mysql-connection-factory.js';
import Query from './mysql-query.js';

const clearInvalidationSql = "truncate table JwtBlacklist;";
const upsertInvalidationSql = "insert into JwtBlacklist(`UserId`, `MinimumIat`)"
    + " values(?, ?) as new"
    + " on duplicate key update `MinimumIat` = new.`MinimumIat`;";
const getInvalidationSql = "select `Id`, `UserId`, `MinimumIat` from JwtBlacklist where `UserId` = ?;";

export default {
    clear: async function (txn) {
        const dbConn = txn?.connection ?? await dbConnectionFactory();
        const q = new Query(dbConn);
        const r = await q.query(clearInvalidationSql);
        console.log('Cleared table.  Results: %o', r);
    },
    add: async function (userId, newTime, txn) {
        const dbConn = txn?.connection ?? await dbConnectionFactory();
        const q = new Query(dbConn);
        const r = await q.query(upsertInvalidationSql, [userId, newTime]);
        console.log('Record upserted.  Results: %o', r);
    },
    get: async function (userId) {
        const dbConn = await dbConnectionFactory();
        const q = new Query(dbConn);
        const r = await q.query(getInvalidationSql, userId);
        console.log('Record read.  Results: %o', r);
        return r.results;
    }
};

You'll notice that the exported object's methods receive an optional transaction object. I included this only in the data-modifying functions because I did not need to use get() while inside a transaction. If you implement a repository like this, consider adding the txn optional parameter to functions that retrieve data as well. This is necessary because only the transacted connection is usually permitted to read data being modified in the transaction (optimistic approach, like SQL Server and probably many others).

Configuration

As you may have noticed in the import sections of mysql-connection-factory.js and token-service.js, a configuration object config is imported. It contains the database connection information as well as the JWT configuration.

This configuration comes courtesy of wj-config, the best configuration solution for JavaScript (server-sided and browser-sided) currently available. This is config.js:

import wjConfig from "wj-config";

export default await wjConfig()
    .addObject({
        port: 7777,
        jwt: {
            tokenTtl: 600, // 10-minute tokens
            secret: "katOnCeyboard"
        },
        db: {
            host: "<your MySQL server, such as localhost>",
            user: "<your MySQL user ID>",
            password: "<the user's MySQL password>",
            database: "<your databse's name>"
        }
    })
    .build();

If you would like to know more about this great configuration package, head to this series.

Entry Point

The index.js file is the express application's entry point:

import express from 'express';
import config from './config.js';
// Service that issues and validates tokens.
import tokenService from './services/token-service.js';
// Middleware that copies a valid token's payload into reqest.sec.
import auth from './middleware/auth-middleware.js';
// Service that provides token invalidation dates.
import jwtInvalidationService from './services/jwt-invalidation-service.js';

const jwtInvSvc = jwtInvalidationService();
const tokenSvc = tokenService(jwtInvSvc);
const app = express();
/**
 * Homepage.  It greets the user.  If the request comes with a token,
 * then the greeting contains the user's name and age.
 * 
 * It uses the authentication middleware to ensure the token, if present, 
 * is read.  If a token is present and valid, then the token's payload 
 * will be available as req.sec.
 */
app.get('/', auth, (req, res) => {
    let name = 'stranger';
    let age = null;
    if (req.sec) {
        name = req.sec.name;
        age = req.sec.age;
    }
    res.write(`Hi, ${name}!`);
    if (age) {
        res.write(`  I see you are ${age} years old.`);
    }
    res.status(200).end();
});
/**
 * Login.  Specify name and age in the query string.
 */
app.get('/login', (req, res) => {
    const token = tokenSvc.issueToken({
        name: req.query.name,
        age: req.query.age
    });
    res.status(200).send(token).end();
});
/**
 * Globally invalidate.  Invalidates all previously issued tokens.
 */
app.get('/ginv', (req, res) => {
    const invDate = new Date();
    jwtInvSvc.globalInvalidation(invDate);
    res.write(`Global invalidation set at ${invDate}.`);
    res.status(200).end();
});
/**
 * Per-user invalidate.  Provide the user's name in the query string.
 */
app.get('/uinv', (req, res) => {
    const invDate = new Date();
    jwtInvSvc.userInvalidation(req.query.name, invDate);
    res.write(`Invalidation for user ${req.query.name} set at ${invDate}.`);
    res.status(200).end();
});

app.listen(config.port, () => {
    console.log(`Running on port ${config.port}.  Now listening...`);
});

This is a super simple Express server that serves a single page: The homepage. This page is not even HTML: It is just a greeting. If the HTTP request comes with a valid token, then the homepage greets the user by name. If the token is invalid or there is no token, the greeting just says "Hi, stranger!".

The other 3 routes are used to manipulate invalidations and to log in. Logging in does not require a password, and a valid token is issued simply by providing name and age. Like this: http://localhost:7777/login?name=jose&age=18. Both pieces of data become part of the token's payload (claims).

To globally invalidate just visit http://localhost:7777/ginv; to invalidate for a given user, visit http://localhost:7777/uinv?name=userToInvalidate.

Feel free to play around. While you play, peek at the server console to see the output messages. Here's the package.json file and a screenshot of the folder structure:

{
  "name": "minimalexpress",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2",
    "jsonwebtoken": "^8.5.1",
    "mysql2": "^2.3.3",
    "wj-config": "^2.0.0-beta.2"
  },
  "type": "module"
}

File structure:

Using the Token Service

You may have noticed that I did not present (so far) the contents of ./middleware/auth-middleware.js. Well, let's present it now:

import tokenService from "../services/token-service.js";

export default async function (req, res, next) {
    const tokenSvc = tokenService();
    // Look for the Authorization header.  It must be a bearer token header.
    const authHeader = req.headers['authorization'];
    if (authHeader && authHeader.startsWith('Bearer ')) {
        const token = authHeader.substring(7);
        const vToken = await tokenSvc.validateToken(token);
        if (vToken.valid) {
            req.sec = vToken.token
        }
    }
    await next();
};

This is an Express middleware function that searches for the Authorization HTTP header. If found, it extracts the token and validates it. If validation passes, the token's payload is added to the request object under the property named sec.

This middleware is added to the homepage's route in index.js, making the homepage user-aware.

This middleware is designed to merely collect the token's payload if available. If not available, the HTTP server does not bother the user about it. This means that the homepage may be visited by authenticated and unauthenticated users alike. This is usually desirable if your Express server serves actual web content.

There will be cases, however, where you may want to deny access to a resource. In this case, you need a middleware that either redirects the user to a login page, or that simply returns an HTTP 401 UNAUTHORIZED error code. I do not recommend the latter for servers that serve web content, though. This behavior should only be seen in API-only servers.

The middleware above can be followed by a redirecting middleware to obtain the redirection behavior. One like this one:

export default async function (req, res, next) {
    if (!req.sec) {
        // Unauthenticated user.  Redirect.
        res.redirect('/login');
        res.end();
        return;
    }
    // Authenticated user.  Proceed.
    await next();
};

A middleware that flat-out rejects unauthenticated users would be very similar:

export default async function (req, res, next) {
    if (!req.sec) {
        // Unauthenticated user.  Redirect.
        res.status(401).end();
        return;
    }
    // Authenticated user.  Proceed.
    await next();
};

The former is suitable for web servers that serve application pages; the latter is suitable for API servers.


Hopefully, this has been helpful to many of you. The next article in the series will showcase this same exercise but in C# for ASP.Net 6.

Happy coding!


FAQ

Why expose the MySQL transaction to the token invalidation service? Isn't this a violation of the layered architecture?

Layering is tricky; unit testing is tricky too. When it comes to repositories, it is usually very difficult to mock a database. I once worked for a company that dedicated a database server for unit testing. I don't like this approach. I prefer to not unit-test database-driven repositories. If you also choose this path, the correct way is to make repositories completely dumb, devoided of all business logic. This means that all business logic must go into service objects that can be unit-tested.

So for this particular example, the business logic is to wipe the invalidations table clean before inserting a global invalidation record. To achieve this safely in a multi-user environment, I must transact the operations. The only way to keep the repository dumb and transact the operations is if the service gains knowledge of the transacting technology that the repository understands.

In JavaScript there is no violation of the layered architecture because JavaScript is not typed, and basically any object exposing start(), commit() and rollback() functions will do. I can then freely jump from MySQL to MariaDB or SQL Server or any other RDBMS without having to make code changes to the invalidation service.

In typed languages like C#, the same is achieved by consuming an interface.