Node.js API for authentication using JSON Web Tokens (JWT) and refresh tokens

Nuts and bolts of a Node.js API for authentication and authorization using JSON Web Tokens (JWT), refresh tokens, Sequelize with PostgreSQL, Swagger OAS3 documentation, Jest & Supertest.

July 19, 2020

What is covered

Code repository

Online resources

Authentication package structure

The repository is organized as a monorepo using yarn workspaces, to be able to share resources between packages /authentication, /hasura, /frontend. The packages used in common can be found under /packages folder. Under /docs folder reside documentation files.

The /authentication package is the one we are going to explore in this blog post. The main part of the package is under /authentication/src folder. The entry point is /authentication/src/index.js file that is used to start the /authentication/src/server.js. There are two separate files because server.js is required for testing but without the listening part at the end. The folders under /authentication/src are constants, controllers, middlewares, models, services and utils.

Node.js Express server

The server.js has a typical Node.js Express setup: the main middlewares are provided by cors, body-parser, cookie-parser packages. http-logger is a morgan middleware passing data to a winston logger stream. I will write another blog post about the rate limiter as a DDoS mitigation measure (lines 12-14).

On lines 31-32 we register two more middlewares to the Express app, one being the users Express router having all the API routes defined and the other being in charge with formatting the errors before sending them to the client application.

We need the Swagger API documentation under the /api-docs route only for development environment and we serve the index.html and other static files from frontend/build folder only for production.

const path = require('path');
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const helmet = require('helmet');

const { httpLogger, rateLimiterRedis } = require('./middlewares');

const app = express();

if (process.env.NODE_ENV !== 'test') {
  app.use(rateLimiterRedis);
}

app.use(
  cors({
    credentials: true,
    origin: true,
  })
);

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

app.use(cookieParser());
app.use(httpLogger);
app.disable('x-powered-by');
app.use(helmet());

app.use('/api/authentication', require('./controllers/users'));
app.use(require('./middlewares/handleErrors'));

if (process.env.NODE_ENV === 'development') {
  app.use('/api-docs', require('./utils/swagger'));
}

if (process.env.NODE_ENV === 'production') {
  const frontendPath = path.join(__dirname, '../..', 'frontend/build');
  app.use(express.static(frontendPath));

  app.get('*', (req, res) => {
    res.sendFile(path.join(frontendPath, 'index.html'));
  });
}

module.exports = app;

Sequelize models

Take a look at /authentication/.sequelizerc file to understand what sequelize init cli command needs to generate the folders and files. As you can see, /authentication/src/utils/sequelize/config.js has the configuration for each environment to interract with the PostgreSQL database.

You can use sequelize command to generate most of a model file and of the related migration file like so sequelize model:generate --name RemovedUser --attributes removedId:integer. The /authentication/src/models/index.js file is autogenerated and it is collecting all the models, executes the associate methods of the models, adds the sequelize instance of the Sequelize class and the class itself to the exported db object to be used throughout the project.

Let's review the /authentication/src/models/user.js model. At lines 6-10 it defines the associated models, all of them children of the User model, in this case. For the role attribute there is a setter and a getter defined to transform to and from the raw value stored into the database (lines 32-42). For the fullName attribute we have defined a virtual field (lines 56-64).

'use strict';
const { Model } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
  class User extends Model {
    static associate(models) {
      models.User.hasMany(models.RefreshToken);
      models.User.hasMany(models.SignupInvitation);
      models.User.hasMany(models.ForgotPassword);
    }
  }

  User.init(
    {
      firstName: {
        type: DataTypes.STRING,
        allowNull: false,
      },
      lastName: {
        type: DataTypes.STRING,
        allowNull: false,
      },
      email: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: true,
      },
      passwordHash: {
        type: DataTypes.STRING,
        allowNull: false,
      },
      role: {
        type: DataTypes.STRING,
        allowNull: false,
        get() {
          const rawValue = this.getDataValue('role');
          return rawValue.split(',');
        },
        set(value) {
          this.setDataValue('role', value.join(','));
        },
      },
      isBlocked: {
        type: DataTypes.BOOLEAN,
        allowNull: false,
        defaultValue: false,
      },
      blockedBy: {
        type: DataTypes.INTEGER,
        allowNull: true,
      },
      blockedByIp: {
        type: DataTypes.STRING,
        allowNull: true,
      },
      fullName: {
        type: DataTypes.VIRTUAL,
        get() {
          return `${this.firstName} ${this.lastName}`;
        },
        set(value) {
          throw new Error('Do not try to set the `fullName` value!');
        },
      },
    },
    {
      sequelize,
      modelName: 'User',
      timestamps: true,
    }
  );

  return User;
};

