passport-azure-ad_无限刷新问题

问题描述

passport-azure-ad 是我们在使用node express作为服务器,passport用于验证登陆,同时又需要去azure第三方认证登陆时需要用到的插件。

但是在使用过程中我们可能会遇到这样一种情况,就是明明我们的所有配置都已经配置好了,但是会遇到无限 回调的情况,具体表现形式就是我们利用微软账号登陆成功之后,页面会不断刷新,最终提示我们无法成功登录。

这个不断刷新的过程其实是, 首先发送一个OAuth登陆流程过程中的认证请求,认证成功之后会回调你事先配置好的redirectURL,这个url一般是我们自己服务器需要处理的请求,在这个请求中你需要用到passport的authenticate去认证是否登陆成功,如果这个认证中遇到id-token为空的情况,就会再次发起请求,一旦认证成功又会调用redirectURL。

在这个过程中,如果你确实在微软那里认证成功了,但是回调自己的服务器请求时又确实没有拿到id-token,就会出现无限回调的情况。

解决方法

一般出现这样的情况都是因为项目中没有加入 bodyparser 插件,倒是无法解析微软认证请求回来的body,就无法获取其中的id-token,所以我们需要加入body-parser。

源码解析

假设我们的redirectURL是这样的

1
localhost:8080/auth/auth/openid/return

它的处理函数是这样的

1
2
3
4
passport.authenticate('azuread-openidconnect', { failureRedirect: '/' }),
function(req, res) {
log.info('Login was called in the Sample');
res.redirect('/');

那么我们去看一下为什么这个函数什么情况下会再次触发去微软的认证。

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;
});
};

我们通过在这段函数中打断点就可以发现它一直都会反复调用**_flowInitializationHandler** 函数

首先我们看一下,_flowInitializationHandler函数究竟做了什么

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);
};

那么为什么会一直进入这个函数呢?

我们看一下他的判断条件,是因为没有id-token并且没有code(这两个参数是做什么的可以看我的上一篇文章)

那么为什么会没有呢?应该在哪里设置这两个参数呢?

其实我们向前看一下,就会发现他首先执行了一个函数叫做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();
};

这个函数最开始就会去请求中获取id-token,如果这里获取不到就会导致接下来的函数无法获取,

总结

综上所述,无限回调出现的流程就是首先去微软验证成功并返回了id-token,之后调用了我们的/auth/openid/return,也就是passport.authenciate,在这个函数中又因为无法解析body而无法获取id-token,导致重新去微软请求,再次请求成功返回,调用passport.authenciate,再次无法获取id-token,再次认证,无限循环。

2020-02-08更新

这个周的工作中遇到了另外一个原因会导致passport-azure-ad的登录失败,虽然不会导致无限循环,但是究其根本原因还是passport-azure-ad内部原因导致,表现的形式就是去azure登陆成功了,但是passport却偏偏认为没有登陆成功。

看一下log发现他会提示

1
params.metadata.generateOidcPEM is not a function

那么这个 params.metadata.generateOidcPEM 是哪里来的呢?

仔细看一下passport-azure-ad的源码发现,如果从azure那边成功返回数据之后,passport还要去检验数据的合法性,这个函数就是用于检验合法性的,没有这个函数就会导致校验不通过,passport认为返回的数据是不合法的。

那么为什么会突然导致这个问题呢?

原因是passport-azure-ad本身又依赖了一个叫做cache-manager的包,而且这个对于这个包的版本要求是 “^2.0.0”,最近这个叫做cache-manager的包做了一次升级,2.11.0以上的版本一个深度克隆的方法从lodash的deepClone变成了deepmerge,导致了参数中的部分数据丢失。

所以我们只需要在package.json中指定cache-manager版本为2.10.0或者2.10.2就好。