Authentication

This example project builds on the previous RESTful API project by adding user authentication. This will ensure users are identified within the system and are only able to perform operations according to the roles assigned to their user accounts.

Project Deliverables

At the end of this example, we will have a project with the following features:

  1. An authentication system using Passport.js and CAS
  2. Valid JSON Web Tokens (JWTs) for authentication within the RESTful API
  3. Proper middleware to verify users have the correct role for each operation in the API
Prior Work

This project picks up right where the last one left off, so if you haven’t completed that one yet, go back and do that before starting this one.

Let’s get started!

Subsections of Authentication

Bypass Auth

YouTube Video

Authentication Libraries

There are many different authentication libraries and methods available for Node.js and Express. For this project, we will use the Passport.js library. It supports many different authentication strategy, and is a very common way that authentication is handled within JavaScript applications.

For our application, we’ll end up using several strategies to authenticate our users:

Let’s first set up our unique token strategy, which allows us to test our authentication routes before setting up anything else.

Authentication Router

First, we’ll need to create a new route file at routes/auth.js to contain our authentication routes. We’ll start with this basic structure and work on filling in each method as we go.

/**
 * @file Auth router
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports router an Express router
 *
 * @swagger
 * tags:
 *   name: auth
 *   description: Authentication Routes
 * components:
 *   responses:
 *     AuthToken:
 *       description: authentication success
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required:
 *               - token
 *             properties:
 *               token:
 *                 type: string
 *                 description: a JWT for the user
 *             example:
 *               token: abcdefg12345
 */

// Import libraries
import express from "express";
import passport from "passport";

// Import configurations
import "../configs/auth.js";

// Create Express router
const router = express.Router();

/**
 * Authentication Response Handler
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 */
const authSuccess = function (req, res, next) {

};

/**
 * Bypass authentication for testing
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /auth/bypass:
 *   get:
 *     summary: bypass authentication for testing
 *     description: Bypasses CAS authentication for testing purposes
 *     tags: [auth]
 *     parameters:
 *       - in: query
 *         name: token
 *         required: true
 *         schema:
 *           type: string
 *         description: username
 *     responses:
 *       200:
 *         description: success
 */
router.get("/bypass", function (req, res, next) {

});

/**
 * CAS Authentication
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /auth/cas:
 *   get:
 *     summary: CAS authentication
 *     description:  CAS authentication for deployment
 *     tags: [auth]
 *     responses:
 *       200:
 *         description: success
 */
router.get("/cas", function (req, res, next) {

});

/**
 * Request JWT based on previous authentication
 *
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next middleware function
 *
 * @swagger
 * /auth/token:
 *   get:
 *     summary: request JWT 
 *     description: request JWT based on previous authentication
 *     tags: [auth]
 *     responses:
 *       200:
 *         $ref: '#/components/responses/AuthToken'
 */
router.get("/token", function (req, res, next) {

});

export default router;

This file includes a few items to take note of:

  • In the top-level Open API comment, we define a new AuthToken response that we’ll send to the user when they request a token.
  • We create three routes. The first two, /auth/bypass and /auth/cas, for each of our authentication strategies. The last one, /auth/token will be used by our frontend to request a token to access the API.
  • Finally, we’ll build a authSuccess function to handle actually sending the response to the user

Before moving on, let’s go ahead and add this router to our app.js file along with the other routers:

// -=-=- other code omitted here -=-=-

// Import routers
import indexRouter from "./routes/index.js";
import apiRouter from "./routes/api.js";
import authRouter from "./routes/auth.js";

// -=-=- other code omitted here -=-=-

// Use routers
app.use("/", indexRouter);
app.use("/api", apiRouter);
app.use("/auth", authRouter);

// -=-=- other code omitted here -=-=-

We’ll come back to this file once we are ready to link up our authentication strategies.

Unique Token Authentication

Next, let’s install both passport and the passport-unique-token authentication strategy:

$ passport passport-unique-token

We’ll configure that strategy in a new configs/auth.js file with the following content:

/**
 * @file Configuration information for Passport.js Authentication
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Import libraries
import passport from "passport";
import { UniqueTokenStrategy } from "passport-unique-token";

// Import models
import { User, Role } from "../models/models.js";

// Import logger
import logger from "./logger.js";

/**
 * Authenticate a user
 * 
 * @param {string} username the username to authenticate
 * @param {function} next the next middleware function
 */
const authenticateUser = function(username, next) {
  // Find user with the username
  User.findOne({ 
    attributes: ["id", "username"],
    include: {
      model: Role,
      as: "roles",
      attributes: ["id", "role"],
      through: {
        attributes: [],
      },
    },
    where: { username: username },
  })
  .then((user) => {
    // User not found
    if (user === null) {
      logger.debug("Login failed for user: " + username);
      return next(null, false);
    }

    // User authenticated
    logger.debug("Login succeeded for user: " + user.username);

    // Convert Sequelize object to plain JavaScript object
    user = JSON.parse(JSON.stringify(user))
    return next(null, user);
  });
}

