passport-azure-ad_ infinite refresh problem

Problem description

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
Strategy.prototype.authenticate = function authenticateStrategy(req, options) {
/*
* We should be careful using 'this'. Avoid the usage like `this.xxx = ...`
* unless you know what you are doing.
*
* In the passport source code
* (https://github.com/jaredhanson/passport/blob/master/lib/middleware/authenticate.js)
* when it attempts to call the `oidcstrategy.authenticate` function, passport
* creates an instance inherting oidcstrategy and then calls `instance.authenticate`.
* Therefore, when we come here, `this` is the instance, its prototype is the
* actual oidcstrategy, i.e. the `Strategy`. This means:
* (1) `this._options = `, `this._verify = `, etc only adds new fields to the
* instance, it doesn't change the values in oidcstrategy, i.e. `Strategy`.
* (2) `this._options`, `this._verify`, etc returns the field in the instance,
* and if there is none, returns the field in oidcstrategy, i.e. `strategy`.
* (3) each time we call `authenticate`, we will get a brand new instance
*
* If you want to change the values in `Strategy`, use
* const oidcstrategy = Object.getPrototypeOf(self);
* to get the strategy first.
*
* Note: Simply do `const self = Object.getPrototypeOf(this)` and use `self`
* won't work, since the `this` instance has a couple of functions like
* success/fail/error... which `authenticate` will call. The following is the
* structure of `this`:
*
* this
* | -- success: function(user, info)
* | -- fail: function(challenge, status)
* | -- redirect: function(url, status)
* | -- pass: function()
* | -- __proto__: Strategy
* | -- _verify
* | -- _options
* | -- ...
* | -- __proto__:
* | -- authenticate: function(req, options)
* | -- ...
*/
const self = this;

var resource = options && options.resourceURL;
var customState = options && options.customState;
var tenantIdOrName = options && options.tenantIdOrName;
var login_hint = options && options.login_hint;
var domain_hint = options && options.domain_hint;
var prompt = options && options.prompt;
var extraAuthReqQueryParams = options && options.extraAuthReqQueryParams;
var extraTokenReqQueryParams = options && options.extraTokenReqQueryParams;
var response = options && options.response || req.res;

// 'params': items we get from the request or metadata, such as id_token, code, policy, metadata, cacheKey, etc
var params = { 'tenantIdOrName': tenantIdOrName, 'extraAuthReqQueryParams': extraAuthReqQueryParams, 'extraTokenReqQueryParams': extraTokenReqQueryParams };
// 'oauthConfig': items needed for oauth flow (like redirection, code redemption), such as token_endpoint, userinfo_endpoint, etc
var oauthConfig = { 'resource': resource, 'customState': customState, 'domain_hint': domain_hint, 'login_hint': login_hint, 'prompt': prompt, 'response': response };
// 'optionsToValidate': items we need to validate id_token against, such as issuer, audience, etc
var optionsToValidate = {};

async.waterfall(
[
/*****************************************************************************
* Step 1. Collect information from the req and save the info into params
****************************************************************************/
(next) => {
return self.collectInfoFromReq(params, req, next, response);
},

/*****************************************************************************
* Step 2. Load metadata, use the information from 'params' and 'self._options'
* to configure 'oauthConfig' and 'optionsToValidate'
****************************************************************************/
(next) => {
return self.setOptions(params, oauthConfig, optionsToValidate, next);
},

/*****************************************************************************
* Step 3. Handle the flows
*----------------------------------------------------------------------------
* (1) implicit flow (response_type = 'id_token')
* This case we get a 'id_token'
* (2) hybrid flow (response_type = 'id_token code')
* This case we get both 'id_token' and 'code'
* (3) authorization code flow (response_type = 'code')
* This case we get a 'code', we will use it to get 'access_token' and 'id_token'
* (4) for any other request, we will ask for authorization and initialize
* the authorization process
****************************************************************************/
(next) => {
if (params.err) {
// handle the error
return self._errorResponseHandler(params.err, params.err_description, next);
} else if (!params.id_token && !params.code) {
// ask for authorization, initialize the authorization process
return self._flowInitializationHandler(oauthConfig, req, next);
} else if (params.id_token && params.code) {
// handle hybrid flow
return self._hybridFlowHandler(params, oauthConfig, optionsToValidate, req, next);
} else if (params.id_token) {
// handle implicit flow
return self._implicitFlowHandler(params, optionsToValidate, req, next);
} else {
// handle authorization code flow
return self._authCodeFlowHandler(params, oauthConfig, optionsToValidate, req, next);
}
}
],

(waterfallError) => {
// this code gets called after the three steps above are done
if (waterfallError) {
return self.failWithLog(`${aadutils.getErrorMessage(waterfallError)}`);
}
return true;
});
};

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 = 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
// discovered dynamically. When using dynamic discovery, a user supplies
// their identifer as input.

