@igxjs/node-components 1.0.14 → 1.0.16

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 CHANGED
@@ -46,12 +46,18 @@ export const tokenSession = new SessionManager({
46
46
  });
47
47
 
48
48
  // Setup in your app
49
- await session.setup(app, (user) => ({ ...user, displayName: user.email }));
49
+ await session.setup(app);
50
50
 
51
- // Protect routes
52
- app.get('/protected', session.authenticate(), (req, res) => {
51
+ // Protect routes - user data automatically loaded into req.user
52
+ app.get('/protected', session.authenticate(), session.requireUser(), (req, res) => {
53
53
  res.json({ user: req.user });
54
54
  });
55
+
56
+ // SSO callback with user transformation
57
+ app.get('/auth/callback', session.callback((user) => ({
58
+ ...user,
59
+ displayName: user.email
60
+ })));
55
61
  ```
56
62
 
57
63
  [📖 Full SessionManager Documentation](./docs/session-manager.md)
@@ -131,8 +137,9 @@ Uses traditional server-side session cookies. When a user authenticates via SSO,
131
137
  - `REDIS_URL`: Redis connection string for session storage
132
138
 
133
139
  **Auth Methods:**
134
- - `session.authenticate()` - Protect routes with SSO session verification
135
- - `session.verifySession(isDebugging, redirectUrl)` - Explicit session verification method
140
+ - `session.authenticate(errorRedirectUrl)` - Protect routes with SSO session verification
141
+ - `session.verifySession(errorRedirectUrl)` - Explicit session verification method
142
+ - `session.requireUser()` - Middleware to load user data into `req.user` from session store
136
143
  - `session.logout(redirect?, all?)` - Logout current session (or logout all for token mode)
137
144
 
138
145
  ### TOKEN Mode
@@ -148,10 +155,11 @@ Uses JWT bearer tokens instead of session cookies. When a user authenticates via
148
155
  - `JWT_CLOCK_TOLERANCE`: Clock skew tolerance in seconds (default: 30)
149
156
 
150
157
  **Auth Methods:**
151
- - `session.verifyToken(isDebugging, redirectUrl)` - Protect routes with token verification
158
+ - `session.verifyToken(errorRedirectUrl)` - Protect routes with token verification
159
+ - `session.requireUser()` - Middleware to load user data into `req.user` from Redis using JWT token
152
160
  - `session.callback(initUser)` - SSO callback handler for token generation
153
161
  - `session.refresh(initUser)` - Refresh user authentication based on auth mode
154
- - `session.logout(redirect?, all?)` - Logout current or all tokens
162
+ - `session.logout(redirect?, all?)` - Logout current token or all tokens for user
155
163
 
156
164
  **Token Storage (Client-Side):**
157
165
 
@@ -186,6 +194,9 @@ fetch('/api/protected', {
186
194
  | `SESSION_COOKIE_PATH` | string | `'/'` | Session cookie path |
187
195
  | `SESSION_SECRET` | string | - | Session/JWT secret key |
188
196
  | `SESSION_PREFIX` | string | `'ibmid:'` | Redis session/key prefix |
197
+ | `SESSION_KEY` | string | `'session_token'` | Redis key for session data (SESSION mode) or localStorage key for token (TOKEN mode) |
198
+ | `SESSION_EXPIRY_KEY` | string | `'session_expires_at'` | localStorage key for session expiry timestamp (TOKEN mode) |
199
+ | `TOKEN_STORAGE_TEMPLATE_PATH` | string | - | Path to custom HTML template for TOKEN mode callback |
189
200
  | `REDIS_URL` | string | - | Redis connection URL (optional) |
190
201
  | `REDIS_CERT_PATH` | string | - | Path to Redis TLS certificate |
191
202
  | `JWT_ALGORITHM` | string | `'dir'` | JWT signing algorithm |
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <title>Sign in IBM Garage</title>
5
- <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no,viewport-fit=cover,minimum-scale=1,maximum-scale=1,user-scalable=no">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no,viewport-fit=cover,minimum-scale=1,maximum-scale=2">
6
6
  <meta name="robots" content="noindex, nofollow" />
7
7
  <style>
8
8
  :root {
@@ -94,9 +94,14 @@
94
94
  localStorage.setItem('{{SESSION_EXPIRY_KEY}}', '{{SESSION_EXPIRY_VALUE}}');
95
95
  }
96
96
 
97
+ /**
98
+ * Note: You can also request full user informlation by using ajax request or loading server side page.
99
+ */
100
+
97
101
  // Fall back to simple navigation
98
102
  location.href = '{{SSO_SUCCESS_URL}}';
99
- } catch (e) {
103
+ }
104
+ catch (e) {
100
105
  console.error('Redirect failed:', e);
101
106
  success = false;
102
107
  }
@@ -137,6 +137,8 @@ export class SessionManager {
137
137
  #jwtManager = null;
138
138
  /** @type {import('./logger.js').Logger} */
139
139
  #logger = Logger.getInstance('SessionManager');
140
+ /** @type {string?} Cached HTML template for token storage */
141
+ #htmlTemplate = null;
140
142
 
141
143
  /**
142
144
  * Create a new session manager
@@ -270,7 +272,7 @@ export class SessionManager {
270
272
  * @private
271
273
  */
272
274
  #getTokenRedisKey(email, tid) {
273
- return `${this.#config.SESSION_KEY}:t:${email}:${tid}`;
275
+ return `${this.#config.SESSION_KEY}:${email}:${tid}`;
274
276
  }
275
277
 
276
278
  /**
@@ -280,7 +282,7 @@ export class SessionManager {
280
282
  * @private
281
283
  */
282
284
  #getTokenRedisPattern(email) {
283
- return `${this.#config.SESSION_KEY}:t:${email}:*`;
285
+ return `${this.#config.SESSION_KEY}:${email}:*`;
284
286
  }
285
287
 
286
288
  /**
@@ -291,75 +293,103 @@ export class SessionManager {
291
293
  return this.#redisManager;
292
294
  }
293
295
 
296
+ /**
297
+ * Setup the session/user handlers with configurations
298
+ * @param {import('@types/express').Application} app Express application
299
+ */
300
+ async setup(app) {
301
+ this.#redisManager = new RedisManager();
302
+ this.#jwtManager = new JwtManager({
303
+ ...this.#config,
304
+ JWT_EXPIRATION_TIME: this.#config.SESSION_AGE, // SESSION_AGE is already in seconds
305
+ });
306
+
307
+ // Identity Provider Request
308
+ this.#idpRequest = axios.create({
309
+ baseURL: this.#config.SSO_ENDPOINT_URL,
310
+ timeout: 30000,
311
+ });
312
+ app.set('trust proxy', 1);
313
+ const isOK = await this.#redisManager.connect(this.#config.REDIS_URL, this.#config.REDIS_CERT_PATH);
314
+ if (this.getSessionMode() === SessionMode.SESSION) {
315
+ app.use(this.#sessionHandler(isOK));
316
+ }
317
+
318
+ // Cache HTML template for TOKEN mode
319
+ if (this.getSessionMode() === SessionMode.TOKEN) {
320
+ const templatePath = this.#config.TOKEN_STORAGE_TEMPLATE_PATH ||
321
+ path.resolve(__dirname, 'assets', 'template.html');
322
+ this.#htmlTemplate = fs.readFileSync(templatePath, 'utf8');
323
+ this.#logger.debug('### HTML TEMPLATE CACHED ###');
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Generate lightweight JWT token
329
+ * @param {string} email User email
330
+ * @param {string} tokenId Token ID
331
+ * @param {number} expirationTime Expiration time in seconds
332
+ * @returns {Promise<string>} Returns the generated JWT token
333
+ * @private
334
+ */
335
+ async #getLightweightToken(email, tokenId, expirationTime) {
336
+ return await this.#jwtManager.encrypt({ email, tid: tokenId }, this.#config.SSO_CLIENT_SECRET, { expirationTime });
337
+ }
338
+
294
339
  /**
295
340
  * Generate and store JWT token in Redis
296
341
  * - JWT payload contains only { email, tid } for minimal size
297
342
  * - Full user data is stored in Redis as single source of truth
298
- * @param {object} user User object with email and attributes
343
+ * @param {string} tid Token ID
344
+ * @param {Record<string, any> & { email: string, tid: string }} user User object with email and attributes
299
345
  * @returns {Promise<string>} Returns the generated JWT token
300
346
  * @throws {Error} If JWT encryption fails
301
347
  * @throws {Error} If Redis storage fails
302
348
  * @private
303
349
  * @example
304
- * const token = await this.#generateAndStoreToken({
350
+ * const token = await this.#generateAndStoreToken('tid', {
305
351
  * email: 'user@example.com',
306
352
  * attributes: { /* user data * / }
307
353
  * });
308
354
  */
309
- async #generateAndStoreToken(user) {
310
- // Generate unique token ID for this device/session
311
- const tid = crypto.randomUUID();
312
- // SESSION_AGE is already in seconds
313
- const ttlSeconds = this.#config.SESSION_AGE;
314
- // Create JWT token with only email and tid (minimal payload)
315
- const token = await this.#jwtManager.encrypt(
316
- { email: user.email, tid },
317
- this.#config.SESSION_SECRET,
318
- { expirationTime: ttlSeconds }
319
- );
355
+ async #generateAndStoreToken(tid, user) {
356
+ // Create JWT token with only email, tid and idp (minimal payload)
357
+ const token = await this.#getLightweightToken(user.email, tid, this.#config.SESSION_AGE);
320
358
 