// Bypass Authentication via Token
passport.use(new UniqueTokenStrategy(
  // verify callback function
  (token, next) => {
    return authenticateUser(token, next);
  }
))

// Default functions to serialize and deserialize a session
passport.serializeUser(function(user, done) {
  done(null, user);
});

passport.deserializeUser(function(user, done) {
  done(null, user);
});

In this file, we created an authenticateUser function that will look for a user based on a given username. If found, it will return that user by calling the next middleware function. Otherwise, it will call that function and provide false.

Below, we configure Passport.js using the passport.use function to define the various authentication strategies we want to use. In this case, we’ll start with the Unique Token Strategy, which uses a token provided as part of a query to the web server.

In addition, we need to implement some default functions to handle serializing and deserializing a user from a session. These functions don’t really have any content in our implementation; we just need to include the default code.

Finally, since Passport.js acts as a global object, we don’t even have to export anything from this file!

Testing Authentication

To test this authentication strategy, let’s modify routes/auth.js to use this strategy. We’ll update the /auth/bypass route and also add some temporary code to the authSuccess function:

// -=-=- other code omitted here -=-=-

// Import libraries
import express from "express";
import passport from "passport";

// Import configurations
import "../configs/auth.js";

// -=-=- other code omitted here -=-=-
const authSuccess = function (req, res, next) {
  res.json(req.user);
};

// -=-=- other code omitted here -=-=-
router.get("/bypass", passport.authenticate('token', {session: false}), authSuccess);

// -=-=- other code omitted here -=-=-

In the authSuccess function, right now we are just sending the content of req.user, which is set by Passport.js on a successful authentication (it is the value we returned when calling the next function in our authentication strategy earlier). We’ll come back to this later when we implement JSON Web Tokens (JWT) later in this tutorial.

The other major change is that now the /auth/bypass route calls the passport.authenticate method with the 'token' strategy specified. It also uses {session: false} as one of the options provided to Passport.js since we aren’t actually going to be using sessions. Finally, if that middleware is satisfied, it will call the authSuccess function to handle sending the response to the user. This takes advantage of the chaining that we can do in Express!

With all of that in place, we can test our server and see if it works:

$ npm run dev

Once the page loads, we want to navigate to the /auth/bypass?token=admin path to see if we can log in as the admin user. Notice that we are including a query parameter named token to include the username in the URL.

Successful Authentication Successful Authentication

There we go! We see that it successfully finds our admin user and returns data about that user, including the roles assigned. This is what we want to see. We can also test this by providing other usernames to make sure it is working.

Securing Authentication

Of course, we don’t want to have this bypass authentication system available all the time in our application. In fact, we really only want to use it for testing and debugging; otherwise, our application will have a major security flaw! So, let’s add a new environment variable BYPASS_AUTH to our .env, .env.test and .env.example files. We should set it to TRUE in the .env.test file, and for now we’ll have it enabled in our .env file as well, but this option should NEVER be enabled in a production setting.

# -=-=- other settings omitted here -=-=-
BYPASS_AUTH=true

With that setting in place, we can add it to our configs/auth.js file to only allow bypass authentication if that setting is enabled:

// -=-=- other code omitted here -=-=-

// Bypass Authentication via Token
passport.use(new UniqueTokenStrategy(
  // verify callback function
  (token, next) => {
    // Only allow token authentication when enabled
    if (process.env.BYPASS_AUTH === "true") {
      return authenticateUser(token, next);
    } else {
      return next(null, false);
    }
  }
))

Before moving on, we should make sure we test both enabling and disabling this setting actually disables bypass authentication. We want to be absolutely sure it works as intended!

Disabled Authentication Disabled Authentication

Cookie Sessions

YouTube Video

One of the most common methods for keeping track of users after they are authenticated is by setting a cookie on their browser that is sent with each request. We’ve already explored this method earlier in this course, so let’s go ahead and configure cookie sessions for our application, storing them in our existing database.

We’ll start by installing both the express-session middleware and the connect-session-sequelize library that we can use to store our sessions in a Sequelize database:

$ npm install express-session connect-session-sequelize

Once those libraries are installed, we can create a configuration for sessions in a new configs/sessions.js file:

/**
 * @file Configuration for cookie sessions stored in Sequelize
 * @author Russell Feldhausen <russfeld@ksu.edu>
 * @exports sequelizeSession a Session instance configured for Sequelize
 */

// Import Libraries
import session from 'express-session'
import connectSession from 'connect-session-sequelize'

// Import Database
import database from './database.js'
import logger from './logger.js'

// Initialize Store
const sequelizeStore = connectSession(session.Store)
const store = new sequelizeStore({
    db: database
})

// Create tables in Sequelize
store.sync();

if (!process.env.SESSION_SECRET) {
    logger.error("Cookie session secret not set! Set a SESSION_SECRET environment variable.")
}