Inside of the related migration file /authentication/src/utils/sequelize/migrations/20200704114716-create-users-table.js you can see we have also the id, updateAt, createdAt fields under the up key of the migration.

'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('Users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      firstName: {
        type: Sequelize.STRING,
        allowNull: false,
      },
      lastName: {
        type: Sequelize.STRING,
        allowNull: false,
      },
      email: {
        type: Sequelize.STRING,
        allowNull: false,
        unique: true,
      },
      passwordHash: {
        type: Sequelize.STRING,
        allowNull: false,
      },
      role: {
        type: Sequelize.STRING,
        allowNull: false,
      },
      isBlocked: {
        type: Sequelize.BOOLEAN,
        allowNull: false,
        defaultValue: false,
      },
      blockedBy: {
        type: Sequelize.INTEGER,
        allowNull: true,
      },
      blockedByIp: {
        type: Sequelize.STRING,
        allowNull: true,
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('Users');
  }
};

User controller and routes

/authentication/src/controllers/users.js is importing all the /handlers objects associated to each of the Express routes inside of the router middleware that is exported to be used by the server.

If you browse one of those handler folders, let's say /authentication/src/controllers/handlers/fetchUsers/ you can see a pattern of having 3 files there: one containing the implementation, one with the yaml definition of the endpoint for Swagger and one test file containing the related tests. Let's have a look at the first 2, the 3rd being covered under testing section.

/authentication/src/controllers/handlers/fetchUsers/index.js

const models = require('../../../models');
const generateWhere = require('../../../services/generateWhere');
const logger = require('../../../services/logger');

const attributes = [
  'id',
  'email',
  'firstName',
  'lastName',
  'fullName',
  'role',
  'isBlocked',
  'createdAt',
  'updatedAt',
];

module.exports = {
  fetchUsers: async (req, res, next) => {
    const where = req.query ? generateWhere(req.query, attributes) : {};

    let users = [];

    try {
      users = await models.User.findAll({ where, attributes });
    } catch (error) {
      logger.error('[API] fetchUsers findAll error', error);
    }

    res.json(users);
  },
};

/authentication/src/controllers/handlers/fetchUsers/index.test.js

const { testApi } = require('../../../services');
const prefix = require('../../../constants/apiUrlPrefix');
const { adminUser, login } = require('../../../utils/testHelpers/user');

const path = `${prefix}/fetch-users`;

describe('/fetch-users endpoint', () => {
  it('should return the users array', async (done) => {
    const credentials = await login(adminUser);

    const res = await testApi
      .get(
        `${path}?email[Op.startsWith]=cat&email[Op.endsWith]=ment&id[Op.lt]=2`
      )
      .set('Authorization', `Bearer ${credentials.jwtToken}`);

    expect(res.statusCode).toEqual(200);
    expect(res.body).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          id: expect.any(Number),
          fullName: `${adminUser.firstName} ${adminUser.lastName}`,
          firstName: adminUser.firstName,
          lastName: adminUser.lastName,
          role: adminUser.role,
          isBlocked: adminUser.isBlocked,
          createdAt: expect.any(String),
          updatedAt: expect.any(String),
        }),
      ])
    );

    done();
  });
});

Middlewares and services

/authentication/src/middlewares/ folder contains two of the middlewares we already disscussed about, handleErrors.js and httpLogger.js, but also authorize.js and validate.js.

/authentication/src/middlewares/authorize.js is applying two middlewares, one being expressJwt created by the express-jwt package to validate the JWT token provided by the client application and the other being the one that compares the roles contained by the JWT token with that required by a specific authorized only route.

