From 9e05268fa18ebbfabaf3a4439179cbbc94689d25 Mon Sep 17 00:00:00 2001 From: "A. Amri" Date: Fri, 12 Jan 2018 13:25:54 +0100 Subject: [PATCH] init (#2) * inital commit * conform errors to match jwt's --- .eslintrc.json | 15 ++++ .gitignore | 59 +----------- .npmrc | 1 + .travis.yml | 4 + README.md | 12 ++- index.js | 214 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 28 ++++++ tests/index.test.js | 155 ++++++++++++++++++++++++++++++++ 8 files changed, 429 insertions(+), 59 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .npmrc create mode 100644 .travis.yml create mode 100644 index.js create mode 100644 package.json create mode 100644 tests/index.test.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..29f32b9 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "root": true, + "extends": ["warp/node", "warp/es6"], + "parserOptions": { + "ecmaVersion": 2017 + }, + "overrides": [ + { + "files": [ "tests/*.js" ], + "env": { + "jest": true + } + } + ] +} diff --git a/.gitignore b/.gitignore index 00cbbdf..8afdb48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,59 +1,2 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - # Dependency directories -node_modules/ -jspm_packages/ - -# Typescript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env - +/node_modules/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6aa4646 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: node_js +node_js: + - "8" +after_success: ./node_modules/.bin/codecov diff --git a/README.md b/README.md index 6c7d57b..9801081 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ # jwt-plus -Opinionated JWT library with sane defaults. +[![Build Status](https://travis-ci.com/wearereasonablepeople/jwt-plus.svg?token=yQTBKvDF8NXw5WvCpzqf&branch=master)](https://travis-ci.com/wearereasonablepeople/jwt-plus) +[![codecov](https://codecov.io/gh/wearereasonablepeople/jwt-plus/branch/master/graph/badge.svg?token=tHRvIF5F3v)](https://codecov.io/gh/wearereasonablepeople/jwt-plus) + + +## Description +An opinionated JWT library with sensible defaults that implements the complete token flow. + +## Install +``` +npm install jwt-plus +``` diff --git a/index.js b/index.js new file mode 100644 index 0000000..f519ee1 --- /dev/null +++ b/index.js @@ -0,0 +1,214 @@ +'use strict'; + +const jsonwebtoken = require('jsonwebtoken'); +const randToken = require('rand-token'); +const generator = randToken.generator({source: 'crypto'}); +const t = require('tcomb'); +const {mergeAll, dissoc} = require('ramda'); +const StandardError = require('standard-error'); +const RefreshTokenExpired = + new StandardError('The refresh token has expired', {name: 'RefreshTokenExpiredError'}); +const InvalidAccessToken = + new StandardError('The access token provided is invalid', {name: 'InvalidAccessToken'}); + +const Store = t.interface({ + // Signature: (userId, refreshToken) + remove: t.Function, + // Signature: (userId) + removeAll: t.Function, + // Signature: (userId, refreshToken) + getAccessToken: t.Function, + // Signature: (userId, refreshToken, accessToken, ttl) + registerTokens: t.Function +}, 'Stores'); + +const JWT = t.interface({ + // Signature: (payload, secret, {algorithm: String}) + sign: t.Function, + // Signature: (payload, secret, {algorithm: String, otherVerifyOptions}) + verify: t.Function, + // Signature: (payload) + decode: t.Function, +}, 'JWT'); + +// 30 minutes +const regularTokenLifeInSeconds = 60 * 30; +// 1 hour +const tokenLifeUpperLimitInSeconds = 60 * 60; +// 1 day +const regularRefreshTokenLifeInMS = 1000 * 60 * 60 * 24; +// 7 days +const prolongedRefreshTokenLifeInMS = 1000 * 60 * 60 * 24 * 7; + +const Secret = t.refinement(t.String, s => s.length >= 20, 'Secret'); +const ExpiresIn = t.refinement(t.Number, e => e <= tokenLifeUpperLimitInSeconds, 'ExpiresIn'); +const Algorithm = t.enums.of(['HS256', 'HS384', 'HS512', 'RS256'], 'Algorithm'); + +const pld = t.refinement(t.Object, o => typeof o.userId !== 'undefined', 'pld'); + +const VerifyOptions = t.interface({ + audience: t.maybe(t.union([t.String, t.Array, t.Object])), + issuer: t.maybe(t.union([t.String, t.Array])), + ignoreExpiration: t.maybe(t.Boolean), + ignoreNotBefore: t.maybe(t.Boolean), + subject: t.maybe(t.String), + clockTolerance: t.maybe(t.union([t.Number, t.String])), + maxAge: t.maybe(t.union([t.String, t.Number])), + clockTimestamp: t.maybe(t.Number) +}, {name: 'VerifyOptions', strict: true}); + +const UserSignOptions = t.interface({ + nbf: t.maybe(t.Number), + aud: t.maybe(t.String), + iss: t.maybe(t.String), + jti: t.maybe(t.String), + sub: t.maybe(t.String), +}, {name: 'UserSignOption', strict: true}); + +const Payload = UserSignOptions.extend(t.interface({ + pld: pld, + exp: ExpiresIn, + rme: t.Boolean +}, {name: 'Payload', strict: true})); + +const getTTL = rememberMe => + rememberMe ? prolongedRefreshTokenLifeInMS : regularRefreshTokenLifeInMS; + +const getTokensObj = (token, tokenTTL, refreshToken, refreshTokenTTL) => ({ + token, + tokenTTL, + refreshToken, + refreshTokenTTL +}); + +module.exports = class JWTPlus { + /** + * Constructor + * @param {Object} store + * @param {string} [algorithm='HS256] algorithm cannot be 'none' + * @param {Number} [expiresIn=60 * 30] expiration time in seconds. + * @param {Object} [jwt] jsonwebtoken instance, by default it uses require('jsonwebtoken') + * @param {Object} [defaultSignInOptions] + * @param {Object} [defaultVerifyOptions] + */ + constructor({ + store, algorithm = 'HS256', expiresIn = regularTokenLifeInSeconds, jwt = jsonwebtoken, + defaultSignInOptions = {}, defaultVerifyOptions = {} + }) { + this._store = Store(store); + this._defaultSignInOptions = UserSignOptions(defaultSignInOptions); + this._defaultVerifyOptions = VerifyOptions(defaultVerifyOptions); + this._algorithm = Algorithm(algorithm); + this._expiresIn = ExpiresIn(expiresIn); + this._jwt = JWT(jwt); + } + + /** + * @private + * A private function that creates a refresh token + * @param {String|Number} userId + * @param {String} accessToken + * @param {Number} ttl time to live in milliseconds + * @returns {Promise} + */ + async _createRefreshToken(userId, accessToken, ttl) { + const refreshToken = generator.generate(256); + await this._store.registerTokens(userId, refreshToken, accessToken, ttl); + return refreshToken; + } + + /** + * Returns access and refresh tokens + * @param {Object} content token's payload + * @param secret + * @param {Boolean} rememberMe if true, the token will last 7 days instead of 1. + * @param {Object} [signOptions] Options to be passed to jwt.sign + * @returns {Promise<{ + * token: *, tokenTTL: Number, refreshToken: *, refreshTokenTTL: Number + * }>} + */ + async sign(content, secret, rememberMe = false, signOptions = {}) { + const token = this._jwt.sign( + // Payload + Payload({pld: content, + ...mergeAll([ + this._defaultSignInOptions, UserSignOptions(signOptions), + {exp: this._expiresIn, rme: rememberMe} + ])}), + // Secret + Secret(secret), + // Options + {algorithm: this._algorithm}); + const ttl = getTTL(rememberMe); + return getTokensObj(token, + this._expiresIn, + await this._createRefreshToken(content.userId, token, ttl), + ttl); + } + + /** + * Verifies token, might throw jwt.verify errors + * @param {String} token + * @param secret + * @param {Object} [verifyOptions] Options to pass to jwt.verify. + * @returns {Promise<*>} + */ + verify(token, secret, verifyOptions = {}) { + return this._jwt.verify(token, Secret(secret), + mergeAll([this._defaultVerifyOptions, VerifyOptions(verifyOptions), + {algorithm: this._algorithm}])); + } + + /** + * Issues a new access token using a refresh token and an old token. + * There is no need to verify the old token provided because this method uses the stored one. + * @param {String} refreshToken + * @param {String} oldToken + * @param secret + * @param {Object} [signOptions] Options passed to jwt.sign + * @returns {Promise<*>} + */ + async refresh(refreshToken, oldToken, secret, signOptions) { + t.String(refreshToken); + t.String(oldToken); + const untrustedPayload = Payload(this._jwt.decode(oldToken).payload); + const trustedToken = await this._store.getAccessToken(untrustedPayload.userId, refreshToken); + // Remove the refresh token even if the following operations were not successful. + // RefreshTokens are one time use only + if(!await this._store.remove(untrustedPayload.userId, refreshToken)) { + throw RefreshTokenExpired; + } + // RefreshTokens works with only one AccessToken + if (trustedToken !== oldToken) {throw InvalidAccessToken;} + + // Token is safe since it is stored by us + const {payload: {pld: payload, rme: rememberMe, ...jwtOptions}} = + this._jwt.decode(trustedToken); + + // Finally, sign new tokens for the user + return this.sign( + payload, + Secret(secret), + rememberMe, + // Ignoring exp + UserSignOptions({...dissoc('exp', jwtOptions), ...signOptions}) + ); + } + + /** + * Invalidates refresh token + * @param {String|Number} userId + * @param {String} refreshToken + * @returns {Promise} + */ + invalidateRefreshToken(userId, refreshToken) { + return this._store.remove(userId, refreshToken); + } + + /** + * Invalidates all refresh tokens + * @param {String|Number} userId + * @returns {Promise} + */ + invalidateAllRefreshTokens(userId) {return this._store.removeAll(userId);} +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5a53fa7 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "jwt-plus", + "version": "0.0.0", + "description": "An opinionated JWT library with sensible defaults that implements the complete token flow.", + "main": "index.js", + "scripts": { + "test": "npm run test:lint && npm run test:coverage", + "test:coverage": "jest tests --coverage", + "test:lint": "eslint tests index.js" + }, + "repository": "https://github.com/wearereasonablepeople/jwt-plus", + "author": "Abdulrahman Amri", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "latest", + "ramda": "^0.25.0", + "rand-token": "^0.4.0", + "standard-error": "^1.1.0", + "tcomb": "^3.2.24" + }, + "devDependencies": { + "codecov": "^3.0.0", + "eslint": "^4.15.0", + "eslint-config-warp": "^2.1.0", + "jest": "^21.0.0", + "ms": "latest" + } +} diff --git a/tests/index.test.js b/tests/index.test.js new file mode 100644 index 0000000..8132ee0 --- /dev/null +++ b/tests/index.test.js @@ -0,0 +1,155 @@ +'use strict'; + +const JWTPlus = require('../index'); +const {omit, merge} = require('ramda'); +const ms = require('ms'); + +const customAlgorithm = 'HS256'; + +const acceptableVerifyOptions = { + algorithm: customAlgorithm +}; + +const expiresIn = 1800; +const acceptableSignOptions = { + algorithm: customAlgorithm +}; + +describe('jwtPlus', () => { + let jwtPlus, fakeStore, fakeJWT; + const userId = '123', token = '789', refreshToken = '456', secret = 'asdfasdfasdfasdf1234'; + beforeEach(() => { + fakeStore = { + // Signature: (userId, refreshToken) + remove: jest.fn(() => 1), + // Signature: (userId) + removeAll: jest.fn(() => 1), + // Signature: (userId, refreshToken) + getAccessToken: jest.fn(() => token), + // Signature: (userId, refreshToken, accessToken, ttl) + registerTokens: jest.fn(() => 1), + }; + fakeJWT = { + verify: jest.fn(token => token), + sign: jest.fn(() => 'I am a token'), + decode: jest.fn(() => ({payload: {exp: expiresIn, rme: true, pld: {userId: 123}}})) + }; + jwtPlus = new JWTPlus({store: fakeStore, algorithm: customAlgorithm, jwt: fakeJWT}); + }); + + describe('#constructor', () => { + it('should not allow incomplete store object', () => { + // Store missing the remove function + expect(() => new JWTPlus({store: omit(['remove'], fakeStore)})).toThrow(); + }); + it('should not allow the none algorithm', () => { + expect(() => new JWTPlus({algorithm: 'none'})).toThrow(); + }); + it('should not accept values greater than an hour', () => { + expect(() => new JWTPlus({expiresIn: 60 * 60 * 12})).toThrow(); + }); + }); + describe('#invalidateRefreshToken', () => { + it('should instruct the store to remove the refresh token', () => { + jwtPlus.invalidateRefreshToken(userId, refreshToken); + expect(fakeStore.remove.mock.calls[0][0]).toBe(userId); + expect(fakeStore.remove.mock.calls[0][1]).toBe(refreshToken); + }); + it('Should be truthy on success', () => { + expect(jwtPlus.invalidateRefreshToken(userId, refreshToken)).toBeTruthy(); + }); + it('Should be falsey if token was not found', () => { + // Make remove unsuccessful + jwtPlus = new JWTPlus({store: merge(fakeStore, {remove: jest.fn(() => 0)})}); + expect(jwtPlus.invalidateRefreshToken(userId, refreshToken)).toBeFalsy(); + }); + }); + describe('#invalidateAllRefreshTokens', () => { + it('should instruct the store to remove all refresh tokens', () => { + jwtPlus.invalidateAllRefreshTokens(userId); + expect(fakeStore.removeAll.mock.calls[0][0]).toBe(userId); + }); + it('Should be truthy on success', () => { + expect(jwtPlus.invalidateAllRefreshTokens(userId)).toBeTruthy(); + }); + it('Should be falsey if no tokens were found', () => { + // make removeAll unsuccessful + jwtPlus = new JWTPlus({store: merge(fakeStore, {removeAll: jest.fn(() => 0)})}); + expect(jwtPlus.invalidateAllRefreshTokens(userId)).toBeFalsy(); + }); + }); + describe('#verify', () => { + it('should instruct jwt to verify the token', async () => { + await jwtPlus.verify(token, secret); + expect(fakeJWT.verify.mock.calls[0][0]).toBe(token); + expect(fakeJWT.verify.mock.calls[0][1]).toBe(secret); + expect(fakeJWT.verify.mock.calls[0][2]).toEqual(acceptableVerifyOptions); + }); + it('should return jwt.verify results', async () => { + expect(await jwtPlus.verify(token, secret)).toBeTruthy(); + }); + }); + describe('#refresh', () => { + it('should instruct sign to refresh token with correct arguments', async () => { + const trustedToken = '123'; + jwtPlus = new JWTPlus({ + // Make store returns expected token + jwt: fakeJWT, store: merge(fakeStore, {getAccessToken: jest.fn(() => trustedToken)}) + }); + // Stub out sign + jwtPlus.sign = jest.fn(); + await jwtPlus.refresh(refreshToken, trustedToken, secret); + expect(jwtPlus.sign.mock.calls[0][1]).toBe(secret); + expect(jwtPlus.sign.mock.calls[0][2]).toBeTruthy(); + }); + it('should throw an error if the tokens mismatch', async () => { + const trustedToken = '123', oldToken = '1234'; + expect.assertions(1); + jwtPlus = new JWTPlus({ + // Make store return expected token + store: merge(fakeStore, {getAccessToken: jest.fn(() => trustedToken)}), jwt: fakeJWT + }); + try { + await jwtPlus.refresh(refreshToken, oldToken, secret); + } catch(e) { + expect(e.name).toBe('InvalidAccessToken'); + } + }); + it('should throw an error if the refresh token expired', async () => { + expect.assertions(1); + jwtPlus = new JWTPlus({ + // Make remove operation unsuccessful + store: merge(fakeStore, {remove: jest.fn(() => 0)}), jwt: fakeJWT + }); + try { + await jwtPlus.refresh(refreshToken, token, secret); + } catch(e) { + expect(e.name).toBe('RefreshTokenExpiredError'); + } + }); + }); + describe('#signIn', () => { + it('should instruct jwt.sign to sign a token with correct arguments', async () => { + const content = {userId: '123'}; + await jwtPlus.sign(content, secret); + expect(fakeJWT.sign.mock.calls[0][0].pld).toEqual(content); + expect(fakeJWT.sign.mock.calls[0][1]).toBe(secret); + expect(fakeJWT.sign.mock.calls[0][2]).toEqual(acceptableSignOptions); + }); + it('should return correct object', async () => { + const content = {userId: '123'}; + const object = await jwtPlus.sign(content, secret); + expect(object).toEqual(expect.objectContaining({ + token: expect.any(String), + tokenTTL: expect.any(Number), + refreshToken: expect.any(String), + refreshTokenTTL: expect.any(Number) + })); + }); + it('should change refreshToken life when using rememberMe option', async () => { + const content = {userId: '123'}; + const object = await jwtPlus.sign(content, secret, true); + expect(object.refreshTokenTTL).toBe(ms('7d')); + }); + }); +});