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
- Add a rate limiter and a message broker to your Node.js API
- Express - Fast, unopinionated, minimalist web framework for Node.js
- PostgreSQL: The World's Most Advanced Open Source Relational Database
- Sequelize - a promise-based Node.js ORM for Postgres, MySQL, MariaDB, SQLite and Microsoft SQL Server
- Simplify API development for users, teams, and enterprises with the Swagger open source and professional toolset
- Jest - a delightful JavaScript Testing Framework with a focus on simplicity
- Supertest - provide a high-level abstraction for testing HTTP
- Yup - a JavaScript schema builder for value parsing and validation
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.
You can drill down every response type for examples and also explore schemas.
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.
You can drill down every file to see the lines not covered by test to improve the percentage.