Skip to content
This repository has been archived by the owner on Aug 28, 2023. It is now read-only.

Commit

Permalink
Solved #90: Cannot read property 'keys' of undefined
Browse files Browse the repository at this point in the history
BearerStrategy used to inherit from passport-http-bearer strategy
   https://github.com/jaredhanson/passport-http-bearer/
when BearerStrategy is created, it reads the info (from developer and metadata). In the authentication time, passport-http-bearer does all the work using these info read at the BearerStrategy's creation time.

This is not good since:
(1) for B2C, different policy may have different metadata, so the metadata should be loaded dynamically (and cached) in the authentication time based on the request
(2) async issue, metadata loading is async, passport-http-bearer's `authenticate` function depends on the metadata, and it might be called before the metadata loading finishes. This is in fact the cause of this bug, where we validate the token before we load the keys from metadata url.

In this fix, the following are done:

(1) load metadata dynamically at `authenticate` time, from server or memory cache
(2) solved async issue using waterfall
(3) added a couple of unit tests
  • Loading branch information
lovemaths committed Jul 13, 2016
1 parent 7b8056f commit 9f19b1e
Show file tree
Hide file tree
Showing 4 changed files with 485 additions and 87 deletions.
345 changes: 260 additions & 85 deletions lib/bearerstrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Loading

0 comments on commit 9f19b1e

Please sign in to comment.