321
359
  // Store user data in Redis with TTL
322
360
  const redisKey = this.#getTokenRedisKey(user.email, tid);
323
361
 
324
- await this.#redisManager.getClient().setEx(
325
- redisKey,
326
- ttlSeconds,
327
- JSON.stringify(user)
328
- );
362
+ await this.#redisManager.getClient().setEx(redisKey, this.#config.SESSION_AGE, JSON.stringify(user));
329
363
  this.#logger.debug(`### TOKEN GENERATED: ${user.email} ###`);
330
364
  return token;
331
365
  }
332
366
 
333
367
  /**
334
- * Verify token authentication - extracts and validates JWT from Authorization header
335
- * @param {import('@types/express').Request} req Request with Authorization header
336
- * @param {import('@types/express').Response} res Response object
337
- * @param {import('@types/express').NextFunction} next Next middleware function
338
- * @param {boolean} isDebugging If true, allows unauthenticated requests
339
- * @param {string} redirectUrl URL to redirect to on authentication failure
340
- * @throws {CustomError} If token is missing, invalid, or expired
368
+ * Extract and validate user data from Authorization header (TOKEN mode only)
369
+ * @param {string} authHeader Authorization header in format "Bearer {token}"
370
+ * @param {boolean} [includeUserData=true] Whether to include full user data in response
371
+ * - true: Returns { tid, user } with full user data (default)
372
+ * - false: Returns JWT payload only (lightweight validation)
373
+ * @returns {Promise<{ tid: string, user: { email: string, attributes: { expires_at: number, sub: string } } } & object>}
374
+ * - When includeUserData=true: { tid: string, user: object }
375
+ * - When includeUserData=false: JWT payload object
376
+ * @throws {CustomError} UNAUTHORIZED (401) if:
377
+ * - Authorization header is missing or invalid format
378
+ * - Token decryption fails
379
+ * - Token payload is invalid (missing email/tid)
380
+ * - Token not found in Redis (when includeUserData=true)
341
381
  * @private
342
- * @example
343
- * // Authorization header format: "Bearer {jwt_token}"
344
- * await this.#verifyToken(req, res, next, false, '/login');
345
382
  */
