From d14db4cb1093462e649e801022eb26c27336d633 Mon Sep 17 00:00:00 2001 From: Alec Embke Date: Fri, 31 Mar 2017 10:32:38 -0700 Subject: [PATCH 1/2] useCookies, tokenKey, keepRequestArgHeader, and tests --- README.md | 21 ++--- lib/jwtRedisSession.js | 47 +++++++---- package.json | 1 + test/tests.js | 177 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8bcfd6b..434b85a 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ JSON Web Token session middleware backed by [Redis](http://redis.io/). This conn # Important Notes -Developers are free to use either the JWT claims or redis to store session related data. In many cases when serializing a user's session only the minimal amount of data necessary to uniquely identify the user's session is actually serialized and sent to the client. By default when this module creates a JWT token it will only reserve the "jti" property on the JWT claims object. This property will refer to a UUID that acts as the key in redis for the user's session data. This ensures that by default this module will only serialize the minimal amount of data needed. Any other data stored on the JWT session object throughout the request-response process will be serialized and stored in redis. +Developers are free to use either the JWT claims or redis to store session related data. In many cases when serializing a user's session only the minimum amount of data necessary to uniquely identify the user's session is actually serialized and sent to the client. By default when this module creates a JWT token it will only reserve the "jti" property on the JWT claims object. This property will refer to a UUID that acts as the key in redis for the user's session data. This ensures that by default this module will only serialize the minimum amount of data needed. Any other data stored on the JWT session object throughout the request-response process will be serialized and stored in Redis. -Due to the way JSON Web Tokens work the claims object can only be modified when creating a new token. Because of this by default this module does not attach a TTL to the JWT. Any TTL attached to the JWT cannot be refreshed without regenerating a new JWT so this module instead manages a session's expiration via redis key expirations. Aside from the "jti" property, which this module reserves, developers are free to attach any data to the claims object when creating a new JWT, including a TTL, but need to be aware that any TTL on the claims object will supercede the TTL managed by redis. +Due to the way JSON Web Tokens work the claims object can only be modified when creating a new token. Because of this by default this module does not attach a TTL to the JWT. Any TTL attached to the JWT cannot be refreshed without regenerating a new JWT so this module instead manages a session's expiration via redis key expirations. Aside from the "jti" property, which this module reserves, developers are free to attach any data to the claims object when creating a new JWT, including a TTL, but should be aware that if a TTL is set on the JWT claims (`exp`) then the actual TTL used will be `min(claims.exp, options.maxAge)`. # API Overview @@ -28,8 +28,11 @@ This module supports a few initialization parameters that can be used to support * **algorithm** - The hashing algorithm to use, the default is "HS256" (SHA-256). * **client** - The redis client to use to perform redis commands. * **maxAge** - The maximum age (in seconds) of a session. +* **tokenKey** - The key on the session on which to store the token. By default this is `jwt`. +* **keepRequestArgHeader** - A boolean flag indicating whether or not to look for the `requestArg` value directly in the headers, instead of the `x-...` expanded form. +* **useCookies** - A boolean flag indicating whether or not to also look for the JWT in the request cookies. -``` +```javascript var JWTRedisSession = require("jwt-redis-session"), express = require("express"), redis = require("redis"); @@ -55,7 +58,7 @@ app.use(JWTRedisSession({ Create a new JSON Web Token from the provided claims and store any relevant data in redis. -``` +```javascript var handleRequest = function(req, res){ User.login(req.param("username"), req.param("password"), function(error, user){ @@ -81,7 +84,7 @@ var handleRequest = function(req, res){ The session's UUID, JWT claims, and the JWT itself are all available on the jwtSession object as well. Any of these properties can be used to test for the existence of a valid JWT and session. -``` +```javascript var handleRequest = function(req, res){ console.log("Request JWT session data: ", @@ -99,7 +102,7 @@ var handleRequest = function(req, res){ Any modifications to the jwtSession will be reflected in redis. -``` +```javascript var handleRequest = function(req, res){ if(req.jwtSession.id){ @@ -120,7 +123,7 @@ var handleRequest = function(req, res){ Force a reload of the session data from redis. -``` +```javascript var handleRequest = function(req, res){ setTimeout(function(){ @@ -136,7 +139,7 @@ var handleRequest = function(req, res){ ## Refresh the TTL on a Session -``` +```javascript var handleRequest = function(req, res){ req.jwtSession.touch(function(error){ @@ -150,7 +153,7 @@ var handleRequest = function(req, res){ Remove the session data from redis. The user's JWT may still be valid within its expiration window, but the backing data in redis will no longer exist. This module will not recognize the JWT when this is the case. -``` +```javascript var handleRequest = function(req, res){ req.jwtSession.destroy(function(error){ diff --git a/lib/jwtRedisSession.js b/lib/jwtRedisSession.js index 48f1dae..ddb752a 100644 --- a/lib/jwtRedisSession.js +++ b/lib/jwtRedisSession.js @@ -2,6 +2,7 @@ var _ = require("lodash"), jwt = require("jsonwebtoken"), + debug = require("debug")("jwt-redis-session"), utils = require("./utils"); module.exports = function(options){ @@ -9,21 +10,29 @@ module.exports = function(options){ if(!options.client || !options.secret) throw new Error("Redis client and secret required for JWT Redis Session!"); - options = { - client: options.client, - secret: options.secret, - algorithm: options.algorithm || "HS256", - keyspace: options.keyspace || "sess:", - maxAge: options.maxAge || 86400, - requestKey: options.requestKey || "session", - requestArg: options.requestArg || "accessToken" - }; + options.keepRequestArgHeader = !!options.keepRequestArgHeader; + options.useCookies = !!options.useCookies; + + options = _.defaults(options, { + algorithm: "HS256", + keyspace: "sess:", + maxAge: 86400, + requestKey: "session", + requestArg: "accessToken", + tokenKey: "jwt", + keepRequestArgHeader: false, + useCookies: false + }); - var SessionUtils = utils(options); + var requestHeader, SessionUtils = utils(options); - var requestHeader = _.reduce(options.requestArg.split(""), function(memo, ch){ - return memo + (ch.toUpperCase() === ch ? "-" + ch.toLowerCase() : ch); - }, "x" + (options.requestArg.charAt(0) === options.requestArg.charAt(0).toUpperCase() ? "" : "-")); + if(options.keepRequestArgHeader){ + requestHeader = options.requestArg; + }else{ + requestHeader = _.reduce(options.requestArg.split(""), function(memo, ch){ + return memo + (ch.toUpperCase() === ch ? "-" + ch.toLowerCase() : ch); + }, "x" + (options.requestArg.charAt(0) === options.requestArg.charAt(0).toUpperCase() ? "" : "-")); + } return function jwtRedisSession(req, res, next){ @@ -33,11 +42,19 @@ module.exports = function(options){ || req.query[options.requestArg] || (req.body && req.body[options.requestArg]); + if(!token && options.useCookies){ + token = req.cookies[options.requestArg]; + } + if(token){ + debug("Verifying JWT", token); + jwt.verify(token, options.secret, function(error, decoded){ if(error || !decoded.jti) return next(); + debug("Verfied JWT", token); + options.client.get(options.keyspace + decoded.jti, function(err, session){ if(err || !session) return next(); @@ -48,10 +65,12 @@ module.exports = function(options){ return next(); } + debug("Found JWT session", token, session); + _.extend(req[options.requestKey], session); req[options.requestKey].claims = decoded; req[options.requestKey].id = decoded.jti; - req[options.requestKey].jwt = token; + req[options.requestKey][options.tokenKey] = token; // Update the TTL req[options.requestKey].touch(_.noop); next(); diff --git a/package.json b/package.json index 4826d56..3519411 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test": "mocha test/tests.js" }, "dependencies": { + "debug": "^2.6.3", "jsonwebtoken": "^7.1.9", "lodash": "^4.16.1", "node-uuid": "^1.4.1" diff --git a/test/tests.js b/test/tests.js index 165a691..193f106 100644 --- a/test/tests.js +++ b/test/tests.js @@ -447,6 +447,183 @@ describe("JWT Redis Session Tests", function(){ }); + it("Should allow the user to use a custom request argument name with tokenKey", function(done){ + + var testResponse = function(error, resp, callback){ + assert.notOk(error, "No error thrown"); + assert.isObject(resp, "Response is an object"); + assert.deepEqual(resp, {}, "Response is a blank object"); + callback(error); + }; + + var restartServer = function(options, callback){ + server.inspect().client.quit(); + server.end(function(){ + server.start(console.log, function(app, redisClient, cb){ + options.client = redisClient; + app.use(JWT(options)); + cb(8000); + }, callback); + }); + }; + + var testData = {}; + + var handler1 = function handler1(req, res){ + req.session.create(function(error, token){ + assert.isString(token, "Token is a string"); + assert.notOk(error, "Error is null when creating token"); + res.json({ token: token }); + }); + }; + var handler2 = function handler2(req, res){ + assert.isObject(req.session, "Request object has JWT object"); + assert.isString(req.session.foo, "Request object found the token"); + res.json({}); + }; + + async.series([ + function(callback){ + restartServer({ + secret: "abc123", + requestArg: customArg, + tokenKey: "foo" + }, callback); + }, + function(callback){ + server.addRoute("/login", "get", handler1); + server.addRoute("/ping", "all", handler2); + callback(); + }, + function(callback){ + request({ method: "get", path: "/login" }, null, function(error, resp){ + assert.notOk(error, "Token creation did not return an error"); + assert.isObject(resp, "Response is an object"); + assert.property(resp, "token", "Response contains a token property"); + assert.isString(resp.token, "Token is a string"); + token = resp.token; + testData[customArg] = token; + callback(error); + }); + }, + function(callback){ + request({ method: "get", path: "/ping" }, testData, _.partialRight(testResponse, callback)); + }, + function(callback){ + request({ method: "post", path: "/ping" }, testData, _.partialRight(testResponse, callback)); + }, + function(callback){ + request({ + method: "get", + path: "/ping", + headers: { "x-fancy-access-token": token } + }, + null, + _.partialRight(testResponse, callback) + ); + } + ], function(error){ + assert.notOk(error, "Async waterfall did not return an error"); + server.removeRoute("/login", "get"); + server.removeRoute("/ping"); + restartServer({ + secret: "abc123", + requestKey: customRequestKey, + keyspace: customRedisKeyspace + }, done); + }); + }); + + + it("Should allow the user to use a custom request argument name with keepRequestArgHeader", function(done){ + + var testResponse = function(error, resp, callback){ + assert.notOk(error, "No error thrown"); + assert.isObject(resp, "Response is an object"); + assert.deepEqual(resp, {}, "Response is a blank object"); + callback(error); + }; + + var restartServer = function(options, callback){ + server.inspect().client.quit(); + server.end(function(){ + server.start(console.log, function(app, redisClient, cb){ + options.client = redisClient; + app.use(JWT(options)); + cb(8000); + }, callback); + }); + }; + + var testData = {}; + + var handler1 = function handler1(req, res){ + req.session.create(function(error, token){ + assert.isString(token, "Token is a string"); + assert.notOk(error, "Error is null when creating token"); + res.json({ token: token }); + }); + }; + var handler2 = function handler2(req, res){ + assert.isObject(req.session, "Request object has JWT object"); + assert.isString(req.session.jwt, "Request object found the token"); + res.json({}); + }; + + async.series([ + function(callback){ + restartServer({ + secret: "abc123", + requestArg: customArg, + keepRequestArgHeader: true + }, callback); + }, + function(callback){ + server.addRoute("/login", "get", handler1); + server.addRoute("/ping", "all", handler2); + callback(); + }, + function(callback){ + request({ method: "get", path: "/login" }, null, function(error, resp){ + assert.notOk(error, "Token creation did not return an error"); + assert.isObject(resp, "Response is an object"); + assert.property(resp, "token", "Response contains a token property"); + assert.isString(resp.token, "Token is a string"); + token = resp.token; + testData[customArg] = token; + callback(error); + }); + }, + function(callback){ + request({ method: "get", path: "/ping" }, testData, _.partialRight(testResponse, callback)); + }, + function(callback){ + request({ method: "post", path: "/ping" }, testData, _.partialRight(testResponse, callback)); + }, + function(callback){ + request({ + method: "get", + path: "/ping", + headers: _.set({}, customArg, token) + }, + null, + _.partialRight(testResponse, callback) + ); + } + ], function(error){ + assert.notOk(error, "Async waterfall did not return an error"); + server.removeRoute("/login", "get"); + server.removeRoute("/ping"); + restartServer({ + secret: "abc123", + requestKey: customRequestKey, + keyspace: customRedisKeyspace + }, done); + }); + }); + + + }); }); From e6782275f0ca9832b3623e530245c15539a012b3 Mon Sep 17 00:00:00 2001 From: Alec Embke Date: Fri, 31 Mar 2017 14:18:05 -0700 Subject: [PATCH 2/2] travis --- .gitignore | 4 ++++ .jsdoc.conf.json | 26 ++++++++++++++++++++++++++ .travis.yml | 24 ++++++++++++++++++++++++ LICENSE | 2 +- README.md | 6 ++++-- lib/utils.js | 3 +-- package.json | 9 +++++++-- 7 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 .jsdoc.conf.json create mode 100644 .travis.yml diff --git a/.gitignore b/.gitignore index da23d0d..a8cf19a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ build/Release # Deployed apps should consider commenting this line out: # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git node_modules + +coverage/* +!coverage/.gitkeep + diff --git a/.jsdoc.conf.json b/.jsdoc.conf.json new file mode 100644 index 0000000..98f0ab5 --- /dev/null +++ b/.jsdoc.conf.json @@ -0,0 +1,26 @@ +{ + "tags": { + "allowUnknownTags": true + }, + "plugins": ["plugins/markdown"], + "templates": { + "cleverLinks": false, + "monospaceLinks": false, + "dateFormat": "ddd MMM Do YYYY", + "outputSourceFiles": true, + "outputSourcePath": true, + "systemName": "JWT Redis Session", + "footer": "", + "copyright": "Azuqua © 2017", + "navType": "vertical", + "linenums": true, + "collapseSymbols": false, + "inverseNav": true, + "highlightTutorialCode": true, + "protocol": "html://" + }, + "markdown": { + "parser": "gfm", + "hardwrap": true + } +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1354498 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,24 @@ +os: +- linux +language: node_js +notifications: + email: + on_success: never + on_failure: never +sudo: true +branches: + only: + - master + - develop +node_js: +- '7' +- '6' +- '4' +cache: + apt: true + directories: + - node_modules +before_install: npm install -g grunt-cli +script: npm run-script test-travis +after_script: +- cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js \ No newline at end of file diff --git a/LICENSE b/LICENSE index bf4ea1a..531d69c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 Azuqua +Copyright (c) 2017 Azuqua Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 434b85a..782e43f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ JWT-Redis-Session ================= +[![Build Status](https://travis-ci.org/azuqua/jwt-redis-session.svg?branch=master)](https://travis-ci.org/azuqua/jwt-redis-session) + JSON Web Token session middleware backed by [Redis](http://redis.io/). This connect middleware module exposes an API surface similar to a [session middleware](https://github.com/expressjs/session#reqsession) module, however instead of using cookies to transport session details this module uses JSON Web Tokens. This is useful for cookie-less clients or for cross service user authentication. [Some info on JSON Web Tokens](http://tools.ietf.org/html/draft-ietf-oauth-json-web-token-19#section-3) @@ -11,9 +13,9 @@ JSON Web Token session middleware backed by [Redis](http://redis.io/). This conn # Important Notes -Developers are free to use either the JWT claims or redis to store session related data. In many cases when serializing a user's session only the minimum amount of data necessary to uniquely identify the user's session is actually serialized and sent to the client. By default when this module creates a JWT token it will only reserve the "jti" property on the JWT claims object. This property will refer to a UUID that acts as the key in redis for the user's session data. This ensures that by default this module will only serialize the minimum amount of data needed. Any other data stored on the JWT session object throughout the request-response process will be serialized and stored in Redis. +Developers are free to use either the JWT claims or redis to store session related data. In many cases when serializing a user's session only the minimum amount of data necessary to uniquely identify the user's session is actually serialized and sent to the client. By default when this module creates a JWT token it will only reserve the `jti` property on the JWT claims object. This property will refer to a UUID that acts as the key in redis for the user's session data. This ensures that by default this module will only serialize the minimum amount of data needed. Any other data stored on the JWT session object throughout the request-response process will be serialized and stored in Redis. -Due to the way JSON Web Tokens work the claims object can only be modified when creating a new token. Because of this by default this module does not attach a TTL to the JWT. Any TTL attached to the JWT cannot be refreshed without regenerating a new JWT so this module instead manages a session's expiration via redis key expirations. Aside from the "jti" property, which this module reserves, developers are free to attach any data to the claims object when creating a new JWT, including a TTL, but should be aware that if a TTL is set on the JWT claims (`exp`) then the actual TTL used will be `min(claims.exp, options.maxAge)`. +Due to the way JSON Web Tokens work the claims object can only be modified when creating a new token. Because of this by default this module does not attach a TTL to the JWT. Any TTL attached to the JWT cannot be refreshed without regenerating a new JWT so this module instead manages a session's expiration via redis key expirations. Aside from the `jti` property, which this module reserves, developers are free to attach any data to the claims object when creating a new JWT, including a TTL, but should be aware that if a TTL is set on the JWT claims (`exp`) then the actual TTL used will be `min(claims.exp, options.maxAge)`. # API Overview diff --git a/lib/utils.js b/lib/utils.js index 9292bcb..0b0469d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,8 +2,7 @@ var _ = require("lodash"), jwt = require("jsonwebtoken"), - uuid = require("node-uuid"); - + uuid = require("uuid"); var extendSession = function(session, data){ _.reduce(data, function(memo, val, key){ diff --git a/package.json b/package.json index 3519411..2676da6 100644 --- a/package.json +++ b/package.json @@ -20,22 +20,27 @@ "url": "git://github.com/azuqua/jwt-redis-session" }, "scripts": { - "test": "mocha test/tests.js" + "test": "mocha test/tests.js", + "test-travis": "./node_modules/istanbul/lib/cli.js cover ./node_modules/mocha/bin/_mocha -- -R spec ./test/*.js" }, "dependencies": { "debug": "^2.6.3", "jsonwebtoken": "^7.1.9", "lodash": "^4.16.1", - "node-uuid": "^1.4.1" + "uuid": "^3.0.1" }, "devDependencies": { "async": "^0.9.0", "body-parser": "^1.15.0", "chai": "^1.9.1", + "coveralls": "^2.13.0", "express": "^4.13.4", "grunt": "^0.4.5", "grunt-contrib-jshint": "^0.10.0", + "grunt-jsdoc": "^2.1.0", "grunt-mocha-test": "^0.11.0", + "ink-docstrap": "^1.3.0", + "istanbul": "^0.4.5", "redis": "^2.4.2", "restjs": "azuqua/restjs" },