Michael Herman

Software Developer

Token-Based Authentication With Node

This tutorial takes a test-first approach to implementing token-based authentication in a Node app using JSON Web Tokens (JWTs) and Postgres.

Contents

  1. Objectives
  2. Introduction
  3. Project Setup
  4. Database Setup
  5. JWT Setup
  6. Auth Routes
  7. Conclusion

Objectives

By the end of this tutorial, you will be able to…

  1. Discuss the benefits of using JWTs versus sessions and cookies
  2. Implement user authentication using JWTs
  3. Write tests to create and verify JWTs and user authentication
  4. Practice test-driven development

Introduction

JSON Web Tokens (or JWTs) provide a means of authenticating every request from the client to the server in a stateless, secure way. On the server, JWTs are generated by signing user information via a secret key, which are then securely stored on the client. This form of auth works well with modern, single page applications. For more on this, along with the pros and cons of using JWTs vs. session and cookie-based auth, please review the following articles:

  1. Cookies vs Tokens: The Definitive Guide
  2. Token Authentication vs. Cookies

NOTE: Keep in mind that since a JWT is signed rather than encrypted it should never contain sensitive information like a user’s password.

Project Setup

Start by cloning the project structure:

1
$ git clone https://github.com/mjhea0/node-token-auth --branch v1 --single-branch -b master

NOTE: This project structure is based off of the Express boilerplate from the following generator (v1.2.4). What are the differences?

Install the dependencies, and then fire up the app by running gulp to make sure all is well. Kill the server. Run the tests with npm test. They all should pass.

This is optional, but it’s a good idea to create a new Github repository and update the remote:

1
$ git remote set-url origin <newurl>

Database Setup

We’ll be using Knex.js to interact with the database.

NOTE: Are you new to Knex.js? Check out the documentation along with the “Database Setup” section of the Testing Node and Express blog post for more information on how to use it to interact with Postgres.

Migrations

First, fire up your local Postgres server and create two new databases:

1
2
3
4
5
$ psql
# create database node_token_auth;
CREATE DATABASE
# create database node_token_auth_test;
CREATE DATABASE

Generate a new migration template:

1
$ knex migrate:make users

Then update the newly created file in “src/server/db/migrations/”:

1
2
3
4
5
6
7
8
9
10
11
12
13
exports.up = (knex, Promise) => {
  return knex.schema.createTable('users', (table) => {
    table.increments();
    table.string('username').unique().notNullable();
    table.string('password').notNullable();
    table.boolean('admin').notNullable().defaultTo(false);
    table.timestamp('created_at').notNullable().defaultTo(knex.raw('now()'));
  });
};

exports.down = (knex, Promise) => {
  return knex.schema.dropTable('users');
};

Apply the migration:

1
$ knex migrate:latest --env development

Sanity Check

Did it work?

1
2
3
4
5
6
7
8
9
10
11
12
13
$ psql
# \c node_token_auth
# \d

                     List of relations
 Schema |          Name          |   Type   |     Owner
--------+------------------------+----------+---------------
 public | knex_migrations        | table    | michaelherman
 public | knex_migrations_id_seq | sequence | michaelherman
 public | knex_migrations_lock   | table    | michaelherman
 public | users                  | table    | michaelherman
 public | users_id_seq           | sequence | michaelherman
(5 rows)

JWT Setup

First install the jwt-simple package for managing JSON Web Tokens:

1
$ npm install jwt-simple@0.5.0 --save

NOTE: As the name suggests, jwt-simple is a minimal JWT library. It is not recommended for a production app. jsonwebtoken is a more robust option. Want a challenge? Use jsonwebtoken for the app in this blog post.

Encode Token

Create a new folder called “auth” in “src/server/”. Then add a file called local.js to the “auth” folder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const moment = require('moment');
const jwt = require('jwt-simple');

function encodeToken(user) {
  const playload = {
    exp: moment().add(14, 'days').unix(),
    iat: moment().unix(),
    sub: user.id
  };
  return jwt.encode(playload, process.env.TOKEN_SECRET);
}

