diff --git a/CHANGELOG.md b/CHANGELOG.md index cb72d22e..9639f15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,29 @@ # 3.0.0 -## Breaking changes from 2.0.1 +## OIDCStrategy -### OIDCStrategy +### Breaking changes * skipUserProfile option is no longer provided. We will load 'userinfo' if AAD v1 is being used and if there is 'code' involved in the flow (in other words, one of the following flows: 'code', 'code id_token', 'id_token code'). For all other scenarios, we do an 'id_token' fallback, since we cannot obtain 'userinfo' (AAD v2 doesn't have an userinfo endpoint, and 'id_token' flow doesn't yield an 'access_token' to exchange for an 'userinfo' token). +* changed the `returnURL` option name to `redirectUrl`. + +* added `isB2C` option. In order to use B2C feature, user must have this option and set it to true. + +* added `oid` claim in the returned profile. + +* removed `email` claim, and added `emails` claim in the returned profile. `emails` claim is always an array. + +### New features +* multiple nonce and state support in OIDCStrategy. Provided `nonceLifetime` option to configure the lifetime of nonce saved in session. + +* enabled `issuer` validation against common endpoint. To validate issuer on common endpoint, user must +specify the allowed issuer(s) in `issuer` option, and set `validateIssuer` option to true. + # 2.0.1 ## Major changes from 2.0.0 diff --git a/lib/aadutils.js b/lib/aadutils.js index 7b91368e..0e5438cc 100644 --- a/lib/aadutils.js +++ b/lib/aadutils.js @@ -225,3 +225,29 @@ exports.findAndDeleteTupleByState = (array, state) => { return null; }; + +// copy the fields from source to dest +exports.copyObjectFields = (source, dest, fields) => { + if (!source || !dest || !fields || !Array.isArray(fields)) + return; + + for (var i = 0; i < fields.length; i++) + dest[fields[i]] = source[fields[i]]; +}; + +exports.getErrorMessage = (err) => { + if (typeof err === 'string') + return err; + if (err instanceof Error) + return err.message; + + // if not string or Error, we try to stringify it + var str; + try { + str = JSON.stringify(err); + } catch (ex) { + return err; + } + return str; +}; + diff --git a/lib/oidcstrategy.js b/lib/oidcstrategy.js index 1f63c838..47a2ed5c 100644 --- a/lib/oidcstrategy.js +++ b/lib/oidcstrategy.js @@ -31,10 +31,10 @@ const base64url = require('base64url'); const cacheManager = require('cache-manager'); const _ = require('lodash'); const jws = require('jws'); -const objectTransform = require('oniyi-object-transform'); 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 @@ -48,7 +48,6 @@ const Log = require('./logging').getLogger; const Metadata = require('./metadata').Metadata; const OAuth2 = require('oauth').OAuth2; const SessionContentHandler = require('./sessionContentHandler').SessionContentHandler; -const UrlValidator = require('valid-url'); const Validator = require('./validator').Validator; // global variable definitions @@ -69,18 +68,22 @@ const NONCE_MAX_AMOUNT = 10; const NONCE_LIFE_TIME = 3600; // second function makeProfileObject(src, raw) { + var emails = []; + if (src.upn) + emails = [src.upn]; + else if (src.emails) + emails = src.emails; + return { - // Prior to OpenID Connect Basic Client Profile 1.0 - draft 22, the - // 'sub' key was named 'user_id'. Many providers still use the old - // key, so fallback to that. - id: src.sub || src.oid || src.user_id, + sub: src.sub, + oid: src.oid, displayName: src.name, name: { familyName: src.family_name, givenName: src.given_name, middleName: src.middle_name, }, - email: src.upn || src.preferred_username || src.oid, + emails: emails, _raw: raw, _json: src, }; @@ -92,7 +95,7 @@ function onProfileLoaded(strategy, args) { return strategy.error(err); } if (!user) { - return strategy.fail(info); + return strategy.failWithLog(info); } return strategy.success(user, info); } @@ -129,6 +132,10 @@ function onProfileLoaded(strategy, args) { * 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 @@ -144,42 +151,114 @@ function onProfileLoaded(strategy, args) { * * Options: * - * - `scope` 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 and will reject you if you send it a second time. - * - `audience` we check JWT audience (aud), provide a value here - * - `clientID` the application ID of your app in Microsoft Identity platform - * - `identityMetadata` the metadata endpoint provided by the Microsoft Identity Portal that provides - * the keys and other important info at runtime. We roll keys frequently, so don't - * override this to supply your own. - * - `responseType` for login only flows use id_token. For accessing resources use `id_token code` - * - `responseMode` For login only flows we should have token passed back to us in a POST - * - `validateIssuer` if you have validation on, you cannot have users from multiple tenants sign in - * - `passReqToCallback` if you want the Req to go back to the calling function for other processing use this. - * - `nonceLifetime` the lifetime of nonce in session, default is NONCE_LIFE_TIME + * - `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 non-common 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 non-common 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 http or https url string + * (3) Description: + * The reply URL registered in AAD for your app + * + * - `clientSecret` (1) Required if `responseType` is 'code', 'id_token code' or 'code id_token' + * (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 + * + * - `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 + * + * - `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, default value is NONCE_LIFE_TIME * * Examples: * * passport.use(new OIDCStrategy({ - * callbackURL: config.creds.returnURL, - * scope: config.creds.scopes, - * clientID: config.creds.clientID, - * identityMetadata: config.creds.identityMetadata, - * responseType: config.creds.responseType, - * responseMode: config.creds.responseMode, - * validateIssuer: config.creds.validateIssuer, - * passReqToCallback: config.creds.passReqToCallback, - * loggingLevel: config.creds.loggingLevel, - * nonceLifetime: config.creds.nonceLifetime, - * }, - * 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); - * }); - * } - * )); + * identityMetadata: config.creds.identityMetadata, + * clientID: config.creds.clientID, + * responseType: config.creds.responseType, + * responseMode: config.creds.responseMode + * redirectUrl: config.creds.redirectUrl, + * clientSecret: config.creds.clientSecret, + * 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, + * }, + * 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) @@ -200,6 +279,8 @@ function Strategy(options, verify) { */ this._options = options; this.name = 'azuread-openidconnect'; + + // stuff related to the verify function this._verify = verify; this._passReqToCallback = !!options.passReqToCallback; @@ -241,7 +322,7 @@ function Strategy(options, verify) { * |--- ... * |--- 'user': full user info */ - this._key = options.sessionKey || ('OIDC: ' + options.callbackURL); + this._key = options.sessionKey || ('OIDC: ' + options.redirectUrl); if (!options.identityMetadata) { // default value should be https://login.microsoftonline.com/common/.well-known/openid-configuration @@ -251,33 +332,103 @@ function Strategy(options, verify) { // if logging level specified, switch to it. if (options.loggingLevel) { log.levels('console', options.loggingLevel); } + this.log = log; + + /**************************************************************************************** + * 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 && options.identityMetadata.indexOf('/common/') != -1); + options.isCommonEndpoint = (options.identityMetadata.indexOf('/common/') != -1); - // default: validate Issuer - if (options.validateIssuer === undefined || options.validateIssuer === null) - options.validateIssuer = true; + // isB2C is false by default + if (options.isB2C !== true) + options.isB2C = false; - // issuer validation for common endpoint is not supported - if (options._isCommonEndpoint && options.validateIssuer) { - throw new Error(`Configuration error. Please either replace 'common' in identity metadata url with your tenant guid (something like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), or set 'validateIssuer' false. Issuer validation is not supported for common endpoint.`); + // common endpoint is not allowed for B2C + if (options.isCommonEndpoint && options.isB2C) { + throw new Error(`Cannot use common endpoint for B2C. Please use your tenant name or tenant guid.`); } - // give a warning if user is not validating issuer for non-common endpoint - if (!options._isCommonEndpoint && !options.validateIssuer) { + // add telemetry + options.identityMetadata = options.identityMetadata.concat(`?${aadutils.getLibraryProductParameterName()}=${aadutils.getLibraryProduct()}`) + .concat(`&${aadutils.getLibraryVersionParameterName()}=${aadutils.getLibraryVersion()}`); + + /**************************************************************************************** + * Take care of issuer and audience + * (1) We use the `issuer` if it is provided. + * If `issuer` is not provided, then + * for non-common endpoint, we use the issuer provided by metadata + * for common endpoint, 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.issuer === '') + options.issuer = null; + + if (options.isCommonEndpoint && options.validateIssuer && !options.issuer) + throw new Error('we are using common endpoint, please either set validateIssuer to false or provide issuer in the configuration.'); + + if (!options.validateIssuer) log.warn(`Production environments should always validate the issuer.`); - } - // validate other necessary option items provided, we validate them here and only once - var itemsToValidate = objectTransform({ - source: options, - pick: ['clientID', 'callbackURL', 'responseType', 'responseMode', 'identityMetadata'] - }); + // 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'); + // B2C, use 'offline_access' scope to get refresh_token + if (options.isB2C && options.scope.indexOf('offline_access') === -1) + options.scope.push('offline_access'); + // B2C, use clientId scope to get access_token + if (options.isB2C && options.scope.indexOf(options.clientID) === -1) + options.scope.push(options.clientID); + 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, - callbackURL: Validator.isURL, + redirectUrl: Validator.isURL, responseType: Validator.isTypeLegal, responseMode: Validator.isModeLegal, identityMetadata: Validator.isHttpsURL @@ -286,13 +437,9 @@ function Strategy(options, verify) { var validator = new Validator(validatorConfiguration); validator.validate(itemsToValidate); - // we allow 'http' for the callbackURL, but don't recommend using 'http' - if (UrlValidator.isHttpUri(options.callbackURL)) - log.warn(`Using http for callbackURL is not recommended, please consider using https`); - - // check if Azure v2.0 is being used, v2.0 doesn't have a userinfo endpoint - if (options.identityMetadata.indexOf('/v2.0/') != -1) - this._options._isV2 = true; + // 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`. @@ -346,133 +493,58 @@ Strategy.prototype.authenticate = function authenticateStrategy(req, options) { */ const self = this; - // Allow for some overrides that may come in to the authenticate strategy. - // - // It's important all options are in self._options before we continue, as we'll be validating these and - // loading them through a validator. We should only use the data in configurator() for actual param passing - // otherwise we could have injection issues. - // - - if (options.resourceURL) { self._options.resourceURL = options.resourceURL; } - if (options.resourceType) { self._options.responseType = options.responseType; } - if (options.responseMode) { self._options.responseMode = options.responseMode; } + // 'params': items we get from the request or metadata, such as id_token, code, policy, metadata, cacheKey, etc + var params = {}; + // 'oauthConfig': items needed for oauth flow (like redirection, code redemption), such as token_endpoint, userinfo_endpoint, etc + var oauthConfig = {}; + // 'optionsToValidate': items we need to validate id_token against, such as issuer, audience, etc + var optionsToValidate = {}; async.waterfall( [ - /* - * Step 1. compute metadata url - */ + /***************************************************************************** + * Step 1. Collect information from the req and save the info into params + ****************************************************************************/ (next) => { - // B2C interception - let metadataUrl = self._options.identityMetadata; - - // use 'ordinary' as the default cachekey, in the case of B2C, we will - // change the value to policy later - let cachekey = 'ordinary'; - - // We listen for the p paramter in any response and set it. If it has been set already and in memory (profile) we skip this as it's not necessary to set again. - // @TODO when forceB2C is set but no req.query.p is present, the policy variable would be 'undefined' - if (req.query.p || options.forceB2C) { - log.info('B2C: Found a policy inside of the login request. This is a B2C tenant!'); - - if (!self._options.tenantName) { - log.error(`For B2C you must specify a tenant name, none was presented to Strategy as required. - (example: tenantName:contoso.onmicrosoft.com)`); - return next(new TypeError(`OIDCStrategy requires you specify a tenant name to - Strategy if using a B2C tenant. (example: tenantName:contoso.onmicrosoft.com')`)); - } - - // @TODO: could be undefined - const policy = req.query.p; - - metadataUrl = self._options.identityMetadata - .replace('common', self._options.tenantName) - .concat(`?p=${policy}`) - .concat(`&${aadutils.getLibraryProductParameterName()}=${aadutils.getLibraryProduct()}`) - .concat(`&${aadutils.getLibraryVersionParameterName()}=${aadutils.getLibraryVersion()}`); - - cachekey = 'policy: ' + policy; // this policy will become cache key. - - log.info('B2C: New Metadata url provided to Strategy was: ', metadataUrl); - } - else - { - metadataUrl = metadataUrl.concat(`?${aadutils.getLibraryProductParameterName()}=${aadutils.getLibraryProduct()}`) - .concat(`&${aadutils.getLibraryVersionParameterName()}=${aadutils.getLibraryVersion()}`); - } - - self.metadata = new Metadata(metadataUrl, 'oidc', self._options); - - return next(null, metadataUrl, cachekey); + return self.collectInfoFromReq(params, req, next); }, - /* - * Step 2. load options from metadata url - */ - (metadataUrl, cachekey, next) => { - return self.setOptions(self._options, metadataUrl, cachekey, next); + /***************************************************************************** + * 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. the following are the scenarios for the coming request - * (1) error response + /***************************************************************************** + * 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' - * (5) for any other request, we will ask for authorization and initialize the authorization process - */ + * (4) for any other request, we will ask for authorization and initialize + * the authorization process + ****************************************************************************/ (next) => { - var err, err_description, id_token, code, state; - err = err_description = id_token = code = state = null; - - // 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 self.fail('neither access token nor refresh token is expected in the incoming request'); - - // the source (query or body) to get err, id_token, code etc - 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) { - err = source.error; - err_description = source.error_description; - id_token = source.id_token; - code = source.code; - state = source.state; - } - - if (!err && !id_token && !code) { + if (params.err) { + // handle the error + return self._errorResponseHandler(params.err, params.err_description); + } else if (!params.id_token && !params.code) { // ask for authorization, initialize the authorization process - return self._flowInitializationHandler(req, next); - } - - // find the {state: x, nonce: x, policy: x, timeStamp: x} tuple by state from the session - if (!state) - return self.fail('state is missing in the request'); - var tuple = self._sessionContentHandler.findAndDeleteTupleByState(req, self._key, state); - if (!tuple) - return self.fail('state provided in the request is not recognized'); - - if (err) { - // handle error response - return self._errorResponseHandler(err, err_description); - } else if (id_token && code) { + return self._flowInitializationHandler(oauthConfig, req, next); + } else if (params.id_token && params.code) { // handle hybrid flow - return self._hybridFlowHandler(id_token, code, tuple, req, next); - } else if (id_token) { + return self._hybridFlowHandler(params, oauthConfig, optionsToValidate, req, next); + } else if (params.id_token) { // handle implicit flow - return self._implicitFlowHandler(id_token, tuple, req, next); + return self._implicitFlowHandler(params, optionsToValidate, req, next); } else { // handle authorization code flow - return self._authCodeFlowHandler(code, tuple, req, next); + return self._authCodeFlowHandler(params, oauthConfig, optionsToValidate, req, next); } } ], @@ -480,143 +552,197 @@ Strategy.prototype.authenticate = function authenticateStrategy(req, options) { (waterfallError) => { // this code gets called after the three steps above are done if (waterfallError) { - return self.error(waterfallError); + return self.failWithLog(`In authenticate: ${aadutils.getErrorMessage(waterfallError)}`); } return true; }); }; /** - * Pick the parameters required for oauth flow + * Collect information from the request, for instance, code, err, id_token etc * - * @param {options} parameters from metadata and user config file + * @param {Object} params + * @param {Object} req + * @param {Object} next */ -Strategy.prototype.configOauth = function configOauth(options) { - this._options._configForOauth = objectTransform({ - source: options, - pick: [ - 'algorithms', - 'authorizationURL', - 'callbackURL', - 'clientID', - 'clientSecret', - 'identifierField', - 'oidcIssuer', - 'passReqToCallback', - 'resourceURL', - 'responseMode', - 'responseType', - 'revocationURL', - 'scope', - 'scopeSeparator', - 'tokenInfoURL', - 'tokenURL', - 'userInfoURL', - ], - }); -} +Strategy.prototype.collectInfoFromReq = function(params, req, next) { + 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 self.failWithLog('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 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 self.failWithLog('In collectInfoFromReq: missing state in the request'); + var tuple = self._sessionContentHandler.findAndDeleteTupleByState(req, self._key, params.state); + if (!tuple) + return self.failWithLog('In collectInfoFromReq: invalid state received in the request'); + params.nonce = tuple['nonce']; + params.policy = tuple['policy']; + } else { + params.policy = req.query.p; + } + + // for B2C, we must have policy + if (self._options.isB2C && !params.policy) + return self.failWithLog('In collectInfoFromReq: policy is missing'); + + // ------------------------------------------------------------------------- + // 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 (self._options.isB2C) { + metadataUrl = metadataUrl.concat(`&p=${params.policy}`); + params.cachekey = 'policy: ' + params.policy; // B2C metadata cachekey + log.info(`B2C metadataUrl is: ${metadataUrl}`); + } else { + params.cachekey = 'ordinary'; // non B2C metadata cachekey + log.info(`metadataUrl is: ${metadataUrl}`); + } + + params.metadata = new Metadata(metadataUrl, 'oidc', self._options); + + log.info(`received the following items in params: ${JSON.stringify(params)}`); + + return next(); +}; /** - * Load options from metadata to be included in the authorization request. - * - * Some OpenID Connect providers allow additional, non-standard parameters to be - * included when requesting authorization. Since these parameters are not - * standardized by the OpenID Connect specification, OpenID Connect-based - * authentication strategies can overrride this function in order to populate - * these parameters as required by the provider. + * Set the information we need for oauth flow and id_token validation * - * @param {Object} options - * @return {Object} options + * @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(options, metadataUrl, cachekey, done) { +Strategy.prototype.setOptions = function setOptions(params, oauthConfig, optionsToValidate, done) { const self = this; - // Loading metadata from endpoint. async.waterfall([ - // fetch the metadata - function loadMetadata(next) { - log.info('Parsing Metadata: ', metadataUrl); - - memoryCache.wrap(cachekey, (cacheCallback) => { - self.metadata.fetch((fetchMetadataError) => { + // ------------------------------------------------------------------------ + // load metadata + // ------------------------------------------------------------------------ + (next) => { + memoryCache.wrap(params.cachekey, (cacheCallback) => { + params.metadata.fetch((fetchMetadataError) => { if (fetchMetadataError) { - return cacheCallback(new Error(`Unable to fetch metadata: ${fetchMetadataError}`)); + return self.failWithLog(`In setOptions: Unable to fetch metadata: ${aadutils.getErrorMessage(fetchMetadataError)}`); } - return cacheCallback(null, self.metadata); + return cacheCallback(null, params.metadata); }); }, { ttl }, next); }, - // merge fetched metadata with options - function loadOptions(metadata, next) { - self.metadata = metadata; - // fetched metadata always takes precedence over configured options - - // use default values where no option is present - const opts = { - identifierField: 'openid_identifier', // What's the recommended field name for OpenID Connect? - scopeSeparator: ' ', - tokenInfoURL: null, - }; + // ------------------------------------------------------------------------ + // set oauthConfig: the information we need for oauth flow like redeeming code/access_token + // ------------------------------------------------------------------------ + (metadata, next) => { + if (!metadata.oidc) + return self.failWithLog('In setOptions: failed to load metadata'); + params.metadata = metadata; + + // copy the fields needed into 'oauthConfig' + aadutils.copyObjectFields(metadata.oidc, oauthConfig, ['auth_endpoint', 'token_endpoint', 'userinfo_endpoint']); + aadutils.copyObjectFields(self._options, oauthConfig, ['clientID', 'clientSecret', 'responseType', 'responseMode', 'scope', 'redirectUrl']); - const pickedFromOptions = objectTransform({ - source: options, - pick: [ - 'callbackURL', - 'clientID', - 'clientSecret', - 'identifierField', - 'passReqToCallback', - 'resourceURL', - 'responseMode', - 'responseType', - 'scope', - 'scopeSeparator', - ], - }); - - // pick values from fetched metadata - const pickedFromMetadata = objectTransform({ - source: metadata.oidc, - map: { - auth_endpoint: 'authorizationURL', - end_session_endpoint: 'revocationURL', - issuer: 'oidcIssuer', - token_endpoint: 'tokenURL', - tokeninfo_endpoint: 'tokenInfoURL', - userinfo_endpoint: 'userInfoURL', - }, - pick: [ - 'algorithms' - ] - }); - - _.assign(opts, pickedFromOptions, pickedFromMetadata); - - // Now that we have our options for configuration, let's check them for issues. + // validate oauthConfig const validatorConfig = { - authorizationURL: Validator.isHttpsURL, - tokenURL: Validator.isHttpsURL, - algorithms: Validator.isNonEmpty + auth_endpoint: Validator.isHttpsURL, + token_endpoint: Validator.isHttpsURL, + userinfo_endpoint: Validator.isHttpsURLIfExists, }; - // validator will throw exception if a required option is missing - const checker = new Validator(validatorConfig); - checker.validate(opts); + try { + // validator will throw exception if a required option is missing + const checker = new Validator(validatorConfig); + checker.validate(oauthConfig); + } catch (ex) { + return self.failWithLog(`In setOptions: ${aadutils.getErrorMessage(ex)}`); + } - // set the oidcIssuer - self._options.oidcIssuer = opts.oidcIssuer; - self._options.algorithms = opts.algorithms; + // 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.auth_endpoint, params.policy)) + return self.failWithLog('policy in ${oauthConfig.auth_endpoint} should be ${params.policy}'); + if (!policyChecker(oauthConfig.token_endpoint, params.policy)) + return self.failWithLog('policy in ${oauthConfig.token_endpoint} should be ${params.policy}'); + } - next(null, opts); + next(null, metadata); }, - // push merged options to self._configurers for later use - function setConfiguration(opts, next) { - log.info('Setting parameters for oauth', opts); - self.configOauth(opts); + // ------------------------------------------------------------------------ + // 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) + next(null); + + // set items from self._options + aadutils.copyObjectFields(self._options, optionsToValidate, + ['validateIssuer', 'audience', 'allowMultiAudiencesInToken', 'ignoreExpiration']); + + // algorithms + var algorithms = metadata.oidc.algorithms; + if (!algorithms) + return self.fail('In setOptions: algorithms is missing in metadata'); + if (!Array.isArray(algorithms) || algorithms.length == 0 || (algorithms.length === 1 && algorithms[0] === 'none')) + return self.fail('In setOptions: algorithms must be an array containing at least one algorithm'); + optionsToValidate.algorithms = algorithms; + + // nonce + optionsToValidate.nonce = params.nonce; + + // issuer + if (self._options.isCommonEndpoint) + optionsToValidate.issuer = self._options.issuer; + else + optionsToValidate.issuer = self._options.issuer || metadata.oidc.issuer; - next(); + next(null); }, ], done); }; @@ -625,78 +751,71 @@ Strategy.prototype.setOptions = function setOptions(options, metadataUrl, cachek * 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} id_token - * @param {String} code (if you want to validate c_hash) - * @param {String} access_token (if you want to validate at_hash) - * @param {String} nonce + * @param {String} params + * @param {String} optionsToValidate * @param {Object} req * @param {Function} callback */ -Strategy.prototype._validateResponse = function validateResponse(id_token, code, access_token, nonce, req, callback) { +Strategy.prototype._validateResponse = function validateResponse(params, optionsToValidate, req, 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 self. fail(null, false, 'Invalid JWT token'); + return self.failWithLog('In _validateResponse: Invalid JWT token'); log.info('token decoded: ', decoded); // get Pem Key var PEMkey = null; if (decoded.header.kid) { - PEMkey = self.metadata.generateOidcPEM(decoded.header.kid); + PEMkey = params.metadata.generateOidcPEM(decoded.header.kid); } else if (decoded.header.x5t) { - PEMkey = self.metadata.generateOidcPEM(decoded.header.x5t); + PEMkey = params.metadata.generateOidcPEM(decoded.header.x5t); } else { - return self.fail('We did not receive a token we know how to validate'); - } - - var options = self._options; - - // if user didn't set audience use clientID by default - if (!options.audience) - options.audience = options.clientID; - // since we are asking token for ourselves, we don't allow multiple audiences in id_token - options.allowMultiAudiencesInToken = false; - - // if the user wants to validate issuer, we must have it - if (options.validateIssuer) { - options.issuer = options.oidcIssuer; - if (!options.issuer) - return self.fail("options.validateIssuer is true, but options.oidcIssuer is null."); + return self.failWithLog('In _validateResponse: We did not receive a token we know how to validate'); } + log.info('PEMkey generated: ' + PEMkey); // verify id_token signature and claims - return jwt.verify(id_token, PEMkey, options, (err, jwtClaims) => { - if (err) { - if (err.message) - return self.fail(err.message); - else - return self.fail("cannot verify id token"); - } + return jwt.verify(id_token, PEMkey, optionsToValidate, (err, jwtClaims) => { + if (err) + return self.failWithLog(`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 - // check the nonce in claims - if (!jwtClaims.nonce || jwtClaims.nonce !== nonce) - return self.fail('invalid nonce'); + // For B2C, check the policy + if (self._options.isB2C) { + if (!jwtClaims.acr || jwtClaims.acr.length <= 6 || jwtClaims.acr.substring(0,6).toUpperCase() !== 'B2C_1_') + return self.failWithLog('In _validateResponse: invalid B2C policy in id_token'); + + if (params.policy !== jwtClaims.acr) + return self.failWithLog("In _validateResponse: acr in id_token does not match the policy used"); + } + + // check nonce + if (!jwtClaims.nonce || jwtClaims.nonce === '' || jwtClaims.nonce !== optionsToValidate.nonce) + return self.failWithLog('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 self.fail("invalid c_hash"); + return self.failWithLog("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 self.fail("invalid at_hash"); + return self.failWithLog("In _validateResponse: invalid at_hash"); } // return jwt claims and jwt claims string @@ -722,18 +841,18 @@ Strategy.prototype._errorResponseHandler = function errorResponseHandler(err, er // 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. - return self.fail(err); + return self.failWithLog(err); }; /** * handle the response where we only get 'id_token' in the response * - * @params {Object} id_token - * @params {Object} tuple -- state/nonce/policy/timeStamp tuple + * @params {Object} params + * @params {Object} optionsToValidate * @params {Object} req * @params {Function} next */ -Strategy.prototype._implicitFlowHandler = function implicitFlowHandler(id_token, tuple, req, next) { +Strategy.prototype._implicitFlowHandler = function implicitFlowHandler(params, optionsToValidate, req, next) { /* we will do the following things in order * (1) validate id_token * (2) use the claims in the id_token for user's profile @@ -741,17 +860,12 @@ Strategy.prototype._implicitFlowHandler = function implicitFlowHandler(id_token, const self = this; - log.info('entering Strategy.prototype._implicitFlowHandler, received id_token: ' + id_token); + log.info('entering Strategy.prototype._implicitFlowHandler, received id_token: ' + params.id_token); // validate the id_token - return self._validateResponse(id_token, null, null, tuple.nonce, req, (jwtClaimsStr, jwtClaims) => { + return self._validateResponse(params, optionsToValidate, req, (jwtClaimsStr, jwtClaims) => { const sub = jwtClaims.sub; const iss = jwtClaims.iss; - - // we are not doing auth code so we set the tokens to null - const access_token = null; - const refresh_token = null; - const params = null; log.info('we are in implicit flow, use the content in id_token as the profile'); @@ -761,9 +875,9 @@ Strategy.prototype._implicitFlowHandler = function implicitFlowHandler(id_token, iss, profile: makeProfileObject(jwtClaims, jwtClaimsStr), jwtClaims, - access_token, - refresh_token, - params, + access_token: null, + refresh_token: null, + params: null }); }); }; @@ -771,13 +885,13 @@ Strategy.prototype._implicitFlowHandler = function implicitFlowHandler(id_token, /** * handle the response where we get 'id_token' and 'code' in the response * - * @params {Object} id_token - * @params {Object} code - * @params {Object} tuple -- state/nonce/policy/timeStamp tuple + * @params {Object} params + * @params {Object} oauthConfig + * @params {Object} optionsToValidate * @params {Object} req * @params {Function} next */ -Strategy.prototype._hybridFlowHandler = function hybridFlowHandler(id_token, code, tuple, req, next) { +Strategy.prototype._hybridFlowHandler = function hybridFlowHandler(params, oauthConfig, optionsToValidate, req, next) { /* we will do the following things in order * (1) validate the id_token and the code * (2) if there is no userinfo token needed (or ignored if using AAD v2 ), we use @@ -786,30 +900,34 @@ Strategy.prototype._hybridFlowHandler = function hybridFlowHandler(id_token, cod */ const self = this; - log.info('entering Strategy.prototype._hybridFlowHandler, received code: ' + code + ', received id_token: ' + id_token); + log.info('entering Strategy.prototype._hybridFlowHandler, received code: ' + params.code + ', received id_token: ' + params.id_token); + + // save nonce, since if we use the authorization code flow later, we have to check + // nonce again. // validate the id_token and the code - return self._validateResponse(id_token, code, null, tuple.nonce, req, (jwtClaimsStr, jwtClaims) => { + return self._validateResponse(params, optionsToValidate, req, (jwtClaimsStr, jwtClaims) => { // c_hash is required for 'code id_token' flow. If we have c_hash, then _validateResponse already // validates it; otherwise, _validateResponse ignores the c_hash check, and we check here if (!jwtClaims.c_hash) - return self.fail("we are in hybrid flow using code id_token, but c_hash is not found in id_token"); + return self.failWithLog("In _hybridFlowHandler: we are in hybrid flow using code id_token, but c_hash is not found in id_token"); const sub = jwtClaims.sub; const iss = jwtClaims.iss; // now we use the authorization code flow - return self._authCodeFlowHandler(code, tuple, req, next, iss, sub); + return self._authCodeFlowHandler(params, oauthConfig, optionsToValidate, req, next, iss, sub); }); }; /** * handle the response where we only get 'code' in the response * - * @params {Object} code - * @params {Object} tuple -- state/nonce/policy/timeStamp tuple + * @params {Object} params + * @params {Object} oauthConfig + * @params {Object} optionsToValidate * @params {Object} req - * @params {Function} next + * @params {Function} nextt * // the following are required if you used 'code id_token' flow then call this function to * // redeem the code for another id_token from the token endpoint. iss and sub are those * // in the id_token from authorization endpoint, and they should match those in the id_token @@ -817,7 +935,7 @@ Strategy.prototype._hybridFlowHandler = function hybridFlowHandler(id_token, cod * @params {String} iss * @params {String} sub */ -Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tuple, req, next, iss, sub) { +Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(params, oauthConfig, optionsToValidate, req, next, iss, sub) { /* we will do the following things in order: * (1) use code to get id_token and access_token * (2) validate the id_token and the access_token received @@ -825,6 +943,7 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup * userinfo, then make sure the userinfo has the same 'sub' as that in the 'id_token' */ const self = this; + var code = params.code; log.info('entering Strategy.prototype._authCodeFlowHandler, received code: ' + code); @@ -836,60 +955,47 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup let libraryProduct = aadutils.getLibraryProduct(); let libraryProductParameterName = aadutils.getLibraryProductParameterName(); - const config = self._options._configForOauth; - const oauth2 = new OAuth2( - config.clientID, // consumerKey - config.clientSecret, // consumer secret + oauthConfig.clientID, // consumerKey + oauthConfig.clientSecret, // consumer secret '', // baseURL (empty string because we use absolute urls for authorize and token paths) - config.authorizationURL, // authorizePath - config.tokenURL, // accessTokenPath + oauthConfig.auth_endpoint, // authorizePath + oauthConfig.token_endpoint, // accessTokenPath {libraryProductParameterName : libraryProduct, libraryVersionParameterName : libraryVersion} // customHeaders ); - let callbackURL = config.callbackURL; - // options.callbackURL is merged into config object while `setOptions` call - if (!callbackURL) { - return next(new Error('no callbackURL found')); - } - - const parsedCallbackURL = url.parse(callbackURL); - if (!parsedCallbackURL.protocol) { - // The callback URL is relative, resolve a fully qualified URL from the - // URL of the originating request. - callbackURL = url.resolve(aadutils.originalURL(req), callbackURL); - } - return oauth2.getOAuthAccessToken(code, { grant_type: 'authorization_code', - redirect_uri: callbackURL, - }, (getOAuthAccessTokenError, access_token, refresh_token, params) => { - if (getOAuthAccessTokenError) { - return next(new InternalOAuthError('failed to obtain access token', getOAuthAccessTokenError)); - } - - var id_token = params.id_token; + redirect_uri: oauthConfig.redirectUrl, + scope: oauthConfig.scope + }, (getOAuthAccessTokenError, access_token, refresh_token, items) => { + if (getOAuthAccessTokenError) + return self.failWithLog(`In _authCodeFlowHandler: failed to redeem authorization code: ${aadutils.getErrorMessage(getOAuthAccessTokenError)}`); // id_token should be present - if (!id_token) - return self.fail('id_token is not received'); + if (!items.id_token) + return self.failWithLog('In _authCodeFlowHandler: id_token is not received'); // token_type must be 'Bearer' - if (params.token_type !== 'Bearer') { - log.info('token_type received is: ', params.token_type); - return self.fail(`token_type received is not 'Bearer'`); + if (items.token_type !== 'Bearer') { + log.info('token_type received is: ', items.token_type); + return self.failWithLog(`In _authCodeFlowHandler: token_type received is not 'Bearer'`); } - log.info('received id_token: ', id_token); + log.info('received id_token: ', items.id_token); + + // validate id_token and access_token, so put them into params + params.access_token = access_token; + params.id_token = items.id_token; - return self._validateResponse(id_token, null, access_token, tuple.nonce, req, (jwtClaimsStr, jwtClaims) => { + return self._validateResponse(params, optionsToValidate, req, (jwtClaimsStr, jwtClaims) => { // for 'code id_token' flow, check iss/sub in the id_token from the authorization endpoint // with those in the id_token from token endpoint if (issFromPrevIdToken && issFromPrevIdToken !== jwtClaims.iss) - return self.fail('After redeeming the code, iss in id_token from authorize_endpoint does not match iss in id_token from token_endpoint'); + return self.failWithLog('In _authCodeFlowHandler: After redeeming the code, iss in id_token from authorize_endpoint does not match iss in id_token from token_endpoint'); if (subFromPrevIdToken && subFromPrevIdToken !== jwtClaims.sub) - return self.fail('After redeeming the code, iss in id_token from authorize_endpoint does not match iss in id_token from token_endpoint'); + return self.failWithLog('In _authCodeFlowHandler: After redeeming the code, sub in id_token from authorize_endpoint does not match sub in id_token from token_endpoint'); const sub = jwtClaims.sub; const iss = jwtClaims.iss; @@ -898,18 +1004,13 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup if (!self._options._isV2) { // make sure we get an access_token if (!access_token) - return self.fail("we want to access userinfo endpoint, but access_token is not received"); + return self.failWithLog("In _authCodeFlowHandler: we want to access userinfo endpoint, but access_token is not received"); let parsedUrl; try { - parsedUrl = url.parse(config.userInfoURL, true); + parsedUrl = url.parse(oauthConfig.userinfo_endpoint, true); } catch (urlParseException) { - return next( - new InternalOpenIDError( - `Failed to parse config property 'userInfoURL' with value ${config.userInfoURL}`, - urlParseException - ) - ); + return self.failWithLog(`In _authCodeFlowHandler: Failed to parse config property 'userInfoURL' with value ${oauthConfig.userinfo_endpoint}`); } parsedUrl.query.schema = 'openid'; @@ -919,9 +1020,8 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup // ask oauth2 to use authorization header to bearer access token oauth2.useAuthorizationHeaderforGET(true); return oauth2.get(userInfoURL, access_token, (getUserInfoError, body) => { - if (getUserInfoError) { - return next(new InternalOAuthError('failed to fetch user profile', getUserInfoError)); - } + if (getUserInfoError) + return self.failWithLog(`In _authCodeFlowHandler: failed to fetch user profile: ${aadutils.getErrorMessage(getUserInfoError)}`); log.info('Profile loaded from MS identity', body); @@ -930,14 +1030,12 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup try { userinfoReceived = JSON.parse(body); } catch (ex) { - return next(ex); + return self.failWithLog(`In _authCodeFlowHandler: failed to parse userinfo ${body}, due to ${aadutils.getErrorMessage(ex)}`); } // make sure the 'sub' in userinfo is the same as the one in 'id_token' - if (userinfoReceived.sub !== jwtClaims.sub) { - log.error('sub in userinfo is ' + userinfoReceived.sub + ', but does not match sub in id_token, which is ' + id_token.sub); - return self.fail('sub received in userinfo and id_token do not match'); - } + if (userinfoReceived.sub !== jwtClaims.sub) + return self.failWithLog('In _authCodeFlowHandler: sub received in userinfo and id_token do not match'); return onProfileLoaded(self, { req, @@ -947,7 +1045,7 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup jwtClaims, access_token, refresh_token, - params, + params: items, }); }); } else { @@ -962,7 +1060,7 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup jwtClaims, access_token, refresh_token, - params, + params: items, }); } }); @@ -972,10 +1070,11 @@ Strategy.prototype._authCodeFlowHandler = function authCodeFlowHandler(code, tup /** * prepare the initial authorization request * + * @params {Object} oauthConfig * @params {Object} req * @params {Function} next */ -Strategy.prototype._flowInitializationHandler = function flowInitializationHandler(req, next) { +Strategy.prototype._flowInitializationHandler = function flowInitializationHandler(oauthConfig, req, next) { // The request being authenticated is initiating OpenID Connect // authentication. Prior to redirecting to the provider, configuration will // be loaded. The configuration is typically either pre-configured or @@ -986,78 +1085,55 @@ Strategy.prototype._flowInitializationHandler = function flowInitializationHandl log.info(`entering Strategy.prototype._flowInitializationHandler`); - let identifier; - if (req.body && req.body[this._identifierField]) { - identifier = req.body[this._identifierField]; - } else if (req.query && req.query[this._identifierField]) { - identifier = req.query[this._identifierField]; - } - - var options = self._options; - var config = options._configForOauth; - - let callbackURL = options.callbackURL || config.callbackURL; - if (callbackURL) { - const parsed = url.parse(callbackURL); - if (!parsed.protocol) { - // The callback URL is relative, resolve a fully qualified URL from the - // URL of the originating request. - callbackURL = url.resolve(aadutils.originalURL(req), callbackURL); - } - } - - log.info('Going in with our config loaded as: ', config); - - const params = {}; - if (self.authorizationParams) { - _.assign(params, self.authorizationParams(options)); - } - _.assign(params, { - redirect_uri: callbackURL, - }, objectTransform({ - source: config, - map: { - responseMode: 'response_mode', - responseType: 'response_type', - clientID: 'client_id', - resourceURL: 'resource', - }, - })); + const params = { + 'redirect_uri': oauthConfig.redirectUrl, + 'response_type': oauthConfig.responseType, + 'response_mode': oauthConfig.responseMode, + 'client_id': oauthConfig.clientID + }; log.info('We are sending the response_type: ', params.response_type); log.info('We are sending the response_mode: ', params.response_mode); - let scope = config.scope; - if (Array.isArray(scope)) { - scope = scope.join(config.scopeSeparator); + var policy = null; + if (self._options.isB2C) { + if (!req.query.p || req.query.p === '') + return self.failWithLog('In _flowInitializationHandler: missing policy in the request for B2C'); + // policy is not case sensitive. AAD turns policy to lower case. + policy = req.query.p.toLowerCase(); + if (!policy.startsWith('b2c_1_')) + return self.failWithLog(`In _flowInitializationHandler: the given policy ${policy} given in the request is invalid`); } - if (scope) { - params.scope = ['openid', scope].join(config.scopeSeparator); - } else { - params.scope = 'openid'; - } - - // @TODO: Policy parameter should be included. - // if (policy) { params['p'] = policy; }; - // add state/nonce/policy/timeStamp tuple to session let state = params.state = aadutils.uid(32); let nonce = params.nonce = aadutils.uid(32); + self._sessionContentHandler.add(req, self._key, {state: state, nonce: nonce, policy: policy, timeStamp: Date.now()}); - self._sessionContentHandler.add(req, self._key, {state: state, nonce: nonce, policy: req.query.p, timeStamp: Date.now()}); + // add scope + params.scope = oauthConfig.scope; + // add telemetry params[aadutils.getLibraryProductParameterName()] = aadutils.getLibraryProduct(); params[aadutils.getLibraryVersionParameterName()] = aadutils.getLibraryVersion(); let location; // Implement support for standard OpenID Connect params (display, prompt, etc.) - if (req.query.p) { - location = `${config.authorizationURL}&${querystring.stringify(params)}`; - } else { - location = `${config.authorizationURL}?${querystring.stringify(params)}`; - } + if (self._options.isB2C) + location = `${oauthConfig.auth_endpoint}&${querystring.stringify(params)}`; + else + location = `${oauthConfig.auth_endpoint}?${querystring.stringify(params)}`; return self.redirect(location); }; +/** + * fail and log the given message + * + * @params {String} message + */ +Strategy.prototype.failWithLog = function(message) { + this.log.info(`authentication failed due to: ${message}`); + return this.fail(message); +}; + module.exports = Strategy; diff --git a/lib/validator.js b/lib/validator.js index ae5a97bf..f00cfe76 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -101,4 +101,15 @@ types.isHttpsURL = { error: 'The URL must be valid and be https://', }; +Validator.isHttpsURLIfExists = 'isHttpsURLIfExists'; +types.isHttpsURLIfExists = { + validate: (value) => { + if (value) + return UrlValidator.isHttpsUri(value); + else + return true; + }, + error: 'The URL must be valid and be https://', +}; + exports.Validator = Validator; diff --git a/test/Chai-passport_test/b2c_oidc_implicit_flow_test.js b/test/Chai-passport_test/b2c_oidc_implicit_flow_test.js new file mode 100644 index 00000000..f12b9c1d --- /dev/null +++ b/test/Chai-passport_test/b2c_oidc_implicit_flow_test.js @@ -0,0 +1,184 @@ +/** + * 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 */ + +var chai = require('chai'); +var url = require('url'); +chai.use(require('chai-passport-strategy')); + +var Metadata = require('../../lib/metadata').Metadata; +var OIDCStrategy = require('../../lib/index').OIDCStrategy; + +var nonce = 'OKK4cd2ftdqsj4n0Gr+GCHOMH6cJ00oO'; +var policy = 'b2c_1_signin'; +var id_token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IklkVG9rZW5TaWduaW5nS2V5Q29udGFpbmVyLnYyIn0.eyJleHAiOjE0NzQ2OTY0MjQsIm5iZiI6MTQ3NDY5MjgyNCwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tLzIyYmY0MGM2LTExODYtNGVhNS1iNDliLTNkYzRlYzBmNTRlYi92Mi4wLyIsInN1YiI6Ik5vdCBzdXBwb3J0ZWQgY3VycmVudGx5LiBVc2Ugb2lkIGNsYWltLiIsImF1ZCI6ImYwYjZlNGViLTJkOGMtNDBiNi1iOWM2LWUyNmQxMDc0ODQ2ZCIsImFjciI6ImIyY18xX3NpZ25pbiIsIm5vbmNlIjoiT0tLNGNkMmZ0ZHFzajRuMEdyK0dDSE9NSDZjSjAwb08iLCJpYXQiOjE0NzQ2OTI4MjQsImF1dGhfdGltZSI6MTQ3NDY5MjgyNCwib2lkIjoiNDMyOWQ2YmMtMGY4NC00NWQ4LTg3MDktMmM4YjA5MTM1N2QxIiwiZW1haWxzIjpbInNpanVuLndvcmtAZ21haWwuY29tIl19.BzKOUVnE6s6c03CFkS1DceJNvwXwHXE4IlXxXJyjNrD6LGKoMnRqI2mFzylCpjib4QM7byjHLs6MumwjrIR4iu_m-ryU6_2NMB0ry8cVCzm7g3QQklNGlsGAeHT69yl8TBqQpUCB71NoDu830nTcLwzN490id4RiWlTiJboyCkOHGZ36hMd4L-9qR-GtWKIJQR8-bgZRjS9vysaUQigIyMEaZzqQ3HBF1gq1euXLfiL_QAaFVay1CvT3kcvFN7wUfdMP6QvpwnzKTQW3CSpLbQlcxdc1bsNWvnd9d6ASxZVHMSxljJ7ZK0YHg6mUCDEH3r4nK9Sdvy_CHeKKOuPZtQ'; +// change the last character in id_token to make a id_token with wrong signature +var id_token_wrong_signature = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IklkVG9rZW5TaWduaW5nS2V5Q29udGFpbmVyLnYyIn0.eyJleHAiOjE0NzQ2OTY0MjQsIm5iZiI6MTQ3NDY5MjgyNCwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tLzIyYmY0MGM2LTExODYtNGVhNS1iNDliLTNkYzRlYzBmNTRlYi92Mi4wLyIsInN1YiI6Ik5vdCBzdXBwb3J0ZWQgY3VycmVudGx5LiBVc2Ugb2lkIGNsYWltLiIsImF1ZCI6ImYwYjZlNGViLTJkOGMtNDBiNi1iOWM2LWUyNmQxMDc0ODQ2ZCIsImFjciI6ImIyY18xX3NpZ25pbiIsIm5vbmNlIjoiT0tLNGNkMmZ0ZHFzajRuMEdyK0dDSE9NSDZjSjAwb08iLCJpYXQiOjE0NzQ2OTI4MjQsImF1dGhfdGltZSI6MTQ3NDY5MjgyNCwib2lkIjoiNDMyOWQ2YmMtMGY4NC00NWQ4LTg3MDktMmM4YjA5MTM1N2QxIiwiZW1haWxzIjpbInNpanVuLndvcmtAZ21haWwuY29tIl19.BzKOUVnE6s6c03CFkS1DceJNvwXwHXE4IlXxXJyjNrD6LGKoMnRqI2mFzylCpjib4QM7byjHLs6MumwjrIR4iu_m-ryU6_2NMB0ry8cVCzm7g3QQklNGlsGAeHT69yl8TBqQpUCB71NoDu830nTcLwzN490id4RiWlTiJboyCkOHGZ36hMd4L-9qR-GtWKIJQR8-bgZRjS9vysaUQigIyMEaZzqQ3HBF1gq1euXLfiL_QAaFVay1CvT3kcvFN7wUfdMP6QvpwnzKTQW3CSpLbQlcxdc1bsNWvnd9d6ASxZVHMSxljJ7ZK0YHg6mUCDEH3r4nK9Sdvy_CHeKKOuPZtM'; + +// Mock the process of getting PEMkey +var PEMkey = "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAs4W7xjkQZP3OwG7PfRgcYKn8eRYXHiz1iK503fS+K2FZo+Ublwwa\n2xFZWpsUU/jtoVCwIkaqZuo6xoKtlMYXXvfVHGuKBHEBVn8b8x/57BQWz1d0KdrN\nXxuMvtFe6RzMqiMqzqZrzae4UqVCkYqcR9gQx66Ehq7hPmCxJCkg7ajo7fu6E7dP\nd34KH2HSYRsaaEA/BcKTeb9H1XE/qEKjog68wUU9Ekfl3FBIRN+1Ah/BoktGFoXy\ni/jt0+L0+gKcL1BLmUlGzMusvRbjI/0+qj+mc0utGdRjY+xIN2yBj8vl4DODO+wM\nwfp+cqZbCd9TENyHaTb8iA27s+73L3ExOQIDAQAB\n-----END RSA PUBLIC KEY-----\n"; + +/* + * test strategy (for response_type = 'id_token') which checks the expiration of id_token + */ + +var options = { + redirectUrl: 'http://localhost:3000/auth/openid/return', + clientID: 'f0b6e4eb-2d8c-40b6-b9c6-e26d1074846d', + identityMetadata: 'https://login.microsoftonline.com/sijun1b2c.onmicrosoft.com/v2.0/.well-known/openid-configuration', + responseType: 'id_token', + responseMode: 'form_post', + validateIssuer: true, + passReqToCallback: false, + isB2C: true, + sessionKey: 'my_key', + ignoreExpiration: true, + issuer: 'https://login.microsoftonline.com/22bf40c6-1186-4ea5-b49b-3dc4ec0f54eb/v2.0/', +}; + +var testStrategy = new OIDCStrategy(options, function(profile, done) { + done(null, profile.oid); +}); + +/* + * Begin the testing + */ +var challenge; +var user; + +var setIgnoreExpirationFalse = function(options) { options.ignoreExpiration = false; }; +var setWrongIssuer = function(options) { options.issuer = 'wrong_issuer'; }; +var rmValidateIssuer = function(options) { options.validateIssuer = undefined; }; + +var testPrepare = function(id_token_to_use, nonce_to_use, policy_to_use, action) { + return function(done) { + // Mock `setOptions` + testStrategy.setOptions = function(params, oauthConfig, optionsToValidate, done) { + params.metadata.generateOidcPEM = () => { return PEMkey; }; + + oauthConfig.auth_endpoint = 'https://login.microsoftonline.com/sijun1b2c.onmicrosoft.com/oauth2/v2.0/authorize?p=b2c_1_signin'; + oauthConfig.token_endpoint = 'https://login.microsoftonline.com/sijun1b2c.onmicrosoft.com/oauth2/v2.0/token?p=b2c_1_signin'; + + optionsToValidate.validateIssuer = true; + optionsToValidate.issuer = 'https://login.microsoftonline.com/22bf40c6-1186-4ea5-b49b-3dc4ec0f54eb/v2.0/'; + optionsToValidate.audience = 'f0b6e4eb-2d8c-40b6-b9c6-e26d1074846d'; + optionsToValidate.allowMultiAudiencesInToken = false; + optionsToValidate.ignoreExpiration = true; + optionsToValidate.algorithms = ['RS256']; + optionsToValidate.nonce = nonce_to_use; + + if (action) { + for (let i = 0; i < action.length; i++) + action[i](optionsToValidate); + } + return done(); + }; + + chai.passport + .use(testStrategy) + .fail(function(c) { + challenge = c; done(); + }) + .success(function(u) { + user = u; done(); + }) + .req(function(req) { + // reset the value of challenge and user + challenge = user = undefined; + var time = Date.now(); + // add state and nonce to session + req.session = {'my_key': {'content': [{'state': 'my_state', 'nonce': nonce_to_use, 'policy': policy_to_use, 'timeStamp': time}]}}; + // add id_token and state to body + req.body = {'id_token': id_token_to_use, 'state' : 'my_state'}; + // empty query + req.query = {}; + }) + .authenticate({}); + }; +}; + +describe('B2C OIDCStrategy implicit flow test', function() { + + describe('should succeed without expiration checking', function() { + before(testPrepare(id_token, nonce, policy)); + + it('should succeed with expected user', function() { + chai.expect(user).to.equal('4329d6bc-0f84-45d8-8709-2c8b091357d1'); + }); + }); + + describe('should fail for id_token with invalid signature', function() { + before(testPrepare(id_token_wrong_signature, nonce, policy)); + + it('should fail', function() { + chai.expect(challenge).to.equal('In _validateResponse: invalid signature'); + }); + }); + + describe('should fail for id_token with wrong nonce', function() { + before(testPrepare(id_token, 'wrong_nonce', policy)); + + it('should fail', function() { + chai.expect(challenge).to.equal('In _validateResponse: invalid nonce'); + }); + }); + + describe('should fail with id_token expiration checking', function() { + before(testPrepare(id_token, nonce, policy, [setIgnoreExpirationFalse])); + + it('should fail', function() { + chai.expect(challenge).to.equal('In _validateResponse: jwt is expired'); + }); + }); + + describe('should fail with wrong issuer', function() { + // we check the issuer by default + before(testPrepare(id_token, nonce, policy, [setWrongIssuer])); + + it('should fail', function() { + chai.expect(challenge).to.equal('In _validateResponse: jwt issuer is invalid. expected: wrong_issuer'); + }); + }); + + describe('should fail with wrong issuer with default value of validateIssuer', function() { + // for non-common endpoint, we force to validate issuer + before(testPrepare(id_token, nonce, policy, [rmValidateIssuer, setWrongIssuer])); + + it('should fail', function() { + chai.expect(challenge).to.equal('In _validateResponse: jwt issuer is invalid. expected: wrong_issuer'); + }); + }); + + describe('should fail with wrong policy', function() { + // for non-common endpoint, we force to validate issuer + before(testPrepare(id_token, nonce, 'wrong policy')); + + it('should fail', function() { + chai.expect(challenge).to.equal('In _validateResponse: acr in id_token does not match the policy used'); + }); + }); +}); + diff --git a/test/Chai-passport_test/b2c_oidc_incoming_request_test.js b/test/Chai-passport_test/b2c_oidc_incoming_request_test.js new file mode 100644 index 00000000..64dfac49 --- /dev/null +++ b/test/Chai-passport_test/b2c_oidc_incoming_request_test.js @@ -0,0 +1,185 @@ +/** + * 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 restrict'; + +var chai = require('chai'); +var url = require('url'); +var OIDCStrategy = require('../../lib/index').OIDCStrategy; + +chai.use(require('chai-passport-strategy')); + +// Mock options required to create a OIDC strategy +var options = { + redirectUrl: 'http://returnURL', + clientID: 'my_client_id', + clientSecret: 'my_client_secret', + identityMetadata: 'https://login.microsoftonline.com/xxx.onmicrosoft.com/v2.0/.well-known/openid-configuration', + responseType: 'id_token', + responseMode: 'form_post', + validateIssuer: true, + passReqToCallback: false, + isB2C: true, + sessionKey: 'my_key' //optional sessionKey +}; + +var testStrategy = new OIDCStrategy(options, function(profile, done) {}); + +// Mock `setOptions` +testStrategy.setOptions = function(params, oauthConfig, optionsToValidate, done) { + oauthConfig.auth_endpoint = 'https://login.microsoftonline.com/sijun1b2c.onmicrosoft.com/oauth2/v2.0/authorize?p=b2c_1_signin'; + oauthConfig.token_endpoint = 'https://login.microsoftonline.com/sijun1b2c.onmicrosoft.com/oauth2/v2.0/token?p=b2c_1_signin'; + + done(); +}; + + +describe('OIDCStrategy incoming state and nonce checking', function() { + var redirectUrl; + var request; + var challenge; + + var testPrepare = function(policy) { + return function(done) { + chai.passport + .use(testStrategy) + .fail(function(c) { challenge = c; done(); }) + .redirect(function(u) {redirectUrl = u; done(); }) + .req(function(req) { + request = req; + req.session = {}; + req.query = {p: policy}; + }) + .authenticate({}); + }; + }; + + describe('state/nonce/policy checking', function() { + before(testPrepare('B2C_1_signin')); + + it('should have the same state/nonce/policy', function() { + var u = url.parse(redirectUrl, true); + chai.expect(request.session['my_key']['content'][0]['state']).to.equal(u.query.state); + chai.expect(request.session['my_key']['content'][0]['nonce']).to.equal(u.query.nonce); + // policy should be changed to lower case + chai.expect(request.session['my_key']['content'][0]['policy']).to.equal('b2c_1_signin'); + }); + }); + + describe('missing policy', function() { + before(testPrepare(null)); + + it('should fail if policy is missing', function() { + chai.expect(challenge).to.equal('In collectInfoFromReq: policy is missing'); + }); + }); + + describe('wrong policy', function() { + before(testPrepare('wrong_policy_not_starting_with_b2c_1_')); + + it('should fail if policy is wrong', function() { + chai.expect(challenge).to.equal('In _flowInitializationHandler: the given policy wrong_policy_not_starting_with_b2c_1_ given in the request is invalid'); + }); + }); +}); + +describe('OIDCStrategy error flow checking', function() { + var challenge; + + var testPrepare = function() { + return function(done) { + chai.passport + .use(testStrategy) + .fail(function(c) { challenge = c; done(); }) + .req(function(req) { + var time = Date.now(); + req.session = {'my_key': {'content': [{'state': 'my_state', 'nonce': 'my_nonce', 'policy': 'b2c_1_signin', 'timeStamp': time}]}}; + req.query = {}; + req.body = {state: 'my_state', error: 'my_error'}; + }) + .authenticate({}); + }; + }; + + describe('error checking', function() { + before(testPrepare()); + + it('should have the same error', function() { + chai.expect(challenge).to.equal('my_error'); + }); + }); +}); + +describe('OIDCStrategy token in request checking', function() { + var challenge; + + var testPrepare = function(access_token, refresh_token, query_or_body) { + return function(done) { + chai.passport + .use(testStrategy) + .fail(function(c) { + challenge = c; done(); + }) + .req(function(req) { + req.query = {}; + + if (query_or_body == 'query') + req.query = {'access_token' : access_token, 'refresh_token' : refresh_token}; + else if (query_or_body == 'body') + req.body = {'access_token' : access_token, 'refresh_token' : refresh_token}; + }) + .authenticate({}); + }; + }; + + describe('access_token in query', function() { + before(testPrepare('my_access_token', null, 'query')); + + it('should fail', function() { + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); + }); + }); + describe('access_token in body', function() { + before(testPrepare('my_access_token', null, 'body')); + + it('should fail', function() { + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); + }); + }); + describe('refresh_token in query', function() { + before(testPrepare('my_refresh_token', null, 'query')); + + it('should fail', function() { + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); + }); + }); + describe('refresh_token in body', function() { + before(testPrepare('my_refresh_token', null, 'body')); + + it('should fail', function() { + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); + }); + }); +}); diff --git a/test/Chai-passport_test/oidc_hybrid_and_code_flow_test.js b/test/Chai-passport_test/oidc_hybrid_and_code_flow_test.js index f202b66a..9718b5a2 100644 --- a/test/Chai-passport_test/oidc_hybrid_and_code_flow_test.js +++ b/test/Chai-passport_test/oidc_hybrid_and_code_flow_test.js @@ -59,7 +59,7 @@ KzveKf3l5UU3c6PkGy+BB3E/ChqFm6sPWwIDAQAB\n\ */ var options = { - callbackURL: 'http://localhost:3000/auth/openid/return', + redirectUrl: 'http://localhost:3000/auth/openid/return', clientID: '2abf3a52-7d86-460b-a1ef-77dc43de8aad', identityMetadata: 'https://login.microsoftonline.com/sijun.onmicrosoft.com/.well-known/openid-configuration', responseType: 'id_token code', @@ -73,40 +73,16 @@ var options = { algorithms: ['RS256'] }; -// this is used in resetOptions -var options_copy = JSON.parse(JSON.stringify(options)); - -// function used to reset options -var resetOptions = function(options) { - for (var p in options_copy) { - if (options_copy.hasOwnProperty(p)) - options[p] = options_copy[p]; - } -}; - // functions used to change the fields in options var setIgnoreExpirationFalse = function(options) { options.ignoreExpiration = false; }; -var setWrongIssuer = function(options) { options.oidcIssuer = 'wrong_issuer'; }; +var setWrongIssuer = function(options) { options.issuer = 'wrong_issuer'; }; var rmValidateIssuer = function(options) { options.validateIssuer = undefined; }; var setWrongAudience = function(options) { options.audience = 'wrong audience'; }; var testStrategy = new OIDCStrategy(options, function(profile, done) { - done(null, profile.email); + done(null, profile.emails[0]); }); -// Moch configure function -// This function is used to configure oauth2 -testStrategy.configOauth = function() { - this._options._configForOauth = { - authorizationURL: "https://login.microsoftonline.com/268da1a1-9db4-48b9-b1fe-683250ba90cc/oauth2/authorize", - callbackURL: "http://localhost:3000/auth/openid/return", - clientID: "2abf3a52-7d86-460b-a1ef-77dc43de8aad", - identifierField: "openid_identifier", - tokenURL: "https://login.microsoftonline.com/268da1a1-9db4-48b9-b1fe-683250ba90cc/oauth2/token", - userInfoURL: "https://login.microsoftonline.com/268da1a1-9db4-48b9-b1fe-683250ba90cc/openid/userinfo" , - }; -}; - // mock the userinfo endpoint response var setUserInfoResponse = function(sub_choice) { if (sub_choice === 'use_valid_sub') { @@ -146,16 +122,28 @@ var user; var setReqFromAuthRespRedirect = function(id_token_in_auth_resp, code_in_auth_resp, nonce_to_use, action) { return function(done) { // Mock `setOptions` - testStrategy.setOptions = function(options, metadata, cachekey, next) { - const self = this; - self.metadata = new Metadata(self._options.identityMetadata, 'oidc', self._options); - self.metadata.generateOidcPEM = () => { return PEMkey; }; + testStrategy.setOptions = function(params, oauthConfig, optionsToValidate, done) { + params.metadata.generateOidcPEM = () => { return PEMkey; }; + + optionsToValidate.validateIssuer = true; + optionsToValidate.issuer = 'https://sts.windows.net/268da1a1-9db4-48b9-b1fe-683250ba90cc/'; + optionsToValidate.audience = '2abf3a52-7d86-460b-a1ef-77dc43de8aad'; + optionsToValidate.allowMultiAudiencesInToken = false; + optionsToValidate.ignoreExpiration = true; + optionsToValidate.algorithms = ['RS256']; + optionsToValidate.nonce = nonce_to_use; + + oauthConfig.auth_endpoint = "https://login.microsoftonline.com/268da1a1-9db4-48b9-b1fe-683250ba90cc/oauth2/authorize"; + oauthConfig.redirectUrl = "http://localhost:3000/auth/openid/return"; + oauthConfig.clientID = "2abf3a52-7d86-460b-a1ef-77dc43de8aad"; + oauthConfig.token_endpoint = "https://login.microsoftonline.com/268da1a1-9db4-48b9-b1fe-683250ba90cc/oauth2/token"; + oauthConfig.userinfo_endpoint = "https://login.microsoftonline.com/268da1a1-9db4-48b9-b1fe-683250ba90cc/openid/userinfo"; + if (action) { for (let i = 0; i < action.length; i++) - action[i](self._options); + action[i](optionsToValidate); } - self.configOauth(); - return next(); + return done(); }; chai.passport @@ -219,7 +207,7 @@ describe('OIDCStrategy hybrid flow test', function() { before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, 'wrong_nonce')); it('should fail with wrong nonce', function() { - chai.expect(challenge).to.equal('invalid nonce'); + chai.expect(challenge).to.equal('In _validateResponse: invalid nonce'); }); }); @@ -227,43 +215,39 @@ describe('OIDCStrategy hybrid flow test', function() { before(setReqFromAuthRespRedirect(id_token_in_auth_resp, 'wrong_code', nonce)); it('should fail with invalid c_hash', function() { - chai.expect(challenge).to.equal('invalid c_hash'); + chai.expect(challenge).to.equal('In _validateResponse: invalid c_hash'); }); }); describe('fail: expired id_token', function() { - before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, - [resetOptions, setIgnoreExpirationFalse])); + before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, [setIgnoreExpirationFalse])); it('should fail with expired id_token', function() { - chai.expect(challenge).to.equal('jwt is expired'); + chai.expect(challenge).to.equal('In _validateResponse: jwt is expired'); }); }); describe('fail: invalid signature in id_token', function() { - before(setReqFromAuthRespRedirect(id_token_in_auth_resp_wrong_signature, code, nonce, - [resetOptions])); + before(setReqFromAuthRespRedirect(id_token_in_auth_resp_wrong_signature, code, nonce)); it('should fail with invalid signature in id_token', function() { - chai.expect(challenge).to.equal('invalid signature'); + chai.expect(challenge).to.equal('In _validateResponse: invalid signature'); }); }); describe('fail: invalid issuer', function() { - before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, - [resetOptions, setWrongIssuer])); + before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, [setWrongIssuer])); it('should fail with invalid issuer', function() { - chai.expect(challenge).to.equal('jwt issuer is invalid. expected: wrong_issuer'); + chai.expect(challenge).to.equal('In _validateResponse: jwt issuer is invalid. expected: wrong_issuer'); }); }); describe('fail: invalid audience', function() { - before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, - [resetOptions, setWrongAudience])); + before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, [setWrongAudience])); it('should fail with invalid audience', function() { - chai.expect(challenge).to.equal('jwt audience is invalid. expected: wrong audience'); + chai.expect(challenge).to.equal('In _validateResponse: jwt audience is invalid. expected: wrong audience'); }); }); @@ -276,10 +260,10 @@ describe('OIDCStrategy hybrid flow test', function() { describe('fail: invalid sub in id_token in token response', function() { before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp_wrong_sub, access_token)])); + [setTokenResponse(id_token_in_token_resp_wrong_sub, access_token)])); it('should fail with invalid sub in id_token', function() { - chai.expect(challenge).to.equal('After redeeming the code, iss in id_token from authorize_endpoint does not match iss in id_token from token_endpoint'); + chai.expect(challenge).to.equal('In _authCodeFlowHandler: After redeeming the code, sub in id_token from authorize_endpoint does not match sub in id_token from token_endpoint'); }); }); @@ -291,7 +275,7 @@ describe('OIDCStrategy hybrid flow test', function() { describe('success', function() { before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp, access_token)])); + [setTokenResponse(id_token_in_token_resp, access_token)])); it('should succeed with expected user', function() { chai.expect(user).to.equal('robot@sijun.onmicrosoft.com'); @@ -300,20 +284,20 @@ describe('OIDCStrategy hybrid flow test', function() { describe('fail: missing access_token', function() { before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp, null)])); + [setTokenResponse(id_token_in_token_resp, null)])); it('should fail with access_token missing', function() { - chai.expect(challenge).to.equal('we want to access userinfo endpoint, but access_token is not received'); + chai.expect(challenge).to.equal('In _authCodeFlowHandler: we want to access userinfo endpoint, but access_token is not received'); }); }); describe('fail: invalid sub in userinfo', function() { before(setReqFromAuthRespRedirect(id_token_in_auth_resp, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp, access_token), + [setTokenResponse(id_token_in_token_resp, access_token), setUserInfoResponse('use_invalid_sub')])); it('should fail with invalid sub in userInfo', function() { - chai.expect(challenge).to.equal('sub received in userinfo and id_token do not match'); + chai.expect(challenge).to.equal('In _authCodeFlowHandler: sub received in userinfo and id_token do not match'); }); }); }); @@ -326,7 +310,7 @@ describe('OIDCStrategy authorization code flow test', function() { describe('success', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp, access_token), setUserInfoResponse('use_valid_sub')])); + [setTokenResponse(id_token_in_token_resp, access_token), setUserInfoResponse('use_valid_sub')])); it('should succeed with expected user', function() { chai.expect(user).to.equal('robot@sijun.onmicrosoft.com'); @@ -346,43 +330,43 @@ describe('OIDCStrategy authorization code flow test', function() { before(setReqFromAuthRespRedirect(null, code, 'wrong_nonce')); it('should fail with wrong nonce', function() { - chai.expect(challenge).to.equal('invalid nonce'); + chai.expect(challenge).to.equal('In _validateResponse: invalid nonce'); }); }); describe('fail: expired id_token', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setIgnoreExpirationFalse])); + [setIgnoreExpirationFalse])); it('should fail with expired id_token', function() { - chai.expect(challenge).to.equal('jwt is expired'); + chai.expect(challenge).to.equal('In _validateResponse: jwt is expired'); }); }); describe('fail: invalid issuer', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setWrongIssuer])); + [setWrongIssuer])); it('should fail with invalid issuer', function() { - chai.expect(challenge).to.equal('jwt issuer is invalid. expected: wrong_issuer'); + chai.expect(challenge).to.equal('In _validateResponse: jwt issuer is invalid. expected: wrong_issuer'); }); }); describe('fail: invalid audience', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setWrongAudience])); + [setWrongAudience])); it('should fail with invalid audience', function() { - chai.expect(challenge).to.equal('jwt audience is invalid. expected: wrong audience'); + chai.expect(challenge).to.equal('In _validateResponse: jwt audience is invalid. expected: wrong audience'); }); }); describe('fail: invalid signature in id_token', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setTokenResponse(id_token_in_auth_resp_wrong_signature, access_token)])); + [setTokenResponse(id_token_in_auth_resp_wrong_signature, access_token)])); it('should fail with invalid signature in id_token', function() { - chai.expect(challenge).to.equal('invalid signature'); + chai.expect(challenge).to.equal('In _validateResponse: invalid signature'); }); }); @@ -392,7 +376,7 @@ describe('OIDCStrategy authorization code flow test', function() { describe('success', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp, access_token)])); + [setTokenResponse(id_token_in_token_resp, access_token)])); it('should succeed with expected user', function() { chai.expect(user).to.equal('robot@sijun.onmicrosoft.com'); @@ -401,20 +385,20 @@ describe('OIDCStrategy authorization code flow test', function() { describe('fail: access_token is not received', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp, null)])); + [setTokenResponse(id_token_in_token_resp, null)])); it('should fail with access_token missing', function() { - chai.expect(challenge).to.equal('we want to access userinfo endpoint, but access_token is not received'); + chai.expect(challenge).to.equal('In _authCodeFlowHandler: we want to access userinfo endpoint, but access_token is not received'); }); }); describe('fail: invalid sub in userinfo', function() { before(setReqFromAuthRespRedirect(null, code, nonce, - [resetOptions, setTokenResponse(id_token_in_token_resp, access_token), + [setTokenResponse(id_token_in_token_resp, access_token), setUserInfoResponse('use_invalid_sub')])); it('should fail with invalid sub in userInfo', function() { - chai.expect(challenge).to.equal('sub received in userinfo and id_token do not match'); + chai.expect(challenge).to.equal('In _authCodeFlowHandler: sub received in userinfo and id_token do not match'); }); }); }); diff --git a/test/Chai-passport_test/oidc_implicit_flow_test.js b/test/Chai-passport_test/oidc_implicit_flow_test.js index 16b1b91b..d98247f7 100644 --- a/test/Chai-passport_test/oidc_implicit_flow_test.js +++ b/test/Chai-passport_test/oidc_implicit_flow_test.js @@ -53,7 +53,7 @@ KzveKf3l5UU3c6PkGy+BB3E/ChqFm6sPWwIDAQAB\n\ */ var options = { - callbackURL: 'http://localhost:3000/auth/openid/return', + redirectUrl: 'http://localhost:3000/auth/openid/return', clientID: '2abf3a52-7d86-460b-a1ef-77dc43de8aad', identityMetadata: 'https://login.microsoftonline.com/sijun.onmicrosoft.com/.well-known/openid-configuration', responseType: 'id_token', @@ -61,13 +61,11 @@ var options = { validateIssuer: true, passReqToCallback: false, sessionKey: 'my_key', - oidcIssuer: 'https://sts.windows.net/268da1a1-9db4-48b9-b1fe-683250ba90cc/', - ignoreExpiration: true, - algorithms: ['RS256'] + issuer: 'https://sts.windows.net/268da1a1-9db4-48b9-b1fe-683250ba90cc/', }; var testStrategy = new OIDCStrategy(options, function(profile, done) { - done(null, profile.email); + done(null, profile.emails[0]); }); /* @@ -77,23 +75,28 @@ var challenge; var user; var setIgnoreExpirationFalse = function(options) { options.ignoreExpiration = false; }; -var setIgnoreExpirationTrue = function(options) { options.ignoreExpiration = true; }; -var setWrongIssuer = function(options) { options.oidcIssuer = 'wrong_issuer'; }; -var setValidIssuer = function(options) { options.oidcIssuer = 'https://sts.windows.net/268da1a1-9db4-48b9-b1fe-683250ba90cc/'; }; +var setWrongIssuer = function(options) { options.issuer = 'wrong_issuer'; }; var rmValidateIssuer = function(options) { options.validateIssuer = undefined; }; var testPrepare = function(id_token_to_use, nonce_to_use, action) { return function(done) { // Mock `setOptions` - testStrategy.setOptions = function(options, metadata, cachekey, next) { - const self = this; - self.metadata = new Metadata(self._options.identityMetadata, 'oidc', self._options); - self.metadata.generateOidcPEM = () => { return PEMkey; }; + testStrategy.setOptions = function(params, oauthConfig, optionsToValidate, done) { + params.metadata.generateOidcPEM = () => { return PEMkey; }; + + optionsToValidate.validateIssuer = true; + optionsToValidate.issuer = 'https://sts.windows.net/268da1a1-9db4-48b9-b1fe-683250ba90cc/'; + optionsToValidate.audience = '2abf3a52-7d86-460b-a1ef-77dc43de8aad'; + optionsToValidate.allowMultiAudiencesInToken = false; + optionsToValidate.ignoreExpiration = true; + optionsToValidate.algorithms = ['RS256']; + optionsToValidate.nonce = nonce_to_use; + if (action) { for (let i = 0; i < action.length; i++) - action[i](self._options); + action[i](optionsToValidate); } - return next(); + return done(); }; chai.passport @@ -133,7 +136,7 @@ describe('OIDCStrategy implicit flow test', function() { before(testPrepare(id_token_wrong_signature, nonce)); it('should fail', function() { - chai.expect(challenge).to.equal('invalid signature'); + chai.expect(challenge).to.equal('In _validateResponse: invalid signature'); }); }); @@ -141,7 +144,7 @@ describe('OIDCStrategy implicit flow test', function() { before(testPrepare(id_token, 'wrong_nonce')); it('should fail', function() { - chai.expect(challenge).to.equal('invalid nonce'); + chai.expect(challenge).to.equal('In _validateResponse: invalid nonce'); }); }); @@ -149,16 +152,16 @@ describe('OIDCStrategy implicit flow test', function() { before(testPrepare(id_token, nonce, [setIgnoreExpirationFalse])); it('should fail', function() { - chai.expect(challenge).to.equal('jwt is expired'); + chai.expect(challenge).to.equal('In _validateResponse: jwt is expired'); }); }); describe('should fail with wrong issuer', function() { // we check the issuer by default - before(testPrepare(id_token, nonce, [setIgnoreExpirationTrue, setWrongIssuer])); + before(testPrepare(id_token, nonce, [setWrongIssuer])); it('should fail', function() { - chai.expect(challenge).to.equal('jwt issuer is invalid. expected: wrong_issuer'); + chai.expect(challenge).to.equal('In _validateResponse: jwt issuer is invalid. expected: wrong_issuer'); }); }); @@ -167,7 +170,7 @@ describe('OIDCStrategy implicit flow test', function() { before(testPrepare(id_token, nonce, [rmValidateIssuer, setWrongIssuer])); it('should fail', function() { - chai.expect(challenge).to.equal('jwt issuer is invalid. expected: wrong_issuer'); + chai.expect(challenge).to.equal('In _validateResponse: jwt issuer is invalid. expected: wrong_issuer'); }); }); }); diff --git a/test/Chai-passport_test/oidc_incoming_request_test.js b/test/Chai-passport_test/oidc_incoming_request_test.js index d886a939..0e3cb6eb 100644 --- a/test/Chai-passport_test/oidc_incoming_request_test.js +++ b/test/Chai-passport_test/oidc_incoming_request_test.js @@ -33,7 +33,7 @@ chai.use(require('chai-passport-strategy')); // Mock options required to create a OIDC strategy var options = { - callbackURL: 'http://returnURL', + redirectUrl: 'http://returnURL', clientID: 'my_client_id', clientSecret: 'my_client_secret', identityMetadata: 'https://login.microsoftonline.com/xxx.onmicrosoft.com/.well-known/openid-configuration', @@ -48,16 +48,13 @@ var options = { var testStrategy = new OIDCStrategy(options, function(profile, done) {}); // Mock `setOptions` -testStrategy.setOptions = function(options, metadata, cachekey, next) { - var opt = { - clientID: options.clientID, - clientSecret: options.clientSecret, - authorizationURL: 'https://www.example.com/authorizationURL', - tokenURL: 'https://www.example.com/tokenURL' - }; +testStrategy.setOptions = function(params, oauthConfig, optionsToValidate, done) { + oauthConfig.clientID = options.clientID; + oauthConfig.clientSecret = options.clientSecret; + oauthConfig.authorizationURL = 'https://www.example.com/authorizationURL'; + oauthConfig.tokenURL = 'https://www.example.com/tokenURL'; - this.configOauth(opt); - return next(); + done(); }; @@ -79,10 +76,10 @@ describe('OIDCStrategy incoming state and nonce checking', function() { }; }; - describe('state checking', function() { + describe('state/nonce checking', function() { before(testPrepare()); - it('should have the same state', function() { + it('should have the same state/nonce', function() { var u = url.parse(redirectUrl, true); chai.expect(request.session['my_key']['content'][0]['state']).to.equal(u.query.state); chai.expect(request.session['my_key']['content'][0]['nonce']).to.equal(u.query.nonce); @@ -143,28 +140,28 @@ describe('OIDCStrategy token in request checking', function() { before(testPrepare('my_access_token', null, 'query')); it('should fail', function() { - chai.expect(challenge).to.equal('neither access token nor refresh token is expected in the incoming request'); + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); }); }); describe('access_token in body', function() { before(testPrepare('my_access_token', null, 'body')); it('should fail', function() { - chai.expect(challenge).to.equal('neither access token nor refresh token is expected in the incoming request'); + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); }); }); describe('refresh_token in query', function() { before(testPrepare('my_refresh_token', null, 'query')); it('should fail', function() { - chai.expect(challenge).to.equal('neither access token nor refresh token is expected in the incoming request'); + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); }); }); describe('refresh_token in body', function() { before(testPrepare('my_refresh_token', null, 'body')); it('should fail', function() { - chai.expect(challenge).to.equal('neither access token nor refresh token is expected in the incoming request'); + chai.expect(challenge).to.equal('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'); }); }); }); diff --git a/test/Nodeunit_test/oidc_b2c_test.js b/test/Nodeunit_test/oidc_b2c_test.js index 5d76f03b..365c2589 100644 --- a/test/Nodeunit_test/oidc_b2c_test.js +++ b/test/Nodeunit_test/oidc_b2c_test.js @@ -57,13 +57,13 @@ exports.oidc = { const oidcConfig = { // required options - identityMetadata: 'https://login.microsoftonline.com/common/.well-known/openid-configuration', - tenantName: 'hypercubeb2c.onmicrosoft.com', + identityMetadata: 'https://login.microsoftonline.com/test.onmicrosoft.com/.well-known/openid-configuration', forceB2C: true, clientID: '123', - callbackURL: 'http://www.example.com', + redirectUrl: 'http://www.example.com', responseType: 'id_token', responseMode: 'form_post', + isB2C: true, validateIssuer: false }; diff --git a/test/Nodeunit_test/oidc_test.js b/test/Nodeunit_test/oidc_test.js index 71d6b2d4..8c9e4f16 100644 --- a/test/Nodeunit_test/oidc_test.js +++ b/test/Nodeunit_test/oidc_test.js @@ -49,10 +49,10 @@ const OidcStrategy = require('../../lib/index').OIDCStrategy; function noop() {} -function setConfig(callbackURL, clientID, responseType, responseMode, validateIssuer, testCallback) { +function setConfig(redirectUrl, clientID, responseType, responseMode, validateIssuer, testCallback) { var config = { identityMetadata: 'https://login.microsoftonline.com/contoso.onmicrosoft.com/v2.0/.well-known/openid-configuration', - callbackURL: callbackURL, + redirectUrl: redirectUrl, clientID: clientID, responseType: responseType, responseMode: responseMode, @@ -62,14 +62,15 @@ function setConfig(callbackURL, clientID, responseType, responseMode, validateIs testCallback(config); } -function setConfigCommon(callbackURL, clientID, responseType, responseMode, validateIssuer, testCallback) { +function setConfigCommon(redirectUrl, clientID, responseType, responseMode, validateIssuer, issuer, testCallback) { var config = { identityMetadata: 'https://login.microsoftonline.com/contoso.onmicrosoft.com/common/v2.0/.well-known/openid-configuration', - callbackURL: callbackURL, + redirectUrl: redirectUrl, clientID: clientID, responseType: responseType, responseMode: responseMode, validateIssuer: validateIssuer, + issuer: issuer, }; testCallback(config); @@ -125,7 +126,7 @@ exports.oidc = { test.done(); }, - 'callbackURL (empty)': (test) => { + 'redirectUrl (empty)': (test) => { test.expect(1); setConfig('', '123', 'id_token token', 'form_post', 'true', (oidcConfig) => @@ -134,7 +135,7 @@ exports.oidc = { new OidcStrategy(oidcConfig, noop); }, TypeError, - 'Should have failed: callbackURL (empty)' + 'Should have failed: redirectUrl (empty)' ); }); @@ -325,39 +326,51 @@ exports.oidc = { test.done(); }, 'validateIssuer tests on v2 common endpoint': (test) => { - test.expect(5); + test.expect(7); - setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', true, (oidcConfig) => + setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', true, null, (oidcConfig) => { test.throws(() => { new OidcStrategy(oidcConfig, noop); }, Error, - 'Should throw with validateIssuer set true on common endpoint' + 'Should throw with validateIssuer set true on common endpoint without issuer provided' ); }); - setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', null, (oidcConfig) => + setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', null, null, (oidcConfig) => { test.throws(() => { new OidcStrategy(oidcConfig, noop); }, Error, - 'Should throw with the default validateIssuer value on common endpoint' + 'Should throw with the default validateIssuer value on common endpoint without issuer provided' ); }); - setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', undefined, (oidcConfig) => + setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', undefined, null, (oidcConfig) => { test.throws(() => { new OidcStrategy(oidcConfig, noop); }, Error, - 'Should throw with the default validateIssuer value on common endpoint' + 'Should throw with the default validateIssuer value on common endpoint without issuer provided' ); }); - setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', false, (oidcConfig) => + setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', true, 'my_issuer', (oidcConfig) => + { + var strategy; + test.doesNotThrow(() => { + strategy = new OidcStrategy(oidcConfig, noop); + }, + Error, + 'Should not throw with validateIssuer set true on common endpoint with issuer provided' + ); + test.ok(strategy._options.responseType === 'code id_token', 'should have changed id_token code to code id_token'); + }); + + setConfigCommon('https://www.example.com', '123', 'id_token code', 'form_post', false, null, (oidcConfig) => { var strategy; test.doesNotThrow(() => { diff --git a/test/Nodeunit_test/oidc_v2_test.js b/test/Nodeunit_test/oidc_v2_test.js index 40496e76..f4ae2c96 100644 --- a/test/Nodeunit_test/oidc_v2_test.js +++ b/test/Nodeunit_test/oidc_v2_test.js @@ -74,7 +74,7 @@ exports.oidc = { // required options identityMetadata: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration', clientID: '123', - callbackURL: 'http://www.example.com', + redirectUrl: 'http://www.example.com', responseType: 'id_token', // for login only flows use id_token. For accessing resources use `id_token code` responseMode: 'form_post', // For login only flows we should have token passed back to us in a POST validateIssuer: false