diff --git a/README.md b/README.md index eb7ba62..8c75ce6 100755 --- a/README.md +++ b/README.md @@ -6,15 +6,19 @@ JSON Web Token authentication requires verifying a signed token. The `'jwt'` scheme takes the following options: -- `key` - (required) The private key the token was signed with. -- `validateFunc` - (optional) validation and user lookup function with the signature `function(token, callback)` where: +- `key` - (required) Either the private key the token was signed with or a key lookup function with signature `function(token, callback)` where: + - `token` - the *unverified* encoded jwt token + - `callback` - a callback function with the signature `function(err, key, extraInfo)` where: + - `err` - an internal error + - `key` - the private key that will be used to verify the token + - `extraInfo` - data that will be passed to `validateFunc` (e.g. credentials) +- `validateFunc` - (optional) validation and user lookup function with the signature `function(token, extraInfo, callback)` where: - `token` - the verified and decoded jwt token + - `extraInfo` - data that was passed from the key lookup function (e.g. credentials) - `callback` - a callback function with the signature `function(err, isValid, credentials)` where: - `err` - an internal error. - `isValid` - `true` if the token was valid otherwise `false`. - - `credentials` - a credentials object passed back to the application in `request.auth.credentials`. Typically, `credentials` are only - included when `isValid` is `true`, but there are cases when the application needs to know who tried to authenticate even when it fails - (e.g. with authentication mode `'try'`). + - `credentials` - a credentials object passed back to the application in `request.auth.credentials`. Typically, `credentials` are only included when `isValid` is `true`, but there are cases when the application needs to know who tried to authenticate even when it fails (e.g. with authentication mode `'try'`). See the example folder for an executable example. @@ -26,7 +30,6 @@ var Hapi = require('hapi'), server.connection({ port: 8080 }); - var accounts = { 123: { id: 123, @@ -36,7 +39,6 @@ var accounts = { } }; - var privateKey = 'BbZJjyoXAdr8BUZuiKKARWimKfrSmQ6fv8kZ7OFfc'; // Use this token to build your request with the 'Authorization' header. @@ -44,8 +46,7 @@ var privateKey = 'BbZJjyoXAdr8BUZuiKKARWimKfrSmQ6fv8kZ7OFfc'; // Authorization: Bearer var token = jwt.sign({ accountId: 123 }, privateKey); - -var validate = function (decodedToken, callback) { +var validate = function (decodedToken, extraInfo, callback) { var error, credentials = accounts[decodedToken.accountId] || {}; @@ -57,7 +58,6 @@ var validate = function (decodedToken, callback) { return callback(error, true, credentials) }; - server.register(require('hapi-auth-jwt'), function (error) { server.auth.strategy('token', 'jwt', { @@ -86,6 +86,89 @@ server.register(require('hapi-auth-jwt'), function (error) { }); }); +server.start(); + +``` + +With a key lookup method: + +```javascript + +var Hapi = require('hapi'), + jwt = require('jsonwebtoken'), + server = new Hapi.Server(); + +server.connection({ port: 8080 }); + +var accounts = { + 123: { + id: 123, + user: 'john', + fullName: 'John Doe', + privateKey: 'BbZJjyoXAdr8BUZuiKKARWimKfrSmQ6fv8kZ7OFfc' + scope: ['a', 'b'] + } +}; + +var getKey = function(token, callback) { + var data = jwt.decode(token); + var account = accounts[data.id]; + var key = account.privateKey; + + var credentials = { + id: account.id, + user: account.user + fullName: account.fullName, + scope: account.scope + } + + callback(null, key, credentials); +} + +// Use this token to build your request with the 'Authorization' header. +// Ex: +// Authorization: Bearer +var token = jwt.sign({ accountId: 123 }, privateKey); + +var validate = function (decodedToken, extraInfo, callback) { + + var error, + credentials = extraInfo || {}; + + if (!credentials) { + return callback(error, false, credentials); + } + + return callback(error, true, credentials) +}; + +server.register(require('hapi-auth-jwt'), function (error) { + + server.auth.strategy('token', 'jwt', { + key: getKey, + validateFunc: validate + }); + + server.route({ + method: 'GET', + path: '/', + config: { + auth: 'token' + } + }); + + // With scope requirements + server.route({ + method: 'GET', + path: '/withScope', + config: { + auth: { + strategy: 'token', + scope: ['a'] + } + } + }); +}); server.start(); diff --git a/lib/index.js b/lib/index.js index 396bdaa..5f56b76 100755 --- a/lib/index.js +++ b/lib/index.js @@ -20,6 +20,9 @@ exports.register.attributes = { pkg: require('../package.json') }; +function isFunction(functionToCheck) { + return Object.prototype.toString.call(functionToCheck) === '[object Function]'; +} internals.implementation = function (server, options) { @@ -53,42 +56,49 @@ internals.implementation = function (server, options) { var token = parts[1]; - jwt.verify(token, settings.key, function(err, decoded) { - if(err && err.message === 'jwt expired') { - return reply(Boom.unauthorized('Expired token received for JSON Web Token validation', 'Bearer')); - } else if (err) { - return reply(Boom.unauthorized('Invalid signature received for JSON Web Token validation', 'Bearer')); - } + var getKey = isFunction(settings.key) ? + settings.key : + function(token, callback) { callback(null, settings.key); }; - if (!settings.validateFunc) { - return reply.continue({ credentials: decoded }); - } + getKey(token, function(err, key, extraInfo){ + if (err) { return reply(Boom.wrap(err)); } + // handle err + jwt.verify(token, key, function(err, decoded) { + if(err && err.message === 'jwt expired') { + return reply(Boom.unauthorized('Expired token received for JSON Web Token validation', 'Bearer')); + } else if (err) { + return reply(Boom.unauthorized('Invalid signature received for JSON Web Token validation', 'Bearer')); + } - settings.validateFunc(decoded, function (err, isValid, credentials) { + if (!settings.validateFunc) { + return reply.continue({ credentials: decoded }); + } - credentials = credentials || null; + settings.validateFunc(decoded, extraInfo, function (err, isValid, credentials) { - if (err) { - return reply(err, null, { credentials: credentials }); - } + credentials = credentials || null; - if (!isValid) { - return reply(Boom.unauthorized('Invalid token', 'Bearer'), null, { credentials: credentials }); - } + if (err) { + return reply(err, null, { credentials: credentials, log: { tags: ['auth', 'jwt'], data: err } }); + } - if (!credentials || typeof credentials !== 'object') { + if (!isValid) { + return reply(Boom.unauthorized('Invalid token', 'Bearer'), null, { credentials: credentials }); + } - return reply(Boom.badImplementation('Bad credentials object received for jwt auth validation'), null, { log: { tags: 'credentials' } }); - } + if (!credentials || typeof credentials !== 'object') { - // Authenticated + return reply(Boom.badImplementation('Bad credentials object received for jwt auth validation'), null, { log: { tags: 'credentials' } }); + } - return reply.continue({ credentials: credentials }); - }); + // Authenticated - }); + return reply.continue({ credentials: credentials }); + }); + }); + }); } }; diff --git a/package.json b/package.json index 0db84a1..374fe50 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hapi-auth-jwt", "description": "JSON Web Token (JWT) authentication plugin", - "version": "2.1.0", + "version": "2.1.1", "author": "Ryan Fitzgerald ", "repository": "git://github.com/ryanfitz/hapi-auth-jwt", "main": "index", @@ -17,7 +17,7 @@ "dependencies": { "boom": "2.x.x", "hoek": "2.x.x", - "jsonwebtoken": "3.2.x" + "jsonwebtoken": "^4.2.0" }, "peerDependencies": { "hapi": ">=8.x.x" diff --git a/test/dynamicKeys.tests.js b/test/dynamicKeys.tests.js new file mode 100644 index 0000000..260290e --- /dev/null +++ b/test/dynamicKeys.tests.js @@ -0,0 +1,142 @@ +// Load modules + +var Lab = require('lab'); +var Hapi = require('hapi'); +var Code = require('code'); +var Hoek = require('hoek') +var Boom = require('boom'); +var jwt = require('jsonwebtoken'); + + +// Test shortcuts + +var lab = exports.lab = Lab.script(); +var expect = Code.expect; +var before = lab.before; +var describe = lab.describe; +var it = lab.it; + +describe('Dynamic Secret', function () { + var keys = { + 'john': 'johnkey', + 'jane': 'janekey' + }; + + var info = { + 'john': 'johninfo', + 'jane': 'janeinfo', + } + + var tokenHeader = function (username, options) { + if (!keys[username]){ + throw new Error('Invalid user name ' + username + '. Valid options \'john\' or \'jane\''); + } + + options = options || {}; + + return 'Bearer ' + jwt.sign({username: username}, keys[username], options); + }; + + var tokenHandler = function (request, reply) { + reply(request.auth.credentials.username); + }; + + var getKey = function(token, callback){ + getKey.lastToken = token; + var data = jwt.decode(token); + Hoek.nextTick(function(){ + callback(null, keys[data.username], info[data.username]); + })(); + } + + var validateFunc = function(decoded, extraInfo, callback){ + validateFunc.lastExtraInfo = extraInfo; + callback(null, true, decoded); + } + + var errorGetKey = function(token, callback){ + callback(new Error('Failed')); + } + + var boomErrorGetKey = function(token, callback){ + callback(Boom.forbidden('forbidden')); + } + + var server = new Hapi.Server({ debug: false }); + server.connection(); + + before(function (done) { + server.register(require('../'), function (err) { + expect(err).to.not.exist; + server.auth.strategy('normalError', 'jwt', false, { key: errorGetKey }); + server.auth.strategy('boomError', 'jwt', false, { key: boomErrorGetKey }); + server.auth.strategy('default', 'jwt', false, { key: getKey, validateFunc: validateFunc }); + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } }, + { method: 'POST', path: '/normalError', handler: tokenHandler, config: { auth: 'normalError' } }, + { method: 'POST', path: '/boomError', handler: tokenHandler, config: { auth: 'boomError' } } + ]); + + done(); + }); + }); + + ['jane', 'john'].forEach(function(user){ + + it('uses key function passing ' + user + '\'s token if ' + user + ' is user', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader(user) } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.result).to.equal(user); + + jwt.verify(getKey.lastToken, keys[user], function(err, decoded){ + if (err) { return done(err); } + expect(decoded.username).to.equal(user); + + done(); + }); + }); + }); + + it('uses validateFunc function passing ' + user + '\'s extra info if ' + user + ' is user', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader(user) } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.result).to.equal(user); + + expect(validateFunc.lastExtraInfo).to.equal(info[user]); + done(); + }); + }); + }); + + it('return 500 if an is error thrown when getting key', function(done){ + + var request = { method: 'POST', url: '/normalError', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res).to.exist; + expect(res.result.statusCode).to.equal(500); + expect(res.result.error).to.equal('Internal Server Error'); + expect(res.result.message).to.equal('An internal server error occurred'); + done(); + }); + }); + + it('return 403 if an is error thrown when getting key', function(done){ + + var request = { method: 'POST', url: '/boomError', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res).to.exist; + expect(res.result.statusCode).to.equal(403); + expect(res.result.error).to.equal('Forbidden'); + expect(res.result.message).to.equal('forbidden'); + done(); + }); + }); +}); \ No newline at end of file diff --git a/test/index.js b/test/index.js index 4d2e5a4..0810047 100755 --- a/test/index.js +++ b/test/index.js @@ -24,7 +24,7 @@ describe('Token', function () { return 'Bearer ' + Jwt.sign({username : username}, privateKey, options); }; - var loadUser = function (decodedToken, callback) { + var loadUser = function (decodedToken, _, callback) { var username = decodedToken.username; if (username === 'john') {