Passport-azure-ad is a plug-in that we need to use when we use node express as the server, passport to verify login, and azure third-party authentication login.
But in the use of the process we may encounter such a situation, that is, clearly all our configuration has been configured, but will encounter unlimited, callback situation, the specific form is that we use the Microsoft account login success, the page will continue to refresh, and finally prompt us can not successfully log in.
This constantly refresh process is actually to send an authentication request in the OAuth login process first. After the authentication is successful, the redirectURL that you configured in advance will be called back. This url is generally a request that our own server needs to handle. In this request, you need to Use the authenticate of passport to authenticate whether the login is successful. If the id-token is empty in this authentication, the request will be initiated again. Once the authentication is successful, the redirectURL will be called.
During this process, if you do authenticate successfully with Microsoft, but do not get the id-token when calling back your server request, there will be an infinite callback situation.
Solution
Generally, this situation occurs because the bodyparser plugin is not added to the project, but it cannot parse the body returned by the Microsoft authentication request, so we cannot obtain the id-token, so we need to join the bodyparser.
Source code parsing
Suppose our redirectURL looks like this
1
localhost:8080/auth/auth/openid/return
Its processing function is like this
1 2 3 4
passport.authenticate('azuread-openidconnect', { failureRedirect: '/' }), function(req, res) { log.info('Login was called in the Sample'); res.redirect('/');
So let’s take a look at why this function will trigger the certification to Microsoft again.
Strategy.prototype.authenticate = functionauthenticateStrategy(req, options) { /* * We should be careful using 'this'. Avoid the usage like `this.xxx = ...` * unless you know what you are doing. * * In the passport source code * (https://github.com/jaredhanson/passport/blob/master/lib/middleware/authenticate.js) * when it attempts to call the `oidcstrategy.authenticate` function, passport * creates an instance inherting oidcstrategy and then calls `instance.authenticate`. * Therefore, when we come here, `this` is the instance, its prototype is the * actual oidcstrategy, i.e. the `Strategy`. This means: * (1) `this._options = `, `this._verify = `, etc only adds new fields to the * instance, it doesn't change the values in oidcstrategy, i.e. `Strategy`. * (2) `this._options`, `this._verify`, etc returns the field in the instance, * and if there is none, returns the field in oidcstrategy, i.e. `strategy`. * (3) each time we call `authenticate`, we will get a brand new instance * * If you want to change the values in `Strategy`, use * const oidcstrategy = Object.getPrototypeOf(self); * to get the strategy first. * * Note: Simply do `const self = Object.getPrototypeOf(this)` and use `self` * won't work, since the `this` instance has a couple of functions like * success/fail/error... which `authenticate` will call. The following is the * structure of `this`: * * this * | -- success: function(user, info) * | -- fail: function(challenge, status) * | -- redirect: function(url, status) * | -- pass: function() * | -- __proto__: Strategy * | -- _verify * | -- _options * | -- ... * | -- __proto__: * | -- authenticate: function(req, options) * | -- ... */ const self = this;
var resource = options && options.resourceURL; var customState = options && options.customState; var tenantIdOrName = options && options.tenantIdOrName; var login_hint = options && options.login_hint; var domain_hint = options && options.domain_hint; var prompt = options && options.prompt; var extraAuthReqQueryParams = options && options.extraAuthReqQueryParams; var extraTokenReqQueryParams = options && options.extraTokenReqQueryParams; var response = options && options.response || req.res;
// 'params': items we get from the request or metadata, such as id_token, code, policy, metadata, cacheKey, etc var params = { 'tenantIdOrName': tenantIdOrName, 'extraAuthReqQueryParams': extraAuthReqQueryParams, 'extraTokenReqQueryParams': extraTokenReqQueryParams }; // 'oauthConfig': items needed for oauth flow (like redirection, code redemption), such as token_endpoint, userinfo_endpoint, etc var oauthConfig = { 'resource': resource, 'customState': customState, 'domain_hint': domain_hint, 'login_hint': login_hint, 'prompt': prompt, 'response': response }; // 'optionsToValidate': items we need to validate id_token against, such as issuer, audience, etc var optionsToValidate = {};
async.waterfall( [ /***************************************************************************** * Step 1. Collect information from the req and save the info into params ****************************************************************************/ (next) => { return self.collectInfoFromReq(params, req, next, response); },
/***************************************************************************** * Step 2. Load metadata, use the information from 'params' and 'self._options' * to configure 'oauthConfig' and 'optionsToValidate' ****************************************************************************/ (next) => { return self.setOptions(params, oauthConfig, optionsToValidate, next); },
/***************************************************************************** * Step 3. Handle the flows *---------------------------------------------------------------------------- * (1) implicit flow (response_type = 'id_token') * This case we get a 'id_token' * (2) hybrid flow (response_type = 'id_token code') * This case we get both 'id_token' and 'code' * (3) authorization code flow (response_type = 'code') * This case we get a 'code', we will use it to get 'access_token' and 'id_token' * (4) for any other request, we will ask for authorization and initialize * the authorization process ****************************************************************************/ (next) => { if (params.err) { // handle the error return self._errorResponseHandler(params.err, params.err_description, next); } elseif (!params.id_token && !params.code) { // ask for authorization, initialize the authorization process return self._flowInitializationHandler(oauthConfig, req, next); } elseif (params.id_token && params.code) { // handle hybrid flow return self._hybridFlowHandler(params, oauthConfig, optionsToValidate, req, next); } elseif (params.id_token) { // handle implicit flow return self._implicitFlowHandler(params, optionsToValidate, req, next); } else { // handle authorization code flow return self._authCodeFlowHandler(params, oauthConfig, optionsToValidate, req, next); } } ],
(waterfallError) => { // this code gets called after the three steps above are done if (waterfallError) { return self.failWithLog(`${aadutils.getErrorMessage(waterfallError)}`); } returntrue; }); };
By breaking the dots in this function, we can see that it keeps calling ** _flowInitializationHandler ** function repeatedly.
First, let’s take a look at what _flowInitializationHandler function does
1 2 3 4 5 6 7 8 9 10 11 12 13
Strategy.prototype._flowInitializationHandler = functionflowInitializationHandler(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 // discovered dynamically. When using dynamic discovery, a user supplies // their identifer as input.
Let’s take a look at his judgment condition, because there is no id-token and no code (you can see what these two parameters do in my previous article).
So why not? Where should these two parameters be set?
In fact, if we look forward, we will find that he first executes a function called ** collectInfoFromReq **
// 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))) returnnext(newError('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 (source) { params.err = source.error; params.err_description = source.error_description; params.id_token = source.id_token; params.code = source.code; params.state = source.state; if (source.state && source.state.length >= 38) { // the random generated state always has 32 characters. This state is longer than 32 // so it must be a custom state. We added 'CUSTOM' prefix and a random 32 byte long // string in front of the original custom state, now we change it back. if (!source.state.startsWith('CUSTOM')) returnnext(newError(`In collectInfoFromReq: invalid custom state ${state}`));
source.state = source.state.substring(38); } }
// ------------------------------------------------------------------------- // If we received code, id_token or err, we must have received state, now we // find the state/nonce/policy tuple from session. // If we received none of them, find policy in query // ------------------------------------------------------------------------- if (params.id_token || params.code || params.err) { if (!params.state) returnnext(newError('In collectInfoFromReq: missing state in the request'));
// user provided tenantIdOrName will be ignored for redirectUrl, since we saved the one we used in session if (params.tenantIdOrName) { if (self._options.loggingNoPII) log.info('user provided tenantIdOrName is ignored for redirectUrl, we will use the one stored in session'); else log.info(`user provided tenantIdOrName '${params.tenantIdOrName}' is ignored for redirectUrl, we will use the one stored in session`); } params.tenantIdOrName = tuple['tenantIdOrName']; } else { params.policy = req.query.p ? req.query.p.toLowerCase() : null; }
// if we are not using the common endpoint, but we have tenantIdOrName, just ignore it if (!self._options.isCommonEndpoint && params.tenantIdOrName) { if (self._options.loggingNoPII) log.info('identityMetadata is tenant-specific, so we ignore the tenantIdOrName provided'); else log.info(`identityMetadata is tenant-specific, so we ignore the tenantIdOrName '${params.tenantIdOrName}'`); params.tenantIdOrName = null; }
// if we are using the common endpoint and we want to validate issuer, then user has to // provide issuer in config, or provide tenant id or name using tenantIdOrName option in // passport.authenticate. Otherwise we won't know the issuer. if (self._options.isCommonEndpoint && self._options.validateIssuer && (!self._options.issuer && !params.tenantIdOrName)) returnnext(newError('In collectInfoFromReq: issuer or tenantIdOrName must be provided in order to validate issuer on common endpoint'));
// for B2C, we must have policy if (self._options.isB2C && !params.policy) returnnext(newError('In collectInfoFromReq: policy is missing')); // for B2C, if we are using common endpoint, we must have tenantIdOrName provided if (self._options.isB2C && self._options.isCommonEndpoint && !params.tenantIdOrName) returnnext(newError('In collectInfoFromReq: we are using common endpoint for B2C but tenantIdOrName is not provided'));
// ------------------------------------------------------------------------- // calculate metadataUrl, create a cachekey and an Metadata object instance // we will fetch the metadata, save it into the object using the cachekey // ------------------------------------------------------------------------- var metadataUrl = self._options.identityMetadata;
// if we are using common endpoint and we are given the tenantIdOrName, let's replace it if (self._options.isCommonEndpoint && params.tenantIdOrName) { metadataUrl = metadataUrl.replace('/common/', `/${params.tenantIdOrName}/`); if (self._options.loggingNoPII) log.info(`we are replacing 'common' with the tenantIdOrName provided`); else log.info(`we are replacing 'common' with the tenantIdOrName ${params.tenantIdOrName}`); }
// add policy for B2C if (self._options.isB2C) metadataUrl = metadataUrl.concat(`&p=${params.policy}`);
// we use the metadataUrl as the cachekey params.cachekey = metadataUrl; params.metadata = newMetadata(metadataUrl, 'oidc', self._options);
if (!self._options.loggingNoPII) { log.info(`metadataUrl is: ${metadataUrl}`); log.info(`received the following items in params: ${JSON.stringify(params)}`); }
returnnext(); };
This function will get the id-token from the request at the beginning. If it cannot be obtained here, the next function will not be able to obtain it.
Summary
To sum up, the process of infinite callback is to first go to Microsoft to verify successfully and return the id-token, and then call our/auth/openid/return, which is passport.authenciate. In this function, because the body cannot be parsed, the id-token cannot be obtained, resulting in a re-request to Microsoft, a successful return of the request, a call to passport.authenciate, again unable to obtain the id-token, re-authentication, infinite loop.
Updated on 2020-02-08
This week’s work encountered another reason that will cause the login failure of passport-azure-ad. Although it will not lead to an infinite loop, the root cause is the internal cause of passport-azure-ad. The form of performance is that the login to azure is successful., but passport just thinks that the login was not successful.
Take a look at the log and find that he will prompt.
1
params.metadata.generateOidcPEM is not a function
So where does this params.metadata.generateOidcPEM come from?
Take a closer look at the source code of passport-azure-ad and find that if the data is successfully returned from azure, passport still needs to check the legitimacy of the data. This function is used to check the legitimacy. Without this function, the verification will result. If it does not pass, passport believes that the returned data is illegal.
So why is this suddenly causing this problem?
The reason is that passport-azure-ad itself relies on a package called cache-manager, and the version requirement for this package is “^ 2.0.0”. Recently, the package called cache-manager has been upgraded, and the version above 2.11.0 The method of deep cloning has changed from lodash’s deepClone to deepmerge, resulting in the loss of some data in the parameters.
So we just need to specify the cache-manager version in package.json as 2.10.0 or 2.10.2.