@depup/express-openid-connect 2.19.4-depup.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +37 -0
- package/changes.json +34 -0
- package/index.d.ts +1055 -0
- package/index.js +9 -0
- package/lib/appSession.js +420 -0
- package/lib/client.js +167 -0
- package/lib/config.js +326 -0
- package/lib/context.js +498 -0
- package/lib/cookies.js +3 -0
- package/lib/crypto.js +119 -0
- package/lib/debug.js +2 -0
- package/lib/hooks/backchannelLogout/isLoggedOut.js +22 -0
- package/lib/hooks/backchannelLogout/onLogIn.js +21 -0
- package/lib/hooks/backchannelLogout/onLogoutToken.js +37 -0
- package/lib/hooks/getLoginState.js +51 -0
- package/lib/once.js +19 -0
- package/lib/transientHandler.js +155 -0
- package/lib/utils/promisifyCompat.js +90 -0
- package/lib/weakCache.js +8 -0
- package/middleware/attemptSilentLogin.js +66 -0
- package/middleware/auth.js +129 -0
- package/middleware/requiresAuth.js +133 -0
- package/middleware/unauthorizedHandler.js +15 -0
- package/package.json +129 -0
package/lib/context.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
const url = require('url');
|
|
2
|
+
const urlJoin = require('url-join');
|
|
3
|
+
const { JWT } = require('jose');
|
|
4
|
+
const { TokenSet } = require('openid-client');
|
|
5
|
+
const clone = require('clone');
|
|
6
|
+
|
|
7
|
+
const { strict: assert } = require('assert');
|
|
8
|
+
const createError = require('http-errors');
|
|
9
|
+
|
|
10
|
+
const debug = require('./debug')('context');
|
|
11
|
+
const { once } = require('./once');
|
|
12
|
+
const { get: getClient } = require('./client');
|
|
13
|
+
const { encodeState, decodeState } = require('../lib/hooks/getLoginState');
|
|
14
|
+
const onLogin = require('./hooks/backchannelLogout/onLogIn');
|
|
15
|
+
const onLogoutToken = require('./hooks/backchannelLogout/onLogoutToken');
|
|
16
|
+
const {
|
|
17
|
+
cancelSilentLogin,
|
|
18
|
+
resumeSilentLogin,
|
|
19
|
+
} = require('../middleware/attemptSilentLogin');
|
|
20
|
+
const weakRef = require('./weakCache');
|
|
21
|
+
const {
|
|
22
|
+
regenerateSessionStoreId,
|
|
23
|
+
replaceSession,
|
|
24
|
+
} = require('../lib/appSession');
|
|
25
|
+
|
|
26
|
+
function isExpired() {
|
|
27
|
+
return tokenSet.call(this).expired();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function refresh({ tokenEndpointParams } = {}) {
|
|
31
|
+
let { config, req } = weakRef(this);
|
|
32
|
+
const { client, issuer } = await getClient(config);
|
|
33
|
+
const oldTokenSet = tokenSet.call(this);
|
|
34
|
+
|
|
35
|
+
let extras;
|
|
36
|
+
if (config.tokenEndpointParams || tokenEndpointParams) {
|
|
37
|
+
extras = {
|
|
38
|
+
exchangeBody: { ...config.tokenEndpointParams, ...tokenEndpointParams },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const newTokenSet = await client.refresh(oldTokenSet, {
|
|
43
|
+
...extras,
|
|
44
|
+
clientAssertionPayload: {
|
|
45
|
+
aud: issuer.issuer,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Update the session
|
|
50
|
+
const session = req[config.session.name];
|
|
51
|
+
Object.assign(session, {
|
|
52
|
+
access_token: newTokenSet.access_token,
|
|
53
|
+
// If no new ID token assume the current ID token is valid.
|
|
54
|
+
id_token: newTokenSet.id_token || oldTokenSet.id_token,
|
|
55
|
+
// If no new refresh token assume the current refresh token is valid.
|
|
56
|
+
refresh_token: newTokenSet.refresh_token || oldTokenSet.refresh_token,
|
|
57
|
+
token_type: newTokenSet.token_type,
|
|
58
|
+
expires_at: newTokenSet.expires_at,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Delete the old token set
|
|
62
|
+
const cachedTokenSet = weakRef(session);
|
|
63
|
+
delete cachedTokenSet.value;
|
|
64
|
+
|
|
65
|
+
return this.accessToken;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tokenSet() {
|
|
69
|
+
const contextCache = weakRef(this);
|
|
70
|
+
const session = contextCache.req[contextCache.config.session.name];
|
|
71
|
+
|
|
72
|
+
if (!session || !('id_token' in session)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cachedTokenSet = weakRef(session);
|
|
77
|
+
|
|
78
|
+
if (!('value' in cachedTokenSet)) {
|
|
79
|
+
const { id_token, access_token, refresh_token, token_type, expires_at } =
|
|
80
|
+
session;
|
|
81
|
+
cachedTokenSet.value = new TokenSet({
|
|
82
|
+
id_token,
|
|
83
|
+
access_token,
|
|
84
|
+
refresh_token,
|
|
85
|
+
token_type,
|
|
86
|
+
expires_at,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return cachedTokenSet.value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class RequestContext {
|
|
94
|
+
constructor(config, req, res, next) {
|
|
95
|
+
Object.assign(weakRef(this), { config, req, res, next });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
isAuthenticated() {
|
|
99
|
+
return !!this.idTokenClaims;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get idToken() {
|
|
103
|
+
try {
|
|
104
|
+
return tokenSet.call(this).id_token;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get refreshToken() {
|
|
111
|
+
try {
|
|
112
|
+
return tokenSet.call(this).refresh_token;
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get accessToken() {
|
|
119
|
+
try {
|
|
120
|
+
const { access_token, token_type, expires_in } = tokenSet.call(this);
|
|
121
|
+
|
|
122
|
+
if (!access_token || !token_type || typeof expires_in !== 'number') {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
access_token,
|
|
128
|
+
token_type,
|
|
129
|
+
expires_in,
|
|
130
|
+
isExpired: isExpired.bind(this),
|
|
131
|
+
refresh: refresh.bind(this),
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get idTokenClaims() {
|
|
139
|
+
try {
|
|
140
|
+
const {
|
|
141
|
+
config: { session },
|
|
142
|
+
req,
|
|
143
|
+
} = weakRef(this);
|
|
144
|
+
|
|
145
|
+
// The ID Token from Auth0's Refresh Grant doesn't contain a "sid"
|
|
146
|
+
// so we should check the backup sid we stored at login.
|
|
147
|
+
const { sid } = req[session.name];
|
|
148
|
+
return { sid, ...clone(tokenSet.call(this).claims()) };
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get user() {
|
|
155
|
+
try {
|
|
156
|
+
const {
|
|
157
|
+
config: { identityClaimFilter },
|
|
158
|
+
} = weakRef(this);
|
|
159
|
+
const { idTokenClaims } = this;
|
|
160
|
+
const user = clone(idTokenClaims);
|
|
161
|
+
identityClaimFilter.forEach((claim) => {
|
|
162
|
+
delete user[claim];
|
|
163
|
+
});
|
|
164
|
+
return user;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async fetchUserInfo() {
|
|
171
|
+
const { config } = weakRef(this);
|
|
172
|
+
|
|
173
|
+
const { client } = await getClient(config);
|
|
174
|
+
return client.userinfo(tokenSet.call(this));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class ResponseContext {
|
|
179
|
+
constructor(config, req, res, next, transient) {
|
|
180
|
+
Object.assign(weakRef(this), { config, req, res, next, transient });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get errorOnRequiredAuth() {
|
|
184
|
+
return weakRef(this).config.errorOnRequiredAuth;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getRedirectUri() {
|
|
188
|
+
const { config } = weakRef(this);
|
|
189
|
+
if (config.routes.callback) {
|
|
190
|
+
return urlJoin(config.baseURL, config.routes.callback);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
silentLogin(options = {}) {
|
|
195
|
+
return this.login({
|
|
196
|
+
...options,
|
|
197
|
+
silent: true,
|
|
198
|
+
authorizationParams: { ...options.authorizationParams, prompt: 'none' },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async login(options = {}) {
|
|
203
|
+
let { config, req, res, next, transient } = weakRef(this);
|
|
204
|
+
next = once(next);
|
|
205
|
+
try {
|
|
206
|
+
const { client, issuer } = await getClient(config);
|
|
207
|
+
|
|
208
|
+
// Set default returnTo value, allow passed-in options to override or use originalUrl on GET
|
|
209
|
+
let returnTo = config.baseURL;
|
|
210
|
+
if (options.returnTo) {
|
|
211
|
+
returnTo = options.returnTo;
|
|
212
|
+
debug('req.oidc.login() called with returnTo: %s', returnTo);
|
|
213
|
+
} else if (req.method === 'GET' && req.originalUrl) {
|
|
214
|
+
// Collapse any leading slashes to a single slash to prevent Open Redirects
|
|
215
|
+
returnTo = req.originalUrl.replace(/^\/+/, '/');
|
|
216
|
+
debug('req.oidc.login() without returnTo, using: %s', returnTo);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
options = {
|
|
220
|
+
authorizationParams: {},
|
|
221
|
+
returnTo,
|
|
222
|
+
...options,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Ensure a redirect_uri, merge in configuration options, then passed-in options.
|
|
226
|
+
options.authorizationParams = {
|
|
227
|
+
redirect_uri: this.getRedirectUri(),
|
|
228
|
+
...config.authorizationParams,
|
|
229
|
+
...options.authorizationParams,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const stateValue = await config.getLoginState(req, options);
|
|
233
|
+
if (typeof stateValue !== 'object') {
|
|
234
|
+
next(new Error('Custom state value must be an object.'));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (options.silent) {
|
|
238
|
+
stateValue.attemptingSilentLogin = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const validResponseTypes = ['id_token', 'code id_token', 'code'];
|
|
242
|
+
assert(
|
|
243
|
+
validResponseTypes.includes(options.authorizationParams.response_type),
|
|
244
|
+
`response_type should be one of ${validResponseTypes.join(', ')}`
|
|
245
|
+
);
|
|
246
|
+
assert(
|
|
247
|
+
/\bopenid\b/.test(options.authorizationParams.scope),
|
|
248
|
+
'scope should contain "openid"'
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const authVerification = {
|
|
252
|
+
nonce: transient.generateNonce(),
|
|
253
|
+
state: encodeState(stateValue),
|
|
254
|
+
...(options.authorizationParams.max_age
|
|
255
|
+
? {
|
|
256
|
+
max_age: options.authorizationParams.max_age,
|
|
257
|
+
}
|
|
258
|
+
: undefined),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
let authParams = {
|
|
262
|
+
...options.authorizationParams,
|
|
263
|
+
...authVerification,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const usePKCE =
|
|
267
|
+
options.authorizationParams.response_type.includes('code');
|
|
268
|
+
if (usePKCE) {
|
|
269
|
+
debug(
|
|
270
|
+
'response_type includes code, the authorization request will use PKCE'
|
|
271
|
+
);
|
|
272
|
+
authVerification.code_verifier = transient.generateCodeVerifier();
|
|
273
|
+
|
|
274
|
+
authParams.code_challenge_method = 'S256';
|
|
275
|
+
authParams.code_challenge = transient.calculateCodeChallenge(
|
|
276
|
+
authVerification.code_verifier
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (config.pushedAuthorizationRequests) {
|
|
281
|
+
const { request_uri } = await client.pushedAuthorizationRequest(
|
|
282
|
+
authParams,
|
|
283
|
+
{
|
|
284
|
+
clientAssertionPayload: {
|
|
285
|
+
aud: issuer.issuer,
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
authParams = { request_uri };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
transient.store(config.transactionCookie.name, req, res, {
|
|
293
|
+
sameSite:
|
|
294
|
+
options.authorizationParams.response_mode === 'form_post'
|
|
295
|
+
? 'None'
|
|
296
|
+
: config.transactionCookie.sameSite,
|
|
297
|
+
value: JSON.stringify(authVerification),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const authorizationUrl = client.authorizationUrl(authParams);
|
|
301
|
+
debug('redirecting to %s', authorizationUrl);
|
|
302
|
+
res.redirect(authorizationUrl);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
next(err);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async logout(params = {}) {
|
|
309
|
+
let { config, req, res, next } = weakRef(this);
|
|
310
|
+
next = once(next);
|
|
311
|
+
let returnURL = params.returnTo || config.routes.postLogoutRedirect;
|
|
312
|
+
debug('req.oidc.logout() with return url: %s', returnURL);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const { client } = await getClient(config);
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Generates the logout URL.
|
|
319
|
+
*
|
|
320
|
+
* Depending on the configuration, this function will either perform a local only logout
|
|
321
|
+
* or a federated logout by redirecting to the appropriate URL.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} idTokenHint - The ID token hint to be used for the logout request.
|
|
324
|
+
* @returns {string} The URL to redirect the user to for logout.
|
|
325
|
+
*/
|
|
326
|
+
const getLogoutUrl = (idTokenHint) => {
|
|
327
|
+
// if idpLogout is not configured, perform a local only logout
|
|
328
|
+
if (!config.idpLogout) {
|
|
329
|
+
debug('performing a local only logout, redirecting to %s', returnURL);
|
|
330
|
+
return returnURL;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// if idpLogout is configured, perform a federated logout
|
|
334
|
+
return client.endSessionUrl({
|
|
335
|
+
...config.logoutParams,
|
|
336
|
+
...(idTokenHint && { id_token_hint: idTokenHint }),
|
|
337
|
+
post_logout_redirect_uri: returnURL,
|
|
338
|
+
...params.logoutParams,
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
if (url.parse(returnURL).host === null) {
|
|
343
|
+
returnURL = urlJoin(config.baseURL, returnURL);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
cancelSilentLogin(req, res);
|
|
347
|
+
|
|
348
|
+
if (!req.oidc.isAuthenticated()) {
|
|
349
|
+
debug('end-user already logged out, redirecting to %s', returnURL);
|
|
350
|
+
|
|
351
|
+
// perform idp logout with no token hint
|
|
352
|
+
return res.redirect(getLogoutUrl(undefined));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { idToken: id_token_hint } = req.oidc;
|
|
356
|
+
req[config.session.name] = undefined;
|
|
357
|
+
|
|
358
|
+
returnURL = getLogoutUrl(id_token_hint);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
return next(err);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
debug('logging out of identity provider, redirecting to %s', returnURL);
|
|
364
|
+
res.redirect(returnURL);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async callback(options = {}) {
|
|
368
|
+
let { config, req, res, transient, next } = weakRef(this);
|
|
369
|
+
next = once(next);
|
|
370
|
+
try {
|
|
371
|
+
const { client, issuer } = await getClient(config);
|
|
372
|
+
const redirectUri = options.redirectUri || this.getRedirectUri();
|
|
373
|
+
|
|
374
|
+
let tokenSet;
|
|
375
|
+
try {
|
|
376
|
+
const callbackParams = client.callbackParams(req);
|
|
377
|
+
const authVerification = transient.getOnce(
|
|
378
|
+
config.transactionCookie.name,
|
|
379
|
+
req,
|
|
380
|
+
res
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const checks = authVerification ? JSON.parse(authVerification) : {};
|
|
384
|
+
|
|
385
|
+
req.openidState = decodeState(checks.state);
|
|
386
|
+
|
|
387
|
+
tokenSet = await client.callback(redirectUri, callbackParams, checks, {
|
|
388
|
+
exchangeBody: {
|
|
389
|
+
...(config && config.tokenEndpointParams),
|
|
390
|
+
...options.tokenEndpointParams,
|
|
391
|
+
},
|
|
392
|
+
clientAssertionPayload: {
|
|
393
|
+
aud: issuer.issuer,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
} catch (error) {
|
|
397
|
+
throw createError(400, error.message, {
|
|
398
|
+
error: error.error,
|
|
399
|
+
error_description: error.error_description,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let session = Object.assign({}, tokenSet); // Remove non-enumerable methods from the TokenSet
|
|
404
|
+
const claims = tokenSet.claims();
|
|
405
|
+
// Must store the `sid` separately as the ID Token gets overridden by
|
|
406
|
+
// ID Token from the Refresh Grant which may not contain a sid (In Auth0 currently).
|
|
407
|
+
session.sid = claims.sid;
|
|
408
|
+
|
|
409
|
+
if (config.afterCallback) {
|
|
410
|
+
session = await config.afterCallback(
|
|
411
|
+
req,
|
|
412
|
+
res,
|
|
413
|
+
session,
|
|
414
|
+
req.openidState
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (req.oidc.isAuthenticated()) {
|
|
419
|
+
if (req.oidc.user.sub === claims.sub) {
|
|
420
|
+
// If it's the same user logging in again, just update the existing session.
|
|
421
|
+
Object.assign(req[config.session.name], session);
|
|
422
|
+
} else {
|
|
423
|
+
// If it's a different user, replace the session to remove any custom user
|
|
424
|
+
// properties on the session
|
|
425
|
+
replaceSession(req, session, config);
|
|
426
|
+
// And regenerate the session id so the previous user wont know the new user's session id
|
|
427
|
+
await regenerateSessionStoreId(req, config);
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
// If a new user is replacing an anonymous session, update the existing session to keep
|
|
431
|
+
// any anonymous session state (eg. checkout basket)
|
|
432
|
+
Object.assign(req[config.session.name], session);
|
|
433
|
+
// But update the session store id so a previous anonymous user wont know the new user's session id
|
|
434
|
+
await regenerateSessionStoreId(req, config);
|
|
435
|
+
}
|
|
436
|
+
resumeSilentLogin(req, res);
|
|
437
|
+
|
|
438
|
+
if (
|
|
439
|
+
req.oidc.isAuthenticated() &&
|
|
440
|
+
config.backchannelLogout &&
|
|
441
|
+
config.backchannelLogout.onLogin !== false
|
|
442
|
+
) {
|
|
443
|
+
await (config.backchannelLogout.onLogin || onLogin)(req, config);
|
|
444
|
+
}
|
|
445
|
+
} catch (err) {
|
|
446
|
+
if (!req.openidState || !req.openidState.attemptingSilentLogin) {
|
|
447
|
+
return next(err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
res.redirect(req.openidState.returnTo || config.baseURL);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async backchannelLogout() {
|
|
454
|
+
let { config, req, res } = weakRef(this);
|
|
455
|
+
res.setHeader('cache-control', 'no-store');
|
|
456
|
+
const logoutToken = req.body.logout_token;
|
|
457
|
+
if (!logoutToken) {
|
|
458
|
+
res.status(400).json({
|
|
459
|
+
error: 'invalid_request',
|
|
460
|
+
error_description: 'Missing logout_token',
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const onToken =
|
|
465
|
+
(config.backchannelLogout && config.backchannelLogout.onLogoutToken) ||
|
|
466
|
+
onLogoutToken;
|
|
467
|
+
let token;
|
|
468
|
+
try {
|
|
469
|
+
const { issuer } = await getClient(config);
|
|
470
|
+
const keyInput = await issuer.keystore();
|
|
471
|
+
|
|
472
|
+
token = await JWT.LogoutToken.verify(logoutToken, keyInput, {
|
|
473
|
+
issuer: issuer.issuer,
|
|
474
|
+
audience: config.clientID,
|
|
475
|
+
algorithms: [config.idTokenSigningAlg],
|
|
476
|
+
});
|
|
477
|
+
} catch (e) {
|
|
478
|
+
res.status(400).json({
|
|
479
|
+
error: 'invalid_request',
|
|
480
|
+
error_description: e.message,
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
await onToken(token, config);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
debug('req.oidc.backchannelLogout() failed with: %s', e.message);
|
|
488
|
+
res.status(400).json({
|
|
489
|
+
error: 'application_error',
|
|
490
|
+
error_description: `The application failed to invalidate the session.`,
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
res.status(204).send();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
module.exports = { RequestContext, ResponseContext };
|
package/lib/cookies.js
ADDED
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const { JWKS, JWK, JWS } = require('jose');
|
|
3
|
+
|
|
4
|
+
const BYTE_LENGTH = 32;
|
|
5
|
+
const ENCRYPTION_INFO = 'JWE CEK';
|
|
6
|
+
const SIGNING_INFO = 'JWS Cookie Signing';
|
|
7
|
+
const DIGEST = 'sha256';
|
|
8
|
+
const ALG = 'HS256';
|
|
9
|
+
const CRITICAL_HEADER_PARAMS = ['b64'];
|
|
10
|
+
|
|
11
|
+
let encryption, signing;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
*
|
|
15
|
+
* Derives appropriate sized keys from the end-user provided secret random string/passphrase using
|
|
16
|
+
* HKDF (HMAC-based Extract-and-Expand Key Derivation Function) defined in RFC 8569.
|
|
17
|
+
*
|
|
18
|
+
* @see https://tools.ietf.org/html/rfc5869
|
|
19
|
+
*
|
|
20
|
+
*/
|
|
21
|
+
/* istanbul ignore else */
|
|
22
|
+
if (crypto.hkdfSync) {
|
|
23
|
+
// added in v15.0.0
|
|
24
|
+
encryption = (secret) =>
|
|
25
|
+
Buffer.from(
|
|
26
|
+
crypto.hkdfSync(
|
|
27
|
+
DIGEST,
|
|
28
|
+
secret,
|
|
29
|
+
Buffer.alloc(0),
|
|
30
|
+
ENCRYPTION_INFO,
|
|
31
|
+
BYTE_LENGTH
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
signing = (secret) =>
|
|
35
|
+
Buffer.from(
|
|
36
|
+
crypto.hkdfSync(
|
|
37
|
+
DIGEST,
|
|
38
|
+
secret,
|
|
39
|
+
Buffer.alloc(0),
|
|
40
|
+
SIGNING_INFO,
|
|
41
|
+
BYTE_LENGTH
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
} else {
|
|
45
|
+
const hkdf = require('futoin-hkdf');
|
|
46
|
+
encryption = (secret) =>
|
|
47
|
+
hkdf(secret, BYTE_LENGTH, { info: ENCRYPTION_INFO, hash: DIGEST });
|
|
48
|
+
signing = (secret) =>
|
|
49
|
+
hkdf(secret, BYTE_LENGTH, { info: SIGNING_INFO, hash: DIGEST });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const getKeyStore = (secret, forEncryption) => {
|
|
53
|
+
let current;
|
|
54
|
+
const secrets = Array.isArray(secret) ? secret : [secret];
|
|
55
|
+
let keystore = new JWKS.KeyStore();
|
|
56
|
+
secrets.forEach((secretString, i) => {
|
|
57
|
+
const key = JWK.asKey(
|
|
58
|
+
forEncryption ? encryption(secretString) : signing(secretString)
|
|
59
|
+
);
|
|
60
|
+
if (i === 0) {
|
|
61
|
+
current = key;
|
|
62
|
+
}
|
|
63
|
+
keystore.add(key);
|
|
64
|
+
});
|
|
65
|
+
return [current, keystore];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const header = { alg: ALG, b64: false, crit: CRITICAL_HEADER_PARAMS };
|
|
69
|
+
|
|
70
|
+
const getPayload = (cookie, value) => Buffer.from(`${cookie}=${value}`);
|
|
71
|
+
const flattenedJWSFromCookie = (cookie, value, signature) => ({
|
|
72
|
+
protected: Buffer.from(JSON.stringify(header))
|
|
73
|
+
.toString('base64')
|
|
74
|
+
.replace(/=/g, '')
|
|
75
|
+
.replace(/\+/g, '-')
|
|
76
|
+
.replace(/\//g, '_'),
|
|
77
|
+
payload: getPayload(cookie, value),
|
|
78
|
+
signature,
|
|
79
|
+
});
|
|
80
|
+
const generateSignature = (cookie, value, key) => {
|
|
81
|
+
const payload = getPayload(cookie, value);
|
|
82
|
+
return JWS.sign.flattened(payload, key, header).signature;
|
|
83
|
+
};
|
|
84
|
+
const verifySignature = (cookie, value, signature, keystore) => {
|
|
85
|
+
try {
|
|
86
|
+
return !!JWS.verify(
|
|
87
|
+
flattenedJWSFromCookie(cookie, value, signature),
|
|
88
|
+
keystore,
|
|
89
|
+
{ algorithms: [ALG], crit: CRITICAL_HEADER_PARAMS }
|
|
90
|
+
);
|
|
91
|
+
// eslint-disable-next-line no-unused-vars
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const verifyCookie = (cookie, value, keystore) => {
|
|
97
|
+
if (!value) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
let signature;
|
|
101
|
+
[value, signature] = value.split('.');
|
|
102
|
+
if (verifySignature(cookie, value, signature, keystore)) {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return undefined;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const signCookie = (cookie, value, key) => {
|
|
110
|
+
const signature = generateSignature(cookie, value, key);
|
|
111
|
+
return `${value}.${signature}`;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
module.exports.signCookie = signCookie;
|
|
115
|
+
module.exports.verifyCookie = verifyCookie;
|
|
116
|
+
|
|
117
|
+
module.exports.getKeyStore = getKeyStore;
|
|
118
|
+
module.exports.encryption = encryption;
|
|
119
|
+
module.exports.signing = signing;
|
package/lib/debug.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const safePromisify = require('../../utils/promisifyCompat');
|
|
2
|
+
const { get: getClient } = require('../../client');
|
|
3
|
+
|
|
4
|
+
// Default hook that checks if the user has been logged out via Back-Channel Logout
|
|
5
|
+
module.exports = async (req, config) => {
|
|
6
|
+
const store =
|
|
7
|
+
(config.backchannelLogout && config.backchannelLogout.store) ||
|
|
8
|
+
config.session.store;
|
|
9
|
+
const get = safePromisify(store.get, store);
|
|
10
|
+
const {
|
|
11
|
+
issuer: { issuer },
|
|
12
|
+
} = await getClient(config);
|
|
13
|
+
const { sid, sub } = req.oidc.idTokenClaims;
|
|
14
|
+
if (!sid && !sub) {
|
|
15
|
+
throw new Error(`The session must have a 'sid' or a 'sub'`);
|
|
16
|
+
}
|
|
17
|
+
const [logoutSid, logoutSub] = await Promise.all([
|
|
18
|
+
sid && get(`${issuer}|${sid}`),
|
|
19
|
+
sub && get(`${issuer}|${sub}`),
|
|
20
|
+
]);
|
|
21
|
+
return !!(logoutSid || logoutSub);
|
|
22
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const safePromisify = require('../../utils/promisifyCompat');
|
|
2
|
+
const { get: getClient } = require('../../client');
|
|
3
|
+
|
|
4
|
+
// Remove any Back-Channel Logout tokens for this `sub` and `sid`
|
|
5
|
+
module.exports = async (req, config) => {
|
|
6
|
+
const {
|
|
7
|
+
issuer: { issuer },
|
|
8
|
+
} = await getClient(config);
|
|
9
|
+
const { session, backchannelLogout } = config;
|
|
10
|
+
const store = (backchannelLogout && backchannelLogout.store) || session.store;
|
|
11
|
+
const destroy = safePromisify(store.destroy, store);
|
|
12
|
+
|
|
13
|
+
// Get the sub and sid from the ID token claims
|
|
14
|
+
const { sub, sid } = req.oidc.idTokenClaims;
|
|
15
|
+
|
|
16
|
+
// Remove both sub and sid based entries
|
|
17
|
+
await Promise.all([
|
|
18
|
+
destroy(`${issuer}|${sub}`),
|
|
19
|
+
sid && destroy(`${issuer}|${sid}`),
|
|
20
|
+
]);
|
|
21
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const safePromisify = require('../../utils/promisifyCompat');
|
|
2
|
+
|
|
3
|
+
// Default hook stores an entry in the logout store for `sid` (if available) and `sub` (if available).
|
|
4
|
+
module.exports = async (token, config) => {
|
|
5
|
+
const {
|
|
6
|
+
session: {
|
|
7
|
+
absoluteDuration,
|
|
8
|
+
rolling: rollingEnabled,
|
|
9
|
+
rollingDuration,
|
|
10
|
+
store,
|
|
11
|
+
},
|
|
12
|
+
backchannelLogout,
|
|
13
|
+
} = config;
|
|
14
|
+
const backchannelLogoutStore =
|
|
15
|
+
(backchannelLogout && backchannelLogout.store) || store;
|
|
16
|
+
const maxAge =
|
|
17
|
+
(rollingEnabled
|
|
18
|
+
? Math.min(absoluteDuration, rollingDuration)
|
|
19
|
+
: absoluteDuration) * 1000;
|
|
20
|
+
const payload = {
|
|
21
|
+
// The "cookie" prop makes the payload compatible with
|
|
22
|
+
// `express-session` stores.
|
|
23
|
+
cookie: {
|
|
24
|
+
expires: Date.now() + maxAge,
|
|
25
|
+
maxAge,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const set = safePromisify(backchannelLogoutStore.set, backchannelLogoutStore);
|
|
29
|
+
const { iss, sid, sub } = token;
|
|
30
|
+
if (!sid && !sub) {
|
|
31
|
+
throw new Error(`The Logout Token must have a 'sid' or a 'sub'`);
|
|
32
|
+
}
|
|
33
|
+
await Promise.all([
|
|
34
|
+
sid && set(`${iss}|${sid}`, payload),
|
|
35
|
+
sub && set(`${iss}|${sub}`, payload),
|
|
36
|
+
]);
|
|
37
|
+
};
|