const Boom = require('@hapi/boom');
const expressJwt = require('express-jwt');
const get = require('lodash.get');
const intersection = require('lodash.intersection');

const models = require('../models');
const { KEY } = require('../constants/claims');
const { revokeAccess } = require('../controllers/handlers/logout');

const secret = JSON.parse(process.env.HASURA_GRAPHQL_JWT_SECRET);

const authorize = (roles) => {
  if (typeof roles === 'string') {
    roles = [roles];
  }

  return [
    expressJwt({ secret: secret.key, algorithms: [secret.type] }),

    async (req, res, next) => {
      const userId = get(req, `user['${KEY}'].x-hasura-user-id`, null);

      if (!userId || !roles.length) {
        return next(Boom.unauthorized('Unauthorized'));
      }

      const user = await models.User.findOne({ where: { id: userId } });

      if (!user || !intersection(roles, user.role).length) {
        return next(Boom.unauthorized('Unauthorized'));
      }

      if (user.isBlocked) {
        await revokeAccess(req, res);
        return next(Boom.unauthorized('Access revoked'));
      }

      req.user = user;

      const ownTokens = await models.RefreshToken.findAll({
        where: { UserId: userId },
      });

      req.user.ownsToken = (token) =>
        !!ownTokens.find((tokn) => tokn.token === token);

      next();
    },
  ];
};

module.exports = authorize;

The /authentication/src/services/ folder contains generateJwtToken.js and generateRefreshToken.js files for the two kinds of tokens required by authentication, generateWhere.js to assemble the where object, from provided query string, to be used inside of Sequelize queries. We have already seen what logger.js is for. testApi.js takes the server Express app and it creates a request object with it using supertest package. refreshToken.js takes a valid old refresh token, it revokes it and generate a new set of credentials.

/authentication/src/services/refreshTokens.js

const Boom = require('@hapi/boom');

const generateJwtToken = require('./generateJwtToken');
const generateRefreshToken = require('./generateRefreshToken');
const logger = require('./logger');
const models = require('../models');

const refreshTokens = async (oldRefreshToken, ip, next) => {
  const { User: user } = oldRefreshToken;

  const newRefreshToken = await generateRefreshToken(user, ip);

  if (!newRefreshToken) {
    return next(Boom.badImplementation());
  }

  const revokedToken = {
    revokedAt: new Date(),
    revokedBy: user.id,
    revokedByIp: ip,
    replacedByToken: newRefreshToken.token,
  };

  let tokenRevoked = null;

  try {
    tokenRevoked = await models.RefreshToken.update(revokedToken, {
      where: { id: oldRefreshToken.id },
    });
  } catch (error) {
    logger.error('[API] refreshTokens error', error)
  }
  
  if (!tokenRevoked) {
    return next(Boom.badImplementation());
  }

  const jwtToken = generateJwtToken(user);
  const jwtTokenExpiry = new Date(
    new Date().getTime() +
      process.env.AUTHENTICATION_JWT_TOKEN_EXPIRES * 60 * 1000
  );

  return { jwtToken, jwtTokenExpiry, refreshToken: newRefreshToken };
};

module.exports = refreshTokens;

Validation with Yup

As I have previously mentioned, @medical-equipment-tracker/validator is a shared package as it can be used both by the backend and by the frontend. It defines the validation schemas and the error mesages as objects in a easy to read and convenient way.

const { object, string, ref, number, array, boolean } = require('yup');

const roles = {
  Admin: 'Admin',
  Default: 'User',
};

const uuidRegex = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;

const emailValidation = string()
  .trim()
  .email('Email has to be valid')
  .required('Email is required');

const firstNameValidation = string().trim().strict().required('First name is required');

const tokenValidation = string()
  .trim()
  .matches(uuidRegex, 'Token is not valid')
  .required('Token is required');

const passwordAndConfirm = {
  password: string()
    .trim()
    .required('Password is required')
    .min(8, `Password has to contain at least 8 characters`)
    .matches(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/,
      'Password has to contain lowercase and uppercase and numeric characters'
    ),
  confirmPassword: string()
    .trim()
    .required('Please confirm password')
    .oneOf([ref('password'), null], 'Passwords must match'),
};

