diff --git a/README.md b/README.md index eb7ba62..6d838af 100755 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ JSON Web Token authentication requires verifying a signed token. The `'jwt'` sch - `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'`). +- `audience` (optional): string or array of strings of valid values for the `aud` field. +- `issuer` (optional): string or array of strings of valid values for the `iss` field. +- `algorithms` (optional): List of strings with the names of the allowed algorithms. For instance, `["HS256", "RS256"]`. +- `subject` (optional): string of valid values for the `sub` field See the example folder for an executable example. @@ -45,7 +49,7 @@ var privateKey = 'BbZJjyoXAdr8BUZuiKKARWimKfrSmQ6fv8kZ7OFfc'; var token = jwt.sign({ accountId: 123 }, privateKey); -var validate = function (decodedToken, callback) { +var validate = function (decodedToken, extraInfo, callback) { var error, credentials = accounts[decodedToken.accountId] || {}; @@ -90,3 +94,16 @@ server.register(require('hapi-auth-jwt'), function (error) { server.start(); ``` + +You can specify audience, issuer, algorithms and/or subject as well: + +```javascript +server.auth.strategy('token', 'jwt', { + key: privateKey, + validateFunc: validate, + audience: 'http://myapi/protected', + issuer: 'http://issuer', + algorithms: ['RS256'], + subject: 'myRequiredSubject' +}); +``` \ No newline at end of file diff --git a/example/index.js b/example/index.js index 8735d38..d4f1757 100644 --- a/example/index.js +++ b/example/index.js @@ -19,10 +19,17 @@ var token = jwt.sign({ accountId: 123 }, privateKey); // use this token to build your web request. You'll need to add it to the headers as 'authorization'. And you will need to prefix it with 'Bearer ' console.log('token: ' + token); +console.log(); +console.log('=== Sample call to secure endpoint: ==='); +console.log("curl 'http://localhost:8080/tokenRequired' -H 'Content-Type:application/json' -H 'Authorization: Bearer " + token + "'"); +console.log(); +console.log('=== Sample call to public endpoint: ==='); +console.log("curl 'http://localhost:8080/noTokenRequired' -H 'Content-Type:application/json'"); -var validate = function (decodedToken, callback) { +var validate = function (decodedToken, extraInfo, callback) { - console.log(decodedToken); // should be {accountId : 123}. + console.log('decodedToken',decodedToken); // should be {accountId : 123}. + console.log('extraInfo',extraInfo); if (decodedToken) { console.log(decodedToken.accountId.toString()); diff --git a/lib/index.js b/lib/index.js index f6e2aa3..07d1ed9 100755 --- a/lib/index.js +++ b/lib/index.js @@ -3,10 +3,18 @@ var Boom = require('boom'); var Hoek = require('hoek'); var jwt = require('jsonwebtoken'); +var Joi = require('joi'); +var optionsSchema = Joi.object().keys({ + key: Joi.alternatives().try(Joi.binary(),Joi.func()).required(), + validateFunc: Joi.func(), + algorithms: Joi.array().items(Joi.string()), + audience: Joi.alternatives().try(Joi.string(),Joi.array().items(Joi.string())), + issuer: Joi.alternatives().try(Joi.string(),Joi.array().items(Joi.string())), + subject: Joi.string() +}).label('jwt auth strategy options'); // Declare internals - var internals = {}; @@ -26,8 +34,11 @@ function isFunction(functionToCheck) { internals.implementation = function (server, options) { - Hoek.assert(options, 'Missing jwt auth strategy options'); - Hoek.assert(options.key, 'Missing required private key in configuration'); + var validationResult = Joi.validate(options, optionsSchema); + + if (validationResult.error){ + throw new Error(validationResult.error.message); + } var settings = Hoek.clone(options); @@ -62,13 +73,11 @@ internals.implementation = function (server, options) { getKey(request, 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')); + jwt.verify(token, key, settings, function(err, decoded) { + + if(err) { + return reply(Boom.unauthorized( 'JSON Web Token validation failed: ' + err.message, 'Bearer')); } if (!settings.validateFunc) { @@ -102,5 +111,6 @@ internals.implementation = function (server, options) { } }; - return scheme; + return scheme; + }; diff --git a/package.json b/package.json index 81729e4..e69a2d8 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "dependencies": { "boom": "2.x.x", "hoek": "2.x.x", + "joi": "^10.0.1", "jsonwebtoken": "^5.4.1" }, "devDependencies": { - "boom": "2.x.x", "code": "1.x.x", "hapi": "11.x.x", "lab": "5.x.x", diff --git a/test/index.js b/test/index.js index d3cb1ff..64444ca 100755 --- a/test/index.js +++ b/test/index.js @@ -15,6 +15,8 @@ var describe = lab.describe; var it = lab.it; var expect = Code.expect; +var jwtErrorPrefix = 'JSON Web Token validation failed: '; + describe('Token', function () { var privateKey = 'PajeH0mz4of85T9FB1oFzaB39lbNLbDbtCQ'; @@ -60,12 +62,13 @@ describe('Token', function () { 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('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser }); + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser}); server.route([ { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } }, @@ -161,7 +164,7 @@ describe('Token', function () { var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', { expiresIn: -10 }) } }; server.inject(request, function (res) { - expect(res.result.message).to.equal('Expired token received for JSON Web Token validation'); + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt expired'); expect(res.statusCode).to.equal(401); done(); }); @@ -173,7 +176,7 @@ describe('Token', function () { var request = { method: 'POST', url: '/token', headers: { authorization: token } }; server.inject(request, function (res) { - expect(res.result.message).to.equal('Invalid signature received for JSON Web Token validation'); + expect(res.result.message).to.equal(jwtErrorPrefix + 'invalid signature'); expect(res.statusCode).to.equal(401); done(); }); @@ -313,4 +316,449 @@ describe('Token', function () { done(); }); + describe('when a single audience is specified for validation', function(){ + var audience = 'https://expected.audience.com'; + + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser, audience: audience}); + + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } } + ]); + }); + + it('fails if token audience is empty', function (done) { + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt audience invalid. expected: ' + audience); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if token audience is invalid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {audience:'https://invalid.audience.com'}) } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt audience invalid. expected: ' + audience); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('works if token audience is valid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {audience: audience}) } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + }); + + describe('when an array of audiences is specified for validation', function(){ + var audience = 'https://expected.audience.com'; + + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser, audience: [audience, 'audience2', 'audience3']}); + + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } } + ]); + }); + + it('fails if token audience is empty', function (done) { + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt audience invalid. expected: ' + audience + ' or audience2 or audience3'); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if token audience is invalid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {audience:'https://invalid.audience.com'}) } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt audience invalid. expected: ' + audience + ' or audience2 or audience3'); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('works if token audience is one of the expected values', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {audience: audience}) } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + }); + + describe('when a single issuer is specified for validation', function(){ + var issuer = 'http://expected.issuer'; + + var server = new Hapi.Server({ debug: false}); + server.log(['error', 'database', 'read']); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser, issuer: issuer}); + + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } } + ]); + }); + + it('fails if token issuer is empty', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt issuer invalid. expected: ' + issuer); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if token issuer is invalid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {issuer:'https://invalid.issuer'}) } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt issuer invalid. expected: ' + issuer); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('works if token issuer is valid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {issuer: issuer}) } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + }); + + describe('when an array of issuers are specified for validation', function(){ + var issuer = 'http://expected.issuer'; + + var server = new Hapi.Server({ debug: false}); + server.log(['error', 'database', 'read']); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser, issuer: [issuer,'issuer2','issuer3']}); + + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } } + ]); + }); + + it('fails if token issuer is empty', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt issuer invalid. expected: ' + issuer + ',issuer2,issuer3'); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if token issuer is invalid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {issuer:'https://invalid.issuer'}) } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt issuer invalid. expected: ' + issuer + ',issuer2,issuer3'); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('works if token issuer contains one of the expected issuers valid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {issuer: issuer}) } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + }); + + describe('when RS256 is specified as algorithm for validation', function(){ + var issuer = 'http://expected.issuer'; + + var server = new Hapi.Server({ debug: false}); + server.log(['error', 'database', 'read']); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser, algorithms: ['RS256']}); + + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } } + ]); + }); + + it('fails if token is signed with HS256 algorithm', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'invalid algorithm'); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + }); + + describe('when HS256 is specified as algorithm for validation', function(){ + var issuer = 'http://expected.issuer'; + + var server = new Hapi.Server({ debug: false}); + server.log(['error', 'database', 'read']); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser, algorithms: ['HS256']}); + + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } } + ]); + }); + + it('works if token is signed with HS256 algorithm', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.statusCode).to.equal(200); + done(); + }); + }); + }); + + describe('when subject is specified for validation', function(){ + var subject = 'http://expected.subject'; + + var server = new Hapi.Server({ debug: false}); + server.log(['error', 'database', 'read']); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + + server.auth.strategy('default', 'jwt', 'required', { key: privateKey, validateFunc: loadUser, subject: subject}); + + server.route([ + { method: 'POST', path: '/token', handler: tokenHandler, config: { auth: 'default' } } + ]); + }); + + it('fails if token subject is empty', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john') } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt subject invalid. expected: ' + subject); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if token subject is invalid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {subject:'https://invalid.subject'}) } }; + + server.inject(request, function (res) { + expect(res.result.message).to.equal(jwtErrorPrefix + 'jwt subject invalid. expected: ' + subject); + expect(res.statusCode).to.equal(401); + done(); + }); + }); + + it('works if token subject is valid', function (done) { + + var request = { method: 'POST', url: '/token', headers: { authorization: tokenHeader('john', {subject: subject}) } }; + + server.inject(request, function (res) { + expect(res.result).to.exist; + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + }); + }); + +describe('Strategy', function(){ + + it('should fail if strategy is initialized without options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required'); + done('Should have failed') + } + catch(err){ + expect(err).to.exist; + expect(err.message).to.equal('"jwt auth strategy options" must be an object'); + done(); + } + }); + }); + + it('should fail if strategy is initialized with a string as options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required', 'wrong options type'); + done('Should have failed') + } + catch(err){ + expect(err).to.exist; + expect(err.message).to.equal('"jwt auth strategy options" must be an object'); + done(); + } + }); + }); + + it('should fail if strategy is initialized with an array as options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required', ['wrong', 'options', 'type']); + done('Should have failed') + } + catch(err){ + expect(err).to.exist; + expect(err.message).to.equal('"jwt auth strategy options" must be an object'); + done(); + } + }); + }); + + it('should fail if strategy is initialized with a function as options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required', function options(){}); + done('Should have failed') + } + catch(err){ + expect(err).to.exist; + expect(err.message).to.equal('"jwt auth strategy options" must be an object'); + done(); + } + }); + }); + + it('should fail if strategy is initialized without a key in options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required', {}); + done(new Error('Should have failed without key in the options')) + } + catch(err){ + expect(err).to.exist; + expect(err.message).to.equal('child "key" fails because ["key" is required]'); + done(); + } + }); + }); + + it('should fail if strategy is initialized with an invalid key type in options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required', {key:10}); + done(new Error('Should have failed with an invalid key type in options')) + } + catch(err){ + expect(err).to.exist; + expect(err.message).to.equal('child "key" fails because ["key" must be a buffer or a string, "key" must be a Function]'); + done(); + } + }); + }); + + it('should work if strategy is initialized with a Bugger as key in options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required', {key: new Buffer('mySuperSecret', 'base64')}); + done(); + } + catch(err){ + done(err); + } + }); + }); + + it('should fail if strategy is initialized with an invalid audience type in options', function (done) { + var server = new Hapi.Server({ debug: false }); + server.connection(); + server.register(require('../'), function (err) { + expect(err).to.not.exist; + try { + server.auth.strategy('default', 'jwt', 'required', {key: '123456', audience: 123}); + done(new Error('Should have failed with an invalid audience type in options')) + } + catch(err){ + expect(err).to.exist; + expect(err.message).to.equal('child "audience" fails because ["audience" must be a string, "audience" must be an array]'); + done(); + } + }); + }); + +}); \ No newline at end of file