passport-azure-ad
Version:
OIDC and Bearer Passport strategies for Azure Active Directory
1,034 lines (924 loc) • 66.3 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.
*/
'use strict';
/* eslint no-underscore-dangle: 0 */
// third party packages
const async = require('async');
const base64url = require('base64url');
const cacheManager = require('cache-manager');
const _ = require('lodash');
const jws = require('jws');
const passport = require('passport');
const querystring = require('querystring');
const url = require('url');
const urlValidator = require('valid-url');
const util = require('util');
// packages from this library
const aadutils = require('./aadutils');
const CONSTANTS = require('./constants');
const jwt = require('./jsonWebToken');
const jwe = require('./jwe');
// For the following packages we get a constructor and we will use 'new' to create an instance
const InternalOAuthError = require('./errors/internaloautherror');
const InternalOpenIDError = require('./errors/internalopeniderror');
const Log = require('./logging').getLogger;
const Metadata = require('./metadata').Metadata;
const OAuth2 = require('oauth').OAuth2;
const SessionContentHandler = require('./sessionContentHandler').SessionContentHandler;
const CookieContentHandler = require('./cookieContentHandler').CookieContentHandler;
const Validator = require('./validator').Validator;
// global variable definitions
const log = new Log('AzureAD: OIDC Passport Strategy');
const memoryCache = cacheManager.caching({ store: 'memory', max: 3600, ttl: 1800 /* seconds */ });
const ttl = 1800; // 30 minutes cache
// Note: callback is optional in set() and del().
// For each microsoft login page, we generate a tuple containing nonce/state/etc, and save it in session.
// 1. NONCE_LIFE_TIME is the default life time of the tuple. The default value is 3600 seconds. The life
// time is configurable by user.
// 2. NONCE_MAX_AMOUNT is the max amount of tuples a user's session can have. We limit it to 10.
// This value limits the amount of microsoft login page tabs a user can open before the user types
// username and password to 10. If the user opens more than 10 login tabs, we only honor the most
// recent 10 tabs within the life time.
const NONCE_MAX_AMOUNT = 10;
const NONCE_LIFE_TIME = 3600; // second
function makeProfileObject(src, raw) {
return {
sub: src.sub,
oid: src.oid,
upn: src.upn,
displayName: src.name,
name: {
familyName: src.family_name,
givenName: src.given_name,
middleName: src.middle_name,
},
emails: src.emails,
_raw: raw,
_json: src,
};
}
function onProfileLoaded(strategy, args) {
function verified(err, user, info) {
if (err) {
return strategy.error(err);
}
if (!user) {
return strategy.failWithLog(info);
}
return strategy.success(user, info);
}
const verifyArityArgsMap = {
8: 'iss sub profile jwtClaims access_token refresh_token params',
7: 'iss sub profile access_token refresh_token params',
6: 'iss sub profile access_token refresh_token',
4: 'iss sub profile',
3: 'iss sub',
};
const arity = (strategy._passReqToCallback) ? strategy._verify.length - 1 : strategy._verify.length;
let verifyArgs = [args.profile, verified];
if (verifyArityArgsMap[arity]) {
verifyArgs = verifyArityArgsMap[arity]
.split(' ')
.map((argName) => {
return args[argName];
})
.concat([verified]);
}
if (strategy._passReqToCallback) {
verifyArgs.unshift(args.req);
}
return strategy._verify.apply(strategy, verifyArgs);
}
/**
* Applications must supply a `verify` callback, for which the function
* signature is:
*
* function(token, done) { ... }
* or
* function(req, token, done) { .... }
*
* (passReqToCallback must be set true in options in order to use the second signature.)
*
* `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
*
* - `clientID` (1) Required
* (2) must be a string
* (3) Description:
* The Client ID of your app in AAD
*
* - `responseType` (1) Required
* (2) must be 'code', 'code id_token', 'id_token code' or 'id_token'
* (3) Description:
* For login only flows use 'id_token'. For accessing resources use `code id_token`, 'id_token code' or `code`
*
* - `responseMode` (1) Required
* (2) must be 'query' or 'form_post'
* (3) Description:
* How you get the authorization code and tokens back
*
* - `redirectUrl` (1) Required
* (2) must be a https url string, unless you set `allowHttpForRedirectUrl` to true
* (3) Description:
* The reply URL registered in AAD for your app
*
* - `allowHttpForRedirectUrl`
* (1) Required to set to true if you want to use http url for redirectUrl
* (2) Description:
* The default value is false. It's OK to use http like 'http://localhost:3000' in the
* dev environment, but in production environment https should always be used.
*
* - `clientSecret` (1) This option only applies when `responseType` is 'code', 'id_token code' or 'code id_token'.
* To redeem an authorization code, we can use either client secret flow or client assertion flow.
* (1.1) For B2C, clientSecret is required since client assertion is not supported
* (1.2) For non-B2C, both flows are supported. Developer must provide either clientSecret, or
* thumbprint and privatePEMKey. We use clientSecret if it is provided, otherwise we use
* thumbprint and privatePEMKey for the client assertion flow.
* (2) must be a string
* (3) Description:
* The app key of your app from AAD.
* NOTE: For B2C, the app key sometimes contains '\', please replace '\' with '\\' in the app key, otherwise
* '\' will be treated as the beginning of a escaping character
*
* - `thumbprint` (1) Required if you want to use client assertion to redeem an authorization code (non-B2C only)
* (2) must be a base64url encoded string
* (3) Description:
* The thumbprint (hash value) of the public key
*
* - `privatePEMKey` (1) Required if you want to use client assertion to redeem an authorization code (non-B2C only)
* (2) must be a pem key
* (3) Description:
* The private key used to sign the client assertion JWT
*
* - `isB2C` (1) Required for B2C
* (2) must be true for B2C, default is false
* (3) Description:
* set to true if you are using B2C, default is false
*
* - `validateIssuer` (1) Required to set to false if you don't want to validate issuer, default is true
* (2) Description:
* For common endpoint, you should either set `validateIssuer` to false, or provide the `issuer`, since
* 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 you are using common endpoint and set `validateIssuer` to true, or if you want to specify the allowed issuers
* (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
*
* - `useCookieInsteadOfSession`
* (1) Required to set true if you don't want to use session. Default value is false.
* (2) Description:
* Passport-azure-ad needs to save state and nonce somewhere for validation purpose. If this option is set true, it will encrypt
* state and nonce and put them into cookie. If this option is false, we save state and nonce in session.
*
* - `cookieEncryptionKeys`
* (1) Required if `useCookieInsteadOfSession` is true.
* (2) Description:
* This must be an array of key items. Each key item has the form { key: '...', iv: '...' }, where key is any string of length 32,
* and iv is any string of length 12.
* We always use the first key item with AES-256-GCM algorithm to encrypt cookie, but we will try every key item when we decrypt
* cookie. This helps when you want to do key roll over.
*
* - `scope` (1) Optional
* (2) must be a string or an array of strings
* (3) Description:
* list of scope values indicating the required scope of the access token for accessing the requested
* resource. Ex: ['email', 'profile'].
* We send 'openid' by default. For B2C, we also send 'offline_access' (to get refresh_token) and
* clientID (to get access_token) by default.
*
* - `loggingLevel` (1) Optional
* (2) must be 'info', 'warn', 'error'
* (3) Description:
* logging level
*
* - `nonceLifetime` (1) Optional
* (2) must be a positive integer
* (3) Description:
* the lifetime of nonce in session or cookie, default value is NONCE_LIFE_TIME
*
* - `nonceMaxAmount` (1) Optional
* (2) must be a positive integer
* (3) Description:
* the max amount of nonce in session or cookie, default value is NONCE_MAX_AMOUNT
*
* - `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 OIDCStrategy({
* identityMetadata: config.creds.identityMetadata,
* clientID: config.creds.clientID,
* responseType: config.creds.responseType,
* responseMode: config.creds.responseMode
* redirectUrl: config.creds.redirectUrl,
* allowHttpForRedirectUrl: config.creds.allowHttpForRedirectUrl,
* clientSecret: config.creds.clientSecret,
* thumbprint: config.creds.thumbprint,
* privatePEMKey: config.crecs.privatePEMKey,
* isB2C: config.creds.isB2C,
* validateIssuer: config.creds.validateIssuer,
* issuer: config.creds.issuer,
* scope: config.creds.scopes,
* passReqToCallback: config.creds.passReqToCallback,
* loggingLevel: config.creds.loggingLevel,
* nonceLifetime: config.creds.nonceLifetime,
* useCookieInsteadOfSession: config.creds.useCookieInsteadOfSession,
* cookieEncryptionKeys: config.creds.cookieEncryptionKeys
* },
* 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);
* });
* }
* ));
*
* 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, verify) {
passport.Strategy.call(this);
/*
* Caution when you want to change these values in the member functions of
* Strategy, don't use `this`, since `this` points to a subclass of `Strategy`.
* To get `Strategy`, use Object.getPrototypeOf(this).
*
* More comments at the beginning of `Strategy.prototype.authenticate`.
*/
this._options = options;
this.name = 'azuread-openidconnect';
// stuff related to the verify function
this._verify = verify;
this._passReqToCallback = !!options.passReqToCallback;
if (options.useCookieInsteadOfSession === true)
this._useCookieInsteadOfSession = true;
else
this._useCookieInsteadOfSession = false;
if (!this._useCookieInsteadOfSession) {
// For each microsoft login page tab, we generate a {state, nonce, policy, timeStamp} tuple,
// normally user won't keep opening microsoft login page in new tabs without putting their
// password for more than 10 tabs, so we only keep the most recent 10 tuples in session.
// The lifetime of each tuple is 60 minutes or user specified.
this._sessionContentHandler = new SessionContentHandler(options.nonceMaxAmount || NONCE_MAX_AMOUNT, options.nonceLifetime || NONCE_LIFE_TIME);
} else {
this._cookieContentHandler = new CookieContentHandler(options.nonceMaxAmount || NONCE_MAX_AMOUNT, options.nonceLifetime || NONCE_LIFE_TIME, options.cookieEncryptionKeys);
}
/* When a user is authenticated for the first time, passport adds a new field
* to req.session called 'passport', and puts a 'user' property inside (or your
* choice of field name and property name if you change passport._key and
* passport._userProperty values). req.session['passport']['user'] is usually
* user_id (or something similar) of the authenticated user to reduce the size
* of session. When the user logs out, req.session['passport']['user'] will be
* destroyed. Any request between login (when authenticated for the first time)
* and logout will have the 'user_id' in req.session['passport']['user'], so
* passport can fetch it, find the user object in database and put the user
* object into a new field: req.user. Then the subsequent middlewares and the
* app can use the user object. This is how passport keeps user authenticated.
*
* For state validation, we also take advantage of req.session. we create a new
* field: req.session[sessionKey], where the sessionKey is our choice (in fact,
* this._key, see below). When we send a request with state, we put state into
* req.session[sessionKey].state; when we expect a request with state in query
* or body, we compare the state in query/body with the one stored in
* req.session[sessionKey].state, and then destroy req.session[sessionKey].state.
* User can provide a state by using `authenticate(Strategy, {state: 'xxx'})`, or
* one will be generated automatically. This is essentially how passport-oauth2
* library does the state validation, and we use the same way in our library.
*
* request structure will look like the following. In real request some fields
* might not be there depending on the purpose of the request.
*
* request ---|--- sessionID
* |--- session --- |--- ...
* | |--- 'passport' ---| --- 'user': 'user_id etc'
* | |--- sessionKey---| --- state: 'xxx'
* |--- ...
* |--- 'user': full user info
*/
this._key = options.sessionKey || ('OIDC: ' + options.clientID);
if (!options.identityMetadata) {
// default value should be https://login.microsoftonline.com/common/.well-known/openid-configuration
log.error('OIDCStrategy requires a metadata location that contains cert data for RSA and ECDSA callback.');
throw new TypeError(`OIDCStrategy requires a metadata location that contains cert data for RSA and ECDSA callback.`);
}
// if logging level specified, switch to it.
if (options.loggingLevel) { log.levels('console', options.loggingLevel); }
this.log = log;
// 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;
/****************************************************************************************
* Take care of identityMetadata
* (1) Check if it is common endpoint
* (2) For B2C, one cannot use the common endpoint. tenant name or guid must be specified
* (3) We add telemetry to identityMetadata automatically
***************************************************************************************/
// check existence
if (!options.identityMetadata) {
log.error('OIDCStrategy requires a metadata location that contains cert data for RSA and ECDSA callback.');
throw new TypeError(`OIDCStrategy requires a metadata location that contains cert data for RSA and ECDSA callback.`);
}
// check if we are using the common endpoint
options.isCommonEndpoint = (options.identityMetadata.indexOf('/common/') != -1);
// isB2C is false by default
if (options.isB2C !== true)
options.isB2C = false;
// add telemetry
options.identityMetadata = aadutils.concatUrl(
options.identityMetadata,
[
`${aadutils.getLibraryProductParameterName()}=${aadutils.getLibraryProduct()}`,
`${aadutils.getLibraryVersionParameterName()}=${aadutils.getLibraryVersion()}`
]
);
/****************************************************************************************
* Take care of issuer and audience
* (1) We use user provided `issuer`, and the issuer value from metadata if the metadata
* comes from tenant-specific endpoint (in other words, either the identityMetadata
* is tenant-specific, or it is common but you provide tenantIdOrName in
* passport.authenticate).
*
* For common endpoint, if `issuer` is not provided by user, and `tenantIdOrName` is
* not used in passport.authenticate, then we don't know the issuer, and `validateIssuer`
* must be set to false
* (2) `validateIssuer` is true by default. we validate issuer unless validateIssuer is set false
* (3) `audience` must be the clientID of this app
***************************************************************************************/
if (options.validateIssuer !== false)
options.validateIssuer = true;
if (!options.validateIssuer)
log.warn(`Production environments should always validate the issuer.`);
if (options.issuer === '')
options.issuer = null;
// make issuer an array
if (options.issuer && !Array.isArray(options.issuer))
options.issuer = [options.issuer];
options.audience = options.clientID;
options.allowMultiAudiencesInToken = false;
/****************************************************************************************
* Take care of scope
***************************************************************************************/
// make scope an array
if (!options.scope)
options.scope = [];
if (!Array.isArray(options.scope))
options.scope = [options.scope];
// always have 'openid' scope for openID Connect
if (options.scope.indexOf('openid') === -1)
options.scope.push('openid');
options.scope = options.scope.join(' ');
/****************************************************************************************
* Check if we are using v2 endpoint, v2 doesn't have an userinfo endpoint
***************************************************************************************/
if (options.identityMetadata.indexOf('/v2.0/') != -1)
options._isV2 = true;
/****************************************************************************************
* validate other necessary option items provided, we validate them here and only once
***************************************************************************************/
// change responseType 'id_token code' to 'code id_token' since the former is not supported by B2C
if (options.responseType === 'id_token code')
options.responseType = 'code id_token';
var itemsToValidate = {};
aadutils.copyObjectFields(options, itemsToValidate, ['clientID', 'redirectUrl', 'responseType', 'responseMode', 'identityMetadata']);
var validatorConfiguration = {
clientID: Validator.isNonEmpty,
responseType: Validator.isTypeLegal,
responseMode: Validator.isModeLegal,
identityMetadata: Validator.isHttpsURL
};
// redirectUrl is https by default
if (options.allowHttpForRedirectUrl === true)
validatorConfiguration.redirectUrl = Validator.isURL;
else
validatorConfiguration.redirectUrl = Validator.isHttpsURL;
// validator will throw exception if a required option is missing
var validator = new Validator(validatorConfiguration);
validator.validate(itemsToValidate);
// validate client secret, thumbprint and privatePEMKey for hybrid and authorization flow
if (options.responseType !== 'id_token') {
if (options.isB2C && !options.clientSecret) {
// for B2C, clientSecret is required to redeem authorization code.
throw new Error('clientSecret must be provided for B2C hybrid flow and authorization code flow.');
} else if (!options.clientSecret) {
// for non-B2C, we can use either clientSecret or clientAssertion to redeem authorization code.
// Therefore, we need either clientSecret, or privatePEMKey and thumbprint (so we can create clientAssertion).
if (!options.privatePEMKey)
throw new Error('privatePEMKey is not provided. Please provide either clientSecret, or privatePEMKey and thumbprint.');
if (!options.thumbprint)
throw new Error('thumbprint is not provided. Please provide either clientSecret, or privatePEMKey and thumbprint.');
}
}
// we allow 'http' for the redirectUrl, but don't recommend using 'http'
if (urlValidator.isHttpUri(options.redirectUrl))
log.warn(`Using http for redirectUrl is not recommended, please consider using https`);
}
// Inherit from `passport.Strategy`.
util.inherits(Strategy, passport.Strategy);
/**
* Authenticate request by delegating to an OpenID Connect provider.
*
* @param {Object} req
* @param {Object} options
* @api protected
*/
Strategy.prototype.authenticate = function authenticateStrategy(req, options) {
/*
* We should be careful using 'this'. Avoid the usage like `this.xxx = ...`
* unless you know what you are doing.
*
* In the passport source code
* (https://github.com/jaredhanson/passport/blob/master/lib/middleware/authenticate.js)
* when it attempts to call the `oidcstrategy.authenticate` function, passport
* creates an instance inherting oidcstrategy and then calls `instance.authenticate`.
* Therefore, when we come here, `this` is the instance, its prototype is the
* actual oidcstrategy, i.e. the `Strategy`. This means:
* (1) `this._options = `, `this._verify = `, etc only adds new fields to the
* instance, it doesn't change the values in oidcstrategy, i.e. `Strategy`.
* (2) `this._options`, `this._verify`, etc returns the field in the instance,
* and if there is none, returns the field in oidcstrategy, i.e. `strategy`.
* (3) each time we call `authenticate`, we will get a brand new instance
*
* If you want to change the values in `Strategy`, use
* const oidcstrategy = Object.getPrototypeOf(self);
* to get the strategy first.
*
* Note: Simply do `const self = Object.getPrototypeOf(this)` and use `self`
* won't work, since the `this` instance has a couple of functions like
* success/fail/error... which `authenticate` will call. The following is the
* structure of `this`:
*
* this
* | -- success: function(user, info)
* | -- fail: function(challenge, status)
* | -- redirect: function(url, status)
* | -- pass: function()
* | -- __proto__: Strategy
* | -- _verify
* | -- _options
* | -- ...
* | -- __proto__:
* | -- authenticate: function(req, options)
* | -- ...
*/
const self = this;
var resource = options && options.resourceURL;
var customState = options && options.customState;
var tenantIdOrName = options && options.tenantIdOrName;
var login_hint = options && options.login_hint;
var domain_hint = options && options.domain_hint;
var prompt = options && options.prompt;
var extraAuthReqQueryParams = options && options.extraAuthReqQueryParams;
var extraTokenReqQueryParams = options && options.extraTokenReqQueryParams;
var response = options && options.response || req.res;
// 'params': items we get from the request or metadata, such as id_token, code, policy, metadata, cacheKey, etc
var params = { 'tenantIdOrName': tenantIdOrName, 'extraAuthReqQueryParams': extraAuthReqQueryParams, 'extraTokenReqQueryParams': extraTokenReqQueryParams };
// 'oauthConfig': items needed for oauth flow (like redirection, code redemption), such as token_endpoint, userinfo_endpoint, etc
var oauthConfig = { 'resource': resource, 'customState': customState, 'domain_hint': domain_hint, 'login_hint': login_hint, 'prompt': prompt, 'response': response };
// 'optionsToValidate': items we need to validate id_token against, such as issuer, audience, etc
var optionsToValidate = {};
async.waterfall(
[
/*****************************************************************************
* Step 1. Collect information from the req and save the info into params
****************************************************************************/
(next) => {
return self.collectInfoFromReq(params, req, next, response);
},
/*****************************************************************************
* Step 2. Load metadata, use the information from 'params' and 'self._options'
* to configure 'oauthConfig' and 'optionsToValidate'
****************************************************************************/
(next) => {
return self.setOptions(params, oauthConfig, optionsToValidate, next);
},
/*****************************************************************************
* Step 3. Handle the flows
*----------------------------------------------------------------------------
* (1) implicit flow (response_type = 'id_token')
* This case we get a 'id_token'
* (2) hybrid flow (response_type = 'id_token code')
* This case we get both 'id_token' and 'code'
* (3) authorization code flow (response_type = 'code')
* This case we get a 'code', we will use it to get 'access_token' and 'id_token'
* (4) for any other request, we will ask for authorization and initialize
* the authorization process
****************************************************************************/
(next) => {
if (params.err) {
// handle the error
return self._errorResponseHandler(params.err, params.err_description, next);
} else if (!params.id_token && !params.code) {
// ask for authorization, initialize the authorization process
return self._flowInitializationHandler(oauthConfig, req, next);
} else if (params.id_token && params.code) {
// handle hybrid flow
return self._hybridFlowHandler(params, oauthConfig, optionsToValidate, req, next);
} else if (params.id_token) {
// handle implicit flow
return self._implicitFlowHandler(params, optionsToValidate, req, next);
} else {
// handle authorization code flow
return self._authCodeFlowHandler(params, oauthConfig, optionsToValidate, req, next);
}
}
],
(waterfallError) => {
// this code gets called after the three steps above are done
if (waterfallError) {
return self.failWithLog(`${aadutils.getErrorMessage(waterfallError)}`);
}
return true;
});
};
/**
* Collect information from the request, for instance, code, err, id_token etc
*
* @param {Object} params
* @param {Object} req
* @param {Object} next
*/
Strategy.prototype.collectInfoFromReq = function(params, req, next, response) {
const self = this;
// the things we will put into 'params':
// err, err_description, id_token, code, policy, state, nonce, cachekey, metadata
// -------------------------------------------------------------------------
// we shouldn't get any access_token or refresh_token from the request
// -------------------------------------------------------------------------
if ((req.query && (req.query.access_token || req.query.refresh_token)) ||
(req.body && (req.body.access_token || req.body.refresh_token)))
return next(new Error('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'));
// -------------------------------------------------------------------------
// we might get err, id_token, code, state from the request
// -------------------------------------------------------------------------
var source = null;
if (req.query && (req.query.error || req.query.id_token || req.query.code))
source = req.query;
else if (req.body && (req.body.error || req.body.id_token || req.body.code))
source = req.body;
if (source) {
params.err = source.error;
params.err_description = source.error_description;
params.id_token = source.id_token;
params.code = source.code;
params.state = source.state;
if (source.state && source.state.length >= 38) {
// the random generated state always has 32 characters. This state is longer than 32
// so it must be a custom state. We added 'CUSTOM' prefix and a random 32 byte long
// string in front of the original custom state, now we change it back.
if (!source.state.startsWith('CUSTOM'))
return next(new Error(`In collectInfoFromReq: invalid custom state ${state}`));
source.state = source.state.substring(38);
}
}
// -------------------------------------------------------------------------
// If we received code, id_token or err, we must have received state, now we
// find the state/nonce/policy tuple from session.
// If we received none of them, find policy in query
// -------------------------------------------------------------------------
if (params.id_token || params.code || params.err) {
if (!params.state)
return next(new Error('In collectInfoFromReq: missing state in the request'));
var tuple;
if (!self._useCookieInsteadOfSession)
tuple = self._sessionContentHandler.findAndDeleteTupleByState(req, self._key, params.state);
else
tuple = self._cookieContentHandler.findAndDeleteTupleByState(req, response, params.state);
if (!tuple)
return next(new Error('In collectInfoFromReq: invalid state received in the request'));
params.nonce = tuple['nonce'];
params.policy = tuple['policy'];
params.resource = tuple['resource'];
// user provided tenantIdOrName will be ignored for redirectUrl, since we saved the one we used in session
if (params.tenantIdOrName)
log.info(`user provided tenantIdOrName '${params.tenantIdOrName}' is ignored for redirectUrl, we will use the one stored in session`);
params.tenantIdOrName = tuple['tenantIdOrName'];
} else {
params.policy = req.query.p ? req.query.p.toLowerCase() : null;
}
// if we are not using the common endpoint, but we have tenantIdOrName, just ignore it
if (!self._options.isCommonEndpoint && params.tenantIdOrName) {
log.info(`identityMetadata is tenant-specific, so we ignore the tenantIdOrName '${params.tenantIdOrName}'`);
params.tenantIdOrName = null;
}
// 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 && !params.tenantIdOrName))
return next(new Error('In collectInfoFromReq: issuer or tenantIdOrName must be provided in order to validate issuer on common endpoint'));
// for B2C, we must have policy
if (self._options.isB2C && !params.policy)
return next(new Error('In collectInfoFromReq: policy is missing'));
// for B2C, if we are using common endpoint, we must have tenantIdOrName provided
if (self._options.isB2C && self._options.isCommonEndpoint && !params.tenantIdOrName)
return next(new Error('In collectInfoFromReq: we are using common endpoint for B2C but tenantIdOrName is not provided'));
// -------------------------------------------------------------------------
// calculate metadataUrl, create a cachekey and an Metadata object instance
// we will fetch the metadata, save it into the object using the cachekey
// -------------------------------------------------------------------------
var metadataUrl = self._options.identityMetadata;
// if we are using common endpoint and we are given the tenantIdOrName, let's replace it
if (self._options.isCommonEndpoint && params.tenantIdOrName) {
metadataUrl = metadataUrl.replace('/common/', `/${params.tenantIdOrName}/`);
log.info(`we are replacing 'common' with the tenantIdOrName ${params.tenantIdOrName}`);
}
// add policy for B2C
if (self._options.isB2C)
metadataUrl = metadataUrl.concat(`&p=${params.policy}`);
// we use the metadataUrl as the cachekey
params.cachekey = metadataUrl;
params.metadata = new Metadata(metadataUrl, 'oidc', self._options);
log.info(`metadataUrl is: ${metadataUrl}`);
log.info(`received the following items in params: ${JSON.stringify(params)}`);
return next();
};
/**
* Set the information we need for oauth flow and id_token validation
*
* @param {Object} params -- parameters we get from the request
* @param {Object} oauthConfig -- the items we need for oauth flow
* @param {Object} optionsToValidate -- the items we need to validate id_token
* @param {Function} done -- the callback
*/
Strategy.prototype.setOptions = function setOptions(params, oauthConfig, optionsToValidate, done) {
const self = this;
async.waterfall([
// ------------------------------------------------------------------------
// load metadata
// ------------------------------------------------------------------------
(next) => {
memoryCache.wrap(params.cachekey, (cacheCallback) => {
params.metadata.fetch((fetchMetadataError) => {
if (fetchMetadataError) {
return cacheCallback(new Error(`In setOptions: Unable to fetch metadata`));
}
return cacheCallback(null, params.metadata);
});
}, { ttl }, next);
},
// ------------------------------------------------------------------------
// set oauthConfig: the information we need for oauth flow like redeeming code/access_token
// ------------------------------------------------------------------------
(metadata, next) => {
if (!metadata.oidc)
return next(new Error('In setOptions: failed to load metadata'));
params.metadata = metadata;
// copy the fields needed into 'oauthConfig'
aadutils.copyObjectFields(metadata.oidc, oauthConfig, ['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint']);
aadutils.copyObjectFields(self._options, oauthConfig, ['clientID', 'clientSecret', 'privatePEMKey', 'thumbprint', 'responseType', 'responseMode', 'scope', 'redirectUrl']);
oauthConfig.tenantIdOrName = params.tenantIdOrName;
oauthConfig.extraAuthReqQueryParams = params.extraAuthReqQueryParams;
oauthConfig.extraTokenReqQueryParams = params.extraTokenReqQueryParams;
// validate oauthConfig
const validatorConfig = {
authorization_endpoint: Validator.isHttpsURL,
token_endpoint: Validator.isHttpsURL,
userinfo_endpoint: Validator.isHttpsURLIfExists,
};
try {
// validator will throw exception if a required option is missing
const checker = new Validator(validatorConfig);
checker.validate(oauthConfig);
} catch (ex) {
return next(new Error(`In setOptions: ${aadutils.getErrorMessage(ex)}`));
}
// for B2C, verify the endpoints in oauthConfig has the correct policy
if (self._options.isB2C){
var policyChecker = (endpoint, policy) => {
var u = {};
try {
u = url.parse(endpoint, true);
} catch (ex) {
}
return u.query && u.query.p && (policy.toLowerCase() === u.query.p.toLowerCase());
};
// B2C has no userinfo_endpoint, so no need to check it
if (!policyChecker(oauthConfig.authorization_endpoint, params.policy))
return next(new Error(`policy in ${oauthConfig.authorization_endpoint} should be ${params.policy}`));
if (!policyChecker(oauthConfig.token_endpoint, params.policy))
return next(new Error(`policy in ${oauthConfig.token_endpoint} should be ${params.policy}`));
}
return next(null, metadata);
},
// ------------------------------------------------------------------------
// set optionsToValidate: the information we need for id_token validation.
// we do this only if params has id_token or code, otherwise there is no
// id_token to validate
// ------------------------------------------------------------------------
(metadata, next) => {
if (!params.id_token && !params.code)
return next(null);
// set items from self._options
aadutils.copyObjectFields(self._options, optionsToValidate,
['validateIssuer', 'audience', 'allowMultiAudiencesInToken', 'ignoreExpiration', 'allowMultiAudiencesInToken']);
// algorithms
var algorithms = metadata.oidc.algorithms;
if (!algorithms)
return next(new Error('In setOptions: algorithms is missing in metadata'));
if (!Array.isArray(algorithms) || algorithms.length == 0 || (algorithms.length === 1 && algorithms[0] === 'none'))
return next(new Error('In setOptions: algorithms must be an array containing at least one algorithm'));
optionsToValidate.algorithms = algorithms;
// nonce
optionsToValidate.nonce = params.nonce;
// clock skew
optionsToValidate.clockSkew = self._options.clockSkew;
// jweKeyStore
optionsToValidate.jweKeyStore = self._options.jweKeyStore;
// issuer
// if the metadata is not coming from common endpoint, we record the issuer value from metadata
if (!self._options.isCommonEndpoint || (self._options.isCommonEndpoint && params.tenantIdOrName))
optionsToValidate.issuer = [metadata.oidc.issuer];
else
optionsToValidate.issuer = [];
// if user provided issuer, we also record these issuer values
if (self._options.issuer)
optionsToValidate.issuer = optionsToValidate.issuer.concat(self._options.issuer);
// if we don't get any issuer value and we want to validate issuer, we should fail
if (optionsToValidate.issuer.length === 0 && self._options.validateIssuer)
return next(new Error('In setOptions: we want to validate issuer but issuer is not found'));
return next(null);
},
], done);
};
/**
* validate id_token, and pass the validated claims and the payload to callback
* if code (resp. access_token) is provided, we will validate the c_hash (resp at_hash) as well
*
* @param {String} params
* @param {String} optionsToValidate
* @param {Object} req
* @param {Function} next -- when error occurs, call next(err)
* @param {Function} callback
*/
Strategy.prototype._idTokenHandler = function idTokenHandler(params, optionsToValidate, req, next, callback) {
const self = this;
var id_token = params.id_token;
var parts = id_token.split('.');
if (parts.length === 3)
return self._validateResponse(params, optionsToValidate, req, next, callback);
else if (parts.length === 5) {
log.info('In _idTokenHandler: we received an id_token of JWE format, we are decrypting it');
var decrypted_token;
return jwe.decrypt(id_token, optionsToValidate.jweKeyStore, log, (err, decrypted_token) => {
if(err)
return next(err);
params.id_token = decrypted_token;
return self._validateResponse(params, optionsToValidate, req, next, callback);
});
} else
return next(new Error(`id_token has ${parts.length} parts, it is neither jwe nor jws`));
};
/**
* validate id_token, and pass the validated claims and the payload to callback
* if code (resp. access_token) is provided, we will validate the c_hash (resp at_hash) as well
*
* @param {String} params
* @param {String} optionsToValidate
* @param {Object} req
* @param {Function} next -- when error occurs, call next(err)
* @param {Function} callback
*/
Strategy.prototype._validateResponse = function validateResponse(params, optionsToValidate, req, next, callback) {
const self = this;
var id_token = params.id_token;
var code = params.code;
var access_token = params.access_token;
// decode id_token
const decoded = jws.decode(id_token);
if (decoded == null)
return next(new Error('In _validateResponse: Invalid JWT token'));
log.info('token decoded: ', decoded);
// get Pem Key
var PEMkey = null;
try {
if (decoded.header.kid) {
PEMkey = params.metadata.generateOidcPEM(decoded.header.kid);
} else if (decoded.header.x5t) {
PEMkey = params.metadata.generateOidcPEM(decoded.header.x5t);
} else {
return next(new Error('In _validateResponse: We did not receive a token we know how to validate'));
}
} catch (error) {
return next(new Error('In _validateResponse: failed to generate PEM key due to: ' + error.message));
}
log.info('PEMkey generated: ' + PEMkey);
// verify id_token signature and claims
return jwt.verify(id_token, PEMkey, optionsToValidate, (err, jwtClaims) => {
if (err)
return next(new Error(`In _validateResponse: ${aadutils.getErrorMessage(err)}`));
log.info("Claims received: ", jwtClaims);
// jwt checks the 'nbf', 'exp', 'aud', 'iss' claims
// there are a few other things we will check below
// For B2C, check the policy
if (self._options.isB2C) {
var policy_in_idToken;
if (jwtClaims.acr && CONSTANTS.POLICY_REGEX.test(jwtClaims.acr))
policy_in_idToken = jwtClaims.acr;
else if (jwtClaims.tfp && CONSTANTS.POLICY_REGEX.test(jwtClaims.tfp))
policy_in_idToken = jwtClaims.tfp.toLowerCase();
else
return next(new Error('In _validateResponse: invalid B2C policy in id_token'));
if (params.policy !== policy_in_idToken)
return next(new Error("In _validateResponse: policy in id_token does not match the policy used"));
}
// check nonce
if (!jwtClaims.nonce || jwtClaims.nonce === '' || jwtClaims.nonce !== optionsToValidate.nonce)
return next(new Error('In _validateResponse: invalid nonce'));
// check c_hash
if (jwtClaims.c_hash) {
// checkHashValueRS256 checks if code is null, so we don't bother here
if (!aadutils.checkHashValueRS256(code, jwtClaims.c_hash))
return next(new Error("In _validateResponse: invalid c_hash"));
}
// check at_hash
if (jwtClaims.at_hash) {
// checkHashValueRS256 checks if access_token is null, so we don't bother here
if (!aadutils.checkHashValueRS256(access_token, jwtClaims.at_hash))
return next(new Error("In _validateResponse: invalid at_hash"));
}
// return jwt claims and jwt claims string
var idTokenSegments = id_token.split('.');
var jwtClaimsStr = base64url.decode(idTokenSegments[1]);
return callback(jwtClaimsStr, jwtClaims);
});
};
/**
* handle error response
*
* @params {String} err
* @params {String} err_description
* @params {Function} next -- callback to pass error to async.waterfall
*/
Strategy.prototype._errorResponseHandler = function errorResponseHandler(err, err_description, next) {
const self = this;
log.info('Error received in the response was: ', err);
if (err_description)
log.info('Error description received in the response was: ', err_description);
// Unfortunately, we cannot return the 'error description' to the user, since
// it goes to http header by default and it usually contains characters that
// http header doesn't like, which causes the program to crash.