const newPasswordValidation = {
  ...passwordAndConfirm,
  token: tokenValidation,
};

const loginSchema = object({
  email: emailValidation,
  password: string().trim().required('Password is required'),
});

const revokeTokenSchema = object({
  token: tokenValidation,
});

const inviteSignupSchema = object({
  email: emailValidation,
  firstName: firstNameValidation,
});

const userSchema = object({
  firstName: firstNameValidation,
  lastName: string().trim().strict().required('Last name is required'),
  email: emailValidation,
  ...newPasswordValidation,
});

const forgotPasswordSchema = object({
  email: emailValidation,
});

const resetPasswordSchema = object({
  ...newPasswordValidation,
});

const userIdSchema = object({
  userId: number()
    .positive('User id has to be positive number')
    .integer('User id has to be an integer')
    .required('User id is required'),
});

const udateUserSchema = object({
  firstName: string().trim().strict().notRequired(),
  lastName: string().trim().strict().notRequired(),
  role: array()
    .of(
      string()
        .trim()
        .oneOf(
          [roles.Default, roles.Admin],
          `Role must be one of ${Object.values(roles).join(',')}`
        )
        .required('Role is required')
    )
    .notRequired(),
  isBlocked: boolean().notRequired(),
});

const changePasswordSchema = object({
  currentPassword: string().trim().notRequired(),
  ...passwordAndConfirm,
});

module.exports = {
  loginSchema,
  revokeTokenSchema,
  inviteSignupSchema,
  userSchema,
  forgotPasswordSchema,
  resetPasswordSchema,
  userIdSchema,
  udateUserSchema,
  roles,
  changePasswordSchema,
};

Utilities

/authentication/src/utils/ folder contains the email messages text and html templates, sequelize related migration and seeders, Swagger main yaml configuration file, test helper methods focused on different Sequelize models.

/authentication/src/utils/sequelize/seeders/20200704115347-insert-users.js

'use strict';