module.exports = {
  encodeToken
};

Given a user object, this function creates and returns a token from the playload and the secret key, process.env.TOKEN_SECRET. Put simply, the payload is where we add metadata about the token and information about the user. This info is often referred to as JWT Claims. In the code above we utilize the following “claims”:

  • exp: expiration date of the token
  • iat: the time the token is generated
  • sub: the subject of the token (the user whom it identifies)

The secret key must be random and only accessible server-side. Use the node-uuid library or Python to generate a key:

1
2
3
4
5
$ python
>>> import os
>>> os.urandom(24)
b'\xf8%\xa8\xf2INz\xcc:\x171\xeei\x82\xce\x81Y\xc2HJ\xe5\x01\xf3$'
>>>

Then add the key to a new file called .env:

1
TOKEN_SECRET=\xf8%\xa8\xf2INz\xcc:\x171\xeei\x82\xce\x81Y\xc2HJ\xe5\x01\xf3$

Don’t forget to install Moment for managing dates:

1
$ npm install moment@2.15.2 --save

Before moving on, let’s write a quick unit test. Add the following code to a new file called auth.local.test.js in “test/unit”:

1
2
3
4
5
6
7
8
9
10
process.env.NODE_ENV = 'test';

const chai = require('chai');
const should = chai.should();

const localAuth = require('../../src/server/auth/local');

describe('auth : local', () => {

});

Now add a test to the describe block:

1
2
3
4
5
6
7
8
describe('encodeToken()', () => {
  it('should return a token', (done) => {
    const results = localAuth.encodeToken({id: 1});
    should.exist(results);
    results.should.be.a('string');
    done();
  });
});

Run the tests. They should pass.

Decode Token

To decode a token, add the following code to src/server/auth/local.js:

1
2
3
4
5
6
7
function decodeToken(token, callback) {
  const payload = jwt.decode(token, process.env.TOKEN_SECRET);
  const now = moment().unix();
  // check if the token has expired
  if (now > payload.exp) callback('Token has expired.');
  else callback(null, payload);
}

Export the function:

1
2
3
4
module.exports = {
  encodeToken,
  decodeToken
};

Then add a test:

1
2
3
4
5
6
7
8
9
10
11
12
describe('decodeToken()', () => {
  it('should return a payload', (done) => {
    const token = localAuth.encodeToken({id: 1});
    should.exist(token);
    token.should.be.a('string');
    localAuth.decodeToken(token, (err, res) => {
      should.not.exist(err);
      res.sub.should.eql(1);
      done();
    });
  });
});

Make sure the tests pass before moving on.

Route Setup

Now we can configure our routes using a test-first approach:

  • /auth/register
  • /auth/login
  • /auth/logout
  • /auth/user

Add the following code to a new file called routes.auth.test.js in “test/integration”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
process.env.NODE_ENV = 'test';

const chai = require('chai');
const should = chai.should();
const chaiHttp = require('chai-http');
chai.use(chaiHttp);

const server = require('../../src/server/app');
const knex = require('../../src/server/db/connection');

describe('routes : auth', () => {

  beforeEach(() => {
    return knex.migrate.rollback()
    .then(() => { return knex.migrate.latest(); });
  });

  afterEach(() => {
    return knex.migrate.rollback();
  });

});

This is a common boilerplate for integration tests with Chai assertions and Chai HTTP for simulating client requests. For more info, check out Test Driven Development With Node, Postgres, and Knex (Red/Green/Refactor).

Register

Start with a test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
describe('POST /auth/register', () => {
  it('should register a new user', (done) => {
    chai.request(server)
    .post('/auth/register')
    .send({
      username: 'michael',
      password: 'herman'
    })
    .end((err, res) => {
      should.not.exist(err);
      res.redirects.length.should.eql(0);
      res.status.should.eql(200);
      res.type.should.eql('application/json');
      res.body.should.include.keys('status', 'token');
      res.body.status.should.eql('success');
      done();
    });
  });
});