......

const location = aadutils.concatUrl(oauthConfig.authorization_endpoint, querystring.stringify(params));

return self.redirect(location);
};

So why do you keep entering this function?

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 **

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
Strategy.prototype.collectInfoFromReq = function(params, req, next, response) {
const self = this;

// the things we will put into 'params':
// err, err_description, id_token, code, policy, state, nonce, cachekey, metadata

// -------------------------------------------------------------------------
// we shouldn't get any access_token or refresh_token from the request
// -------------------------------------------------------------------------
if ((req.query && (req.query.access_token || req.query.refresh_token)) ||
(req.body && (req.body.access_token || req.body.refresh_token)))
return next(new Error('In collectInfoFromReq: neither access token nor refresh token is expected in the incoming request'));

// -------------------------------------------------------------------------
// we might get err, id_token, code, state from the request
// -------------------------------------------------------------------------
var source = null;

if (req.query && (req.query.error || req.query.id_token || req.query.code))
source = req.query;
else if (req.body && (req.body.error || req.body.id_token || req.body.code))
source = req.body;

if (source) {
params.err = source.error;
params.err_description = source.error_description;
params.id_token = source.id_token;
params.code = source.code;
params.state = source.state;
if (source.state && source.state.length >= 38) {
// the random generated state always has 32 characters. This state is longer than 32
// so it must be a custom state. We added 'CUSTOM' prefix and a random 32 byte long
// string in front of the original custom state, now we change it back.
if (!source.state.startsWith('CUSTOM'))
return next(new Error(`In collectInfoFromReq: invalid custom state ${state}`));

source.state = source.state.substring(38);
}
}

// -------------------------------------------------------------------------
// If we received code, id_token or err, we must have received state, now we
// find the state/nonce/policy tuple from session.
// If we received none of them, find policy in query
// -------------------------------------------------------------------------
if (params.id_token || params.code || params.err) {
if (!params.state)
return next(new Error('In collectInfoFromReq: missing state in the request'));

var tuple;

if (!self._useCookieInsteadOfSession)
tuple = self._sessionContentHandler.findAndDeleteTupleByState(req, self._key, params.state);
else
tuple = self._cookieContentHandler.findAndDeleteTupleByState(req, response, params.state);

if (!tuple)
return next(new Error('In collectInfoFromReq: invalid state received in the request'));

params.nonce = tuple['nonce'];
params.policy = tuple['policy'];
params.resource = tuple['resource'];

// user provided tenantIdOrName will be ignored for redirectUrl, since we saved the one we used in session
if (params.tenantIdOrName) {
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))
return next(new Error('In collectInfoFromReq: issuer or tenantIdOrName must be provided in order to validate issuer on common endpoint'));

// for B2C, we must have policy
if (self._options.isB2C && !params.policy)
return next(new Error('In collectInfoFromReq: policy is missing'));
// for B2C, if we are using common endpoint, we must have tenantIdOrName provided
if (self._options.isB2C && self._options.isCommonEndpoint && !params.tenantIdOrName)
return next(new Error('In collectInfoFromReq: we are using common endpoint for B2C but tenantIdOrName is not provided'));

// -------------------------------------------------------------------------
// calculate metadataUrl, create a cachekey and an Metadata object instance
// we will fetch the metadata, save it into the object using the cachekey
// -------------------------------------------------------------------------
var metadataUrl = self._options.identityMetadata;

// if we are using common endpoint and we are given the tenantIdOrName, let's replace it
if (self._options.isCommonEndpoint && params.tenantIdOrName) {
metadataUrl = metadataUrl.replace('/common/', `/${params.tenantIdOrName}/`);
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 = new Metadata(metadataUrl, 'oidc', self._options);

if (!self._options.loggingNoPII) {
log.info(`metadataUrl is: ${metadataUrl}`);
log.info(`received the following items in params: ${JSON.stringify(params)}`);
}

return next();
};

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.