diff --git a/lib/bearerstrategy.js b/lib/bearerstrategy.js index fd1489fd..0edd3d42 100644 --- a/lib/bearerstrategy.js +++ b/lib/bearerstrategy.js @@ -22,14 +22,21 @@ */ 'use strict'; -// This strategy inherts from the passport-http-bearer strategy -const BearerStrategyBase = require('passport-http-bearer').Strategy; +/* eslint no-underscore-dangle: 0 */ -const util = require('util'); +const async = require('async'); +const cacheManager = require('cache-manager'); +const jws = require('jws'); const jwt = require('jsonwebtoken'); +const passport = require('passport'); +const util = require('util'); + const Metadata = require('./metadata').Metadata; const Log = require('./logging').getLogger; -const jws = require('jws'); + +const log = new Log('AzureAD: Bearer Strategy'); +const memoryCache = cacheManager.caching({ store: 'memory', max: 3600, ttl: 1800 /* seconds */ }); +const ttl = 1800; // 30 minutes cache /** * Applications must supply a `verify` callback, for which the function @@ -117,122 +124,290 @@ const jws = require('jws'); * @param {Function} verify - The verify callback. * @constructor */ - function Strategy(options, verifyFn) { - const self = this; - const log = new Log('AzureAD: Bearer Strategy'); - const verify = (typeof options === 'function') ? options : verifyFn; - const opts = (typeof options === 'function') ? {} : options; + passport.Strategy.call(this); + this.name = 'oauth-bearer'; // Me, a name I call myself. + + this._verify = (typeof options === 'function') ? options : verifyFn; + this._options = (typeof options === 'function') ? {} : options; + this._passReqToCallback = this._options.passReqToCallback; - // Passport requires a verify function - if (typeof verify !== 'function') { + // (1) check the existence of the verify function + if (typeof this._verify !== 'function') { throw new TypeError('BearerStrategy requires a verify callback.'); } + // (2) modify some fields of this._options + if (!this._options.realm) + this._options.realm = 'Users'; + if (!Array.isArray(this._options.scope)) + this._options.scope = [this._options.scope]; + + // (3) log some info of this._options + + options = this._options; + // if logging level specified, switch to it. - if (opts.loggingLevel) { log.levels('console', opts.loggingLevel); } - - if (opts.policyName) { - log.info('B2C: We have been instructed that this is a B2C tenant. We will configure as required.'); - - if (!options.tenantName) { - throw new TypeError('BearerStrategy requires you pass the tenant name if using a B2C tenant.'); - } else { - // We are replacing the common endpoint with the concrete metadata of a B2C tenant. - opts.identityMetadata = opts.identityMetadata - .replace('common', opts.tenantName) - .concat(`?p=${opts.policyName}`); - } - } + if (options.loggingLevel) { log.levels('console', options.loggingLevel); } // warn about validating the issuer - if (!opts.validateIssuer) { + if (!options.validateIssuer) { log.warn(`We are not validating the issuer. This is fine if you are expecting multiple organizations to connect to your app. Otherwise you should validate the issuer.`); } // if you want to check JWT audience (aud), provide a value here - if (opts.audience) { - log.info('Audience provided to Strategy was: ', opts.audience); + if (options.audience) { + log.info('Audience provided to Strategy was: ', options.audience); + } else { + log.warn(`We are not checking the audience because audience is not provided in the config file. \ + Checking audience is a mitigation against forwarding attacks, providing the audience in the \ + config file is strongly recommended for the sake of security.`); } +} + +util.inherits(Strategy, passport.Strategy); + +Strategy.prototype.jwtVerify = function jwtVerifyFunc(req, token, done) { + const self = this; - if (opts.identityMetadata) { - log.info('Metadata url provided to Strategy was: ', opts.identityMetadata); - this.metadata = new Metadata(opts.identityMetadata, 'oidc', opts); + const decoded = jws.decode(token); + let PEMkey = null; + + if (decoded == null) { + return done(null, false, 'Invalid JWT token.'); } - if (!opts.certificate && !opts.identityMetadata) { - log.warn('No options was presented to Strategy as required.'); - throw new TypeError(`BearerStrategy requires either a PEM encoded public key - or a metadata location that contains cert data for RSA and ECDSA callback.`); + log.info('token decoded: ', decoded); + + // use the provided PEMkey or generate one using the metadata. WEhen we + // generate the PEMkey, there are two different types of token signatures + // we have to validate here. One provides x5t and the other a kid. We + // need to call the right one. + if (self._options.certificate) { + PEMkey = self._options.certificate; + } else if (decoded.header.x5t) { + PEMkey = self.metadata.generateOidcPEM(decoded.header.x5t); + } else if (decoded.header.kid) { + PEMkey = self.metadata.generateOidcPEM(decoded.header.kid); + } else { + return done(null, false, 'We did not reveive a token we know how to validate'); } - // Token validation settings. Hopefully most of these will be pulled from the metadata and this is not needed - // @TODO: this won't work - fetch is async, throwing in an async callback is not recommended because that exception can not be located. - // Also, this fetch would load parameters required by `BearerStrategy`. It is recommended to write own `authenticate` method for this Strategy - self.metadata.fetch((fetchMetadataError) => { - if (fetchMetadataError) { - throw new Error(`Unable to fetch metadata: ${fetchMetadataError}`); + jwt.verify(token, PEMkey, self._options, (err, verifiedToken) => { + if (err) { + if (err instanceof jwt.TokenExpiredError) { + log.warn('Access token expired'); + return done(null, false, 'The access token expired'); + } + if (err instanceof jwt.JsonWebTokenError) { + log.warn('An error was received validating the token', err.message); + return done(null, false, util.format('Invalid token (%s)', err.message)); + } + return done(err, false); } - - if (opts.validateIssuer) { - opts.issuer = self.metadata.oidc.issuer; + log.info('VerifiedToken: ', verifiedToken); + if (self._passReqToCallback) { + log.info('We did pass Req back to Callback'); + return self._verify(req, verifiedToken, done); } - opts.algorithms = self.metadata.oidc.algorithms; + log.info('We did not pass Req back to Callback'); + return self._verify(verifiedToken, done); }); +} - function jwtVerify(req, token, done) { - const decoded = jws.decode(token); - let PEMkey = null; +/* + * We let the metadata loading happen in `authenticate` function, and use waterfall + * to make sure the authentication code runs after the metadata loading is finished. + */ +Strategy.prototype.authenticate = function authenticateStrategy(req) { + const self = this; - if (decoded == null) { - done(null, false, 'Invalid JWT token.'); - } + /* Some introduction to async.waterfall (from the following link): + * http://stackoverflow.com/questions/28908180/what-is-a-simple-implementation-of-async-waterfall + * + * Runs the tasks array of functions in series, each passing their results + * to the next in the array. However, if any of the tasks pass an error to + * their own callback, the next function is not executed, and the main callback + * is immediately called with the error. + * + * Example: + * + * async.waterfall([ + * function(callback) { + * callback(null, 'one', 'two'); + * }, + * function(arg1, arg2, callback) { + * // arg1 now equals 'one' and arg2 now equals 'two' + * callback(null, 'three'); + * }, + * function(arg1, callback) { + * // arg1 now equals 'three' + * callback(null, 'done'); + * } + * ], function (err, result) { + * // result now equals 'done' + * }); + */ + async.waterfall([ - log.info('token decoded: ', decoded); + // compute metadata url + (next) => { + if (!self._options.certificate && !self._options.identityMetadata) { + var message = 'options.certificate and options.identityMetadata are both null. ' + + 'It is not possible to validate a JWT token, BearerStrategy requires either ' + + 'a PEM encoded public key or a metadata location that contains cert data ' + + ' for RSA and ECDSA callback'; + log.warn(message); + return next(new TypeError(message)); + } - // We have two different types of token signatures we have to validate here. One provides x5t and the other a kid. - // We need to call the right one. + // (1) no metadata but have a certificate, pass `null` to the next function, + // which will skip the metadata loading + if (!self._options.identityMetadata) + return next(null, null); - if (decoded.header.x5t) { - PEMkey = this.metadata.generateOidcPEM(decoded.header.x5t); - } else if (decoded.header.kid) { - PEMkey = this.metadata.generateOidcPEM(decoded.header.kid); - } else { - throw new TypeError('We did not reveive a token we know how to validate'); - } + // (2) there is only metadata, we calulate the metadataURL and pass it to the + // next function for metadata loading - jwt.verify(token, PEMkey, options, (err, verifiedToken) => { - if (err) { - if (err instanceof jwt.TokenExpiredError) { - log.warn('Access token expired'); - return done(null, false, 'The access token expired'); + // default key for metadata cache + var cacheKey = 'ordinary'; + + var metadataURL = self._options.identityMetadata; + + if (self._options.policyName) { + log.info('B2C: We have been instructed that this is a B2C tenant. We will configure as required.'); + + if (!self._options.tenantName) { + return next(new TypeError('B2C: BearerStrategy requires you to pass the tenant name if using a B2C tenant.')); } - if (err instanceof jwt.JsonWebTokenError) { - log.warn('An error was received validating the token', err.message); - return done(null, false, util.format('Invalid token (%s)', err.message)); + + // We are replacing the common endpoint with the concrete metadata of a B2C tenant. + metadataURL = metadataURL + .replace('common', self._options.tenantName) + .concat(`?p=${self._options.policyName}`); + + // use the policy name as the metadata cache key + cacheKey = 'policy: ' + policyName; + } + + log.info('Metadata url provided to Strategy was: ', metadataURL); + self.metadata = new Metadata(metadataURL, 'oidc', self._options); + + return next(null, cacheKey); + }, + + // fetch metadata from server or cache (if there is) + (cacheKey, next) => { + + // if cacheKey is null, then we skip metadata loading + if (!cacheKey) + return next(null, null); + + // usage of memoryCache.wrap(key, work, opt, cb): + // (1) anytime there is an error, it calls cb(err, null) + // (2) if result is found using the key, then it will call cb(null, result) + // (3) if result is not found, it will call work. work is like: + // work(cacheCallback) { + // generate result; + // cacheCallback(err, result); + // } + // `cacheCallback` is provided by memoryCache, which is like: + // cacheCallback(err, result) { + // if no err, save result into memory cache; + // cb(err, result); + // } + // + // So the following function will return `next(err, self.metadata)` + return memoryCache.wrap(cacheKey, (cacheCallback) => { + self.metadata.fetch((fetchMetadataError) => { + if (fetchMetadataError) { + return cacheCallback(new Error(`Unable to fetch metadata: ${fetchMetadataError}`)); + } + return cacheCallback(null, self.metadata); + }); + }, { ttl }, next); + }, + + // configure using metadata + (metadata, next) => { + if (metadata) { + self.metadata = metadata; + if (self._options.validateIssuer) { + self._options.issuer = self.metadata.oidc.issuer; + } + self._options.algorithms = self.metadata.oidc.algorithms; + } + + return next(); + }, + + // extract the access token from the request, after getting the token, it + // will call `jwtVerify` to verify the token. If token is verified, `jwtVerify` + // will provide the token payload to self._verify function. self._verify is + // provided by the developer, it's up to the developer to decide if the token + // payload is considered authenticated. If authenticated, self._verify will + // provide `user` object (developer's decision of its content) to `verified` + // function here, and the `verified` function does the final work of stuffing + // the `user` obejct into req.user, so the following middleware can use it. + // This is basically how bearerStrategy works. + (next) => { + var token; + + // token could be in header, query or body + + if (req.headers && req.headers.authorization) { + var auth_components = req.headers.authorization.split(' '); + if (auth_components.length == 2) { + if (/^Bearer$/.test(auth_components[0])) + token = auth_components[1]; + } else { + return self.fail(400); } - return done(err, false); } - log.info(verifiedToken, 'was token going out of verification'); - if (opts.passReqToCallback) { - log.info('We did pass Req back to Callback'); - return verify(req, verifiedToken, done); + + if (req.query && req.query.access_token) { + if (token) + return self.fail(400); + token = req.query.access_token; } - log.info('We did not pass Req back to Callback'); - return verify(verifiedToken, done); - }); - } - // force passReqToCallback to be `true` for our decoding verify wrapper - /* eslint-disable no-underscore-dangle */ - BearerStrategyBase.call(this, util._extend({}, opts, { passReqToCallback: true }), jwtVerify); - /* eslint-enable no-underscore-dangle */ + if (req.body && req.body.access_token) { + if (token) + return self.fail(400); + token = req.body.access_token; + } - this.name = 'oauth-bearer'; // Me, a name I call myself. -} + if (!token) + return self.fail('token is not found'); + + function verified(err, user, info) { + if (err) + return self.error(err); -util.inherits(Strategy, BearerStrategyBase); + if (!user) { + var err_message = 'error: invalid_token'; + if (info && typeof info == 'string') + err_message += ', error description: ' + info; + else if (info) + err_message += ', error description: ' + JSON.stringify(info); + return self.fail(err_message); + } + + return self.success(user, info); + } + + return self.jwtVerify(req, token, verified); + }], + + (waterfallError) => { // This function gets called after the three tasks have called their 'task callbacks' + if (waterfallError) { + return self.error(waterfallError); + } + return true; + } + ); +}; module.exports = Strategy; diff --git a/package.json b/package.json index 1b5bcf05..8b3f200b 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,10 @@ "grunt-contrib-uglify": "^1.0.1", "grunt-contrib-watch": "^1.0.0", "grunt-eslint": "^18.1.0", - "nodeunit": "^0.9.1" + "nodeunit": "^0.9.1", + "mocha": "2.x.x", + "chai": "2.x.x", + "chai-passport-strategy": "1.x.x" }, "dependencies": { "async": "^1.5.2", @@ -59,7 +62,7 @@ "xtend": "4.0.1" }, "scripts": { - "test": "grunt nodeunit" + "test": "grunt nodeunit && mocha -R spec test/*_mochaTest.js" }, "engines": { "node": ">= ~4.0.1" diff --git a/test/bearer_pem_mochaTest.js b/test/bearer_pem_mochaTest.js new file mode 100644 index 00000000..cde3e018 --- /dev/null +++ b/test/bearer_pem_mochaTest.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) Microsoft Corporation + * All Rights Reserved + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this + * software and associated documentation files (the "Software"), to deal in the Software + * without restriction, including without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable no-new */ + +'use strict'; + +var chai = require('chai'); +chai.use(require('chai-passport-strategy')); +var BearerStrategy = require('../lib/index').BearerStrategy; + +var publicKeyPem = "-----BEGIN RSA PUBLIC KEY-----\n" + + "MIIBCgKCAQEAvbcFrj193Gm6zeo5e2/y54Jx49sIgScv+2JO+n6NxNqQaKVnMkHc\n" + + "z+S1j2FfpFngotwGMzZIKVCY1SK8SKZMFfRTU3wvToZITwf3W1Qq6n+h+abqpyJT\n" + + "aqIcfhA0d6kEAM5NsQAKhfvw7fre1QicmU9LWVWUYAayLmiRX6o3tktJq6H58pUz\n" + + "Ttx/D0Dprnx6z5sW+uiMipLXbrgYmOez7htokJVgDg8w+yDFCxZNo7KVueUkLkxh\n" + + "NjYGkGfnt18s7ZW036WoTmdaQmW4CChf/o4TLE5VyGpYWm7I/+nV95BBvwlzokVV\n" + + "KzveKf3l5UU3c6PkGy+BB3E/ChqFm6sPWwIDAQAB\n" + + "-----END RSA PUBLIC KEY-----"; + +var expired_access_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IlliUkFRUll" + + "jRV9tb3RXVkpLSHJ3TEJiZF85cyIsImtpZCI6IlliUkFRUlljRV9tb3RXVkpLSHJ3T" + + "EJiZF85cyJ9.eyJhdWQiOiJzcG46NjUxNGE4Y2EtZDllNC00MTU1LWIyOTItNjUyNT" + + "gzOThmM2FhIiwiaXNzIjoiaHR0cHM6Ly9zdHMud2luZG93cy5uZXQvMjY4ZGExYTEt" + + "OWRiNC00OGI5LWIxZmUtNjgzMjUwYmE5MGNjLyIsImlhdCI6MTQ2NzMxMTI0OCwibm" + + "JmIjoxNDY3MzExMjQ4LCJleHAiOjE0NjczMTUxNDgsImFjciI6IjEiLCJhbXIiOlsi" + + "cHdkIl0sImFwcGlkIjoiYmViZmFkNTctZWZkNy00MTliLWI3NGItNGI5ZGFiN2JkND" + + "cwIiwiYXBwaWRhY3IiOiIxIiwiZmFtaWx5X25hbWUiOiJvbmUiLCJnaXZlbl9uYW1l" + + "Ijoicm9ib3QiLCJpcGFkZHIiOiIxMzEuMTA3LjE2MC4yMjYiLCJuYW1lIjoicm9ib3" + + "QgMSIsIm9pZCI6Ijc5MTJmZTdiLWI1YWItNDI1Yi1iYjFmLTBlODNiOTlmY2E3ZiIs" + + "InNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6Ikt1Mi1GdDlsWTlpMkJ2Zm" + + "htcTQxNjZaSDNrV0g0V1h0bXpHOU0tOE1GYWMiLCJ0aWQiOiIyNjhkYTFhMS05ZGI0" + + "LTQ4YjktYjFmZS02ODMyNTBiYTkwY2MiLCJ1bmlxdWVfbmFtZSI6InJvYm90QHNpan" + + "VuLm9ubWljcm9zb2Z0LmNvbSIsInVwbiI6InJvYm90QHNpanVuLm9ubWljcm9zb2Z0" + + "LmNvbSIsInZlciI6IjEuMCJ9.VTg8AqnbSzfC7nUmf3xKnNrS_3BcOSGqz_CBPi6Th" + + "2piwNc--3Aq_K6SOt2QlbP7yni8IOqeY2ooqDgj0CvcvV3HHHHFatS7X8Kppg4z35l" + + "B4b67DJuIeHgCYYBR75qMVC1z5n4dgYGoNE-JNvlZZmaeHnrO8FAmQBKJUOrIyCNpo" + + "BjIsUXgXJKTPdL7HQL9nFz6h9sUmvbvpwqk1NgfmfTsJ0wHuSNHjHmryZ7vGnnjJHU" + + "C1zQmo9nesF0t7ad2Gk2RdlU93FbcZEW0hFE5Rtu0SbjOZAQdDVsBj_Voi7iQ_Kr-C" + + "nC14vuZ5kE9ACSMf2VG5wfcg6z4pyQdw-LpjQ"; + +var options = { + certificate: publicKeyPem, // use certificate instead of metadata + validateIssuer: true, + passReqToCallback: false, +}; + +describe('test expired token using pem', function() { + var strategy = new BearerStrategy(options, function(token, done) { + // since we are testing expired token, we won't come here + }); + + var challenge = ''; + + before(function(done) { + chai.passport + .use(strategy) + .req(function(req) { + req.headers.authorization = 'Bearer ' + expired_access_token; + }) + .fail(function(c) { + challenge = c; + done(); + }) + .authenticate(); + }); + + it('should fail with token expired message', function() { + chai.expect(challenge).to.be.a.string; + chai.expect(challenge).to.equal('error: invalid_token, error description: The access token expired'); + }); +}); diff --git a/test/bearer_simple_mochaTest.js b/test/bearer_simple_mochaTest.js new file mode 100644 index 00000000..9e6aa0cf --- /dev/null +++ b/test/bearer_simple_mochaTest.js @@ -0,0 +1,128 @@ +/** + * Copyright (c) Microsoft Corporation + * All Rights Reserved + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this + * software and associated documentation files (the "Software"), to deal in the Software + * without restriction, including without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable no-new */ + +'use strict'; + +var chai = require('chai'); +chai.use(require('chai-passport-strategy')); +var BearerStrategy = require('../lib/index').BearerStrategy; + +// Mock options +var options = { + certificate: 'my_certificate', // use certificate to bypass the metadata loading + validateIssuer: true, + passReqToCallback: false +}; + +describe('token mock test', function() { + var strategy = new BearerStrategy(options, function(token, done) { + if (token === 'good_token') + return done(null, {id: 'Mr noname'}, 'authentication successful'); + return done(null, false, 'access token is invalid'); + }); + + // Mock jwtVerify + strategy.jwtVerify = function(req, token, done) { this._verify(token, done); }; + + var challenge = ''; + var success_user = ''; + var success_info = ''; + + var beforeFunc = function(token, in_header, in_body, in_query) { + return function(done) { + chai.passport + .use(strategy) + .fail(function(c) { + challenge = c; + done(); + }) + .req(function(req) { + if (token && in_header) + req.headers.authorization = 'Bearer ' + token; + if (token && in_query) { + req.query = {}; + req.query.access_token = token; + } + if (token && in_body) { + req.body = {}; + req.body.access_token = token; + } + }) + .success(function(user, info) { + success_user = user.id; + success_info = info; + done(); + }) + .authenticate(); + }; + }; + + describe('should fail with no token', function() { + before(beforeFunc()); + + it('should fail with challenge', function() { + chai.expect(challenge).to.be.a.string; + chai.expect(challenge).to.equal('token is not found'); + }) + }); + + describe('should fail with invalid token', function() { + before(beforeFunc('invalid_token', true)); + + it('should fail with challenge', function() { + chai.expect(challenge).to.be.a.string; + chai.expect(challenge).to.equal('error: invalid_token, error description: access token is invalid'); + }); + }); + + describe('should succeed with good token in header', function() { + before(beforeFunc('good_token', true)); + + it('should succeed', function() { + chai.expect(success_user).to.equal('Mr noname'); + chai.expect(success_info).to.equal('authentication successful'); + }); + }); + + describe('should succeed with good token in body', function() { + before(beforeFunc('good_token', false, true)); + + it('should succeed', function() { + chai.expect(success_user).to.equal('Mr noname'); + chai.expect(success_info).to.equal('authentication successful'); + }); + }); + + describe('should succeed with good token in query', function() { + before(beforeFunc('good_token', false, false, true)); + + it('should succeed', function() { + chai.expect(success_user).to.equal('Mr noname'); + chai.expect(success_info).to.equal('authentication successful'); + }); + }); +}); + +