module.exports = {
  up: (queryInterface, Sequelize) => {
    const adminUser = {
      firstName: 'Catalin',
      lastName: 'Rizea',
      email: 'catalin@medical.equipment',
      passwordHash: '$2a$10$EET8MHMUPZ4s4GkCnqWwp.dG5msvPNw9Ar/4RcsLx.r./Cv6SWGD6', // Password1
      role: 'User,Admin',
      isBlocked: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    const defaultUser = {
      ...adminUser,
      firstName: 'Simona',
      lastName: 'Galushka',
      email: 'simona@medical.equipment',
      role: 'User',
    };
    
    return queryInterface.bulkInsert('Users', [adminUser, defaultUser]);
  },

  down: (queryInterface, Sequelize) => {
    return queryInterface.bulkDelete('Users', null, {});
  },
};

/authentication/src/utils/testHelpers/signupinvitation.js

const models = require('../../models');

module.exports = {
  createSignupInvitation: async (invitee) => {
    const invited = await models.SignupInvitation.create(invitee, { returning: true });
    return invited;
  },

  destroySignupInvitation: async (invitee) => {
    await models.SignupInvitation.destroy({ where: { email: invitee.email } });
  },
};

Yaml definitions for Swagger

It is useless to remind you how efficient is for the whole development team to use an interactive REST or GraphQL API documentation.

Swagger interactive API documentation

You can drill down every response type for examples and also explore schemas.

Explore responses schema on Swagger

Fragment of /authentication/src/utils/swagger/swagger.yaml Swagger main configuration file. This file collects all definitions from the associated handler folder and it is parsed by /authentication/src/utils/swagger/index.js file to generate the UI using swagger-ui-express package.

openapi: 3.0.0
info:
  title: medical.equipment JWT Authentication with Refresh Tokens
  version: 1.0.0

servers:
  - url: ${AUTHENTICATION_EXPRESS_ENDPOINT} 
    description: Development server

paths:
  /api/authentication/login:
    $ref: "../../controllers/handlers/login/index.yaml"

  /api/authentication/logout:
    $ref: "../../controllers/handlers/logout/index.yaml"
  
  /api/authentication/refresh-token:
    $ref: "../../controllers/handlers/refreshToken/index.yaml"
  
  /api/authentication/revoke-token:
    $ref: "../../controllers/handlers/revokeToken/index.yaml"

Endpoint yaml example definition /authentication/src/controllers/handlers/fetchUsers/index.yaml

get:
  summary: Fetch users
  description: Fetch users according to filter criteria as a logged in admin
  operationId: fetch-users
  security:
    - bearerAuth: []
  parameters:
    - in: query
      description: Query object to compose the where clause using Sequelize operators (https://sequelize.org/master/manual/model-querying-basics.html#operators) Example: { "email": { "Op.startsWith": "cat", "Op.endsWith": "ment" }, "id": { "Op.lt": 2 } } 
      name: params
      schema:
        type: object
  responses:
    "200":
      description: Users array
      content:
        application/json:
          schema:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: "123"
                email:
                  type: string
                  format: email
                  example: "catalin@medical.equipment"
                fullName:
                  type: string
                  example: "Catalin Rizea"
                firstName:
                  type: string
                  example: "Catalin"
                lastName:
                  type: string
                  example: "Rizea"
                role:
                  type: array
                  items:
                    type: string
                    example: "User"
                isBlocked:
                  type: boolean
                  example: "false"
                createdAt:
                  type: string
                  example: "2020-07-13 17:16:11.492+00"
                updatedAt:
                  type: string
                  example: "2020-07-13 17:24:36.068+00"
    "400":
      description: Bad request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/EntityParseFailed"
    "401":
      description: Unauthorized
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/InvalidToken"
              - $ref: "#/components/schemas/Unauthorized"
              - $ref: "#/components/schemas/AccessRevoked"

Tests for route handlers

Each endpoint has its related test under the same handler folder where the implementation file can be found. For example /authentication/src/controllers/handlers/revokeAccess/index.test.js:

const { testApi } = require('../../../services');
const prefix = require('../../../constants/apiUrlPrefix');
const {
  adminUser,
  login,
  tempUser,
  createTemp,
  destroyTemp,
} = require('../../../utils/testHelpers/user');

const path = `${prefix}/revoke-access`;

let user;

beforeAll(async () => {
  user = { ...tempUser, email: 'revoke-access@medical.equipment' };
  const createdTemp = await createTemp(user);
  user.id = createdTemp.id;
});

afterAll(async () => {
  await destroyTemp(user);
});

describe('/revoke-access endpoint', () => {
  it('should revoke access to an active user', async (done) => {
    const { jwtToken } = await login(adminUser);

    const res = await testApi
      .get(`${path}/${user.id}`)
      .set('Authorization', `Bearer ${jwtToken}`);

    expect(res.statusCode).toEqual(200);
    expect(res.body).toEqual(
      expect.objectContaining({
        result: 'Access revoked',
      })
    );

    done();
  });

  it('should fail when trying to revoke access to a blocked user', async (done) => {
    const { jwtToken } = await login(adminUser);

    const res = await testApi
      .get(`${path}/${user.id}`)
      .set('Authorization', `Bearer ${jwtToken}`);

    expect(res.statusCode).toEqual(400);
    expect(res.body).toEqual(
      expect.objectContaining({
        type: 'Bad Request',
        message: 'Access already revoked for this account',
      })
    );

    done();
  });

  it('should fail when trying to revoke access to own user', async (done) => {
    const { jwtToken } = await login(adminUser);

    const res = await testApi
      .get(`${path}/1`)
      .set('Authorization', `Bearer ${jwtToken}`);

    expect(res.statusCode).toEqual(401);
    expect(res.body).toEqual(
      expect.objectContaining({
        type: 'Unauthorized',
        message: 'You cannot revoke your own access',
      })
    );

    done();
  });
});

Take a peek at the repository central /package.json file to understand how you can run Jest and generate the interactive test coverage report.

Jest Istanbul coverage report web interface

You can drill down every file to see the lines not covered by test to improve the percentage.

Drill down the test coverage report