Run the tests. You should see the following error:

1
Uncaught AssertionError: expected [Error: Not Found] to not exist

Now let’s write the code to get the test to pass. First, register the new set of auth routes in route-config.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function (routeConfig) {

  'use strict';

  routeConfig.init = function (app) {

    // *** routes *** //
    const routes = require('../routes/index');
    const authRoutes = require('../routes/auth');

    // *** register routes *** //
    app.use('/', routes);
    app.use('/auth', authRoutes);

  };

})(module.exports);

Then add a new file to the “route” folder called auth.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const express = require('express');
const router = express.Router();

const localAuth = require('../auth/local');
const authHelpers = require('../auth/_helpers');

router.post('/register', (req, res, next)  => {
  return authHelpers.createUser(req)
  .then((user) => { return localAuth.encodeToken(user[0]); })
  .then((token) => {
    res.status(200).json({
      status: 'success',
      token: token
    });
  })
  .catch((err) => {
    res.status(500).json({
      status: 'error'
    });
  });
});

module.exports = router;

This route simply handles the creation of a new user. To finish, add a createUser() function to src/server/auth/_helpers.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const bcrypt = require('bcryptjs');
const knex = require('../db/connection');

function createUser(req) {
  const salt = bcrypt.genSaltSync();
  const hash = bcrypt.hashSync(req.body.password, salt);
  return knex('users')
  .insert({
    username: req.body.username,
    password: hash
  })
  .returning('*');
}

module.exports = {
  createUser
};

Since you should never store plain text passwords, install bcrypt.js for salting and hashing:

1
$ npm install bcryptjs@2.3.0 --save

Run the tests to ensure they still pass.

Login

This time, let’s look at how to handle both a success and an error…

Handle Success

Again, start with a test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
describe('POST /auth/login', () => {
  it('should login a user', (done) => {
    chai.request(server)
    .post('/auth/login')
    .send({
      username: 'jeremy',
      password: 'johnson123'
    })
    .end((err, res) => {
      should.not.exist(err);
      res.redirects.length.should.eql(0);
      res.status.should.eql(200);
      res.type.should.eql('application/json');
      res.body.should.include.keys('status', 'token');
      res.body.status.should.eql('success');
      should.exist(res.body.token);
      done();
    });
  });
});

You should see the following failure after running the tests:

1
Uncaught AssertionError: expected [Error: Not Found] to not exist

Now, update the code. Start by adding the route handler to src/server/routes/auth.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
router.post('/login', (req, res, next) => {
  const username = req.body.username;
  const password = req.body.password;
  return authHelpers.getUser(username)
  .then((response) => {
    authHelpers.comparePass(password, response.password);
    return response;
  })
  .then((response) => { return localAuth.encodeToken(response); })
  .then((token) => {
    res.status(200).json({
      status: 'success',
      token: token
    });
  })
  .catch((err) => {
    res.status(500).json({
      status: 'error'
    });
  });
});

Then add the getUser() and comparePass() functions to src/server/auth/_helpers.js:

1
2
3
4
5
6
7
8
9
function getUser(username) {
  return knex('users').where({username}).first();
}

function comparePass(userPassword, databasePassword) {
  const bool = bcrypt.compareSync(userPassword, databasePassword);
  if (!bool) throw new Error('bad pass silly money');
  else return true;
}

Make sure to export the functions:

1
2
3
4
5
module.exports = {
  createUser,
  getUser,
  comparePass
};

Run the tests. You should see:

1
Uncaught AssertionError: expected [Error: Internal Server Error] to not exist

Why? The user does not exist in the database. To fix this, we just need to seed the database before the tests are ran. Create a new seed file:

1
$ knex seed:make users

Then add the following code to the newly created seed file in src/server/db/seeds/users.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const bcrypt = require('bcryptjs');

exports.seed = (knex, Promise) => {
  return knex('users').del()
  .then(() => {
    const salt = bcrypt.genSaltSync();
    const hash = bcrypt.hashSync('johnson123', salt);
    return Promise.join(
      knex('users').insert({
        username: 'jeremy',
        password: hash
      })
    );
  });
};

