@igxjs/node-components 1.0.10 → 1.0.11

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
@@ -12,7 +12,7 @@ npm install @igxjs/node-components
12
12
 
13
13
  | Component | Description | Documentation |
14
14
  |-----------|-------------|---------------|
15
- | **SessionManager** | SSO session management with Redis/memory storage | [View docs](./docs/session-manager.md) |
15
+ | **SessionManager** | SSO session management with Redis/memory storage, supporting both session and token-based authentication | [View docs](./docs/session-manager.md) |
16
16
  | **FlexRouter** | Flexible routing with context paths and middleware | [View docs](./docs/flex-router.md) |
17
17
  | **RedisManager** | Redis connection management with TLS support | [View docs](./docs/redis-manager.md) |
18
18
  | **JWT Manager** | Secure JWT encryption/decryption with JWE | [View docs](./docs/jwt-manager.md) |
@@ -23,9 +23,9 @@ npm install @igxjs/node-components
23
23
  ### SessionManager
24
24
 
25
25
  ```javascript
26
- import { SessionManager } from '@igxjs/node-components';
26
+ import { SessionManager, SessionMode } from '@igxjs/node-components';
27
27
 
28
- // Create singleton instance
28
+ // Create singleton instance with SESSION authentication (default)
29
29
  export const session = new SessionManager({
30
30
  SSO_ENDPOINT_URL: process.env.SSO_ENDPOINT_URL,
31
31
  SSO_CLIENT_ID: process.env.SSO_CLIENT_ID,
@@ -34,6 +34,16 @@ export const session = new SessionManager({
34
34
  REDIS_URL: process.env.REDIS_URL
35
35
  });
36
36
 
37
+ // Create singleton instance with TOKEN authentication
38
+ export const tokenSession = new SessionManager({
39
+ SESSION_MODE: SessionMode.TOKEN, // Use token-based authentication
40
+ SSO_ENDPOINT_URL: process.env.SSO_ENDPOINT_URL,
41
+ SSO_CLIENT_ID: process.env.SSO_CLIENT_ID,
42
+ SSO_CLIENT_SECRET: process.env.SSO_CLIENT_SECRET,
43
+ SESSION_SECRET: process.env.SESSION_SECRET,
44
+ REDIS_URL: process.env.REDIS_URL,
45
+ });
46
+
37
47
  // Setup in your app
38
48
  await session.setup(app, (user) => ({ ...user, displayName: user.email }));
39
49
 
@@ -105,9 +115,93 @@ app.use(httpErrorHandler);
105
115
 
106
116
  [📖 Full HTTP Handlers Documentation](./docs/http-handlers.md)
107
117
 
118
+ ## SessionManager Authentication Modes
119
+
120
+ The `SessionManager` supports two authentication modes:
121
+
122
+ ### SESSION Mode (Default)
123
+
124
+ Uses traditional server-side session cookies. When a user authenticates via SSO, their session is stored in Redis or memory storage. The client sends the session cookie with each request to prove authentication.
125
+
126
+ **Configuration:**
127
+ - `SESSION_MODE`: `SessionMode.SESSION` (default) - Uses session-based authentication
128
+ - `SESSION_AGE`: Session timeout in milliseconds (default: 64800000)
129
+ - `REDIS_URL`: Redis connection string for session storage
130
+
131
+ **Auth Methods:**
132
+ - `session.authenticate()` - Protect routes with SSO session verification
133
+ - `session.verifySession(isDebugging, redirectUrl)` - Explicit session verification method
134
+ - `session.logout(redirect?, all?)` - Logout current session (or logout all for token mode)
135
+
136
+ ### TOKEN Mode
137
+
138
+ Uses JWT bearer tokens instead of session cookies. When a user authenticates via SSO, a JWT token is generated and stored in Redis. The client includes the token in the Authorization header (`Bearer {token}`) with each request.
139
+
140
+ **Configuration:**
141
+ - `SESSION_MODE`: `SessionMode.TOKEN` - Uses token-based authentication
142
+ - `SSO_SUCCESS_URL`: Redirect URL after successful SSO login
143
+ - `SSO_FAILURE_URL`: Redirect URL after failed SSO login
144
+ - `JWT_ALGORITHM`: JWT algorithm (default: `'dir'`)
145
+ - `JWT_ENCRYPTION`: Encryption algorithm (default: `'A256GCM'`)
146
+ - `JWT_EXPIRATION_TIME`: Token expiration time (default: `'10m'`)
147
+ - `JWT_CLOCK_TOLERANCE`: Clock skew tolerance in seconds (default: 30)
148
+
149
+ **Auth Methods:**
150
+ - `session.verifyToken(isDebugging, redirectUrl)` - Protect routes with token verification
151
+ - `session.callback(initUser)` - SSO callback handler for token generation
152
+ - `session.refresh(initUser)` - Refresh user authentication based on auth mode
153
+ - `session.logout(redirect?, all?)` - Logout current or all tokens
154
+
155
+ **Token Storage (Client-Side):**
156
+
157
+ When using token-based authentication, the client-side HTML page stores the token in `localStorage`:
158
+
159
+ ```html
160
+ <script>
161
+ // Store auth data in localStorage
162
+ localStorage.setItem('authToken', ${JSON.stringify(token)});
163
+ localStorage.setItem('tokenExpiry', ${Date.now() + sessionAge});
164
+ localStorage.setItem('user', ${JSON.stringify({
165
+ email: user.email,
166
+ name: user.name,
167
+ })});
168
+
169
+ // Redirect to original destination
170
+ window.location.replace(redirectUrl);
171
+ </script>
172
+ ```
173
+
174
+ ## SessionManager Configuration Options
175
+
176
+ | Option | Type | Default | Description |
177
+ |--------|------|---------|-------------|
178
+ | `SSO_ENDPOINT_URL` | string | - | Identity provider endpoint URL |
179
+ | `SSO_CLIENT_ID` | string | - | SSO client ID |
180
+ | `SSO_CLIENT_SECRET` | string | - | SSO client secret |
181
+ | `SSO_SUCCESS_URL` | string | - | Redirect URL after successful login (token mode) |
182
+ | `SSO_FAILURE_URL` | string | - | Redirect URL after failed login (token mode) |
183
+ | `SESSION_MODE` | string | `SessionMode.SESSION` | Authentication mode: `SessionMode.SESSION` or `SessionMode.TOKEN` |
184
+ | `SESSION_AGE` | number | 64800000 | Session timeout in milliseconds |
185
+ | `SESSION_COOKIE_PATH` | string | `'/'` | Session cookie path |
186
+ | `SESSION_SECRET` | string | - | Session/JWT secret key |
187
+ | `SESSION_PREFIX` | string | `'ibmid:'` | Redis session/key prefix |
188
+ | `REDIS_URL` | string | - | Redis connection URL (optional) |
189
+ | `REDIS_CERT_PATH` | string | - | Path to Redis TLS certificate |
190
+ | `JWT_ALGORITHM` | string | `'dir'` | JWT signing algorithm |
191
+ | `JWT_ENCRYPTION` | string | `'A256GCM'` | JWE encryption algorithm |
192
+ | `JWT_EXPIRATION_TIME` | string | `'10m'` | Token expiration duration |
193
+ | `JWT_CLOCK_TOLERANCE` | number | 30 | Clock skew tolerance in seconds |
194
+ | `JWT_SECRET_HASH_ALGORITHM` | string | `'SHA-256'` | Algorithm for hashing secrets |
195
+ | `JWT_ISSUER` | string | - | JWT issuer identifier |
196
+ | `JWT_AUDIENCE` | string | - | JWT audience identifier |
197
+ | `JWT_SUBJECT` | string | - | JWT subject identifier |
198
+
108
199
  ## Features
109
200
 
110
201
  - ✅ **SSO Integration** - Full SSO support with Redis or memory storage
202
+ - ✅ **Dual Authentication Modes** - SESSION (cookies) or TOKEN (Bearer tokens)
203
+ - ✅ **Token Refresh** - Automatic token refresh via SSO endpoints
204
+ - ✅ **Session Refresh Locks** - Prevent concurrent token/session refresh attacks
111
205
  - ✅ **JWT Security** - Encrypted JWT tokens using JWE (jose library)
112
206
  - ✅ **Flexible Routing** - Easy mounting with context paths and middleware
113
207
  - ✅ **Redis Support** - TLS/SSL and automatic reconnection
@@ -148,4 +242,4 @@ import type {
148
242
 
149
243
  ## License
150
244
 
151
- [Apache 2.0](LICENSE)
245
+ [Apache 2.0](LICENSE)
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import axios from 'axios';
2
3
  import session from 'express-session';
3
4
  import memStore from 'memorystore';
@@ -7,11 +8,25 @@ import { CustomError, httpCodes, httpHelper, httpMessages } from './http-handler
7
8
  import { JwtManager } from './jwt.js';
8
9
  import { RedisManager } from './redis.js';
9
10
 
11
+ /**
12
+ * Session authentication mode constants
13
+ */
14
+ export const SessionMode = {
15
+ SESSION: 'session',
16
+ TOKEN: 'token'
17
+ };
18
+
10
19
  /**
11
20
  * Session configuration options
12
- * Uses strict UPPERCASE naming convention for all property names
13
21
  */
14
22
  export class SessionConfig {
23
+ /**
24
+ * @type {string}
25
+ * Authentication mode for protected routes
26
+ * Supported values: SessionMode.SESSION | SessionMode.TOKEN
27
+ * @default SessionMode.SESSION
28
+ */
29
+ SESSION_MODE;
15
30
  /** @type {string} */
16
31
  SSO_ENDPOINT_URL;
17
32
  /** @type {string} */
@@ -73,6 +88,8 @@ export class SessionManager {
73
88
  */
74
89
  constructor(config) {
75
90
  this.#config = {
91
+ // Session Mode
92
+ SESSION_MODE: config.SESSION_MODE || SessionMode.SESSION,
76
93
  // Session
77
94
  SESSION_AGE: config.SESSION_AGE || 64800000,
78
95
  SESSION_COOKIE_PATH: config.SESSION_COOKIE_PATH || '/',
@@ -141,6 +158,27 @@ export class SessionManager {
141
158
  return 'user';
142
159
  }
143
160
 
161
+ /**
162
+ * Get Redis key for token storage
163
+ * @param {string} email User email
164
+ * @param {string} tokenId Token ID
165
+ * @returns {string} Returns the Redis key for token storage
166
+ * @private
167
+ */
168
+ #getTokenRedisKey(email, tokenId) {
169
+ return `${this.#config.SESSION_PREFIX}token:${email}:${tokenId}`;
170
+ }
171
+
172
+ /**
173
+ * Get Redis key pattern for all user tokens
174
+ * @param {string} email User email
175
+ * @returns {string} Returns the Redis key pattern for all user tokens
176
+ * @private
177
+ */
178
+ #getTokenRedisPattern(email) {
179
+ return `${this.#config.SESSION_PREFIX}token:${email}:*`;
180
+ }
181
+
144
182
  /**
145
183
  * Get RedisManager instance
146
184
  * @returns {import('./redis.js').RedisManager} Returns the RedisManager instance
@@ -149,6 +187,368 @@ export class SessionManager {
149
187
  return this.#redisManager;
150
188
  }
151
189
 
190
+ /**
191
+ * Generate and store JWT token in Redis
192
+ * @param {object} user User object
193
+ * @returns {Promise<string>} Returns the generated JWT token
194
+ * @private
195
+ */
196
+ async #generateAndStoreToken(user) {
197
+ // Generate unique token ID for this device/session
198
+ const tokenId = crypto.randomUUID();
199
+
200
+ // Create JWT token with email and tokenId
201
+ const token = await this.#jwtManager.encrypt(
202
+ {
203
+ email: user.email,
204
+ tokenId
205
+ },
206
+ this.#config.SESSION_SECRET,
207
+ { expirationTime: this.#config.JWT_EXPIRATION_TIME }
208
+ );
209
+
210
+ // Store user data in Redis with TTL
211
+ const redisKey = this.#getTokenRedisKey(user.email, tokenId);
212
+ const ttlSeconds = Math.floor(this.#config.SESSION_AGE / 1000);
213
+
214
+ await this.#redisManager.getClient().setEx(
215
+ redisKey,
216
+ ttlSeconds,
217
+ JSON.stringify(user)
218
+ );
219
+
220
+ console.debug(`### TOKEN GENERATED: ${user.email} ###`);
221
+
222
+ return token;
223
+ }
224
+
225
+ /**
226
+ * Verify token authentication
227
+ * @param {import('@types/express').Request} req Request
228
+ * @param {import('@types/express').Response} res Response
229
+ * @param {import('@types/express').NextFunction} next Next function
230
+ * @param {boolean} isDebugging Debugging flag
231
+ * @param {string} redirectUrl Redirect URL
232
+ * @private
233
+ */
234
+ async #verifyToken(req, res, next, isDebugging, redirectUrl) {
235
+ try {
236
+ // Extract token from Authorization header
237
+ const authHeader = req.headers.authorization;
238
+ if (!authHeader?.startsWith('Bearer ')) {
239
+ throw new CustomError(httpCodes.UNAUTHORIZED, 'Missing or invalid authorization header');
240
+ }
241
+
242
+ const token = authHeader.substring(7); // Remove 'Bearer ' prefix
243
+
244
+ // Decrypt JWT token
245
+ const { payload } = await this.#jwtManager.decrypt(
246
+ token,
247
+ this.#config.SESSION_SECRET
248
+ );
249
+
250
+ // Extract email and tokenId
251
+ const { email, tokenId } = payload;
252
+ if (!email || !tokenId) {
253
+ throw new CustomError(httpCodes.UNAUTHORIZED, 'Invalid token payload');
254
+ }
255
+
256
+ // Lookup user in Redis
257
+ const redisKey = this.#getTokenRedisKey(email, tokenId);
258
+ const userData = await this.#redisManager.getClient().get(redisKey);
259
+
260
+ if (!userData) {
261
+ throw new CustomError(httpCodes.UNAUTHORIZED, 'Token not found or expired');
262
+ }
263
+
264
+ // Parse and attach user to request
265
+ req.user = JSON.parse(userData);
266
+ res.locals.user = req.user;
267
+
268
+ // Validate authorization
269
+ const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
270
+ if (!authorized && !isDebugging) {
271
+ throw new CustomError(httpCodes.FORBIDDEN, 'User is not authorized');
272
+ }
273
+
274
+ return next();
275
+
276
+ } catch (error) {
277
+ if (isDebugging) {
278
+ console.warn('### TOKEN VERIFICATION FAILED (debugging mode) ###', error.message);
279
+ return next();
280
+ }
281
+
282
+ if (redirectUrl) {
283
+ return res.redirect(redirectUrl);
284
+ }
285
+
286
+ // Handle specific JWT errors
287
+ if (error.code === 'ERR_JWT_EXPIRED') {
288
+ return next(new CustomError(httpCodes.UNAUTHORIZED, 'Token expired'));
289
+ }
290
+
291
+ return next(error instanceof CustomError ? error :
292
+ new CustomError(httpCodes.UNAUTHORIZED, 'Token verification failed'));
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Verify session authentication
298
+ * @param {import('@types/express').Request} req Request
299
+ * @param {import('@types/express').Response} res Response
300
+ * @param {import('@types/express').NextFunction} next Next function
301
+ * @param {boolean} isDebugging Debugging flag
302
+ * @param {string} redirectUrl Redirect URL
303
+ * @private
304
+ */
305
+ async #verifySession(req, res, next, isDebugging, redirectUrl) {
306
+ const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
307
+ if (authorized) {
308
+ return next();
309
+ }
310
+ if (redirectUrl) {
311
+ return res.redirect(redirectUrl);
312
+ }
313
+ return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
314
+ }
315
+
316
+ /**
317
+ * Refresh token authentication
318
+ * @param {import('@types/express').Request} req Request
319
+ * @param {import('@types/express').Response} res Response
320
+ * @param {import('@types/express').NextFunction} next Next function
321
+ * @param {(user: object) => object} initUser Initialize user function
322
+ * @param {string} idpUrl Identity provider URL
323
+ * @private
324
+ */
325
+ async #refreshToken(req, res, next, initUser, idpUrl) {
326
+ try {
327
+ // Get current user from verifyToken middleware
328
+ const { email, attributes } = req.user || {};
329
+
330
+ if (!email) {
331
+ throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
332
+ }
333
+
334
+ // Extract tokenId from current token
335
+ const authHeader = req.headers.authorization;
336
+ const token = authHeader?.substring(7);
337
+ const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
338
+ const oldTokenId = payload.tokenId;
339
+
340
+ // Check refresh lock
341
+ if (this.hasLock(email)) {
342
+ throw new CustomError(httpCodes.CONFLICT, 'Token refresh is locked');
343
+ }
344
+ this.lock(email);
345
+
346
+ // Call SSO refresh endpoint
347
+ const response = await this.#idpRequest.post(idpUrl, {
348
+ user: {
349
+ email,
350
+ attributes: {
351
+ idp: attributes?.idp,
352
+ refresh_token: attributes?.refresh_token
353
+ }
354
+ }
355
+ });
356
+
357
+ if (response.status !== httpCodes.OK) {
358
+ throw new CustomError(response.status, response.statusText);
359
+ }
360
+
361
+ // Decrypt new user data from SSO
362
+ const { jwt } = response.data;
363
+ const { payload: newPayload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
364
+
365
+ if (!newPayload?.user) {
366
+ throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload from SSO');
367
+ }
368
+
369
+ // Initialize user with new data
370
+ const user = initUser(newPayload.user);
371
+
372
+ // Generate new token
373
+ const newToken = await this.#generateAndStoreToken(user);
374
+
375
+ // Remove old token from Redis
376
+ const oldRedisKey = this.#getTokenRedisKey(email, oldTokenId);
377
+ await this.#redisManager.getClient().del(oldRedisKey);
378
+
379
+ console.debug('### TOKEN REFRESHED SUCCESSFULLY ###');
380
+
381
+ // Return new token
382
+ return res.json({
383
+ token: newToken,
384
+ user,
385
+ expiresIn: Math.floor(this.#config.SESSION_AGE / 1000),
386
+ tokenType: 'Bearer'
387
+ });
388
+ } catch (error) {
389
+ return next(httpHelper.handleAxiosError(error));
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Refresh session authentication
395
+ * @param {import('@types/express').Request} req Request
396
+ * @param {import('@types/express').Response} res Response
397
+ * @param {import('@types/express').NextFunction} next Next function
398
+ * @param {(user: object) => object} initUser Initialize user function
399
+ * @param {string} idpUrl Identity provider URL
400
+ * @private
401
+ */
402
+ async #refreshSession(req, res, next, initUser, idpUrl) {
403
+ try {
404
+ const { email, attributes } = req.user || { email: '', attributes: {} };
405
+ if (this.hasLock(email)) {
406
+ throw new CustomError(httpCodes.CONFLICT, 'User refresh is locked');
407
+ }
408
+ this.lock(email);
409
+ const response = await this.#idpRequest.post(idpUrl, {
410
+ user: {
411
+ email,
412
+ attributes: {
413
+ idp: attributes?.idp,
414
+ refresh_token: attributes?.refresh_token
415
+ }
416
+ }
417
+ });
418
+ if (response.status === httpCodes.OK) {
419
+ const { jwt } = response.data;
420
+ const payload = await this.#saveSession(req, jwt, initUser);
421
+ return res.json(payload);
422
+ }
423
+ throw new CustomError(response.status, response.statusText);
424
+ } catch (error) {
425
+ return next(httpHelper.handleAxiosError(error));
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Logout all tokens for a user
431
+ * @param {import('@types/express').Request} req Request
432
+ * @param {import('@types/express').Response} res Response
433
+ * @param {boolean} isRedirect Whether to redirect
434
+ * @private
435
+ */
436
+ async #logoutAllTokens(req, res, isRedirect) {
437
+ try {
438
+ const { email } = req.user || {};
439
+
440
+ if (!email) {
441
+ throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
442
+ }
443
+
444
+ // Find all tokens for this user
445
+ const pattern = this.#getTokenRedisPattern(email);
446
+ const keys = await this.#redisManager.getClient().keys(pattern);
447
+
448
+ // Delete all tokens
449
+ if (keys.length > 0) {
450
+ await this.#redisManager.getClient().del(keys);
451
+ }
452
+
453
+ console.info(`### ALL TOKENS LOGGED OUT: ${email} (${keys.length} tokens) ###`);
454
+
455
+ if (isRedirect) {
456
+ return res.redirect(this.#config.SSO_SUCCESS_URL);
457
+ }
458
+ return res.json({
459
+ message: 'All tokens logged out successfully',
460
+ tokensRemoved: keys.length,
461
+ redirect_url: this.#config.SSO_SUCCESS_URL
462
+ });
463
+ } catch (error) {
464
+ console.error('### LOGOUT ALL TOKENS ERROR ###', error);
465
+ if (isRedirect) {
466
+ return res.redirect(this.#config.SSO_FAILURE_URL);
467
+ }
468
+ return res.status(httpCodes.SYSTEM_FAILURE).json({
469
+ error: 'Logout all failed',
470
+ redirect_url: this.#config.SSO_FAILURE_URL
471
+ });
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Logout token authentication
477
+ * @param {import('@types/express').Request} req Request
478
+ * @param {import('@types/express').Response} res Response
479
+ * @param {boolean} isRedirect Whether to redirect
480
+ * @param {boolean} logoutAll Whether to logout all tokens
481
+ * @private
482
+ */
483
+ async #logoutToken(req, res, isRedirect, logoutAll = false) {
484
+ // If logoutAll is true, delegate to the all tokens logout method
485
+ if (logoutAll) {
486
+ return this.#logoutAllTokens(req, res, isRedirect);
487
+ }
488
+
489
+ try {
490
+ // Extract tokenId from current token
491
+ const authHeader = req.headers.authorization;
492
+ const token = authHeader?.substring(7);
493
+
494
+ if (!token) {
495
+ throw new CustomError(httpCodes.BAD_REQUEST, 'No token provided');
496
+ }
497
+
498
+ const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
499
+ const { email, tokenId } = payload;
500
+
501
+ // Remove token from Redis
502
+ const redisKey = this.#getTokenRedisKey(email, tokenId);
503
+ await this.#redisManager.getClient().del(redisKey);
504
+
505
+ console.info('### TOKEN LOGOUT SUCCESSFULLY ###');
506
+
507
+ if (isRedirect) {
508
+ return res.redirect(this.#config.SSO_SUCCESS_URL);
509
+ }
510
+ return res.json({
511
+ message: 'Logout successful',
512
+ redirect_url: this.#config.SSO_SUCCESS_URL
513
+ });
514
+
515
+ } catch (error) {
516
+ console.error('### TOKEN LOGOUT ERROR ###', error);
517
+ if (isRedirect) {
518
+ return res.redirect(this.#config.SSO_FAILURE_URL);
519
+ }
520
+ return res.status(httpCodes.SYSTEM_FAILURE).json({
521
+ error: 'Logout failed',
522
+ redirect_url: this.#config.SSO_FAILURE_URL
523
+ });
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Logout session authentication
529
+ * @param {import('@types/express').Request} req Request
530
+ * @param {import('@types/express').Response} res Response
531
+ * @param {Function} callback Callback function
532
+ * @private
533
+ */
534
+ #logoutSession(req, res, callback) {
535
+ try {
536
+ res.clearCookie('connect.sid');
537
+ } catch (error) {
538
+ console.error('### CLEAR COOKIE ERROR ###');
539
+ console.error(error);
540
+ }
541
+ return req.session.destroy((sessionError) => {
542
+ if (sessionError) {
543
+ console.error('### SESSION DESTROY CALLBACK ERROR ###');
544
+ console.error(sessionError);
545
+ return callback(sessionError);
546
+ }
547
+ console.info('### SESSION LOGOUT SUCCESSFULLY ###');
548
+ return callback(null);
549
+ });
550
+ }
551
+
152
552
  /**
153
553
  * Setup the session/user handlers with configurations
154
554
  * @param {import('@types/express').Application} app Express application
@@ -225,24 +625,44 @@ export class SessionManager {
225
625
  }
226
626
 
227
627
  /**
228
- * Resource protection
628
+ * Resource protection based on configured SESSION_MODE
229
629
  * @param {boolean} [isDebugging=false] Debugging flag
230
- * @param {boolean} [redirectUrl=''] Redirect flag
630
+ * @param {string} [redirectUrl=''] Redirect URL
231
631
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
232
632
  */
233
- authenticate (isDebugging = false, redirectUrl = '') {
633
+ authenticate(isDebugging = false, redirectUrl = '') {
234
634
  return async (req, res, next) => {
235
- /** @type {{ authorized: boolean }} */
236
- const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
237
- if (authorized) {
238
- return next();
635
+ const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
636
+ if (mode === SessionMode.TOKEN) {
637
+ return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
239
638
  }
240
- if(redirectUrl) {
241
- return res.redirect(redirectUrl);
242
- }
243
- return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
639
+ return this.#verifySession(req, res, next, isDebugging, redirectUrl);
244
640
  };
245
- };
641
+ }
642
+
643
+ /**
644
+ * Resource protection by token (explicit token verification)
645
+ * @param {boolean} [isDebugging=false] Debugging flag
646
+ * @param {string} [redirectUrl=''] Redirect URL
647
+ * @returns {import('@types/express').RequestHandler} Returns express Request Handler
648
+ */
649
+ verifyToken(isDebugging = false, redirectUrl = '') {
650
+ return async (req, res, next) => {
651
+ return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Resource protection by session (explicit session verification)
657
+ * @param {boolean} [isDebugging=false] Debugging flag
658
+ * @param {string} [redirectUrl=''] Redirect URL
659
+ * @returns {import('@types/express').RequestHandler} Returns express Request Handler
660
+ */
661
+ verifySession(isDebugging = false, redirectUrl = '') {
662
+ return async (req, res, next) => {
663
+ return this.#verifySession(req, res, next, isDebugging, redirectUrl);
664
+ };
665
+ }
246
666
 
247
667
  /**
248
668
  * Save session
@@ -279,16 +699,77 @@ export class SessionManager {
279
699
  callback(initUser) {
280
700
  return async (req, res, next) => {
281
701
  const { jwt = '' } = req.query;
282
- if(!jwt) {
702
+ if (!jwt) {
283
703
  return next(new CustomError(httpCodes.BAD_REQUEST, 'Missing `jwt` in query parameters'));
284
704
  }
705
+
285
706
  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);
707
+ // Decrypt JWT from Identity Adapter
708
+ const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
709
+
710
+ if (!payload?.user) {
711
+ throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
712
+ }
713
+
714
+ const user = initUser(payload.user);
715
+ const redirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
716
+
717
+ // Check SESSION_MODE to determine response type
718
+ if (this.#config.SESSION_MODE === SessionMode.TOKEN) {
719
+ // Token-based: Generate token and return HTML page that stores it
720
+ const token = await this.#generateAndStoreToken(user);
721
+
722
+ console.debug('### CALLBACK TOKEN GENERATED ###');
723
+
724
+ // Return HTML page that stores token in localStorage and redirects
725
+ return res.send(`
726
+ <!DOCTYPE html>
727
+ <html>
728
+ <head>
729
+ <meta charset="UTF-8">
730
+ <title>Authentication Complete</title>
731
+ <script>
732
+ (function() {
733
+ try {
734
+ // Store auth data in localStorage
735
+ localStorage.setItem('authToken', ${JSON.stringify(token)});
736
+ localStorage.setItem('tokenExpiry', ${Date.now() + this.#config.SESSION_AGE});
737
+ localStorage.setItem('user', ${JSON.stringify({
738
+ email: user.email,
739
+ name: user.name,
740
+ })});
741
+
742
+ // Redirect to original destination
743
+ window.location.replace(${JSON.stringify(redirectUrl)});
744
+ } catch (error) {
745
+ console.error('Failed to store authentication:', error);
746
+ document.getElementById('error').style.display = 'block';
747
+ }
748
+ })();
749
+ </script>
750
+ <style>
751
+ body { font-family: system-ui, sans-serif; text-align: center; padding: 50px; }
752
+ #error { display: none; color: #d32f2f; margin-top: 20px; }
753
+ </style>
754
+ </head>
755
+ <body>
756
+ <p>Completing authentication...</p>
757
+ <div id="error">
758
+ <p>Authentication failed. Please try again.</p>
759
+ <a href="${this.#config.SSO_FAILURE_URL}">Return to login</a>
760
+ </div>
761
+ </body>
762
+ </html>
763
+ `);
764
+ }
765
+ else {
766
+ // Session-based: Save to session and redirect
767
+ await this.#saveSession(req, jwt, initUser);
768
+ return res.redirect(redirectUrl);
769
+ }
288
770
  }
289
771
  catch (error) {
290
- console.error('### LOGIN ERROR ###');
291
- console.error(error);
772
+ console.error('### CALLBACK ERROR ###', error);
292
773
  return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(error.message)));
293
774
  }
294
775
  };
@@ -315,86 +796,57 @@ export class SessionManager {
315
796
  }
316
797
 
317
798
  /**
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
799
+ * Refresh user authentication based on configured SESSION_MODE
342
800
  * @param {(user: object) => object} initUser Initialize user object function
343
801
  * @returns {import('@types/express').RequestHandler} Returns express Request Handler
344
802
  */
345
803
  refresh(initUser) {
346
804
  const idpUrl = '/auth/refresh'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
347
805
  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));
806
+ const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
807
+
808
+ if (mode === SessionMode.TOKEN) {
809
+ return this.#refreshToken(req, res, next, initUser, idpUrl);
810
+ } else {
811
+ return this.#refreshSession(req, res, next, initUser, idpUrl);
373
812
  }
374
813
  };
375
814
  }
376
815
 
377
816
  /**
378
- * Logout
379
- * @param {import('@types/express').Request} req Request
380
- * @param {import('@types/express').Response} res Response
381
- * @param {(error: Error)} callback Callback
817
+ * Application logout based on configured SESSION_MODE (NOT SSO)
818
+ * @returns {import('@types/express').RequestHandler} Returns express Request Handler
382
819
  */
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);
820
+ logout() {
821
+ return async (req, res) => {
822
+ const { redirect = false, all = false } = req.query;
823
+ const isRedirect = (redirect === 'true' || redirect === true);
824
+ const logoutAll = (all === 'true' || all === true);
825
+ const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
826
+
827
+ if (mode === SessionMode.TOKEN) {
828
+ return this.#logoutToken(req, res, isRedirect, logoutAll);
395
829
  }
396
- console.info('### LOGOUT SUCCESSFULLY ###');
397
- return callback(null);
398
- });
830
+
831
+ // Note: 'all' parameter is only applicable for token-based authentication
832
+ // Session-based authentication is already single-instance per cookie
833
+ return this.#logoutSession(req, res, (error) => {
834
+ if (error) {
835
+ console.error('### LOGOUT CALLBACK ERROR ###');
836
+ console.error(error);
837
+ if (isRedirect) {
838
+ return res.redirect(this.#config.SSO_FAILURE_URL);
839
+ }
840
+ return res.status(httpCodes.AUTHORIZATION_FAILED).send({
841
+ redirect_url: this.#config.SSO_FAILURE_URL
842
+ });
843
+ }
844
+ if (isRedirect) {
845
+ return res.redirect(this.#config.SSO_SUCCESS_URL);
846
+ }
847
+ return res.send({ redirect_url: this.#config.SSO_SUCCESS_URL });
848
+ });
849
+ };
399
850
  }
851
+
400
852
  }
package/index.d.ts CHANGED
@@ -5,6 +5,14 @@ import { EncryptJWT, JWTDecryptResult, JWTPayload } from 'jose';
5
5
  import { RedisClientType } from '@redis/client';
6
6
  import { Application, RequestHandler, Request, Response, NextFunction, Router } from 'express';
7
7
 
8
+ export { JWTPayload } from 'jose';
9
+
10
+ // Session Mode constants
11
+ export const SessionMode: {
12
+ SESSION: string;
13
+ TOKEN: string;
14
+ };
15
+
8
16
  // Session Configuration - uses strict UPPERCASE naming convention for all property names
9
17
  export interface SessionConfig {
10
18
  /** Identity Provider */
@@ -14,6 +22,9 @@ export interface SessionConfig {
14
22
  SSO_SUCCESS_URL?: string;
15
23
  SSO_FAILURE_URL?: string;
16
24
 
25
+ /** Authentication mode: 'session' or 'token' (default: 'session') */
26
+ SESSION_MODE?: string;
27
+
17
28
  SESSION_AGE?: number;
18
29
  SESSION_COOKIE_PATH?: string;
19
30
  SESSION_SECRET?: string;
@@ -21,6 +32,15 @@ export interface SessionConfig {
21
32
 
22
33
  REDIS_URL?: string;
23
34
  REDIS_CERT_PATH?: string;
35
+
36
+ JWT_ALGORITHM?: string;
37
+ JWT_ENCRYPTION?: string;
38
+ JWT_EXPIRATION_TIME?: string;
39
+ JWT_CLOCK_TOLERANCE?: number;
40
+ JWT_SECRET_HASH_ALGORITHM?: string;
41
+ JWT_ISSUER?: string;
42
+ JWT_AUDIENCE?: string;
43
+ JWT_SUBJECT?: string;
24
44
  }
25
45
 
26
46
  export interface SessionUserAttributes {
@@ -98,15 +118,35 @@ export class SessionManager {
98
118
  ): Promise<void>;
99
119
 
100
120
  /**
101
- * Resource protection middleware
121
+ * Resource protection middleware based on configured SESSION_MODE
122
+ * Uses verifySession() for SESSION mode and verifyToken() for TOKEN mode
102
123
  * @param isDebugging Debugging flag (default: false)
103
124
  * @param redirectUrl Redirect URL (default: '')
104
125
  * @returns Returns express Request Handler
105
126
  */
106
127
  authenticate(isDebugging?: boolean, redirectUrl?: string): RequestHandler;
107
128
 
129
+ /**
130
+ * Resource protection by token (explicit token verification)
131
+ * Requires Authorization: Bearer {token} header
132
+ * @param isDebugging Debugging flag (default: false)
133
+ * @param redirectUrl Redirect URL (default: '')
134
+ * @returns Returns express Request Handler
135
+ */
136
+ verifyToken(isDebugging?: boolean, redirectUrl?: string): RequestHandler;
137
+
138
+ /**
139
+ * Resource protection by session (explicit session verification)
140
+ * @param isDebugging Debugging flag (default: false)
141
+ * @param redirectUrl Redirect URL (default: '')
142
+ * @returns Returns express Request Handler
143
+ */
144
+ verifySession(isDebugging?: boolean, redirectUrl?: string): RequestHandler;
145
+
108
146
  /**
109
147
  * SSO callback for successful login
148
+ * SESSION mode: Saves session and redirects
149
+ * TOKEN mode: Generates JWT token, returns HTML page with localStorage script
110
150
  * @param initUser Initialize user object function
111
151
  * @returns Returns express Request Handler
112
152
  */
@@ -119,17 +159,22 @@ export class SessionManager {
119
159
  identityProviders(): RequestHandler;
120
160
 
121
161
  /**
122
- * Application logout (NOT SSO)
162
+ * Refresh user authentication based on configured SESSION_MODE
163
+ * SESSION mode: Refreshes session data
164
+ * TOKEN mode: Generates new token, invalidates old token
165
+ * @param initUser Initialize user object function
123
166
  * @returns Returns express Request Handler
124
167
  */
125
- logout(): RequestHandler;
168
+ refresh(initUser: (user: SessionUser) => SessionUser): RequestHandler;
126
169
 
127
170
  /**
128
- * Refresh user session
129
- * @param initUser Initialize user object function
171
+ * Application logout based on configured SESSION_MODE (NOT SSO)
172
+ * SESSION mode: Destroys session and clears cookie
173
+ * TOKEN mode: Invalidates current token or all tokens (with ?all=true query param)
174
+ * Query params: redirect=true (redirect to success/failure URL), all=true (logout all tokens - TOKEN mode only)
130
175
  * @returns Returns express Request Handler
131
176
  */
132
- refresh(initUser: (user: SessionUser) => SessionUser): RequestHandler;
177
+ logout(): RequestHandler;
133
178
  }
134
179
 
135
180
  // Custom Error class
@@ -273,6 +318,7 @@ export interface JwtDecryptOptions {
273
318
  }
274
319
 
275
320
  export type JwtDecryptResult = JWTDecryptResult<EncryptJWT>;
321
+
276
322
  // JwtManager class for JWT encryption and decryption
277
323
  export class JwtManager {
278
324
  algorithm: string;
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { SessionConfig, SessionManager } from './components/session.js';
1
+ export { SessionConfig, SessionManager, SessionMode } from './components/session.js';
2
2
  export { httpCodes, httpMessages, httpErrorHandler, httpNotFoundHandler, CustomError, httpHelper, httpError } from './components/http-handlers.js';
3
3
  export { RedisManager } from './components/redis.js';
4
4
  export { FlexRouter } from './components/router.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igxjs/node-components",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Node components for igxjs",
5
5
  "main": "index.js",
6
6
  "type": "module",