@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/index.js ADDED
@@ -0,0 +1,9 @@
1
+ const auth = require('./middleware/auth');
2
+ const requiresAuth = require('./middleware/requiresAuth');
3
+ const attemptSilentLogin = require('./middleware/attemptSilentLogin');
4
+
5
+ module.exports = {
6
+ auth,
7
+ ...requiresAuth,
8
+ attemptSilentLogin,
9
+ };
@@ -0,0 +1,420 @@
1
+ const { strict: assert, AssertionError } = require('assert');
2
+ const {
3
+ JWE,
4
+ errors: { JOSEError },
5
+ } = require('jose');
6
+ const safePromisify = require('./utils/promisifyCompat');
7
+ const cookie = require('cookie');
8
+ const onHeaders = require('on-headers');
9
+ const COOKIES = require('./cookies');
10
+ const { getKeyStore, verifyCookie, signCookie } = require('./crypto');
11
+ const debug = require('./debug')('appSession');
12
+
13
+ const epoch = () => (Date.now() / 1000) | 0;
14
+ const MAX_COOKIE_SIZE = 4096;
15
+
16
+ const REASSIGN = Symbol('reassign');
17
+ const REGENERATED_SESSION_ID = Symbol('regenerated_session_id');
18
+
19
+ function attachSessionObject(req, sessionName, value) {
20
+ Object.defineProperty(req, sessionName, {
21
+ enumerable: true,
22
+ get() {
23
+ return value;
24
+ },
25
+ set(arg) {
26
+ if (arg === null || arg === undefined || arg[REASSIGN]) {
27
+ value = arg;
28
+ } else {
29
+ throw new TypeError('session object cannot be reassigned');
30
+ }
31
+ return undefined;
32
+ },
33
+ });
34
+ }
35
+
36
+ async function regenerateSessionStoreId(req, config) {
37
+ if (config.session.store) {
38
+ req[REGENERATED_SESSION_ID] = await config.session.genid(req);
39
+ }
40
+ }
41
+
42
+ function replaceSession(req, session, config) {
43
+ session[REASSIGN] = true;
44
+ req[config.session.name] = session;
45
+ }
46
+
47
+ module.exports = (config) => {
48
+ const alg = 'dir';
49
+ const enc = 'A256GCM';
50
+ const sessionName = config.session.name;
51
+ const cookieConfig = config.session.cookie;
52
+ const {
53
+ genid: generateId,
54
+ absoluteDuration,
55
+ rolling: rollingEnabled,
56
+ rollingDuration,
57
+ signSessionStoreCookie,
58
+ requireSignedSessionStoreCookie,
59
+ } = config.session;
60
+
61
+ const { transient: emptyTransient, ...emptyCookieOptions } = cookieConfig;
62
+ emptyCookieOptions.expires = emptyTransient ? 0 : new Date();
63
+ emptyCookieOptions.path = emptyCookieOptions.path || '/';
64
+
65
+ const emptyCookie = cookie.serialize(
66
+ `${sessionName}.0`,
67
+ '',
68
+ emptyCookieOptions,
69
+ );
70
+ const cookieChunkSize = MAX_COOKIE_SIZE - emptyCookie.length;
71
+
72
+ let [current, keystore] = getKeyStore(config.secret, true);
73
+ if (keystore.size === 1) {
74
+ keystore = current;
75
+ }
76
+
77
+ function encrypt(payload, headers) {
78
+ return JWE.encrypt(payload, current, { alg, enc, ...headers });
79
+ }
80
+
81
+ function decrypt(jwe) {
82
+ return JWE.decrypt(jwe, keystore, {
83
+ complete: true,
84
+ contentEncryptionAlgorithms: [enc],
85
+ keyManagementAlgorithms: [alg],
86
+ });
87
+ }
88
+
89
+ function calculateExp(iat, uat) {
90
+ if (!rollingEnabled) {
91
+ return iat + absoluteDuration;
92
+ }
93
+
94
+ if (!absoluteDuration) {
95
+ return uat + rollingDuration;
96
+ }
97
+
98
+ return Math.min(uat + rollingDuration, iat + absoluteDuration);
99
+ }
100
+
101
+ function setCookie(
102
+ req,
103
+ res,
104
+ { uat = epoch(), iat = uat, exp = calculateExp(iat, uat) },
105
+ ) {
106
+ const cookies = req[COOKIES];
107
+ const { transient: cookieTransient, ...cookieOptions } = cookieConfig;
108
+ cookieOptions.expires = cookieTransient ? 0 : new Date(exp * 1000);
109
+
110
+ // session was deleted or is empty, this matches all session cookies (chunked or unchunked)
111
+ // and clears them, essentially cleaning up what we've set in the past that is now trash
112
+ if (!req[sessionName] || !Object.keys(req[sessionName]).length) {
113
+ debug(
114
+ 'session was deleted or is empty, clearing all matching session cookies',
115
+ );
116
+ for (const cookieName of Object.keys(cookies)) {
117
+ if (cookieName.match(`^${sessionName}(?:\\.\\d)?$`)) {
118
+ clearCookie(cookieName, res);
119
+ }
120
+ }
121
+ } else {
122
+ debug(
123
+ 'found session, creating signed session cookie(s) with name %o(.i)',
124
+ sessionName,
125
+ );
126
+
127
+ const value = encrypt(JSON.stringify(req[sessionName]), {
128
+ iat,
129
+ uat,
130
+ exp,
131
+ });
132
+
133
+ const chunkCount = Math.ceil(value.length / cookieChunkSize);
134
+
135
+ if (chunkCount > 1) {
136
+ debug('cookie size greater than %d, chunking', cookieChunkSize);
137
+ for (let i = 0; i < chunkCount; i++) {
138
+ const chunkValue = value.slice(
139
+ i * cookieChunkSize,
140
+ (i + 1) * cookieChunkSize,
141
+ );
142
+
143
+ const chunkCookieName = `${sessionName}.${i}`;
144
+ res.cookie(chunkCookieName, chunkValue, cookieOptions);
145
+ }
146
+ if (sessionName in cookies) {
147
+ debug('replacing non chunked cookie with chunked cookies');
148
+ clearCookie(sessionName, res);
149
+ }
150
+ } else {
151
+ res.cookie(sessionName, value, cookieOptions);
152
+ for (const cookieName of Object.keys(cookies)) {
153
+ debug('replacing chunked cookies with non chunked cookies');
154
+ if (cookieName.match(`^${sessionName}\\.\\d$`)) {
155
+ clearCookie(cookieName, res);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ function clearCookie(name, res) {
163
+ const { domain, path, sameSite, secure } = cookieConfig;
164
+ res.clearCookie(name, {
165
+ domain,
166
+ path,
167
+ sameSite,
168
+ secure,
169
+ });
170
+ }
171
+
172
+ class CookieStore {
173
+ async get(idOrVal) {
174
+ const { protected: header, cleartext } = decrypt(idOrVal);
175
+ return {
176
+ header,
177
+ data: JSON.parse(cleartext),
178
+ };
179
+ }
180
+
181
+ getCookie(req) {
182
+ return req[COOKIES][sessionName];
183
+ }
184
+
185
+ setCookie(req, res, iat) {
186
+ setCookie(req, res, iat);
187
+ }
188
+ }
189
+
190
+ class CustomStore {
191
+ constructor(store) {
192
+ this._get = safePromisify(store.get, store);
193
+ this._set = safePromisify(store.set, store);
194
+ this._destroy = safePromisify(store.destroy, store);
195
+
196
+ let [current, keystore] = getKeyStore(config.secret);
197
+ if (keystore.size === 1) {
198
+ keystore = current;
199
+ }
200
+ this._keyStore = keystore;
201
+ this._current = current;
202
+ }
203
+
204
+ async get(id) {
205
+ return this._get(id);
206
+ }
207
+
208
+ async set(
209
+ id,
210
+ req,
211
+ res,
212
+ { uat = epoch(), iat = uat, exp = calculateExp(iat, uat) },
213
+ ) {
214
+ const hasPrevSession = !!req[COOKIES][sessionName];
215
+ const replacingPrevSession = !!req[REGENERATED_SESSION_ID];
216
+ const hasCurrentSession =
217
+ req[sessionName] && Object.keys(req[sessionName]).length;
218
+ if (hasPrevSession && (replacingPrevSession || !hasCurrentSession)) {
219
+ await this._destroy(id);
220
+ }
221
+ if (hasCurrentSession) {
222
+ await this._set(req[REGENERATED_SESSION_ID] || id, {
223
+ header: { iat, uat, exp },
224
+ data: req[sessionName],
225
+ cookie: {
226
+ expires: exp * 1000,
227
+ maxAge: exp * 1000 - Date.now(),
228
+ },
229
+ });
230
+ }
231
+ }
232
+
233
+ getCookie(req) {
234
+ if (signSessionStoreCookie) {
235
+ const verified = verifyCookie(
236
+ sessionName,
237
+ req[COOKIES][sessionName],
238
+ this._keyStore,
239
+ );
240
+ if (requireSignedSessionStoreCookie) {
241
+ return verified;
242
+ }
243
+ return verified || req[COOKIES][sessionName];
244
+ }
245
+ return req[COOKIES][sessionName];
246
+ }
247
+
248
+ setCookie(
249
+ id,
250
+ req,
251
+ res,
252
+ { uat = epoch(), iat = uat, exp = calculateExp(iat, uat) },
253
+ ) {
254
+ if (!req[sessionName] || !Object.keys(req[sessionName]).length) {
255
+ if (req[COOKIES][sessionName]) {
256
+ clearCookie(sessionName, res);
257
+ }
258
+ } else {
259
+ const cookieOptions = {
260
+ ...cookieConfig,
261
+ expires: cookieConfig.transient ? 0 : new Date(exp * 1000),
262
+ };
263
+ delete cookieOptions.transient;
264
+ let value = id;
265
+ if (signSessionStoreCookie) {
266
+ value = signCookie(sessionName, id, this._current);
267
+ }
268
+ res.cookie(sessionName, value, cookieOptions);
269
+ }
270
+ }
271
+ }
272
+
273
+ const isCustomStore = !!config.session.store;
274
+ const store = isCustomStore
275
+ ? new CustomStore(config.session.store)
276
+ : new CookieStore();
277
+
278
+ return async (req, res, next) => {
279
+ if (req.hasOwnProperty(sessionName)) {
280
+ debug(
281
+ 'request object (req) already has %o property, this is indicative of a middleware setup problem',
282
+ sessionName,
283
+ );
284
+ return next(
285
+ new Error(
286
+ `req[${sessionName}] is already set, did you run this middleware twice?`,
287
+ ),
288
+ );
289
+ }
290
+
291
+ req[COOKIES] = cookie.parse(req.get('cookie') || '');
292
+
293
+ let iat;
294
+ let uat;
295
+ let exp;
296
+ let existingSessionValue;
297
+
298
+ try {
299
+ if (req[COOKIES].hasOwnProperty(sessionName)) {
300
+ // get JWE from unchunked session cookie
301
+ debug('reading session from %s cookie', sessionName);
302
+ existingSessionValue = store.getCookie(req);
303
+ } else if (req[COOKIES].hasOwnProperty(`${sessionName}.0`)) {
304
+ // get JWE from chunked session cookie
305
+ // iterate all cookie names
306
+ // match and filter for the ones that match sessionName.<number>
307
+ // sort by chunk index
308
+ // concat
309
+ existingSessionValue = Object.entries(req[COOKIES])
310
+ .map(([cookie, value]) => {
311
+ const match = cookie.match(`^${sessionName}\\.(\\d+)$`);
312
+ if (match) {
313
+ return [match[1], value];
314
+ }
315
+ })
316
+ .filter(Boolean)
317
+ .sort(([a], [b]) => {
318
+ return parseInt(a, 10) - parseInt(b, 10);
319
+ })
320
+ .map(([i, chunk]) => {
321
+ debug('reading session chunk from %s.%d cookie', sessionName, i);
322
+ return chunk;
323
+ })
324
+ .join('');
325
+ }
326
+ if (existingSessionValue) {
327
+ const sessionData = await store.get(existingSessionValue);
328
+
329
+ // Handle case where store.get() returns undefined/null due to Redis replication lag
330
+ // or race conditions in multi-instance deployments
331
+ if (!sessionData || typeof sessionData !== 'object') {
332
+ debug(
333
+ 'session data not found or invalid, treating as expired session',
334
+ );
335
+ // Skip to creating new session - this will be handled by the code after the try block
336
+ } else {
337
+ const { header, data } = sessionData;
338
+
339
+ // Ensure header exists and has required properties
340
+ if (!header || typeof header !== 'object') {
341
+ debug(
342
+ 'session header missing or invalid, treating as expired session',
343
+ );
344
+ } else {
345
+ ({ iat, uat, exp } = header);
346
+
347
+ // check that the existing session isn't expired based on options when it was established
348
+ assert(
349
+ exp > epoch(),
350
+ 'it is expired based on options when it was established',
351
+ );
352
+
353
+ // check that the existing session isn't expired based on current rollingDuration rules
354
+ if (rollingDuration) {
355
+ assert(
356
+ uat + rollingDuration > epoch(),
357
+ 'it is expired based on current rollingDuration rules',
358
+ );
359
+ }
360
+
361
+ // check that the existing session isn't expired based on current absoluteDuration rules
362
+ if (absoluteDuration) {
363
+ assert(
364
+ iat + absoluteDuration > epoch(),
365
+ 'it is expired based on current absoluteDuration rules',
366
+ );
367
+ }
368
+
369
+ attachSessionObject(req, sessionName, data);
370
+ }
371
+ }
372
+ }
373
+ } catch (err) {
374
+ if (err instanceof AssertionError) {
375
+ debug('existing session was rejected because', err.message);
376
+ } else if (err instanceof JOSEError) {
377
+ debug(
378
+ 'existing session was rejected because it could not be decrypted',
379
+ err,
380
+ );
381
+ } else {
382
+ debug('unexpected error handling session', err);
383
+ }
384
+ }
385
+
386
+ if (!req.hasOwnProperty(sessionName) || !req[sessionName]) {
387
+ attachSessionObject(req, sessionName, {});
388
+ }
389
+
390
+ if (isCustomStore) {
391
+ const id = existingSessionValue || (await generateId(req));
392
+
393
+ onHeaders(res, () =>
394
+ store.setCookie(req[REGENERATED_SESSION_ID] || id, req, res, { iat }),
395
+ );
396
+
397
+ const { end: origEnd } = res;
398
+ res.end = async function resEnd(...args) {
399
+ try {
400
+ await store.set(id, req, res, {
401
+ iat,
402
+ });
403
+ origEnd.call(res, ...args);
404
+ } catch (e) {
405
+ // need to restore the original `end` so that it gets
406
+ // called after `next(e)` calls the express error handling mw
407
+ res.end = origEnd;
408
+ process.nextTick(() => next(e));
409
+ }
410
+ };
411
+ } else {
412
+ onHeaders(res, () => store.setCookie(req, res, { iat }));
413
+ }
414
+
415
+ return next();
416
+ };
417
+ };
418
+
419
+ module.exports.regenerateSessionStoreId = regenerateSessionStoreId;
420
+ module.exports.replaceSession = replaceSession;
package/lib/client.js ADDED
@@ -0,0 +1,167 @@
1
+ const { Issuer, custom } = require('openid-client');
2
+ const url = require('url');
3
+ const urlJoin = require('url-join');
4
+ const pkg = require('../package.json');
5
+ const debug = require('./debug')('client');
6
+ const { JWK } = require('jose');
7
+
8
+ const telemetryHeader = {
9
+ name: 'express-oidc',
10
+ version: pkg.version,
11
+ env: {
12
+ node: process.version,
13
+ },
14
+ };
15
+
16
+ function sortSpaceDelimitedString(string) {
17
+ return string.split(' ').sort().join(' ');
18
+ }
19
+
20
+ async function get(config) {
21
+ const defaultHttpOptions = (options) => {
22
+ options.headers = {
23
+ ...options.headers,
24
+ 'User-Agent': config.httpUserAgent || `${pkg.name}/${pkg.version}`,
25
+ ...(config.enableTelemetry
26
+ ? {
27
+ 'Auth0-Client': Buffer.from(
28
+ JSON.stringify(telemetryHeader)
29
+ ).toString('base64'),
30
+ }
31
+ : undefined),
32
+ };
33
+ options.timeout = config.httpTimeout;
34
+ options.agent = config.httpAgent;
35
+ return options;
36
+ };
37
+
38
+ const applyHttpOptionsCustom = (entity) =>
39
+ (entity[custom.http_options] = defaultHttpOptions);
40
+
41
+ applyHttpOptionsCustom(Issuer);
42
+ const issuer = await Issuer.discover(config.issuerBaseURL);
43
+ applyHttpOptionsCustom(issuer);
44
+
45
+ const issuerTokenAlgs = Array.isArray(
46
+ issuer.id_token_signing_alg_values_supported
47
+ )
48
+ ? issuer.id_token_signing_alg_values_supported
49
+ : [];
50
+ if (!issuerTokenAlgs.includes(config.idTokenSigningAlg)) {
51
+ debug(
52
+ 'ID token algorithm %o is not supported by the issuer. Supported ID token algorithms are: %o.',
53
+ config.idTokenSigningAlg,
54
+ issuerTokenAlgs
55
+ );
56
+ }
57
+
58
+ const configRespType = sortSpaceDelimitedString(
59
+ config.authorizationParams.response_type
60
+ );
61
+ const issuerRespTypes = Array.isArray(issuer.response_types_supported)
62
+ ? issuer.response_types_supported
63
+ : [];
64
+ issuerRespTypes.map(sortSpaceDelimitedString);
65
+ if (!issuerRespTypes.includes(configRespType)) {
66
+ debug(
67
+ 'Response type %o is not supported by the issuer. ' +
68
+ 'Supported response types are: %o.',
69
+ configRespType,
70
+ issuerRespTypes
71
+ );
72
+ }
73
+
74
+ const configRespMode = config.authorizationParams.response_mode;
75
+ const issuerRespModes = Array.isArray(issuer.response_modes_supported)
76
+ ? issuer.response_modes_supported
77
+ : [];
78
+ if (configRespMode && !issuerRespModes.includes(configRespMode)) {
79
+ debug(
80
+ 'Response mode %o is not supported by the issuer. ' +
81
+ 'Supported response modes are %o.',
82
+ configRespMode,
83
+ issuerRespModes
84
+ );
85
+ }
86
+
87
+ if (
88
+ config.pushedAuthorizationRequests &&
89
+ !issuer.pushed_authorization_request_endpoint
90
+ ) {
91
+ throw new TypeError(
92
+ 'pushed_authorization_request_endpoint must be configured on the issuer to use pushedAuthorizationRequests'
93
+ );
94
+ }
95
+
96
+ let jwks;
97
+ if (config.clientAssertionSigningKey) {
98
+ const jwk = JWK.asKey(config.clientAssertionSigningKey).toJWK(true);
99
+ jwks = { keys: [jwk] };
100
+ }
101
+
102
+ const client = new issuer.Client(
103
+ {
104
+ client_id: config.clientID,
105
+ client_secret: config.clientSecret,
106
+ id_token_signed_response_alg: config.idTokenSigningAlg,
107
+ token_endpoint_auth_method: config.clientAuthMethod,
108
+ ...(config.clientAssertionSigningAlg && {
109
+ token_endpoint_auth_signing_alg: config.clientAssertionSigningAlg,
110
+ }),
111
+ },
112
+ jwks
113
+ );
114
+ applyHttpOptionsCustom(client);
115
+ client[custom.clock_tolerance] = config.clockTolerance;
116
+
117
+ if (config.idpLogout) {
118
+ if (
119
+ config.auth0Logout ||
120
+ (url.parse(issuer.issuer).hostname.match('\\.auth0\\.com$') &&
121
+ config.auth0Logout !== false)
122
+ ) {
123
+ Object.defineProperty(client, 'endSessionUrl', {
124
+ value(params) {
125
+ const { id_token_hint, post_logout_redirect_uri, ...extraParams } =
126
+ params;
127
+ const parsedUrl = url.parse(urlJoin(issuer.issuer, '/v2/logout'));
128
+ parsedUrl.query = {
129
+ ...extraParams,
130
+ returnTo: post_logout_redirect_uri,
131
+ client_id: client.client_id,
132
+ };
133
+
134
+ Object.entries(parsedUrl.query).forEach(([key, value]) => {
135
+ if (value === null || value === undefined) {
136
+ delete parsedUrl.query[key];
137
+ }
138
+ });
139
+
140
+ return url.format(parsedUrl);
141
+ },
142
+ });
143
+ } else if (!issuer.end_session_endpoint) {
144
+ debug('the issuer does not support RP-Initiated Logout');
145
+ }
146
+ }
147
+
148
+ return { client, issuer };
149
+ }
150
+
151
+ const cache = new Map();
152
+ let timestamp = 0;
153
+
154
+ exports.get = (config) => {
155
+ const { discoveryCacheMaxAge: cacheMaxAge } = config;
156
+ const now = Date.now();
157
+ if (cache.has(config) && now < timestamp + cacheMaxAge) {
158
+ return cache.get(config);
159
+ }
160
+ timestamp = now;
161
+ const promise = get(config).catch((e) => {
162
+ cache.delete(config);
163
+ throw e;
164
+ });
165
+ cache.set(config, promise);
166
+ return promise;
167
+ };