@igxjs/node-components 1.0.10 → 1.0.12
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/README.md +100 -6
- package/components/assets/template.html +111 -0
- package/components/http-handlers.js +46 -33
- package/components/jwt.js +8 -6
- package/components/logger.js +131 -0
- package/components/redis.js +18 -10
- package/components/session.js +580 -110
- package/index.d.ts +127 -18
- package/index.js +2 -1
- package/package.json +3 -3
package/components/session.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
1
6
|
import axios from 'axios';
|
|
2
7
|
import session from 'express-session';
|
|
3
8
|
import memStore from 'memorystore';
|
|
@@ -6,12 +11,29 @@ import { RedisStore } from 'connect-redis';
|
|
|
6
11
|
import { CustomError, httpCodes, httpHelper, httpMessages } from './http-handlers.js';
|
|
7
12
|
import { JwtManager } from './jwt.js';
|
|
8
13
|
import { RedisManager } from './redis.js';
|
|
14
|
+
import { Logger } from './logger.js';
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Session authentication mode constants
|
|
21
|
+
*/
|
|
22
|
+
export const SessionMode = {
|
|
23
|
+
SESSION: 'session',
|
|
24
|
+
TOKEN: 'token'
|
|
25
|
+
};
|
|
9
26
|
|
|
10
27
|
/**
|
|
11
28
|
* Session configuration options
|
|
12
|
-
* Uses strict UPPERCASE naming convention for all property names
|
|
13
29
|
*/
|
|
14
30
|
export class SessionConfig {
|
|
31
|
+
/**
|
|
32
|
+
* @type {string} Authentication mode for protected routes
|
|
33
|
+
* - Supported values: SessionMode.SESSION | SessionMode.TOKEN
|
|
34
|
+
* @default SessionMode.SESSION
|
|
35
|
+
*/
|
|
36
|
+
SESSION_MODE;
|
|
15
37
|
/** @type {string} */
|
|
16
38
|
SSO_ENDPOINT_URL;
|
|
17
39
|
/** @type {string} */
|
|
@@ -23,35 +45,69 @@ export class SessionConfig {
|
|
|
23
45
|
/** @type {string} */
|
|
24
46
|
SSO_FAILURE_URL;
|
|
25
47
|
|
|
26
|
-
/** @type {number} */
|
|
48
|
+
/** @type {number} Session age in milliseconds */
|
|
27
49
|
SESSION_AGE;
|
|
28
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* @type {string} Session cookie path
|
|
52
|
+
* @default '/'
|
|
53
|
+
*/
|
|
29
54
|
SESSION_COOKIE_PATH;
|
|
30
|
-
/** @type {string} */
|
|
55
|
+
/** @type {string} Session secret */
|
|
31
56
|
SESSION_SECRET;
|
|
32
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* @type {string}
|
|
59
|
+
* @default 'ibmid:'
|
|
60
|
+
*/
|
|
33
61
|
SESSION_PREFIX;
|
|
34
|
-
|
|
35
|
-
|
|
62
|
+
/**
|
|
63
|
+
* @type {string} Session key
|
|
64
|
+
* - In the `SessionMode.SESSION` mode, this is the key used to store the user in the session.
|
|
65
|
+
* - In the `SessionMode.TOKEN` mode, this is the key of localStorage where the user is stored.
|
|
66
|
+
* @default 'session_token'
|
|
67
|
+
*/
|
|
68
|
+
SESSION_KEY;
|
|
69
|
+
/**
|
|
70
|
+
* @type {string} Session expiry key
|
|
71
|
+
* - In the `SessionMode.TOKEN` mode, this is the key of localStorage where the session expiry timestamp is stored.
|
|
72
|
+
* @default 'session_expires_at'
|
|
73
|
+
*/
|
|
74
|
+
SESSION_EXPIRY_KEY;
|
|
75
|
+
/**
|
|
76
|
+
* @type {string} Path to custom HTML template for TOKEN mode callback
|
|
77
|
+
* - Used to customize the redirect page that stores JWT token and expiry in localStorage
|
|
78
|
+
* - Supports placeholders: {{SESSION_DATA_KEY}}, {{SESSION_DATA_VALUE}}, {{SESSION_EXPIRY_KEY}}, {{SESSION_EXPIRY_VALUE}}, {{SSO_SUCCESS_URL}}, {{SSO_FAILURE_URL}}
|
|
79
|
+
* - If not provided, uses default template
|
|
80
|
+
*/
|
|
81
|
+
TOKEN_STORAGE_TEMPLATE_PATH;
|
|
82
|
+
/** @type {string} Redis URL */
|
|
36
83
|
REDIS_URL;
|
|
37
|
-
/** @type {string} */
|
|
84
|
+
/** @type {string} Redis certificate path */
|
|
38
85
|
REDIS_CERT_PATH;
|
|
39
|
-
|
|
40
|
-
|
|
86
|
+
/**
|
|
87
|
+
* @type {string} Algorithm used to encrypt the JWT
|
|
88
|
+
* @default 'dir'
|
|
89
|
+
*/
|
|
41
90
|
JWT_ALGORITHM;
|
|
42
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* @type {string} Encryption algorithm used to encrypt the JWT
|
|
93
|
+
* @default 'A256GCM'
|
|
94
|
+
*/
|
|
43
95
|
JWT_ENCRYPTION;
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
96
|
+
/**
|
|
97
|
+
* @type {number} Clock tolerance in seconds
|
|
98
|
+
* @default 30
|
|
99
|
+
*/
|
|
47
100
|
JWT_CLOCK_TOLERANCE;
|
|
48
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* @type {string} Hash algorithm used to hash the JWT secret
|
|
103
|
+
* @default 'SHA-256'
|
|
104
|
+
*/
|
|
49
105
|
JWT_SECRET_HASH_ALGORITHM;
|
|
50
|
-
/** @type {string} */
|
|
106
|
+
/** @type {string?} JWT issuer claim */
|
|
51
107
|
JWT_ISSUER;
|
|
52
|
-
/** @type {string} */
|
|
108
|
+
/** @type {string?} JWT audience claim */
|
|
53
109
|
JWT_AUDIENCE;
|
|
54
|
-
/** @type {string} */
|
|
110
|
+
/** @type {string?} JWT subject claim */
|
|
55
111
|
JWT_SUBJECT;
|
|
56
112
|
}
|
|
57
113
|
|
|
@@ -66,6 +122,8 @@ export class SessionManager {
|
|
|
66
122
|
#idpRequest = null;
|
|
67
123
|
/** @type {import('./jwt.js').JwtManager} */
|
|
68
124
|
#jwtManager = null;
|
|
125
|
+
/** @type {import('./logger.js').Logger} */
|
|
126
|
+
#logger = Logger.getInstance('SessionManager');
|
|
69
127
|
|
|
70
128
|
/**
|
|
71
129
|
* Create a new session manager
|
|
@@ -73,11 +131,17 @@ export class SessionManager {
|
|
|
73
131
|
*/
|
|
74
132
|
constructor(config) {
|
|
75
133
|
this.#config = {
|
|
134
|
+
// Session Mode
|
|
135
|
+
SESSION_MODE: config.SESSION_MODE || SessionMode.SESSION,
|
|
76
136
|
// Session
|
|
77
137
|
SESSION_AGE: config.SESSION_AGE || 64800000,
|
|
78
138
|
SESSION_COOKIE_PATH: config.SESSION_COOKIE_PATH || '/',
|
|
79
139
|
SESSION_SECRET: config.SESSION_SECRET,
|
|
80
140
|
SESSION_PREFIX: config.SESSION_PREFIX || 'ibmid:',
|
|
141
|
+
SESSION_KEY: config.SESSION_KEY || 'session_token',
|
|
142
|
+
SESSION_EXPIRY_KEY: config.SESSION_EXPIRY_KEY || 'session_expires_at',
|
|
143
|
+
TOKEN_STORAGE_TEMPLATE_PATH: config.TOKEN_STORAGE_TEMPLATE_PATH,
|
|
144
|
+
|
|
81
145
|
// Identity Provider
|
|
82
146
|
SSO_ENDPOINT_URL: config.SSO_ENDPOINT_URL,
|
|
83
147
|
SSO_CLIENT_ID: config.SSO_CLIENT_ID,
|
|
@@ -87,10 +151,10 @@ export class SessionManager {
|
|
|
87
151
|
// Redis
|
|
88
152
|
REDIS_URL: config.REDIS_URL,
|
|
89
153
|
REDIS_CERT_PATH: config.REDIS_CERT_PATH,
|
|
154
|
+
|
|
90
155
|
// JWT Manager
|
|
91
156
|
JWT_ALGORITHM: config.JWT_ALGORITHM || 'dir',
|
|
92
157
|
JWT_ENCRYPTION: config.JWT_ENCRYPTION || 'A256GCM',
|
|
93
|
-
JWT_EXPIRATION_TIME: config.JWT_EXPIRATION_TIME || '10m',
|
|
94
158
|
JWT_CLOCK_TOLERANCE: config.JWT_CLOCK_TOLERANCE ?? 30,
|
|
95
159
|
JWT_SECRET_HASH_ALGORITHM: config.JWT_SECRET_HASH_ALGORITHM || 'SHA-256',
|
|
96
160
|
JWT_ISSUER: config.JWT_ISSUER,
|
|
@@ -138,7 +202,28 @@ export class SessionManager {
|
|
|
138
202
|
* @returns {string} Returns the session key
|
|
139
203
|
*/
|
|
140
204
|
#getSessionKey() {
|
|
141
|
-
return
|
|
205
|
+
return this.#config.SESSION_KEY;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get Redis key for token storage
|
|
210
|
+
* @param {string} email User email
|
|
211
|
+
* @param {string} tid Token ID
|
|
212
|
+
* @returns {string} Returns the Redis key for token storage
|
|
213
|
+
* @private
|
|
214
|
+
*/
|
|
215
|
+
#getTokenRedisKey(email, tid) {
|
|
216
|
+
return `${this.#config.SESSION_KEY}:t:${email}:${tid}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get Redis key pattern for all user tokens
|
|
221
|
+
* @param {string} email User email
|
|
222
|
+
* @returns {string} Returns the Redis key pattern for all user tokens
|
|
223
|
+
* @private
|
|
224
|
+
*/
|
|
225
|
+
#getTokenRedisPattern(email) {
|
|
226
|
+
return `${this.#config.SESSION_KEY}:t:${email}:*`;
|
|
142
227
|
}
|
|
143
228
|
|
|
144
229
|
/**
|
|
@@ -149,6 +234,368 @@ export class SessionManager {
|
|
|
149
234
|
return this.#redisManager;
|
|
150
235
|
}
|
|
151
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Generate and store JWT token in Redis
|
|
239
|
+
* - JWT payload contains only { email, tid } for minimal size
|
|
240
|
+
* - Full user data is stored in Redis as single source of truth
|
|
241
|
+
* @param {object} user User object
|
|
242
|
+
* @returns {Promise<string>} Returns the generated JWT token
|
|
243
|
+
* @private
|
|
244
|
+
*/
|
|
245
|
+
async #generateAndStoreToken(user) {
|
|
246
|
+
// Generate unique token ID for this device/session
|
|
247
|
+
const tid = crypto.randomUUID();
|
|
248
|
+
const ttlSeconds = Math.floor(this.#config.SESSION_AGE / 1000);
|
|
249
|
+
// Create JWT token with only email and tid (minimal payload)
|
|
250
|
+
const token = await this.#jwtManager.encrypt(
|
|
251
|
+
{ email: user.email, tid },
|
|
252
|
+
this.#config.SESSION_SECRET,
|
|
253
|
+
{ expirationTime: ttlSeconds }
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Store user data in Redis with TTL
|
|
257
|
+
const redisKey = this.#getTokenRedisKey(user.email, tid);
|
|
258
|
+
|
|
259
|
+
await this.#redisManager.getClient().setEx(
|
|
260
|
+
redisKey,
|
|
261
|
+
ttlSeconds,
|
|
262
|
+
JSON.stringify(user)
|
|
263
|
+
);
|
|
264
|
+
this.#logger.debug(`### TOKEN GENERATED: ${user.email} ###`);
|
|
265
|
+
return token;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Verify token authentication
|
|
270
|
+
* @param {import('@types/express').Request} req Request
|
|
271
|
+
* @param {import('@types/express').Response} res Response
|
|
272
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
273
|
+
* @param {boolean} isDebugging Debugging flag
|
|
274
|
+
* @param {string} redirectUrl Redirect URL
|
|
275
|
+
* @private
|
|
276
|
+
*/
|
|
277
|
+
async #verifyToken(req, res, next, isDebugging, redirectUrl) {
|
|
278
|
+
try {
|
|
279
|
+
// Extract token from Authorization header
|
|
280
|
+
const authHeader = req.headers.authorization;
|
|
281
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
282
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'Missing or invalid authorization header');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
286
|
+
|
|
287
|
+
// Decrypt JWT token
|
|
288
|
+
const { payload } = await this.#jwtManager.decrypt(
|
|
289
|
+
token,
|
|
290
|
+
this.#config.SESSION_SECRET
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Extract email and token ID
|
|
294
|
+
const { email, tid } = payload;
|
|
295
|
+
if (!email || !tid) {
|
|
296
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'Invalid token payload');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Lookup user in Redis
|
|
300
|
+
const redisKey = this.#getTokenRedisKey(email, tid);
|
|
301
|
+
const userData = await this.#redisManager.getClient().get(redisKey);
|
|
302
|
+
|
|
303
|
+
if (!userData) {
|
|
304
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'Token not found or expired');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Parse and attach user to request
|
|
308
|
+
req.user = JSON.parse(userData);
|
|
309
|
+
res.locals.user = req.user;
|
|
310
|
+
|
|
311
|
+
// Validate authorization
|
|
312
|
+
const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
|
|
313
|
+
if (!authorized && !isDebugging) {
|
|
314
|
+
throw new CustomError(httpCodes.FORBIDDEN, 'User is not authorized');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return next();
|
|
318
|
+
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (isDebugging) {
|
|
321
|
+
this.#logger.warn('### TOKEN VERIFICATION FAILED (debugging mode) ###', error.message);
|
|
322
|
+
return next();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (redirectUrl) {
|
|
326
|
+
return res.redirect(redirectUrl);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Handle specific JWT errors
|
|
330
|
+
if (error.code === 'ERR_JWT_EXPIRED') {
|
|
331
|
+
return next(new CustomError(httpCodes.UNAUTHORIZED, 'Token expired'));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return next(error instanceof CustomError ? error :
|
|
335
|
+
new CustomError(httpCodes.UNAUTHORIZED, 'Token verification failed'));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Verify session authentication
|
|
341
|
+
* @param {import('@types/express').Request} req Request
|
|
342
|
+
* @param {import('@types/express').Response} res Response
|
|
343
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
344
|
+
* @param {boolean} isDebugging Debugging flag
|
|
345
|
+
* @param {string} redirectUrl Redirect URL
|
|
346
|
+
* @private
|
|
347
|
+
*/
|
|
348
|
+
async #verifySession(req, res, next, isDebugging, redirectUrl) {
|
|
349
|
+
const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
|
|
350
|
+
if (authorized) {
|
|
351
|
+
return next();
|
|
352
|
+
}
|
|
353
|
+
if (redirectUrl) {
|
|
354
|
+
return res.redirect(redirectUrl);
|
|
355
|
+
}
|
|
356
|
+
return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Refresh token authentication
|
|
361
|
+
* @param {import('@types/express').Request} req Request
|
|
362
|
+
* @param {import('@types/express').Response} res Response
|
|
363
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
364
|
+
* @param {(user: object) => object} initUser Initialize user function
|
|
365
|
+
* @param {string} idpUrl Identity provider URL
|
|
366
|
+
* @private
|
|
367
|
+
*/
|
|
368
|
+
async #refreshToken(req, res, next, initUser, idpUrl) {
|
|
369
|
+
try {
|
|
370
|
+
// Get current user from verifyToken middleware
|
|
371
|
+
const { email, attributes } = req.user || {};
|
|
372
|
+
|
|
373
|
+
if (!email) {
|
|
374
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Extract Token ID from current token
|
|
378
|
+
const authHeader = req.headers.authorization;
|
|
379
|
+
const token = authHeader?.substring(7);
|
|
380
|
+
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
381
|
+
const { tid: oldTokenId } = payload;
|
|
382
|
+
|
|
383
|
+
// Check refresh lock
|
|
384
|
+
if (this.hasLock(email)) {
|
|
385
|
+
throw new CustomError(httpCodes.CONFLICT, 'Token refresh is locked');
|
|
386
|
+
}
|
|
387
|
+
this.lock(email);
|
|
388
|
+
|
|
389
|
+
// Call SSO refresh endpoint
|
|
390
|
+
const response = await this.#idpRequest.post(idpUrl, {
|
|
391
|
+
user: {
|
|
392
|
+
email,
|
|
393
|
+
attributes: {
|
|
394
|
+
idp: attributes?.idp,
|
|
395
|
+
refresh_token: attributes?.refresh_token
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (response.status !== httpCodes.OK) {
|
|
401
|
+
throw new CustomError(response.status, response.statusText);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Decrypt new user data from SSO
|
|
405
|
+
const { jwt } = response.data;
|
|
406
|
+
const { payload: newPayload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
407
|
+
|
|
408
|
+
if (!newPayload?.user) {
|
|
409
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload from SSO');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Initialize user with new data
|
|
413
|
+
const user = initUser(newPayload.user);
|
|
414
|
+
|
|
415
|
+
// Generate new token
|
|
416
|
+
const newToken = await this.#generateAndStoreToken(user);
|
|
417
|
+
|
|
418
|
+
// Remove old token from Redis
|
|
419
|
+
const oldRedisKey = this.#getTokenRedisKey(email, oldTokenId);
|
|
420
|
+
await this.#redisManager.getClient().del(oldRedisKey);
|
|
421
|
+
|
|
422
|
+
this.#logger.debug('### TOKEN REFRESHED SUCCESSFULLY ###');
|
|
423
|
+
|
|
424
|
+
// Return new token
|
|
425
|
+
return res.json({
|
|
426
|
+
token: newToken,
|
|
427
|
+
user,
|
|
428
|
+
expiresIn: Math.floor(this.#config.SESSION_AGE / 1000),
|
|
429
|
+
tokenType: 'Bearer'
|
|
430
|
+
});
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return next(httpHelper.handleAxiosError(error));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Refresh session authentication
|
|
438
|
+
* @param {import('@types/express').Request} req Request
|
|
439
|
+
* @param {import('@types/express').Response} res Response
|
|
440
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
441
|
+
* @param {(user: object) => object} initUser Initialize user function
|
|
442
|
+
* @param {string} idpUrl Identity provider URL
|
|
443
|
+
* @private
|
|
444
|
+
*/
|
|
445
|
+
async #refreshSession(req, res, next, initUser, idpUrl) {
|
|
446
|
+
try {
|
|
447
|
+
const { email, attributes } = req.user || { email: '', attributes: {} };
|
|
448
|
+
if (this.hasLock(email)) {
|
|
449
|
+
throw new CustomError(httpCodes.CONFLICT, 'User refresh is locked');
|
|
450
|
+
}
|
|
451
|
+
this.lock(email);
|
|
452
|
+
const response = await this.#idpRequest.post(idpUrl, {
|
|
453
|
+
user: {
|
|
454
|
+
email,
|
|
455
|
+
attributes: {
|
|
456
|
+
idp: attributes?.idp,
|
|
457
|
+
refresh_token: attributes?.refresh_token
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
if (response.status === httpCodes.OK) {
|
|
462
|
+
const { jwt } = response.data;
|
|
463
|
+
const payload = await this.#saveSession(req, jwt, initUser);
|
|
464
|
+
return res.json(payload);
|
|
465
|
+
}
|
|
466
|
+
throw new CustomError(response.status, response.statusText);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return next(httpHelper.handleAxiosError(error));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Logout all tokens for a user
|
|
474
|
+
* @param {import('@types/express').Request} req Request
|
|
475
|
+
* @param {import('@types/express').Response} res Response
|
|
476
|
+
* @param {boolean} isRedirect Whether to redirect
|
|
477
|
+
* @private
|
|
478
|
+
*/
|
|
479
|
+
async #logoutAllTokens(req, res, isRedirect) {
|
|
480
|
+
try {
|
|
481
|
+
const { email } = req.user || {};
|
|
482
|
+
|
|
483
|
+
if (!email) {
|
|
484
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Find all tokens for this user
|
|
488
|
+
const pattern = this.#getTokenRedisPattern(email);
|
|
489
|
+
const keys = await this.#redisManager.getClient().keys(pattern);
|
|
490
|
+
|
|
491
|
+
// Delete all tokens
|
|
492
|
+
if (keys.length > 0) {
|
|
493
|
+
await this.#redisManager.getClient().del(keys);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
this.#logger.info(`### ALL TOKENS LOGGED OUT: ${email} (${keys.length} tokens) ###`);
|
|
497
|
+
|
|
498
|
+
if (isRedirect) {
|
|
499
|
+
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
500
|
+
}
|
|
501
|
+
return res.json({
|
|
502
|
+
message: 'All tokens logged out successfully',
|
|
503
|
+
tokensRemoved: keys.length,
|
|
504
|
+
redirect_url: this.#config.SSO_SUCCESS_URL
|
|
505
|
+
});
|
|
506
|
+
} catch (error) {
|
|
507
|
+
this.#logger.error('### LOGOUT ALL TOKENS ERROR ###', error);
|
|
508
|
+
if (isRedirect) {
|
|
509
|
+
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
510
|
+
}
|
|
511
|
+
return res.status(httpCodes.SYSTEM_FAILURE).json({
|
|
512
|
+
error: 'Logout all failed',
|
|
513
|
+
redirect_url: this.#config.SSO_FAILURE_URL
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Logout token authentication
|
|
520
|
+
* @param {import('@types/express').Request} req Request
|
|
521
|
+
* @param {import('@types/express').Response} res Response
|
|
522
|
+
* @param {boolean} isRedirect Whether to redirect
|
|
523
|
+
* @param {boolean} logoutAll Whether to logout all tokens
|
|
524
|
+
* @private
|
|
525
|
+
*/
|
|
526
|
+
async #logoutToken(req, res, isRedirect, logoutAll = false) {
|
|
527
|
+
// If logoutAll is true, delegate to the all tokens logout method
|
|
528
|
+
if (logoutAll) {
|
|
529
|
+
return this.#logoutAllTokens(req, res, isRedirect);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
// Extract Token ID from current token
|
|
534
|
+
const authHeader = req.headers.authorization;
|
|
535
|
+
const token = authHeader?.substring(7);
|
|
536
|
+
|
|
537
|
+
if (!token) {
|
|
538
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'No token provided');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
542
|
+
const { email, tid } = payload;
|
|
543
|
+
|
|
544
|
+
if (!email || !tid) {
|
|
545
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid token payload');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Remove token from Redis
|
|
549
|
+
const redisKey = this.#getTokenRedisKey(email, tid);
|
|
550
|
+
await this.#redisManager.getClient().del(redisKey);
|
|
551
|
+
|
|
552
|
+
this.#logger.info('### TOKEN LOGOUT SUCCESSFULLY ###');
|
|
553
|
+
|
|
554
|
+
if (isRedirect) {
|
|
555
|
+
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
556
|
+
}
|
|
557
|
+
return res.json({
|
|
558
|
+
message: 'Logout successful',
|
|
559
|
+
redirect_url: this.#config.SSO_SUCCESS_URL
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
} catch (error) {
|
|
563
|
+
this.#logger.error('### TOKEN LOGOUT ERROR ###', error);
|
|
564
|
+
if (isRedirect) {
|
|
565
|
+
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
566
|
+
}
|
|
567
|
+
return res.status(httpCodes.SYSTEM_FAILURE).json({
|
|
568
|
+
error: 'Logout failed',
|
|
569
|
+
redirect_url: this.#config.SSO_FAILURE_URL
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Logout session authentication
|
|
576
|
+
* @param {import('@types/express').Request} req Request
|
|
577
|
+
* @param {import('@types/express').Response} res Response
|
|
578
|
+
* @param {Function} callback Callback function
|
|
579
|
+
* @private
|
|
580
|
+
*/
|
|
581
|
+
#logoutSession(req, res, callback) {
|
|
582
|
+
try {
|
|
583
|
+
res.clearCookie('connect.sid');
|
|
584
|
+
} catch (error) {
|
|
585
|
+
this.#logger.error('### CLEAR COOKIE ERROR ###');
|
|
586
|
+
this.#logger.error(error);
|
|
587
|
+
}
|
|
588
|
+
return req.session.destroy((sessionError) => {
|
|
589
|
+
if (sessionError) {
|
|
590
|
+
this.#logger.error('### SESSION DESTROY CALLBACK ERROR ###');
|
|
591
|
+
this.#logger.error(sessionError);
|
|
592
|
+
return callback(sessionError);
|
|
593
|
+
}
|
|
594
|
+
this.#logger.info('### SESSION LOGOUT SUCCESSFULLY ###');
|
|
595
|
+
return callback(null);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
152
599
|
/**
|
|
153
600
|
* Setup the session/user handlers with configurations
|
|
154
601
|
* @param {import('@types/express').Application} app Express application
|
|
@@ -173,7 +620,7 @@ export class SessionManager {
|
|
|
173
620
|
*/
|
|
174
621
|
#redisSession() {
|
|
175
622
|
// Redis Session
|
|
176
|
-
|
|
623
|
+
this.#logger.log('### Using Redis as the Session Store ###');
|
|
177
624
|
return session({
|
|
178
625
|
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
179
626
|
store: new RedisStore({ client: this.#redisManager.getClient(), prefix: this.#config.SESSION_PREFIX, disableTouch: true }),
|
|
@@ -188,7 +635,7 @@ export class SessionManager {
|
|
|
188
635
|
*/
|
|
189
636
|
#memorySession() {
|
|
190
637
|
// Memory Session
|
|
191
|
-
|
|
638
|
+
this.#logger.log('### Using Memory as the Session Store ###');
|
|
192
639
|
const MemoryStore = memStore(session);
|
|
193
640
|
return session({
|
|
194
641
|
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
@@ -225,24 +672,44 @@ export class SessionManager {
|
|
|
225
672
|
}
|
|
226
673
|
|
|
227
674
|
/**
|
|
228
|
-
* Resource protection
|
|
675
|
+
* Resource protection based on configured SESSION_MODE
|
|
229
676
|
* @param {boolean} [isDebugging=false] Debugging flag
|
|
230
|
-
* @param {
|
|
677
|
+
* @param {string} [redirectUrl=''] Redirect URL
|
|
231
678
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
232
679
|
*/
|
|
233
|
-
authenticate
|
|
680
|
+
authenticate(isDebugging = false, redirectUrl = '') {
|
|
234
681
|
return async (req, res, next) => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return next();
|
|
682
|
+
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
683
|
+
if (mode === SessionMode.TOKEN) {
|
|
684
|
+
return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
|
|
239
685
|
}
|
|
240
|
-
|
|
241
|
-
return res.redirect(redirectUrl);
|
|
242
|
-
}
|
|
243
|
-
return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
|
|
686
|
+
return this.#verifySession(req, res, next, isDebugging, redirectUrl);
|
|
244
687
|
};
|
|
245
|
-
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Resource protection by token (explicit token verification)
|
|
692
|
+
* @param {boolean} [isDebugging=false] Debugging flag
|
|
693
|
+
* @param {string} [redirectUrl=''] Redirect URL
|
|
694
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
695
|
+
*/
|
|
696
|
+
verifyToken(isDebugging = false, redirectUrl = '') {
|
|
697
|
+
return async (req, res, next) => {
|
|
698
|
+
return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Resource protection by session (explicit session verification)
|
|
704
|
+
* @param {boolean} [isDebugging=false] Debugging flag
|
|
705
|
+
* @param {string} [redirectUrl=''] Redirect URL
|
|
706
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
707
|
+
*/
|
|
708
|
+
verifySession(isDebugging = false, redirectUrl = '') {
|
|
709
|
+
return async (req, res, next) => {
|
|
710
|
+
return this.#verifySession(req, res, next, isDebugging, redirectUrl);
|
|
711
|
+
};
|
|
712
|
+
}
|
|
246
713
|
|
|
247
714
|
/**
|
|
248
715
|
* Save session
|
|
@@ -255,13 +722,13 @@ export class SessionManager {
|
|
|
255
722
|
/** @type {{ payload: { user: import('../models/types/user').UserModel, redirect_url: string } }} */
|
|
256
723
|
const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
257
724
|
if (payload?.user) {
|
|
258
|
-
|
|
725
|
+
this.#logger.debug('### CALLBACK USER ###');
|
|
259
726
|
request.session[this.#getSessionKey()] = initUser(payload.user);
|
|
260
727
|
return new Promise((resolve, reject) => {
|
|
261
728
|
request.session.touch().save((err) => {
|
|
262
729
|
if (err) {
|
|
263
|
-
|
|
264
|
-
|
|
730
|
+
this.#logger.error('### SESSION SAVE ERROR ###');
|
|
731
|
+
this.#logger.error(err);
|
|
265
732
|
return reject(new CustomError(httpCodes.SYSTEM_FAILURE, 'Session failed to save', err));
|
|
266
733
|
}
|
|
267
734
|
return resolve(payload);
|
|
@@ -279,16 +746,48 @@ export class SessionManager {
|
|
|
279
746
|
callback(initUser) {
|
|
280
747
|
return async (req, res, next) => {
|
|
281
748
|
const { jwt = '' } = req.query;
|
|
282
|
-
if(!jwt) {
|
|
749
|
+
if (!jwt) {
|
|
283
750
|
return next(new CustomError(httpCodes.BAD_REQUEST, 'Missing `jwt` in query parameters'));
|
|
284
751
|
}
|
|
752
|
+
|
|
285
753
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
754
|
+
// Decrypt JWT from Identity Adapter
|
|
755
|
+
const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
756
|
+
|
|
757
|
+
if (!payload?.user) {
|
|
758
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** @type {import('../index.js').SessionUser} */
|
|
762
|
+
const user = initUser(payload.user);
|
|
763
|
+
/** @type {string} */
|
|
764
|
+
const redirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
|
|
765
|
+
|
|
766
|
+
// Check SESSION_MODE to determine response type
|
|
767
|
+
if (this.#config.SESSION_MODE === SessionMode.TOKEN) {
|
|
768
|
+
// Token-based: Generate token and return HTML page that stores it
|
|
769
|
+
const token = await this.#generateAndStoreToken(user);
|
|
770
|
+
|
|
771
|
+
this.#logger.debug('### CALLBACK TOKEN GENERATED ###');
|
|
772
|
+
|
|
773
|
+
const templatePath = this.#config.TOKEN_STORAGE_TEMPLATE_PATH || path.resolve(__dirname, 'assets', 'template.html');
|
|
774
|
+
// Return HTML page that stores token in localStorage and redirects
|
|
775
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
776
|
+
const html = template
|
|
777
|
+
.replaceAll('{{SESSION_DATA_KEY}}', this.#config.SESSION_KEY)
|
|
778
|
+
.replaceAll('{{SESSION_DATA_VALUE}}', token)
|
|
779
|
+
.replaceAll('{{SESSION_EXPIRY_KEY}}', this.#config.SESSION_EXPIRY_KEY)
|
|
780
|
+
.replaceAll('{{SESSION_EXPIRY_VALUE}}', user.attributes.expires_at)
|
|
781
|
+
.replaceAll('{{SSO_SUCCESS_URL}}', redirectUrl)
|
|
782
|
+
.replaceAll('{{SSO_FAILURE_URL}}', this.#config.SSO_FAILURE_URL);
|
|
783
|
+
return res.send(html);
|
|
784
|
+
}
|
|
785
|
+
// Session-based: Save to session and redirect
|
|
786
|
+
await this.#saveSession(req, jwt, initUser);
|
|
787
|
+
return res.redirect(redirectUrl);
|
|
288
788
|
}
|
|
289
789
|
catch (error) {
|
|
290
|
-
|
|
291
|
-
console.error(error);
|
|
790
|
+
this.#logger.error('### CALLBACK ERROR ###', error);
|
|
292
791
|
return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(error.message)));
|
|
293
792
|
}
|
|
294
793
|
};
|
|
@@ -315,86 +814,57 @@ export class SessionManager {
|
|
|
315
814
|
}
|
|
316
815
|
|
|
317
816
|
/**
|
|
318
|
-
*
|
|
319
|
-
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
320
|
-
*/
|
|
321
|
-
logout() {
|
|
322
|
-
return (req, res) => {
|
|
323
|
-
const { redirect = false } = req.query;
|
|
324
|
-
const isRedirect = (redirect === 'true' || redirect === true);
|
|
325
|
-
return this.#logout(req, res, (error => {
|
|
326
|
-
if (error) {
|
|
327
|
-
console.error('### LOGOUT CALLBACK ERROR ###');
|
|
328
|
-
console.error(error);
|
|
329
|
-
if (isRedirect)
|
|
330
|
-
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
331
|
-
return res.status(httpCodes.AUTHORIZATION_FAILED).send({ redirect_url: this.#config.SSO_FAILURE_URL });
|
|
332
|
-
}
|
|
333
|
-
if (isRedirect)
|
|
334
|
-
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
335
|
-
return res.send({ redirect_url: this.#config.SSO_SUCCESS_URL });
|
|
336
|
-
}));
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Refresh user session
|
|
817
|
+
* Refresh user authentication based on configured SESSION_MODE
|
|
342
818
|
* @param {(user: object) => object} initUser Initialize user object function
|
|
343
819
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
344
820
|
*/
|
|
345
821
|
refresh(initUser) {
|
|
346
822
|
const idpUrl = '/auth/refresh'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
|
|
347
823
|
return async (req, res, next) => {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
this
|
|
354
|
-
const response = await this.#idpRequest.post(idpUrl, {
|
|
355
|
-
user: {
|
|
356
|
-
email,
|
|
357
|
-
attributes: {
|
|
358
|
-
idp: attributes?.idp,
|
|
359
|
-
refresh_token: attributes?.refresh_token
|
|
360
|
-
},
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
if(response.status === httpCodes.OK) {
|
|
364
|
-
/** @type {{ jwt: string }} */
|
|
365
|
-
const { jwt } = response.data;
|
|
366
|
-
const payload = await this.#saveSession(req, jwt, initUser);
|
|
367
|
-
return res.json(payload);
|
|
368
|
-
}
|
|
369
|
-
throw new CustomError(response.status, response.statusText);
|
|
370
|
-
}
|
|
371
|
-
catch(error) {
|
|
372
|
-
return next(httpHelper.handleAxiosError(error));
|
|
824
|
+
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
825
|
+
|
|
826
|
+
if (mode === SessionMode.TOKEN) {
|
|
827
|
+
return this.#refreshToken(req, res, next, initUser, idpUrl);
|
|
828
|
+
} else {
|
|
829
|
+
return this.#refreshSession(req, res, next, initUser, idpUrl);
|
|
373
830
|
}
|
|
374
831
|
};
|
|
375
832
|
}
|
|
376
833
|
|
|
377
834
|
/**
|
|
378
|
-
*
|
|
379
|
-
* @
|
|
380
|
-
* @param {import('@types/express').Response} res Response
|
|
381
|
-
* @param {(error: Error)} callback Callback
|
|
835
|
+
* Application logout based on configured SESSION_MODE (NOT SSO)
|
|
836
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
382
837
|
*/
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
console.error('### SESSION DESTROY CALLBACK ERROR ###');
|
|
393
|
-
console.error(sessionError);
|
|
394
|
-
return callback(sessionError);
|
|
838
|
+
logout() {
|
|
839
|
+
return async (req, res) => {
|
|
840
|
+
const { redirect = false, all = false } = req.query;
|
|
841
|
+
const isRedirect = (redirect === 'true' || redirect === true);
|
|
842
|
+
const logoutAll = (all === 'true' || all === true);
|
|
843
|
+
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
844
|
+
|
|
845
|
+
if (mode === SessionMode.TOKEN) {
|
|
846
|
+
return this.#logoutToken(req, res, isRedirect, logoutAll);
|
|
395
847
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
848
|
+
|
|
849
|
+
// Note: 'all' parameter is only applicable for token-based authentication
|
|
850
|
+
// Session-based authentication is already single-instance per cookie
|
|
851
|
+
return this.#logoutSession(req, res, (error) => {
|
|
852
|
+
if (error) {
|
|
853
|
+
this.#logger.error('### LOGOUT CALLBACK ERROR ###');
|
|
854
|
+
this.#logger.error(error);
|
|
855
|
+
if (isRedirect) {
|
|
856
|
+
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
857
|
+
}
|
|
858
|
+
return res.status(httpCodes.AUTHORIZATION_FAILED).send({
|
|
859
|
+
redirect_url: this.#config.SSO_FAILURE_URL
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
if (isRedirect) {
|
|
863
|
+
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
864
|
+
}
|
|
865
|
+
return res.send({ redirect_url: this.#config.SSO_SUCCESS_URL });
|
|
866
|
+
});
|
|
867
|
+
};
|
|
399
868
|
}
|
|
869
|
+
|
|
400
870
|
}
|