346
- async #verifyToken(req, res, next, isDebugging, redirectUrl) {
347
- try {
348
- // Extract token from Authorization header
349
- const authHeader = req.headers.authorization;
350
- if (!authHeader?.startsWith('Bearer ')) {
351
- throw new CustomError(httpCodes.UNAUTHORIZED, 'Missing or invalid authorization header');
352
- }
353
-
354
- const token = authHeader.substring(7); // Remove 'Bearer ' prefix
355
-
356
- // Decrypt JWT token
357
- const { payload } = await this.#jwtManager.decrypt(
358
- token,
359
- this.#config.SESSION_SECRET
360
- );
383
+ async #getUserFromToken(authHeader, includeUserData = true) {
384
+ if (!authHeader?.startsWith('Bearer ')) {
385
+ throw new CustomError(httpCodes.UNAUTHORIZED, 'Missing or invalid authorization header');
386
+ }
387
+ const token = authHeader.substring(7); // Remove 'Bearer ' prefix
388
+ /** @type {{ payload: { email: string, tid: string } & import('jose').JWTPayload }} */
389
+ const { payload } = await this.#jwtManager.decrypt(token, this.#config.SSO_CLIENT_SECRET);
361
390
 
362
- // Extract email and token ID
391
+ if (includeUserData) {
392
+ /** @type {{ email: string, tid: string }} Extract email and token ID */
363
393
  const { email, tid } = payload;
364
394
  if (!email || !tid) {
365
395
  throw new CustomError(httpCodes.UNAUTHORIZED, 'Invalid token payload');
@@ -372,26 +402,59 @@ export class SessionManager {
372
402
  if (!userData) {
373
403
  throw new CustomError(httpCodes.UNAUTHORIZED, 'Token not found or expired');
374
404
  }
405
+ return { tid, user: { ...JSON.parse(userData) } };
406
+ }
407
+ return { tid: payload.tid, user: { email: payload.email, attributes: { sub: payload.sub, expires_at: payload.exp ? payload.exp * 1000 : 0 } } };
408
+ }
375
409
 
376
- // Parse and attach user to request
377
- req.user = JSON.parse(userData);
378
-
379
- // Validate authorization
380
- const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
381
- if (!authorized && !isDebugging) {
382
- throw new CustomError(httpCodes.FORBIDDEN, 'User is not authorized');
383
- }
410
+ /**
411
+ * Get authenticated user data (works for both SESSION and TOKEN modes)
412
+ * @param {import('@types/express').Request} req Express request object
413
+ * @param {boolean} [includeUserData=false] Whether to include full user data in response
414
+ * @returns {Promise<object>} Full user data object
415
+ * @throws {CustomError} If user is not authenticated
416
+ * @public
417
+ * @example
418
+ * // Use in custom middleware
419
+ * app.use(async (req, res, next) => {
420
+ * try {
421
+ * const user = await sessionManager.getUser(req);
422
+ * req.customUser = user;
423
+ * next();
424
+ * } catch (error) {
425
+ * next(error);
426
+ * }
427
+ * });
428
+ */
429
+ async getUser(req, includeUserData = false) {
430
+ if (this.getSessionMode() === SessionMode.TOKEN) {
431
+ const { user } = await this.#getUserFromToken(req.headers.authorization, includeUserData);
432
+ return user;
433
+ }
434
+ // Session mode
435
+ return req.session[this.#getSessionKey()];
436
+ }
384
437
 
438
+ /**
439
+ * Verify token authentication - extracts and validates JWT from Authorization header
440
+ * @param {import('@types/express').Request} req Request with Authorization header
441
+ * @param {import('@types/express').Response} res Response object
442
+ * @param {import('@types/express').NextFunction} next Next middleware function
443
+ * @param {string} errorRedirectUrl URL to redirect to on authentication failure
444
+ * @throws {CustomError} If token is missing, invalid, or expired
445
+ * @private
446
+ * @example
447
+ * // Authorization header format: "Bearer {jwt_token}"
448
+ * await this.#verifyToken(req, res, next, '/login');
449
+ */
450
+ async #verifyToken(req, res, next, errorRedirectUrl) {
451
+ try {
452
+ // Lightweight token validation (no Redis lookup)
453
+ await this.#getUserFromToken(req.headers.authorization, false);
385
454
  return next();
386
-
387
455
  } catch (error) {
388
- if (isDebugging) {
389
- this.#logger.warn('### TOKEN VERIFICATION FAILED (debugging mode) ###', error.message);
390
- return next();
391
- }
392
-
393
- if (redirectUrl) {
394
- return res.redirect(redirectUrl);
456
+ if (errorRedirectUrl) {
457
+ return res.redirect(errorRedirectUrl);
395
458
  }
396
459
 
397
460
  // Handle specific JWT errors
@@ -409,18 +472,19 @@ export class SessionManager {
409
472
  * @param {import('@types/express').Request} req Request with session data
410
473
  * @param {import('@types/express').Response} res Response object
411
474
  * @param {import('@types/express').NextFunction} next Next middleware function
412
- * @param {boolean} isDebugging If true, allows unauthorized users
413
- * @param {string} redirectUrl URL to redirect to if user is unauthorized
475
+ * @param {string} errorRedirectUrl URL to redirect to if user is unauthorized
414
476
  * @throws {CustomError} If user is not authorized
415
477
  * @private
416
478
  */
417
- async #verifySession(req, res, next, isDebugging, redirectUrl) {
418
- const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
479
+ async #verifySession(req, res, next, errorRedirectUrl) {
480
+ // Fix: Check session data directly, not req.user (which is only populated by requireUser())
481
+ const user = req.session[this.#getSessionKey()];
482
+ const { authorized = false } = user ?? { authorized: false };
419
483
  if (authorized) {
420
484
  return next();
421
485
  }
422
- if (redirectUrl) {
423
- return res.redirect(redirectUrl);
486
+ if (errorRedirectUrl) {
487
+ return res.redirect(errorRedirectUrl);
424
488
  }
425
489
  return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
426
490
  }
@@ -431,43 +495,31 @@ export class SessionManager {
431
495
  * @param {import('@types/express').Request} req Request with Authorization header
432
496
  * @param {import('@types/express').Response} res Response object
433
497
  * @param {import('@types/express').NextFunction} next Next middleware function
434
- * @param {(user: object) => object} initUser Function to initialize/transform user object
435
- * @param {string} idpUrl Identity provider refresh endpoint URL
498
+ * @param {(user: object) => object & { email: string }} initUser Function to initialize/transform user object
499
+ * @param {string} idpRefreshUrl Identity provider refresh endpoint URL
436
500
  * @throws {CustomError} If refresh lock is active or SSO refresh fails
437
501
  * @private
438
502
  * @example
439
503
  * // Response format:
440
- * // { token: "new_jwt", user: {...}, expiresIn: 64800, tokenType: "Bearer" }
504
+ * // { jwt: "new_jwt", user: {...} }
441
505
  */
442
- async #refreshToken(req, res, next, initUser, idpUrl) {
506
+ async #refreshToken(req, res, next, initUser, idpRefreshUrl) {
443
507
  try {
444
- // Get current user from verifyToken middleware
445
- const { email, attributes } = req.user || {};
446
-
447
- if (!email) {
448
- throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
449
- }
450
-
451
- // Extract Token ID from current token
452
- const authHeader = req.headers.authorization;
453
- const token = authHeader?.substring(7);
454
- const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
455
- const { tid: oldTokenId } = payload;
508
+ const { tid, user } = await this.#getUserFromToken(req.headers.authorization, true);
456
509
 
457
510
  // Check refresh lock
458
- if (this.hasLock(email)) {
511
+ if (this.hasLock(user?.email)) {
459
512
  throw new CustomError(httpCodes.CONFLICT, 'Token refresh is locked');
460
513
  }
461
- this.lock(email);
514
+ this.lock(user?.email);
462
515
 
463
516
  // Call SSO refresh endpoint
464
- const response = await this.#idpRequest.post(idpUrl, {
465
- user: {
466
- email,
467
- attributes: {
468
- idp: attributes?.idp,
469
- refresh_token: attributes?.refresh_token
470
- }
517
+ const response = await this.#idpRequest.post(idpRefreshUrl, {
518
+ idp: user?.attributes?.idp,
519
+ refresh_token: user?.attributes?.refresh_token
520
+ }, {
521
+ headers: {
522
+ Authorization: req.headers.authorization
471
523
  }
472
524
  });
473
525
 
@@ -484,24 +536,15 @@ export class SessionManager {
484
536
  }
485
537
 
486
538
  // Initialize user with new data
487
- const user = initUser(newPayload.user);
539
+ const newUser = initUser(newPayload.user);
488
540
 
489
541
  // Generate new token
490
- const newToken = await this.#generateAndStoreToken(user);
491
-
492
- // Remove old token from Redis
493
- const oldRedisKey = this.#getTokenRedisKey(email, oldTokenId);
494
- await this.#redisManager.getClient().del(oldRedisKey);
542
+ const newToken = await this.#generateAndStoreToken(tid, newUser);
495
543
 
496
544
  this.#logger.debug('### TOKEN REFRESHED SUCCESSFULLY ###');
497
545
 
498
546
  // Return new token
499
- return res.json({
500
- token: newToken,
501
- user,
502
- expiresIn: this.#config.SESSION_AGE, // Already in seconds
503
- tokenType: 'Bearer'
504
- });
547
+ return res.json({ jwt: newToken, user: newUser });
505
548
  } catch (error) {
506
549
  return next(httpHelper.handleAxiosError(error));
507
550
  }
@@ -513,29 +556,36 @@ export class SessionManager {
513
556
  * @param {import('@types/express').Response} res Response
514
557
  * @param {import('@types/express').NextFunction} next Next function
515
558
  * @param {(user: object) => object} initUser Initialize user function
516
- * @param {string} idpUrl Identity provider URL
559
+ * @param {string} idpRefreshUrl Token Refresh URL
517
560
  * @private
518
561
  */
519
- async #refreshSession(req, res, next, initUser, idpUrl) {
562
+ async #refreshSession(req, res, next, initUser, idpRefreshUrl) {
520
563
  try {
521
564
  const { email, attributes } = req.user || { email: '', attributes: {} };
565
+ // Check refresh lock
522
566
  if (this.hasLock(email)) {
523
- throw new CustomError(httpCodes.CONFLICT, 'User refresh is locked');
567
+ throw new CustomError(httpCodes.CONFLICT, 'Session refresh is locked');
524
568
  }
525
569
  this.lock(email);
526
- const response = await this.#idpRequest.post(idpUrl, {
527
- user: {
528
- email,
529
- attributes: {
530
- idp: attributes?.idp,
531
- refresh_token: attributes?.refresh_token
532
- }
570
+
571
+ /** @type {string} */
572
+ const token = await this.#getLightweightToken(email, req.sessionID, req.user.attributes.expires_at);
573
+
574
+ // Call SSO refresh endpoint
575
+ const response = await this.#idpRequest.post(idpRefreshUrl, {
576
+ idp: attributes?.idp,
577
+ refresh_token: attributes?.refresh_token,
578
+ }, {
579
+ headers: {
580
+ Authorization: `Bearer ${token}`
533
581
  }
534
582
  });
535
583
  if (response.status === httpCodes.OK) {
584
+ /** @type {{ jwt: string }} */
536
585
  const { jwt } = response.data;
537
- const payload = await this.#saveSession(req, jwt, initUser);
538
- return res.json(payload);
586
+ const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
587
+ const result = await this.#saveSession(req, payload, initUser);
588
+ return res.json(result);
539
589
  }
540
590
  throw new CustomError(response.status, response.statusText);
541
591
  } catch (error) {
@@ -604,16 +654,9 @@ export class SessionManager {
604
654
  }
605
655
 
606
656
  try {
607
- // Extract Token ID from current token
608
- const authHeader = req.headers.authorization;
609
- const token = authHeader?.substring(7);
610
-
611
- if (!token) {
612
- throw new CustomError(httpCodes.BAD_REQUEST, 'No token provided');
613
- }
614
-
615
- const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
616
- const { email, tid } = payload;
657
+ // Extract Token ID and email from current token
658
+ const { tid, user } = await this.#getUserFromToken(req.headers.authorization, false);
659
+ const { email } = user;
617
660
 
618
661
  if (!email || !tid) {
619
662
  throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid token payload');
@@ -670,27 +713,6 @@ export class SessionManager {
670
713
  });
671
714
  }
672
715
 
673
- /**
674
- * Setup the session/user handlers with configurations
675
- * @param {import('@types/express').Application} app Express application
676
- * @param {(user: object) => object} updateUser Update user object if user should have proper attributes, e.g. permissions, avatar URL
677
- */
678
- async setup(app, updateUser) {
679
- this.#redisManager = new RedisManager();
680
- this.#jwtManager = new JwtManager({
681
- ...this.#config,
682
- JWT_EXPIRATION_TIME: this.#config.SESSION_AGE, // SESSION_AGE is already in seconds
683
- });
684
- // Identity Provider Request
685
- this.#idpRequest = axios.create({
686
- baseURL: this.#config.SSO_ENDPOINT_URL,
687
- timeout: 30000,
688
- });
689
- app.set('trust proxy', 1);
690
- app.use(await this.#sessionHandler());
691
- app.use(this.#userHandler(updateUser));
692
- }
693
-
694
716
  /**
695
717
  * Get Redis session RequestHandler
696
718
  * @returns {import('@types/express').RequestHandler} Returns RequestHandler instance of Express
@@ -724,80 +746,96 @@ export class SessionManager {
724
746
 
725
747
  /**
726
748
  * Get session RequestHandler
727
- * @returns {Promise<import('@types/express').RequestHandler>} Returns RequestHandler instance of Express
749
+ * @param {boolean} isRedisReady Is Redis Ready
750
+ * @returns {import('@types/express').RequestHandler} Returns RequestHandler instance of Express
728
751
  */
729
- async #sessionHandler() {
730
- if(this.#config.REDIS_URL?.length > 0) {
731
- await this.#redisManager.connect(this.#config.REDIS_URL, this.#config.REDIS_CERT_PATH);
752
+ #sessionHandler(isRedisReady) {
753
+ if(isRedisReady) {
732
754
  return this.#redisSession();
733
755
  }
734
756
  return this.#memorySession();
735
757
  }
736
758
 
737
759
  /**
738
- * User HTTP Handler
739
- * @param {(user: object) => object} updateUser User wrapper
760
+ * Middleware to load full user data (works for both SESSION and TOKEN modes)
740
761
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
741
762
  */
742
- #userHandler (updateUser) {
743
- return (req, res, next) => {
744
- req.user = req.session[this.#getSessionKey()];
745
- /** @type {import('@types/express').Request & { user: object }} Session user */
746
- res.locals.user = updateUser(req.user);
747
- return next();
763
+ requireUser = () => {
764
+ return async (req, _res, next) => {
765
+ try {
766
+ req.user = await this.getUser(req, true);
767
+ return next();
768
+ }
769
+ catch (error) {
770
+ return next(error);
771
+ }
748
772
  };
749
- }
773
+ };
750
774
 
751
775
  /**
752
776
  * Resource protection based on configured SESSION_MODE
753
- * @param {boolean} [isDebugging=false] Debugging flag
754
- * @param {string} [redirectUrl=''] Redirect URL
777
+ * - SESSION mode: Verifies user exists in session store and is authorized (checks req.session data)
778
+ * - TOKEN mode: Validates JWT token from Authorization header (lightweight validation)
779
+ *
780
+ * Note: This method verifies authentication only. Use requireUser() after this to populate req.user.
781
+ *
782
+ * @param {string} [errorRedirectUrl=''] Redirect URL on authentication failure
755
783
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
756
- */
757
- authenticate(isDebugging = false, redirectUrl = '') {
784
+ * @example
785
+ * // Option 1: Just verify authentication (user data remains in req.session or token)
786
+ * app.get('/api/check', session.authenticate(), (req, res) => {
787
+ * res.json({ authenticated: true });
788
+ * });
789
+ *
790
+ * // Option 2: Verify authentication AND load user data into req.user
791
+ * app.get('/api/profile',
792
+ * session.authenticate(), // Verifies session/token
793
+ * session.requireUser(), // Loads user data into req.user
794
+ * (req, res) => {
795
+ * res.json({ user: req.user }); // User data available here
796
+ * }
797
+ * );
798
+ */
799
+ authenticate(errorRedirectUrl = '') {
758
800
  return async (req, res, next) => {
759
- const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
801
+ const mode = this.getSessionMode() || SessionMode.SESSION;
760
802
  if (mode === SessionMode.TOKEN) {
761
- return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
803
+ return this.#verifyToken(req, res, next, errorRedirectUrl);
762
804
  }
763
- return this.#verifySession(req, res, next, isDebugging, redirectUrl);
805
+ return this.#verifySession(req, res, next, errorRedirectUrl);
764
806
  };
765
807
  }
766
808
 
767
809
  /**
768
810
  * Resource protection by token (explicit token verification)
769
- * @param {boolean} [isDebugging=false] Debugging flag
770
- * @param {string} [redirectUrl=''] Redirect URL
811
+ * @param {string} [errorRedirectUrl=''] Redirect URL
771
812
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
772
813
  */
773
- verifyToken(isDebugging = false, redirectUrl = '') {
814
+ verifyToken(errorRedirectUrl = '') {
774
815
  return async (req, res, next) => {
775
- return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
816
+ return this.#verifyToken(req, res, next, errorRedirectUrl);
776
817
  };
777
818
  }
778
819
 
779
820
  /**
780
821
  * Resource protection by session (explicit session verification)
781
- * @param {boolean} [isDebugging=false] Debugging flag
782
- * @param {string} [redirectUrl=''] Redirect URL
822
+ * @param {string} [errorRedirectUrl=''] Redirect URL
783
823
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
784
824
  */
785
- verifySession(isDebugging = false, redirectUrl = '') {
825
+ verifySession(errorRedirectUrl = '') {
786
826
  return async (req, res, next) => {
787
- return this.#verifySession(req, res, next, isDebugging, redirectUrl);
827
+ return this.#verifySession(req, res, next, errorRedirectUrl);
788
828
  };
789
829
  }
790
830
 
791
831
  /**
792
832
  * Save session
793
833
  * @param {import('@types/express').Request} request Request object
794
- * @param {string} jwt JWT
834
+ * @param {import('jose').JWTPayload} payload JWT
795
835
  * @param {(user: object) => object} initUser Redirect URL
796
836
  * @returns {Promise<{ user: import('../models/types/user').UserModel, redirect_url: string }>} Promise
797
837
  */
798
- #saveSession = async (request, jwt, initUser) => {
799
- /** @type {{ payload: { user: import('../models/types/user').UserModel, redirect_url: string } }} */
800
- const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
838
+ #saveSession = async (request, payload, initUser) => {
801
839
  if (payload?.user) {
802
840
  this.#logger.debug('### CALLBACK USER ###');
803
841
  request.session[this.#getSessionKey()] = initUser(payload.user);
@@ -813,62 +851,77 @@ export class SessionManager {
813
851
  });
814
852
  }
815
853
  throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
816
- };
854
+ };
855
+
856
+ /**
857
+ * Render HTML template for token storage
858
+ * @param {string} token JWT token
859
+ * @param {string} expiresAt Expiry timestamp
860
+ * @param {string} sucessRedirectUrl Success redirect URL
861
+ * @returns {string} Rendered HTML
862
+ * @private
863
+ */
864
+ #renderTokenStorageHtml(token, expiresAt, sucessRedirectUrl) {
865
+ return this.#htmlTemplate
866
+ .replaceAll('{{SESSION_DATA_KEY}}', this.#config.SESSION_KEY)
867
+ .replaceAll('{{SESSION_DATA_VALUE}}', token)
868
+ .replaceAll('{{SESSION_EXPIRY_KEY}}', this.#config.SESSION_EXPIRY_KEY)
869
+ .replaceAll('{{SESSION_EXPIRY_VALUE}}', expiresAt)
870
+ .replaceAll('{{SSO_SUCCESS_URL}}', sucessRedirectUrl)
871
+ .replaceAll('{{SSO_FAILURE_URL}}', this.#config.SSO_FAILURE_URL);
872
+ }
873
+
874
+ /**
875
+ * SSO callback for successful login
876
+ * @param {(user: object) => object} initUser Initialize user object function
877
+ * @returns {import('@types/express').RequestHandler} Returns express Request Handler
878
+ */
879
+ callback(initUser) {
880
+ return async (req, res, next) => {
881
+ const { jwt = '' } = req.query;
882
+ if (!jwt) {
883
+ return next(new CustomError(httpCodes.BAD_REQUEST, 'Missing `jwt` in query parameters'));
884
+ }
885
+
886
+ try {
887
+ // Decrypt JWT from Identity Adapter
888
+ const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
817
889
 
818
- /**
819
- * SSO callback for successful login
820
- * @param {(user: object) => object} initUser Initialize user object function
821
- * @returns {import('@types/express').RequestHandler} Returns express Request Handler
822
- */
823
- callback(initUser) {
824
- return async (req, res, next) => {
825
- const { jwt = '' } = req.query;
826
- if (!jwt) {
827
- return next(new CustomError(httpCodes.BAD_REQUEST, 'Missing `jwt` in query parameters'));
828
- }
890
+ if (!payload?.user) {
891
+ throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
892
+ }
893
+
894
+ /** @type {string} */
895
+ const callbackRedirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
896
+
897
+ // Token mode: Generate token and return HTML page
898
+ if (this.getSessionMode() === SessionMode.TOKEN) {
899
+ /** @type {import('../index.js').SessionUser} */
900
+ const user = initUser(payload.user);
901
+ // Generate unique token ID for this device/session
902
+ const tid = crypto.randomUUID();
903
+ const token = await this.#generateAndStoreToken(tid, user);
904
+ this.#logger.debug('### CALLBACK TOKEN GENERATED ###');
905
+ const html = this.#renderTokenStorageHtml(token, user.attributes.expires_at, callbackRedirectUrl);
906
+ return res.send(html);
907
+ }
829
908
 
830
- try {
831
- // Decrypt JWT from Identity Adapter
832
- const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
833
-
834
- if (!payload?.user) {
835
- throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
909
+ // Session mode: Save to session and redirect
910
+ await this.#saveSession(req, payload, initUser);
911
+ return res.redirect(callbackRedirectUrl);
836
912
  }
837
-
838
- /** @type {import('../index.js').SessionUser} */
839
- const user = initUser(payload.user);
840
- /** @type {string} */
841
- const redirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
842
-
843
- // Check SESSION_MODE to determine response type
844
- if (this.#config.SESSION_MODE === SessionMode.TOKEN) {
845
- // Token-based: Generate token and return HTML page that stores it
846
- const token = await this.#generateAndStoreToken(user);
847
-
848
- this.#logger.debug('### CALLBACK TOKEN GENERATED ###');
849
-
850
- const templatePath = this.#config.TOKEN_STORAGE_TEMPLATE_PATH || path.resolve(__dirname, 'assets', 'template.html');
851
- // Return HTML page that stores token in localStorage and redirects
852
- const template = fs.readFileSync(templatePath, 'utf8');
853
- const html = template
854
- .replaceAll('{{SESSION_DATA_KEY}}', this.#config.SESSION_KEY)
855
- .replaceAll('{{SESSION_DATA_VALUE}}', token)
856
- .replaceAll('{{SESSION_EXPIRY_KEY}}', this.#config.SESSION_EXPIRY_KEY)
857
- .replaceAll('{{SESSION_EXPIRY_VALUE}}', user.attributes.expires_at)
858
- .replaceAll('{{SSO_SUCCESS_URL}}', redirectUrl)
859
- .replaceAll('{{SSO_FAILURE_URL}}', this.#config.SSO_FAILURE_URL);
860
- return res.send(html);
913
+ catch (error) {
914
+ this.#logger.error('### CALLBACK ERROR ###', error);
915
+ let errorMessage = error.message;
916
+ if (error.code === 'ERR_JWT_EXPIRED') {
917
+ errorMessage = 'Authentication token expired';
918
+ } else if (error.code === 'ERR_JWT_INVALID') {
919
+ errorMessage = 'Invalid authentication token';
920
+ }
921
+ return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(errorMessage)));
861
922
  }
862
- // Session-based: Save to session and redirect
863
- await this.#saveSession(req, jwt, initUser);
864
- return res.redirect(redirectUrl);
865
- }
866
- catch (error) {
867
- this.#logger.error('### CALLBACK ERROR ###', error);
868
- return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(error.message)));
869
- }
870
- };
871
- }
923
+ };
924
+ }
872
925
 
873
926
  /**
874
927
  * Get Identity Providers
@@ -896,14 +949,15 @@ export class SessionManager {
896
949
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
897
950
  */
898
951
  refresh(initUser) {
899
- const idpUrl = '/auth/refresh'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
952
+ const idpRefreshUrl = '/auth/refresh'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
900
953
  return async (req, res, next) => {
901
- const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
902
-
954
+ const mode = this.getSessionMode() || SessionMode.SESSION;
955
+
903
956
  if (mode === SessionMode.TOKEN) {
904
- return this.#refreshToken(req, res, next, initUser, idpUrl);
905
- } else {
906
- return this.#refreshSession(req, res, next, initUser, idpUrl);
957
+ return this.#refreshToken(req, res, next, initUser, idpRefreshUrl);
958
+ }
959
+ else {
960
+ return this.#refreshSession(req, res, next, initUser, idpRefreshUrl);
907
961
  }
908
962
  };
909
963
  }
@@ -917,8 +971,8 @@ export class SessionManager {
917
971
  const { redirect = false, all = false } = req.query;
918
972
  const isRedirect = (redirect === 'true' || redirect === true);
919
973
  const logoutAll = (all === 'true' || all === true);
920
- const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
921
-
974
+ const mode = this.getSessionMode() || SessionMode.SESSION;
975
+
922
976
  if (mode === SessionMode.TOKEN) {
923
977
  return this.#logoutToken(req, res, isRedirect, logoutAll);
924
978
  }
@@ -944,4 +998,11 @@ export class SessionManager {
944
998
  };
945
999
  }
946
1000
 
1001
+ /**
1002
+ * Get session mode
1003
+ * @returns {string} Session mode
1004
+ */
1005
+ getSessionMode() {
1006
+ return this.#config.SESSION_MODE;
1007
+ }
947
1008
  }
package/index.d.ts CHANGED
@@ -142,24 +142,27 @@ export interface SessionConfig {
142
142
  */
143
143
  SESSION_SECRET?: string;
144
144
 
145
- /**
145
+ /**
146
146
  * Redis key prefix for storing session data
147
147
  * @example 'myapp:session:' (will result in keys like 'myapp:session:user@example.com')
148
- * @default 'sess:'
148
+ * @default 'ibmid:' (legacy default, consider changing for your use case)
149
149
  */
150
150
  SESSION_PREFIX?: string;
151
151
 
152
- /**
152
+ /**
153
153
  * Redis key name for storing session data
154
+ * - In SESSION mode: key used to store the user in the session
155
+ * - In TOKEN mode: key of localStorage where the token is stored
154
156
  * @example 'user' (results in session.user containing user data)
155
- * @default 'user'
157
+ * @default 'session_token'
156
158
  */
157
159
  SESSION_KEY?: string;
158
160
 
159
- /**
161
+ /**
160
162
  * Redis key name for storing session expiry timestamp
163
+ * - In TOKEN mode: key of localStorage where the session expiry timestamp is stored
161
164
  * @example 'expires' (results in session.expires containing expiry time)
162
- * @default 'expires'
165
+ * @default 'session_expires_at'
163
166
  */
164
167
  SESSION_EXPIRY_KEY?: string;
165
168
 
@@ -311,41 +314,99 @@ export class SessionManager {
311
314
  redisManager(): RedisManager;
312
315
 
313
316
  /**
314
- * Initialize the session configurations
317
+ * Get authenticated user data (works for both SESSION and TOKEN modes)
318
+ * @param req Express request object
319
+ * @param includeUserData Include user data in the response (default: false)
320
+ * @returns Promise resolving to full user data object
321
+ * @throws CustomError If user is not authenticated
322
+ * @example
323
+ * ```javascript
324
+ * // Use in custom middleware
325
+ * app.use(async (req, res, next) => {
326
+ * try {
327
+ * const user = await sessionManager.getUser(req, true);
328
+ * req.customUser = user;
329
+ * next();
330
+ * } catch (error) {
331
+ * next(error);
332
+ * }
333
+ * });
334
+ * ```
335
+ */
336
+ getUser(req: Request, includeUserData: boolean?): Promise<SessionUser>;
337
+
338
+ /**
339
+ * Initialize the session configurations and middleware
315
340
  * @param app Express application
316
- * @param config Session configurations
317
- * @param updateUser Process user object to compute attributes like permissions, avatar URL, etc.
318
341
  */
319
- setup(
320
- app: Application,
321
- updateUser: (user: SessionUser | undefined) => any
322
- ): Promise<void>;
342
+ setup(app: Application): Promise<void>;
343
+
344
+ /**
345
+ * Middleware to load full user data into req.user
346
+ * - SESSION mode: Loads user from session store (req.session[SESSION_KEY])
347
+ * - TOKEN mode: Loads user from Redis using JWT token
348
+ * - Provides request-level caching to avoid redundant lookups
349
+ * - Should be used after authenticate() middleware
350
+ * @returns Returns express Request Handler
351
+ * @example
352
+ * ```javascript
353
+ * app.get('/api/profile',
354
+ * session.authenticate(), // Verifies authentication
355
+ * session.requireUser(), // Loads user data into req.user
356
+ * (req, res) => {
357
+ * res.json({ user: req.user }); // User data available here
358
+ * }
359
+ * );
360
+ * ```
361
+ */
362
+ requireUser(): RequestHandler;
323
363
 
324
364
  /**
325
365
  * Resource protection middleware based on configured SESSION_MODE
326
- * Uses verifySession() for SESSION mode and verifyToken() for TOKEN mode
327
- * @param isDebugging Debugging flag (default: false)
328
- * @param redirectUrl Redirect URL (default: '')
366
+ * - SESSION mode: Verifies user exists in session store and is authorized (checks req.session data)
367
+ * - TOKEN mode: Validates JWT token from Authorization header (lightweight validation)
368
+ *
369
+ * Note: This method verifies authentication only and does NOT populate req.user.
370
+ * Use requireUser() after this middleware to load user data into req.user.
371
+ *
372
+ * @param errorRedirectUrl Redirect URL on authentication failure (default: '')
329
373
  * @returns Returns express Request Handler
374
+ * @example
375
+ * ```javascript
376
+ * // Option 1: Just verify authentication (user data remains in req.session or token)
377
+ * app.get('/api/check',
378
+ * session.authenticate(),
379
+ * (req, res) => {
380
+ * res.json({ authenticated: true });
381
+ * }
382
+ * );
383
+ *
384
+ * // Option 2: Verify authentication AND populate req.user (recommended for most use cases)
385
+ * app.get('/api/profile',
386
+ * session.authenticate(), // Verifies session/token validity
387
+ * session.requireUser(), // Loads user data into req.user
388
+ * (req, res) => {
389
+ * res.json({ user: req.user }); // User data now available
390
+ * }
391
+ * );
392
+ * ```
330
393
  */
331
- authenticate(isDebugging?: boolean, redirectUrl?: string): RequestHandler;
394
+ authenticate(errorRedirectUrl?: string): RequestHandler;
332
395
 
333
396
  /**
334
397
  * Resource protection by token (explicit token verification)
335
398
  * Requires Authorization: Bearer {token} header
336
- * @param isDebugging Debugging flag (default: false)
337
- * @param redirectUrl Redirect URL (default: '')
399
+ * @param errorRedirectUrl Redirect URL (default: '')
338
400
  * @returns Returns express Request Handler
339
401
  */
340
- verifyToken(isDebugging?: boolean, redirectUrl?: string): RequestHandler;
402
+ verifyToken(errorRedirectUrl?: string): RequestHandler;
341
403
 
342
404
  /**
343
405
  * Resource protection by session (explicit session verification)
344
- * @param isDebugging Debugging flag (default: false)
345
- * @param redirectUrl Redirect URL (default: '')
406
+ * @param errorRedirectUrl Redirect URL (default: '')
346
407
  * @returns Returns express Request Handler
347
408
  */
348
- verifySession(isDebugging?: boolean, redirectUrl?: string): RequestHandler;
409
+ verifySession(errorRedirectUrl?: string): RequestHandler;
349
410
 
350
411
  /**
351
412
  * SSO callback for successful login
@@ -379,6 +440,12 @@ export class SessionManager {
379
440
  * @returns Returns express Request Handler
380
441
  */
381
442
  logout(): RequestHandler;
443
+
444
+ /**
445
+ * Get the current session mode
446
+ * @returns Returns 'session' or 'token' based on configuration
447
+ */
448
+ getSessionMode(): string;
382
449
  }
383
450
 
384
451
  // Custom Error class
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igxjs/node-components",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Node components for igxjs",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -12,21 +12,7 @@
12
12
  "url": "git+https://github.com/igxjs/node-components.git"
13
13
  },
14
14
  "keywords": [
15
- "igxjs",
16
- "express",
17
- "session-management",
18
- "jwt",
19
- "jwe",
20
- "redis",
21
- "authentication",
22
- "sso",
23
- "oauth",
24
- "middleware",
25
- "node-components",
26
- "session",
27
- "token-auth",
28
- "bearer-token",
29
- "express-middleware"
15
+ "igxjs"
30
16
  ],
31
17
  "author": "Michael",
32
18
  "license": "Apache-2.0",
@@ -43,7 +29,7 @@
43
29
  "axios": "^1.13.6",
44
30
  "connect-redis": "^9.0.0",
45
31
  "express-session": "^1.19.0",
46
- "jose": "^6.2.1",
32
+ "jose": "^6.2.2",
47
33
  "memorystore": "^1.6.7"
48
34
  },
49
35
  "devDependencies": {