Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add "root": true to prevent extension of unrelated configs.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"root": true,
"extends": ["warp/node", "warp/es6"],
"parserOptions": {
"ecmaVersion": 2017
},
"overrides": [
{
"files": [ "tests/*.js" ],
"env": {
"jest": true
}
}
]
}
59 changes: 1 addition & 58 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
4 changes: 4 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
language: node_js
node_js:
- "8"
after_success: ./node_modules/.bin/codecov
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
```
214 changes: 214 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -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);}
};
28 changes: 28 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading