passport-azure-ad
Version:
OIDC and Bearer Passport strategies for Azure Active Directory
558 lines (482 loc) • 24.6 kB
JavaScript
/**
* 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 no-underscore-dangle: 0 */
const async = require('async');
const cacheManager = require('cache-manager');
const jws = require('jws');
const passport = require('passport');
const util = require('util');
const aadutils = require('./aadutils');
const CONSTANTS = require('./constants');
const jwt = require('./jsonWebToken');
const Metadata = require('./metadata').Metadata;
const Log = require('./logging').getLogger;
const UrlValidator = require('valid-url');
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
* signature is:
*
* function(token, done) { ... }
* or
* function(req, token, done) { ... }
*
* The latter enables you to use the request object. In order to use this
* signature, the passReqToCallback value in options (see the Options instructions
* below) must be set true, so the strategy knows you want to pass the request
* to the `verify` callback function.
*
* `token` is the verified and decoded bearer token provided as a credential.
* The verify callback is responsible for finding the user who posesses the
* token, and invoking `done` with the following arguments:
*
* done(err, user, info);
*
* If the token is not valid, `user` should be set to `false` to indicate an
* authentication failure. Additional token `info` can optionally be passed as
* a third argument, which will be set by Passport at `req.authInfo`, where it
* can be used by later middleware for access control. This is typically used
* to pass any scope associated with the token.
*
*
* Options:
*
* - `identityMetadata` (1) Required
* (2) must be a https url string
* (3) Description:
* the metadata endpoint provided by the Microsoft Identity Portal that provides
* the keys and other important info at runtime. Examples:
* <1> v1 tenant-specific endpoint
* - https://login.microsoftonline.com/your_tenant_name.onmicrosoft.com/.well-known/openid-configuration
* - https://login.microsoftonline.com/your_tenant_guid/.well-known/openid-configuration
* <2> v1 common endpoint
* - https://login.microsoftonline.com/common/.well-known/openid-configuration
* <3> v2 tenant-specific endpoint
* - https://login.microsoftonline.com/your_tenant_name.onmicrosoft.com/v2.0/.well-known/openid-configuration
* - https://login.microsoftonline.com/your_tenant_guid/v2.0/.well-known/openid-configuration
* <4> v2 common endpoint
* - https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
*
* Note: you cannot use common endpoint for B2C
*
* - `clientID` (1) Required
* (2) must be a string
* (3) Description:
* The Client ID of your app in AAD
*
* - `validateIssuer` (1) Required to set to false if you don't want to validate issuer, default value is true
* (2) Description:
* For common endpoint, you should either set `validateIssuer` to false, or provide the `issuer`, or provide `tenantIdOrName`
* in passport.authenticate, since otherwise we cannot grab the `issuer` value from metadata.
* For non-common endpoint, we use the `issuer` from metadata, and `validateIssuer` should be always true
*
* - `issuer` (1) Required if set `validateIssuer` to true, but there is no way to get the issuer. For example, when you are using
* common endpoint, but you don't provide `tenantIdOrName` in passport.authenticate.
* (2) must be a string or an array of strings
* (3) Description:
* For common endpoint, we use the `issuer` provided.
* For non-common endpoint, if the `issuer` is not provided, we use the issuer provided by metadata
*
* - `passReqToCallback` (1) Required to set true if you want to use the `function(req, token, done)` signature for the verify function, default is false
* (2) Description:
* Set `passReqToCallback` to true use the `function(req, token, done)` signature for the verify function
* Set `passReqToCallback` to false use the `function(token, done)` signature for the verify function
*
* - `isB2C` (1) Required to set to true for using B2C, default value is false
*
* - `policyName` (1) Required for using B2C
* (2) Description:
* policy name. Should be a string starting with 'B2C_1_' (case insensitive)
*
*
* - `allowMultiAudiencesInToken`
* (1) Required if you allow access_token whose `aud` claim contains multiple values
* (2) Description:
* The default value is false
*
* - `scope` (1) Optional
* (2) Array of accepted scopes.
* (3) Description:
* If `scope` is provided, we will validate if access token contains any of the scopes listed in `scope`.
*
* - `loggingLevel` (1) Optional
* (2) must be 'info', 'warn', 'error'
* (3) Description:
* logging level
*
* - `audience` (1) Optional
* (2) must be a string or an array of strings
* (3) Description:
* We invalidate the `aud` claim in access_token against `audience`. The default value is `clientID`
*
* - `clockSkew` (1) Optional
* (2) must be a positive integer
* (3) Description:
* the clock skew (in seconds) allowed in token validation, default value is CLOCK_SKEW
* Examples:
*
* passport.use(new BearerStrategy(
* options,
* function(token, done) {
* User.findById(token.sub, function (err, user) {
* if (err) { return done(err); }
* if (!user) { return done(null, false); }
* return done(null, user, token);
* });
* }
* ));
*
* The name of this strategy is 'oauth-bearer', so use this name as the first
* parameter of the authenticate function. Moreover, we don't need session
* support for request containing bearer tokens, so the session option can be
* set to false.
*
* app.get('/protected_resource',
* passport.authenticate('oauth-bearer', {session: false}),
* function(req, res) {
* ...
* });
*
*
* For further details on HTTP Bearer authentication, refer to [The OAuth 2.0 Authorization Protocol: Bearer Tokens]
* (http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer)
* For further details on JSON Web Token, refert to [JSON Web Token](http://tools.ietf.org/html/draft-ietf-oauth-json-web-token)
*
* @param {object} options - The Options.
* @param {Function} verify - The verify callback.
* @constructor
*/
function Strategy(options, verifyFn) {
passport.Strategy.call(this);
this.name = 'oauth-bearer'; // Me, a name I call myself.
if (!options)
throw new Error('In BearerStrategy constructor: options is required');
if (!verifyFn || typeof verifyFn !== 'function')
throw new Error('In BearerStrategy constructor: verifyFn is required and it must be a function');
this._verify = verifyFn;
this._options = options;
//---------------------------------------------------------------------------
// Set up the default values
//---------------------------------------------------------------------------
// clock skew. Must be a postive integer
if (options.clockSkew && (typeof options.clockSkew !== 'number' || options.clockSkew <= 0 || options.clockSkew % 1 !== 0))
throw new Error('clockSkew must be a positive integer');
if (!options.clockSkew)
options.clockSkew = CONSTANTS.CLOCK_SKEW;
// default value of passReqToCallback is false
if (options.passReqToCallback !== true)
options.passReqToCallback = false;
// default value of validateIssuer is true
if (options.validateIssuer !== false)
options.validateIssuer = true;
// default value of allowMultiAudiencesInToken is false
if (options.allowMultiAudiencesInToken !== true)
options.allowMultiAudiencesInToken = false;
// if options.audience is a string or an array of string, then we use it;
// otherwise we use the clientID
if (options.audience && typeof options.audience === 'string')
options.audience = [options.audience];
else if (!options.audience || !Array.isArray(options.audience) || options.length === 0)
options.audience = [options.clientID, 'spn:' + options.clientID];
// default value of isB2C is false
if (options.isB2C !== true)
options.isB2C = false;
// turn issuer into an array
if (options.issuer === '')
options.issuer = null;
if (options.issuer && Array.isArray(options.issuer) && options.issuer.length === 0)
options.issuer = null;
if (options.issuer && !Array.isArray(options.issuer))
options.issuer = [options.issuer];
//---------------------------------------------------------------------------
// validate the things in options
//---------------------------------------------------------------------------
// clientID should not be empty
if (!options.clientID || options.clientID === '')
throw new Error('In BearerStrategy constructor: clientID cannot be empty');
// identityMetadata must be https url
if (!options.identityMetadata || !UrlValidator.isHttpsUri(options.identityMetadata))
throw new Error('In BearerStrategy constructor: identityMetadata must be provided and must be a https url');
// if scope is provided, it must be an array
if (options.scope && (!Array.isArray(options.scope) || options.scope.length === 0))
throw new Error('In BearerStrategy constructor: scope must be a non-empty array');
//---------------------------------------------------------------------------
// treatment of common endpoint and issuer
//---------------------------------------------------------------------------
// check if we are using the common endpoint
options._isCommonEndpoint = (options.identityMetadata.indexOf('/common/') != -1);
// give a warning if user is not validating issuer
if (!options.validateIssuer)
log.warn(`Production environments should always validate the issuer.`);
//---------------------------------------------------------------------------
// B2C.
// (1) policy must be provided and must have the valid prefix
// (2) common endpoint is not supported
//---------------------------------------------------------------------------
// for B2C,
if (options.isB2C) {
if (!options.policyName || !CONSTANTS.POLICY_REGEX.test(options.policyName))
throw new Error('In BearerStrategy constructor: invalid policy for B2C');
}
// if logging level specified, switch to it.
if (options.loggingLevel) { log.levels('console', options.loggingLevel); }
log.info(`In BearerStrategy constructor: created strategy with options ${JSON.stringify(options)}`);
}
util.inherits(Strategy, passport.Strategy);
Strategy.prototype.jwtVerify = function jwtVerifyFunc(req, token, metadata, optionsToValidate, done) {
const self = this;
const decoded = jws.decode(token);
let PEMkey = null;
if (decoded == null) {
return done(null, false, 'In Strategy.prototype.jwtVerify: Invalid JWT token.');
}
log.info('In Strategy.prototype.jwtVerify: token decoded: ', decoded);
// When 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.
try {
if (decoded.header.x5t) {
PEMkey = metadata.generateOidcPEM(decoded.header.x5t);
} else if (decoded.header.kid) {
PEMkey = metadata.generateOidcPEM(decoded.header.kid);
} else {
return self.failWithLog('In Strategy.prototype.jwtVerify: We did not receive a token we know how to validate');
}
} catch (error) {
return self.failWithLog('In Strategy.prototype.jwtVerify: We did not receive a token we know how to validate');
}
log.info('PEMkey generated: ' + PEMkey);
jwt.verify(token, PEMkey, optionsToValidate, (err, verifiedToken) => {
if (err) {
if (err.message)
return self.failWithLog(err.message);
else
return self.failWithLog('In Strategy.prototype.jwtVerify: cannot verify token');
}
// scope validation
if (optionsToValidate.scope) {
if (!verifiedToken.scp)
return self.failWithLog('In Strategy.prototype.jwtVerify: scope is not found in token');
// split scope by blanks and remove empty elements in the array
var scopesInToken = verifiedToken.scp.split(/[ ]+/).filter(Boolean);
var hasValidScopeInToken = false;
for (var i = 0; i < scopesInToken.length; i++) {
if (optionsToValidate.scope.indexOf(scopesInToken[i]) !== -1) {
hasValidScopeInToken = true;
break;
}
}
if (!hasValidScopeInToken)
return self.failWithLog(`In Strategy.prototype.jwtVerify: none of the scopes '${verifiedToken.scp}' in token is accepted`);
}
log.info('In Strategy.prototype.jwtVerify: VerifiedToken: ', verifiedToken);
if (self._options.passReqToCallback) {
log.info('In Strategy.prototype.jwtVerify: We did pass Req back to Callback');
return self._verify(req, verifiedToken, done);
} else {
log.info('In Strategy.prototype.jwtVerify: We did not pass Req back to Callback');
return self._verify(verifiedToken, done);
}
});
};
/*
* 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, options) {
const self = this;
var params = {};
var optionsToValidate = {};
var tenantIdOrName = options && options.tenantIdOrName;
/* 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([
// compute metadataUrl
(next) => {
params.metadataURL = aadutils.concatUrl(self._options.identityMetadata,
[
`${aadutils.getLibraryProductParameterName()}=${aadutils.getLibraryProduct()}`,
`${aadutils.getLibraryVersionParameterName()}=${aadutils.getLibraryVersion()}`
]
);
// if we are not using the common endpoint, but we have tenantIdOrName, just ignore it
if (!self._options._isCommonEndpoint && tenantIdOrName) {
log.info(`identityMetadata is tenant-specific, so we ignore the tenantIdOrName '${tenantIdOrName}'`);
tenantIdOrName = null;
}
// if we are using common endpoint and we are given the tenantIdOrName, let's replace it
if (self._options._isCommonEndpoint && tenantIdOrName) {
params.metadataURL = params.metadataURL.replace('/common/', `/${tenantIdOrName}/`);
log.info(`we are replacing 'common' with the tenantIdOrName ${tenantIdOrName}`);
}
// if we are using the common endpoint and we want to validate issuer, then user has to
// provide issuer in config, or provide tenant id or name using tenantIdOrName option in
// passport.authenticate. Otherwise we won't know the issuer.
if (self._options._isCommonEndpoint && self._options.validateIssuer &&
(!self._options.issuer && !tenantIdOrName))
return next(new Error('In passport.authenticate: issuer or tenantIdOrName must be provided in order to validate issuer on common endpoint'));
// for B2C, if we are using common endpoint, we must have tenantIdOrName provided
if (self._options.isB2C && self._options._isCommonEndpoint && !tenantIdOrName)
return next(new Error('In passport.authenticate: we are using common endpoint for B2C but tenantIdOrName is not provided'));
if (self._options.isB2C)
params.metadataURL = aadutils.concatUrl(params.metadataURL, `p=${self._options.policyName}`);
params.cacheKey = params.metadataURL;
log.info(`In Strategy.prototype.authenticate: ${JSON.stringify(params)}`);
return next(null, params);
},
// load metatadata
(params, next) => {
return self.loadMetadata(params, next);
},
// configure using metadata
(metadata, next) => {
params.metadata = metadata;
log.info(`In Strategy.prototype.authenticate: received metadata: ${JSON.stringify(metadata)}`);
// set up issuer
if (self._options.validateIssuer && !self._options.issuer)
optionsToValidate.issuer = metadata.oidc.issuer;
else
optionsToValidate.issuer = self._options.issuer;
// set up algorithm
optionsToValidate.algorithms = metadata.oidc.algorithms;
// set up audience, validateIssuer, allowMultiAudiencesInToken
optionsToValidate.audience = self._options.audience;
optionsToValidate.validateIssuer = self._options.validateIssuer;
optionsToValidate.allowMultiAudiencesInToken = self._options.allowMultiAudiencesInToken;
optionsToValidate.ignoreExpiration = self._options.ignoreExpiration;
// clock skew
optionsToValidate.clockSkew = self._options.clockSkew;
// set up scope
if (self._options.scope)
optionsToValidate.scope = self._options.scope;
// Beaer token is considered as an access_token.
optionsToValidate.isAccessToken = true;
log.info(`In Strategy.prototype.authenticate: we will validate the following options: ${optionsToValidate}`);
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 or body. query is not supported.
if (req.query && req.query.access_token)
return self.failWithLog('In Strategy.prototype.authenticate: access_token should be passed in request header or body. query is unsupported');
if (req.headers && req.headers.authorization) {
var auth_components = req.headers.authorization.split(' ');
if (auth_components.length == 2 &&auth_components[0].toLowerCase() === 'bearer') {
token = auth_components[1];
if (token !== '')
log.info('In Strategy.prototype.authenticate: received access_token from request header: ${token}');
else
self.failWithLog('In Strategy.prototype.authenticate: missing access_token in the header');
}
}
if (req.body && req.body.access_token) {
if (token)
return self.failWithLog('In Strategy.prototype.authenticate: access_token cannot be passed in both request header and body');
token = req.body.access_token;
if (token)
log.info(`In Strategy.prototype.authenticate: received access_token from request body: ${token}`);
}
if (!token)
return self.failWithLog('token is not found');
function verified(err, user, info) {
if (err)
return self.error(err);
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.failWithLog(err_message);
}
return self.success(user, info);
}
return self.jwtVerify(req, token, params.metadata, optionsToValidate, verified);
}],
(waterfallError) => { // This function gets called after the three tasks have called their 'task callbacks'
if (waterfallError) {
return self.error(waterfallError);
}
return true;
}
);
};
Strategy.prototype.loadMetadata = function(params, next) {
const self = this;
var metadata = new Metadata(params.metadataURL, 'oidc', self._options);
// fetch metadata
return memoryCache.wrap(params.cacheKey, (cacheCallback) => {
metadata.fetch((fetchMetadataError) => {
if (fetchMetadataError) {
return self.failWithLog('In loadMetadata: Unable to fetch metadata');
}
return cacheCallback(null, metadata);
});
}, { ttl }, next);
};
/**
* fail and log the given message
*
* @params {String} message
*/
Strategy.prototype.failWithLog = function(message) {
log.info(`authentication failed due to: ${message}`);
return this.fail(message);
};
module.exports = Strategy;