Back in the test file, update the beforeEach():

1
2
3
4
5
beforeEach(() => {
  return knex.migrate.rollback()
  .then(() => { return knex.migrate.latest(); })
  .then(() => { return knex.seed.run(); });
});

Run the tests again. They should pass.

NOTE: We did not write any unit tests to cover the comparePass() function. Do this on your own. Think about what needs to be covered. What if the user does not exist? What if the password is incorrect? Compare your tests to the tests I wrote in the repo.

Handle Error

Add another it block to the previous test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
it('should not login an unregistered user', (done) => {
  chai.request(server)
  .post('/auth/login')
  .send({
    username: 'michael',
    password: 'johnson123'
  })
  .end((err, res) => {
    should.exist(err);
    res.status.should.eql(500);
    res.type.should.eql('application/json');
    res.body.status.should.eql('error');
    done();
  });
});

The tests should still pass. What other errors should we handle? Think about this for a moment. How would you handle an expired token? Start with a test! Once done, move on…

User

Once logged in, users should have access to the /user endpoint. Start with the tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
describe('GET /auth/user', () => {
  it('should return a success', (done) => {
    chai.request(server)
    .post('/auth/login')
    .send({
      username: 'jeremy',
      password: 'johnson123'
    })
    .end((error, response) => {
      should.not.exist(error);
      chai.request(server)
      .get('/auth/user')
      .set('authorization', 'Bearer ' + response.body.token)
      .end((err, res) => {
        should.not.exist(err);
        res.status.should.eql(200);
        res.type.should.eql('application/json');
        res.body.status.should.eql('success');
        done();
      });
    });
  });
  it('should throw an error if a user is not logged in', (done) => {
    chai.request(server)
    .get('/auth/user')
    .end((err, res) => {
      should.exist(err);
      res.status.should.eql(400);
      res.type.should.eql('application/json');
      res.body.status.should.eql('Please log in');
      done();
    });
  });
});

Run the tests, and then add the route handler:

1
2
3
4
5
6
7
router.get('/user',
  authHelpers.ensureAuthenticated,
  (req, res, next)  => {
  res.status(200).json({
    status: 'success',
  });
});

Add a ensureAuthenticated() function to src/server/auth/_helpers.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function ensureAuthenticated(req, res, next) {
  if (!(req.headers && req.headers.authorization)) {
    return res.status(400).json({
      status: 'Please log in'
    });
  }
  // decode the token
  var header = req.headers.authorization.split(' ');
  var token = header[1];
  localAuth.decodeToken(token, (err, payload) => {
    if (err) {
      return res.status(401).json({
        status: 'Token has expired'
      });
    } else {
      // check if the user still exists in the db
      return knex('users').where({id: parseInt(payload.sub)}).first()
      .then((user) => {
        next();
      })
      .catch((err) => {
        res.status(500).json({
          status: 'error'
        });
      });
    }
  });
}

Add the dependency:

1
const localAuth = require('./local');

And export the function:

1
2
3
4
5
6
module.exports = {
  createUser,
  getUser,
  comparePass,
  ensureAuthenticated
};

Run the tests. All should pass.

Conclusion

In this tutorial, we went through the process of adding authentication to a NodeJS app with JSON Web Tokens. Turn back to the objectives. Can you put each one into action? What did you learn?

What’s next? Try adding:

  1. Authorization
  2. Logout (make sure to invalidate the token)
  3. Two factor authentication

Next time, we will incorporate the client to show the full auth process:

  1. Client logs in and the credentials are sent to the server
  2. Server generates a token (if the credentials are correct)
  3. Client receives and stores the token
  4. Client then sends token to server on subsequent requests

Feel free to share your comments, questions, or tips in the comments below. The full code can be found in the node-token-auth repository. Cheers!

Did you enjoy this post? Please share. Sharing is caring.

Comments