Loading repository data…
Loading repository data…
GonzaloHirsch / repository
This repository is for a Toptal blog post on building a Node.js API with JWT authentication using the Express web framework and TypeScript language. It includes code examples and configuration files to help readers implement the authentication flow in their own projects.
This code is published as part of the corresponding blog article at the Toptal Engineering Blog. For the latest articles on software development, visit https://www.toptal.com/developers/blog and subscribe to our newsletter.
Sample Express REST API with JWT authentication/authorization.
Endpoints once the project is finished:
/API_PREFIX/users GET: Get all users (PROTECTED)/API_PREFIX/users POST: Create a new user/API_PREFIX/users/{ID} DELETE: Delete a specific user (PROTECTED)/API_PREFIX/users/{ID} PATCH: Update a specific user (PROTECTED)/API_PREFIX/users/{ID} GET: Get a specific user (PROTECTED)/API_PREFIX/auth/login POST: Log in a user/API_PREFIX/auth/change-password POST: Changes password for a user (PROTECTED)Endpoints marked as PROTECTED require an Authorization: Bearer <TOKEN> header.
Note that this README contains step-by-step instructions on how to create this project from the ground up. In case you are cloning/forking it, the project is fully functional and works by using the following command:
npm i && npm start
Before running it, it's necessary to configure a .env file. To do so, please refer to this section of the README.
Create the project folder and initialize the Node.js project:
mkdir jwt-nodejs-security
cd jwt-nodejs-security
npm init -y
Next, add project dependencies and generate a basic tsconfig file. This file(which we will not edit during this tutorial) is required for TypeScript:
npm install typescript ts-node-dev @types/bcrypt @types/express --save-dev
npm install bcrypt body-parser dotenv express
npx tsc --init
With the project folder and dependencies in place, we now define our API project.
The project will use system environment values within our code. To that end, we’ll create a new configuration file, src/config/index.ts, that retrieves environment variables from the operating system, making them available to our code:
import * as dotenv from 'dotenv';
dotenv.config();
// Create a configuration object to hold those environment variables.
const config = {
// JWT important variables.
jwt: {
// The secret is used to sign and validate signatures.
secret: process.env.JWT_SECRET,
// The audience and issuer are used for validation purposes.
audience: process.env.JWT_AUDIENCE,
issuer: process.env.JWT_ISSUER
},
// The basic API port and prefix configuration values are:
port: process.env.PORT || 3000,
prefix: process.env.API_PREFIX || 'api'
};
// Make our confirmation object available to the rest of our code.
export default config;
The dotenv library allows environment variables to be set in either the operating system or within a .env file. We’ll create an .env file to define the following values:
JWT_SECRETJWT_AUDIENCEJWT_ISSUERPORTAPI_PREFIXYour .env file should look something like the example provided in our repository. With the basic API configuration complete, we now move into coding our API's storage.
To avoid the complexities that come with having a fully-fledged database, we’ll store our data locally in the server state. Let's create a TypeScript file, src/state/users.ts, to contain the storage and CRUD operations for API user information:
import bcrypt from 'bcrypt';
import { NotFoundError } from '../exceptions/notFoundError';
import { ClientError } from '../exceptions/clientError';
// Define the code interface for user objects.
export interface IUser {
id: string;
username: string;
// The password is marked as optional to allow us to return this structure
// without a password value. We'll validate that it is not empty when creating a user.
password?: string;
role: Roles;
}
// Our API supports both an admin and regular user, as defined by a role.
export enum Roles {
ADMIN = 'ADMIN',
USER = 'USER'
}
// Let's initialize our example API with some user records.
// NOTE: We generate passwords using the Node.js CLI with this command:
// "await require('bcrypt').hash('PASSWORD_TO_HASH', 12)"
let users: { [id: string]: IUser } = {
'0': {
id: '0',
username: 'testuser1',
// Plaintext password: testuser1_password
password: '$2b$12$ov6s318JKzBIkMdSMvHKdeTMHSYMqYxCI86xSHL9Q1gyUpwd66Q2e',
role: Roles.USER
},
'1': {
id: '1',
username: 'testuser2',
// Plaintext password: testuser2_password
password: '$2b$12$63l0Br1wIniFBFUnHaoeW.55yh8.a3QcpCy7hYt9sfaIDg.rnTAPC',
role: Roles.USER
},
'2': {
id: '2',
username: 'testuser3',
// Plaintext password: testuser3_password
password: '$2b$12$fTu/nKtkTsNO91tM7wd5yO6LyY1HpyMlmVUE9SM97IBg8eLMqw4mu',
role: Roles.USER
},
'3': {
id: '3',
username: 'testadmin1',
// Plaintext password: testadmin1_password
password: '$2b$12$tuzkBzJWCEqN1DemuFjRuuEs4z3z2a3S5K0fRukob/E959dPYLE3i',
role: Roles.ADMIN
},
'4': {
id: '4',
username: 'testadmin2',
// Plaintext password: testadmin2_password
password: '$2b$12$.dN3BgEeR0YdWMFv4z0pZOXOWfQUijnncXGz.3YOycHSAECzXQLdq',
role: Roles.ADMIN
}
};
let nextUserId = Object.keys(users).length;
Before we implement specific API routing and handler functions, let's focus on error-handling support for our project. This is an opportunity for us to propagate JWT best practices throughout our project code.
We are using the Express framework for our API. Express does not support proper error handling with asynchronous handlers. Specifically, Express doesn't catch promise rejections from within asynchronous handlers. To catch those rejections, we need to implement an error-handling wrapper function.
Let's create a new file src/middleware/asyncHandler.ts with the following content:
import { NextFunction, Request, Response } from 'express';
/**
* Async handler to wrap the API routes, allowing for async error handling.
* @param fn Function to call for the API endpoint
* @returns Promise with a catch statement
*/
export const asyncHandler = (fn: (req: Request, res: Response, next: NextFunction) => void) => (req: Request, res: Response, next: NextFunction) => {
return Promise.resolve(fn(req, res, next)).catch(next);
};
This handler wraps function handlers and propagates promise errors into an error handler. Before we define the error handler, we'll define some custom exceptions in src/exceptions/customError.ts, for use in our application:
// Note: Our custom error extends from Error, so we can throw this error as an exception.
export class CustomError extends Error {
message!: string;
status!: number;
additionalInfo!: any;
constructor(message: string, status: number = 500, additionalInfo: any = undefined) {
super(message);
this.message = message;
this.status = status;
this.additionalInfo = additionalInfo;
}
};
export interface IResponseError {
message: string;
additionalInfo?: string;
}
Now, we create our error handler in the file src/middleware/errorHandler.ts:
import { Request, Response, NextFunction } from 'express';
import { CustomError, IResponseError } from '../exceptions/customError';
export function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
console.error(err);
if (!(err instanceof CustomError)) {
res.status(500).send(
JSON.stringify({
message: 'Server error, please try again later'
})
);
} else {
const customError = err as CustomError;
let response = {
message: customError.message
} as IResponseError;
// Check if there is more info to return.
if (customError.additionalInfo) response.additionalInfo = customError.additionalInfo;
res.status(customError.status).type('json').send(JSON.stringify(response));
}
}
With all this error handling in place, we can add this support to our API within the src/index.ts file:
import { errorHandler } from './middleware/errorHandler';
// Add error handling as the last middleware, just prior to our app.listen call.
// This ensures that all errors are always handled.
app.use(errorHandler);
// app.listen...
Although we have implemented general error handling for our API, we want to support throwing rich errors from within our API handlers. Let's define those rich error utility functions now. Each rich error function is defined in their own file, as such:
src/exceptions/clientError.ts: Handles status code 400 errors.
import { CustomError } from './customError';
export class ClientError extends CustomError {
constructor(message: string) {
super(message, 400);
}
}
src/exceptions/forbiddenError.ts: Handles status code 403 errors.
import { CustomError } from './customError';
export class ForbiddenError extends CustomError {
constructor(message: string) {
super(message, 403);
}
}
src/exceptions/notFoundError.ts: Handles status code 404 errors.
import { CustomError } from './customError';
export class NotFoundError extends CustomError {
constructor(message: string) {
super(message, 404);
}
}
src/exceptions/unauthorizedError.ts: Handles status code 401 errors.
import { CustomError } from './customError';
export class UnauthorizedError extends CustomError {
constructor(message: string) {
super(message, 401);
}
}
With the basic project and error handling functions implemented, let's define our API endpoints and their handler functions.
We're using Express to easily add API support to our application. Let's create a new file src/index.ts to define our API's entrypoint:
import express from 'express';
import { json } from 'body-parser';
import { errorHandler } from './middleware/errorHandler';
import config from './config';
// Instantiate an Express object.
const app = express();
app.use(json());
// Add error handling as the last middleware, just prior to our app.listen call.
// This ensures that all errors are always handled.
app.use(errorHandler);
// Have our API listen on the configured port.
app.listen(config.port, () => {
console.log(`server is listening on port ${config.port}`);
});
We need to update the npm-generated package.json file to add our just-created default application entrypoint. Note we want to place this at the top of the main object's attribute list, as such:
{
"main": "index.js",
"scripts": {
"start": "ts-node-dev src/index.ts"
...
Next, our API needs its routes defined, and to have those routes redirect to their handlers. Let's create a src/routes/index.ts file to link user operation routes into our application. Note: the route specifics and their handler definitions are defined afterwards.
import { Router } from 'express';
import user from './user';
const routes = Router();
// All user operations will be available under the "users" route prefix.
routes.use('/users', user);
// Allow our router to be used outside of this file.
export default routes;
We need to include these routes in the src/index.ts file by importing our