// Session configuration
const sequelizeSession = session({
    secret: process.env.SESSION_SECRET,
    store: store, 
    resave: false,
    proxy: true,
})

export default sequelizeSession;

This file loads our Sequelize database connection and initializes the Express session middleware and the Sequelize session store. We also have a quick sanity check that will ensure there is a SESSION_SECRET environment variable set, otherwise an error will be printed. Finally, we export that session configuration to our application.

So, we’ll need to add a SESSION_SECRET environment variable to our .env, .env.test and .env.example files. This is a secret key used to secure our cookies and prevent them from being modified.

There are many ways to generate a secret key, but one of the simplest is to just use the built in functions in Node.js itself. We can launch the Node.js REPL environment by just running the node command in the terminal:

$ node

From there, we can use this line to get a random secret key:

> require('crypto').randomBytes(64).toString('hex')
Documenting Terminal Commands

Just like we use $ as the prompt for Linux terminal commands, the Node.js REPL environment uses > so we will include that in our documentation. You should not include that character in your command.

If done correctly, we’ll get a random string that you can use as your secret key!

Secret Key Secret Key

We can include that key in our .env file. To help remember how to do this in the future, we can even include the Node.js command as a comment above that line:

# -=-=- other settings omitted here -=-=-
# require('crypto').randomBytes(64).toString('hex')
SESSION_SECRET='46a5fdfe16fa710867102d1f0dbd2329f2eae69be3ed56ca084d9e0ad....'

Finally, we can update our app.js file to use this session configuration:

// -=-=- other code omitted here -=-=-

// Import libraries
import compression from "compression";
import cookieParser from "cookie-parser";
import express from "express";
import helmet from "helmet";
import path from "path";
import swaggerUi from "swagger-ui-express";
import passport from "passport";

// Import configurations
import logger from "./configs/logger.js";
import openapi from "./configs/openapi.js";
import sessions from "./configs/sessions.js";

// -=-=- other code omitted here -=-=-

// Use libraries
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(helmet());
app.use(compression());
app.use(cookieParser());

// Use sessions
app.use(sessions);
app.use(passport.authenticate('session'));

// Use middlewares
app.use(requestLogger);

// -=-=- other code omitted here -=-=-

There we go! Now we can enable cookie sessions in Passport.js by removing the {session: false} setting in our /auth/bypass route in the routes/auth.js file:

// -=-=- other code omitted here -=-=-
router.get("/bypass", passport.authenticate('token'), authSuccess);

// -=-=- other code omitted here -=-=-

Now, when we navigate to that route and authenticate, we should see our application set a session cookie as part of the response.

Cookie Session Cookie Session

We can match the SID in the session cookie with the SID in the Sessions table in our database to confirm that it is working:

Cookie Session in Database Cookie Session in Database

From here, we can use these sessions throughout our application to track users as they make additional requests.

JSON Web Token

YouTube Video

JSON Web Tokens (JWT)

Now that we have a working authentication system, the next step is to configure a method to request a valid JSON Web Token, or JWT, that contains information about the authenticated user. We’ve already learned a bit about JWTs in this course, so we won’t cover too many of the details here.

To work with JWTs, we’ll need to install the jsonwebtoken package from NPM:

$ npm install jsonwebtoken

Next, we’ll need to create a secret key that we can use to sign our tokens. We’ll add this as the JWT_SECRET_KEY setting in our .env, .env.test and .env.example files. We can use the same method discussed on the previous page to generate a new random key:

# -=-=- other settings omitted here -=-=-
# require('crypto').randomBytes(64).toString('hex')
JWT_SECRET_KEY='46a5fdfe16fa710867102d1f0dbd2329f2eae69be3ed56ca084d9e0ad....'

Once we have the library and a key, we can easily create and sign a JWT in the /auth/token route in the routes/auth.js file:

// -=-=- other code omitted here -=-=-

// Import libraries
import express from "express";
import passport from "passport";
import jsonwebtoken from "jsonwebtoken"

// -=-=- other code omitted here -=-=-
router.get("/token", function (req, res, next) {
  // If user is logged in
  if (req.user) {
    const token = jsonwebtoken.sign(
      req.user,
      process.env.JWT_SECRET_KEY,
      {
        expiresIn: '6h'
      }
    )
    res.json({
      token: token
    })
  } else {
    // Send unauthorized response
    res.status(401).send()
  }
});

Now, when we visit the /auth/token URL on our working website (after logging in through the /auth/bypass route), we should receive a JWT as a response:

JWT Response JWT Response

Of course, while that data may seem unreadable, we already know that JWTs are Base64 encoded, so we can easily view the content of the token. Thankfully, there are many great tools we can use to debug our tokens, such as Token.dev, to confirm that they are working correctly.

JWT Debugger JWT Debugger

Do Not Share Live Keys!

While sites like this will also help you confirm that your JWTs are properly signed by asking for your secret key, you SHOULD NOT share a secret key for a live production application with these sites. There is always a chance it has been compromised!