@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.
@@ -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
- /** @type {string} */
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
- /** @type {string} */
57
+ /**
58
+ * @type {string}
59
+ * @default 'ibmid:'
60
+ */
33
61
  SESSION_PREFIX;
34
-
35
- /** @type {string} */
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
- /** @type {string} */
86
+ /**
87
+ * @type {string} Algorithm used to encrypt the JWT
88
+ * @default 'dir'
89
+ */
41
90
  JWT_ALGORITHM;
42
- /** @type {string} */
91
+ /**
92
+ * @type {string} Encryption algorithm used to encrypt the JWT
93
+ * @default 'A256GCM'
94
+ */
43
95
  JWT_ENCRYPTION;
44
- /** @type {string} */
45
- JWT_EXPIRATION_TIME;
46
- /** @type {number} */
96
+ /**
97
+ * @type {number} Clock tolerance in seconds
98
+ * @default 30
99
+ */
47
100
  JWT_CLOCK_TOLERANCE;
48
- /** @type {string} */
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 'user';
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
- console.log('### Using Redis as the Session Store ###');
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
- console.log('### Using Memory as the Session Store ###');
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 {boolean} [redirectUrl=''] Redirect flag
677
+ * @param {string} [redirectUrl=''] Redirect URL
231
678
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
232
679
  */
233
- authenticate (isDebugging = false, redirectUrl = '') {
680
+ authenticate(isDebugging = false, redirectUrl = '') {
234
681
  return async (req, res, next) => {
235
- /** @type {{ authorized: boolean }} */
236
- const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
237
- if (authorized) {
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
- if(redirectUrl) {
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
- console.debug('### CALLBACK USER ###');
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
- console.error('### SESSION SAVE ERROR ###');
264
- console.error(err);
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
- const payload = await this.#saveSession(req, jwt, initUser);
287
- return res.redirect(payload?.redirect_url ? payload.redirect_url : this.#config.SSO_SUCCESS_URL);
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
- console.error('### LOGIN ERROR ###');
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
- * Application logout (NOT SSO)
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
- try {
349
- const { email, attributes } = req.user || { email: '', attributes: {} };
350
- if (this.hasLock(email)) {
351
- throw new CustomError(httpCodes.CONFLICT, 'User refresh is locked');
352
- }
353
- this.lock(email);
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
- * Logout
379
- * @param {import('@types/express').Request} req Request
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
- #logout(req, res, callback) {
384
- try {
385
- res.clearCookie('connect.sid');
386
- } catch (error) {
387
- console.error('### CLEAR COOKIE ERROR ###');
388
- console.error(error);
389
- }
390
- return req.session.destroy((sessionError) => {
391
- if (sessionError) {
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
- console.info('### LOGOUT SUCCESSFULLY ###');
397
- return callback(null);
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
  }