@igxjs/node-components 1.0.14 → 1.0.15
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 +18 -7
- package/components/session.js +248 -207
- package/index.d.ts +83 -23
- package/package.json +1 -1
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
|
|
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(
|
|
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(
|
|
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 |
|
package/components/session.js
CHANGED
|
@@ -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
|
|
@@ -291,6 +293,37 @@ 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.#config.SESSION_MODE === SessionMode.SESSION) {
|
|
315
|
+
app.use(this.#sessionHandler(isOK));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Cache HTML template for TOKEN mode
|
|
319
|
+
if (this.#config.SESSION_MODE === 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
|
+
|
|
294
327
|
/**
|
|
295
328
|
* Generate and store JWT token in Redis
|
|
296
329
|
* - JWT payload contains only { email, tid } for minimal size
|
|
@@ -309,57 +342,44 @@ export class SessionManager {
|
|
|
309
342
|
async #generateAndStoreToken(user) {
|
|
310
343
|
// Generate unique token ID for this device/session
|
|
311
344
|
const tid = crypto.randomUUID();
|
|
312
|
-
// SESSION_AGE is already in seconds
|
|
313
|
-
const ttlSeconds = this.#config.SESSION_AGE;
|
|
314
345
|
// Create JWT token with only email and tid (minimal payload)
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
this.#config.SESSION_SECRET,
|
|
318
|
-
{ expirationTime: ttlSeconds }
|
|
319
|
-
);
|
|
346
|
+
const payload = { email: user.email, tid };
|
|
347
|
+
const token = await this.#jwtManager.encrypt(payload, this.#config.SESSION_SECRET, { expirationTime: this.#config.SESSION_AGE });
|
|
320
348
|
|
|
321
349
|
// Store user data in Redis with TTL
|
|
322
350
|
const redisKey = this.#getTokenRedisKey(user.email, tid);
|
|
323
351
|
|
|
324
|
-
await this.#redisManager.getClient().setEx(
|
|
325
|
-
redisKey,
|
|
326
|
-
ttlSeconds,
|
|
327
|
-
JSON.stringify(user)
|
|
328
|
-
);
|
|
352
|
+
await this.#redisManager.getClient().setEx(redisKey, this.#config.SESSION_AGE, JSON.stringify(user));
|
|
329
353
|
this.#logger.debug(`### TOKEN GENERATED: ${user.email} ###`);
|
|
330
354
|
return token;
|
|
331
355
|
}
|
|
332
356
|
|
|
333
357
|
/**
|
|
334
|
-
*
|
|
335
|
-
* @param {
|
|
336
|
-
* @param {
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* @
|
|
340
|
-
*
|
|
358
|
+
* Extract and validate user data from Authorization header (TOKEN mode only)
|
|
359
|
+
* @param {string} authHeader Authorization header in format "Bearer {token}"
|
|
360
|
+
* @param {boolean} [fetchFromRedis=true] Whether to fetch full user data from Redis
|
|
361
|
+
* - true: Returns { tid, user } with full user data from Redis (default)
|
|
362
|
+
* - false: Returns JWT payload only (lightweight validation)
|
|
363
|
+
* @returns {Promise<{ tid: string?, email: string?, user: object? } | object>}
|
|
364
|
+
* - When fetchFromRedis=true: { tid: string, user: object }
|
|
365
|
+
* - When fetchFromRedis=false: JWT payload object
|
|
366
|
+
* @throws {CustomError} UNAUTHORIZED (401) if:
|
|
367
|
+
* - Authorization header is missing or invalid format
|
|
368
|
+
* - Token decryption fails
|
|
369
|
+
* - Token payload is invalid (missing email/tid)
|
|
370
|
+
* - Token not found in Redis (when fetchFromRedis=true)
|
|
341
371
|
* @private
|
|
342
|
-
* @example
|
|
343
|
-
* // Authorization header format: "Bearer {jwt_token}"
|
|
344
|
-
* await this.#verifyToken(req, res, next, false, '/login');
|
|
345
372
|
*/
|
|
346
|
-
async #
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
);
|
|
373
|
+
async #getUserFromToken(authHeader, fetchFromRedis = true) {
|
|
374
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
375
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'Missing or invalid authorization header');
|
|
376
|
+
}
|
|
377
|
+
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
378
|
+
// Decrypt JWT token
|
|
379
|
+
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
361
380
|
|
|
362
|
-
|
|
381
|
+
if (fetchFromRedis) {
|
|
382
|
+
/** @type {{ email: string, tid: string }} Extract email and token ID */
|
|
363
383
|
const { email, tid } = payload;
|
|
364
384
|
if (!email || !tid) {
|
|
365
385
|
throw new CustomError(httpCodes.UNAUTHORIZED, 'Invalid token payload');
|
|
@@ -372,26 +392,58 @@ export class SessionManager {
|
|
|
372
392
|
if (!userData) {
|
|
373
393
|
throw new CustomError(httpCodes.UNAUTHORIZED, 'Token not found or expired');
|
|
374
394
|
}
|
|
395
|
+
return { tid, user: JSON.parse(userData) };
|
|
396
|
+
}
|
|
397
|
+
return payload;
|
|
398
|
+
}
|
|
375
399
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Get authenticated user data (works for both SESSION and TOKEN modes)
|
|
402
|
+
* @param {import('@types/express').Request} req Express request object
|
|
403
|
+
* @returns {Promise<object>} Full user data object
|
|
404
|
+
* @throws {CustomError} If user is not authenticated
|
|
405
|
+
* @public
|
|
406
|
+
* @example
|
|
407
|
+
* // Use in custom middleware
|
|
408
|
+
* app.use(async (req, res, next) => {
|
|
409
|
+
* try {
|
|
410
|
+
* const user = await sessionManager.getUser(req);
|
|
411
|
+
* req.customUser = user;
|
|
412
|
+
* next();
|
|
413
|
+
* } catch (error) {
|
|
414
|
+
* next(error);
|
|
415
|
+
* }
|
|
416
|
+
* });
|
|
417
|
+
*/
|
|
418
|
+
async getUser(req) {
|
|
419
|
+
if (this.#config.SESSION_MODE === SessionMode.TOKEN) {
|
|
420
|
+
const { user } = await this.#getUserFromToken(req.headers.authorization, true);
|
|
421
|
+
return user;
|
|
422
|
+
}
|
|
423
|
+
// Session mode
|
|
424
|
+
return req.session[this.#getSessionKey()];
|
|
425
|
+
}
|
|
384
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Verify token authentication - extracts and validates JWT from Authorization header
|
|
429
|
+
* @param {import('@types/express').Request} req Request with Authorization header
|
|
430
|
+
* @param {import('@types/express').Response} res Response object
|
|
431
|
+
* @param {import('@types/express').NextFunction} next Next middleware function
|
|
432
|
+
* @param {string} errorRedirectUrl URL to redirect to on authentication failure
|
|
433
|
+
* @throws {CustomError} If token is missing, invalid, or expired
|
|
434
|
+
* @private
|
|
435
|
+
* @example
|
|
436
|
+
* // Authorization header format: "Bearer {jwt_token}"
|
|
437
|
+
* await this.#verifyToken(req, res, next, '/login');
|
|
438
|
+
*/
|
|
439
|
+
async #verifyToken(req, res, next, errorRedirectUrl) {
|
|
440
|
+
try {
|
|
441
|
+
// Lightweight token validation (no Redis lookup)
|
|
442
|
+
await this.#getUserFromToken(req.headers.authorization, false);
|
|
385
443
|
return next();
|
|
386
|
-
|
|
387
444
|
} catch (error) {
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
return next();
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (redirectUrl) {
|
|
394
|
-
return res.redirect(redirectUrl);
|
|
445
|
+
if (errorRedirectUrl) {
|
|
446
|
+
return res.redirect(errorRedirectUrl);
|
|
395
447
|
}
|
|
396
448
|
|
|
397
449
|
// Handle specific JWT errors
|
|
@@ -409,18 +461,19 @@ export class SessionManager {
|
|
|
409
461
|
* @param {import('@types/express').Request} req Request with session data
|
|
410
462
|
* @param {import('@types/express').Response} res Response object
|
|
411
463
|
* @param {import('@types/express').NextFunction} next Next middleware function
|
|
412
|
-
* @param {
|
|
413
|
-
* @param {string} redirectUrl URL to redirect to if user is unauthorized
|
|
464
|
+
* @param {string} errorRedirectUrl URL to redirect to if user is unauthorized
|
|
414
465
|
* @throws {CustomError} If user is not authorized
|
|
415
466
|
* @private
|
|
416
467
|
*/
|
|
417
|
-
async #verifySession(req, res, next,
|
|
418
|
-
|
|
468
|
+
async #verifySession(req, res, next, errorRedirectUrl) {
|
|
469
|
+
// Fix: Check session data directly, not req.user (which is only populated by requireUser())
|
|
470
|
+
const user = req.session[this.#getSessionKey()];
|
|
471
|
+
const { authorized = false } = user ?? { authorized: false };
|
|
419
472
|
if (authorized) {
|
|
420
473
|
return next();
|
|
421
474
|
}
|
|
422
|
-
if (
|
|
423
|
-
return res.redirect(
|
|
475
|
+
if (errorRedirectUrl) {
|
|
476
|
+
return res.redirect(errorRedirectUrl);
|
|
424
477
|
}
|
|
425
478
|
return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
|
|
426
479
|
}
|
|
@@ -432,41 +485,31 @@ export class SessionManager {
|
|
|
432
485
|
* @param {import('@types/express').Response} res Response object
|
|
433
486
|
* @param {import('@types/express').NextFunction} next Next middleware function
|
|
434
487
|
* @param {(user: object) => object} initUser Function to initialize/transform user object
|
|
435
|
-
* @param {string}
|
|
488
|
+
* @param {string} idpRefreshUrl Identity provider refresh endpoint URL
|
|
436
489
|
* @throws {CustomError} If refresh lock is active or SSO refresh fails
|
|
437
490
|
* @private
|
|
438
491
|
* @example
|
|
439
492
|
* // Response format:
|
|
440
|
-
* // {
|
|
493
|
+
* // { jwt: "new_jwt", user: {...}, expires_at: 64800, token_type: "Bearer" }
|
|
441
494
|
*/
|
|
442
|
-
async #refreshToken(req, res, next, initUser,
|
|
495
|
+
async #refreshToken(req, res, next, initUser, idpRefreshUrl) {
|
|
443
496
|
try {
|
|
444
|
-
|
|
445
|
-
const {
|
|
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;
|
|
497
|
+
/** @type {{ tid: string, user: { email: string, attributes: { idp: string, refresh_token: string }? } }} */
|
|
498
|
+
const { tid, user } = await this.#getUserFromToken(req.headers.authorization, true);
|
|
456
499
|
|
|
457
500
|
// Check refresh lock
|
|
458
|
-
if (this.hasLock(email)) {
|
|
501
|
+
if (this.hasLock(user?.email)) {
|
|
459
502
|
throw new CustomError(httpCodes.CONFLICT, 'Token refresh is locked');
|
|
460
503
|
}
|
|
461
|
-
this.lock(email);
|
|
504
|
+
this.lock(user?.email);
|
|
462
505
|
|
|
463
506
|
// Call SSO refresh endpoint
|
|
464
|
-
const response = await this.#idpRequest.post(
|
|
507
|
+
const response = await this.#idpRequest.post(idpRefreshUrl, {
|
|
465
508
|
user: {
|
|
466
|
-
email,
|
|
509
|
+
email: user?.email,
|
|
467
510
|
attributes: {
|
|
468
|
-
idp: attributes?.idp,
|
|
469
|
-
refresh_token: attributes?.refresh_token
|
|
511
|
+
idp: user?.attributes?.idp,
|
|
512
|
+
refresh_token: user?.attributes?.refresh_token
|
|
470
513
|
}
|
|
471
514
|
}
|
|
472
515
|
});
|
|
@@ -484,24 +527,19 @@ export class SessionManager {
|
|
|
484
527
|
}
|
|
485
528
|
|
|
486
529
|
// Initialize user with new data
|
|
487
|
-
const
|
|
530
|
+
const newUser = initUser(newPayload.user);
|
|
488
531
|
|
|
489
532
|
// Generate new token
|
|
490
|
-
const newToken = await this.#generateAndStoreToken(
|
|
533
|
+
const newToken = await this.#generateAndStoreToken(newUser);
|
|
491
534
|
|
|
492
535
|
// Remove old token from Redis
|
|
493
|
-
const oldRedisKey = this.#getTokenRedisKey(email,
|
|
536
|
+
const oldRedisKey = this.#getTokenRedisKey(user.email, tid);
|
|
494
537
|
await this.#redisManager.getClient().del(oldRedisKey);
|
|
495
538
|
|
|
496
539
|
this.#logger.debug('### TOKEN REFRESHED SUCCESSFULLY ###');
|
|
497
540
|
|
|
498
541
|
// 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
|
-
});
|
|
542
|
+
return res.json({ jwt: newToken, user: newUser, expires_at: this.#config.SESSION_AGE, token_type: 'Bearer' });
|
|
505
543
|
} catch (error) {
|
|
506
544
|
return next(httpHelper.handleAxiosError(error));
|
|
507
545
|
}
|
|
@@ -513,17 +551,17 @@ export class SessionManager {
|
|
|
513
551
|
* @param {import('@types/express').Response} res Response
|
|
514
552
|
* @param {import('@types/express').NextFunction} next Next function
|
|
515
553
|
* @param {(user: object) => object} initUser Initialize user function
|
|
516
|
-
* @param {string}
|
|
554
|
+
* @param {string} idpRefreshUrl Token Refresh URL
|
|
517
555
|
* @private
|
|
518
556
|
*/
|
|
519
|
-
async #refreshSession(req, res, next, initUser,
|
|
557
|
+
async #refreshSession(req, res, next, initUser, idpRefreshUrl) {
|
|
520
558
|
try {
|
|
521
559
|
const { email, attributes } = req.user || { email: '', attributes: {} };
|
|
522
560
|
if (this.hasLock(email)) {
|
|
523
561
|
throw new CustomError(httpCodes.CONFLICT, 'User refresh is locked');
|
|
524
562
|
}
|
|
525
563
|
this.lock(email);
|
|
526
|
-
const response = await this.#idpRequest.post(
|
|
564
|
+
const response = await this.#idpRequest.post(idpRefreshUrl, {
|
|
527
565
|
user: {
|
|
528
566
|
email,
|
|
529
567
|
attributes: {
|
|
@@ -604,15 +642,8 @@ export class SessionManager {
|
|
|
604
642
|
}
|
|
605
643
|
|
|
606
644
|
try {
|
|
607
|
-
// Extract Token ID from current token
|
|
608
|
-
const
|
|
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);
|
|
645
|
+
// Extract Token ID and email from current token
|
|
646
|
+
const payload = await this.#getUserFromToken(req.headers.authorization, false);
|
|
616
647
|
const { email, tid } = payload;
|
|
617
648
|
|
|
618
649
|
if (!email || !tid) {
|
|
@@ -670,27 +701,6 @@ export class SessionManager {
|
|
|
670
701
|
});
|
|
671
702
|
}
|
|
672
703
|
|
|
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
704
|
/**
|
|
695
705
|
* Get Redis session RequestHandler
|
|
696
706
|
* @returns {import('@types/express').RequestHandler} Returns RequestHandler instance of Express
|
|
@@ -724,67 +734,85 @@ export class SessionManager {
|
|
|
724
734
|
|
|
725
735
|
/**
|
|
726
736
|
* Get session RequestHandler
|
|
727
|
-
* @
|
|
737
|
+
* @param {boolean} isRedisReady Is Redis Ready
|
|
738
|
+
* @returns {import('@types/express').RequestHandler} Returns RequestHandler instance of Express
|
|
728
739
|
*/
|
|
729
|
-
|
|
730
|
-
if(
|
|
731
|
-
await this.#redisManager.connect(this.#config.REDIS_URL, this.#config.REDIS_CERT_PATH);
|
|
740
|
+
#sessionHandler(isRedisReady) {
|
|
741
|
+
if(isRedisReady) {
|
|
732
742
|
return this.#redisSession();
|
|
733
743
|
}
|
|
734
744
|
return this.#memorySession();
|
|
735
745
|
}
|
|
736
746
|
|
|
737
747
|
/**
|
|
738
|
-
*
|
|
739
|
-
* @param {(user: object) => object} updateUser User wrapper
|
|
748
|
+
* Middleware to load full user data (works for both SESSION and TOKEN modes)
|
|
740
749
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
741
750
|
*/
|
|
742
|
-
|
|
743
|
-
return (req, res, next) => {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
751
|
+
requireUser = () => {
|
|
752
|
+
return async (req, res, next) => {
|
|
753
|
+
try {
|
|
754
|
+
req.user = await this.getUser(req);
|
|
755
|
+
return next();
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
return next(error);
|
|
759
|
+
}
|
|
748
760
|
};
|
|
749
|
-
}
|
|
761
|
+
};
|
|
750
762
|
|
|
751
763
|
/**
|
|
752
764
|
* Resource protection based on configured SESSION_MODE
|
|
753
|
-
*
|
|
754
|
-
*
|
|
765
|
+
* - SESSION mode: Verifies user exists in session store and is authorized (checks req.session data)
|
|
766
|
+
* - TOKEN mode: Validates JWT token from Authorization header (lightweight validation)
|
|
767
|
+
*
|
|
768
|
+
* Note: This method verifies authentication only. Use requireUser() after this to populate req.user.
|
|
769
|
+
*
|
|
770
|
+
* @param {string} [errorRedirectUrl=''] Redirect URL on authentication failure
|
|
755
771
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
756
|
-
|
|
757
|
-
|
|
772
|
+
* @example
|
|
773
|
+
* // Option 1: Just verify authentication (user data remains in req.session or token)
|
|
774
|
+
* app.get('/api/check', session.authenticate(), (req, res) => {
|
|
775
|
+
* res.json({ authenticated: true });
|
|
776
|
+
* });
|
|
777
|
+
*
|
|
778
|
+
* // Option 2: Verify authentication AND load user data into req.user
|
|
779
|
+
* app.get('/api/profile',
|
|
780
|
+
* session.authenticate(), // Verifies session/token
|
|
781
|
+
* session.requireUser(), // Loads user data into req.user
|
|
782
|
+
* (req, res) => {
|
|
783
|
+
* res.json({ user: req.user }); // User data available here
|
|
784
|
+
* }
|
|
785
|
+
* );
|
|
786
|
+
*/
|
|
787
|
+
authenticate(errorRedirectUrl = '') {
|
|
758
788
|
return async (req, res, next) => {
|
|
759
789
|
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
760
790
|
if (mode === SessionMode.TOKEN) {
|
|
761
|
-
return this.#verifyToken(req, res, next,
|
|
791
|
+
return this.#verifyToken(req, res, next, errorRedirectUrl);
|
|
762
792
|
}
|
|
763
|
-
return this.#verifySession(req, res, next,
|
|
793
|
+
return this.#verifySession(req, res, next, errorRedirectUrl);
|
|
764
794
|
};
|
|
765
795
|
}
|
|
766
796
|
|
|
767
797
|
/**
|
|
768
798
|
* Resource protection by token (explicit token verification)
|
|
769
|
-
* @param {
|
|
770
|
-
* @param {string} [redirectUrl=''] Redirect URL
|
|
799
|
+
* @param {string} [errorRedirectUrl=''] Redirect URL
|
|
771
800
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
772
801
|
*/
|
|
773
|
-
verifyToken(
|
|
802
|
+
verifyToken(errorRedirectUrl = '') {
|
|
774
803
|
return async (req, res, next) => {
|
|
775
|
-
return this.#verifyToken(req, res, next,
|
|
804
|
+
return this.#verifyToken(req, res, next, errorRedirectUrl);
|
|
776
805
|
};
|
|
777
806
|
}
|
|
778
807
|
|
|
779
808
|
/**
|
|
780
809
|
* Resource protection by session (explicit session verification)
|
|
781
|
-
* @param {
|
|
782
|
-
* @param {string} [redirectUrl=''] Redirect URL
|
|
810
|
+
* @param {string} [errorRedirectUrl=''] Redirect URL
|
|
783
811
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
784
812
|
*/
|
|
785
|
-
verifySession(
|
|
813
|
+
verifySession(errorRedirectUrl = '') {
|
|
786
814
|
return async (req, res, next) => {
|
|
787
|
-
return this.#verifySession(req, res, next,
|
|
815
|
+
return this.#verifySession(req, res, next, errorRedirectUrl);
|
|
788
816
|
};
|
|
789
817
|
}
|
|
790
818
|
|
|
@@ -813,62 +841,75 @@ export class SessionManager {
|
|
|
813
841
|
});
|
|
814
842
|
}
|
|
815
843
|
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Render HTML template for token storage
|
|
848
|
+
* @param {string} token JWT token
|
|
849
|
+
* @param {string} expiresAt Expiry timestamp
|
|
850
|
+
* @param {string} sucessRedirectUrl Success redirect URL
|
|
851
|
+
* @returns {string} Rendered HTML
|
|
852
|
+
* @private
|
|
853
|
+
*/
|
|
854
|
+
#renderTokenStorageHtml(token, expiresAt, sucessRedirectUrl) {
|
|
855
|
+
return this.#htmlTemplate
|
|
856
|
+
.replaceAll('{{SESSION_DATA_KEY}}', this.#config.SESSION_KEY)
|
|
857
|
+
.replaceAll('{{SESSION_DATA_VALUE}}', token)
|
|
858
|
+
.replaceAll('{{SESSION_EXPIRY_KEY}}', this.#config.SESSION_EXPIRY_KEY)
|
|
859
|
+
.replaceAll('{{SESSION_EXPIRY_VALUE}}', expiresAt)
|
|
860
|
+
.replaceAll('{{SSO_SUCCESS_URL}}', sucessRedirectUrl)
|
|
861
|
+
.replaceAll('{{SSO_FAILURE_URL}}', this.#config.SSO_FAILURE_URL);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* SSO callback for successful login
|
|
866
|
+
* @param {(user: object) => object} initUser Initialize user object function
|
|
867
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
868
|
+
*/
|
|
869
|
+
callback(initUser) {
|
|
870
|
+
return async (req, res, next) => {
|
|
871
|
+
const { jwt = '' } = req.query;
|
|
872
|
+
if (!jwt) {
|
|
873
|
+
return next(new CustomError(httpCodes.BAD_REQUEST, 'Missing `jwt` in query parameters'));
|
|
836
874
|
}
|
|
875
|
+
|
|
876
|
+
try {
|
|
877
|
+
// Decrypt JWT from Identity Adapter
|
|
878
|
+
const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
879
|
+
|
|
880
|
+
if (!payload?.user) {
|
|
881
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** @type {import('../index.js').SessionUser} */
|
|
885
|
+
const user = initUser(payload.user);
|
|
886
|
+
/** @type {string} */
|
|
887
|
+
const callbackRedirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
|
|
888
|
+
|
|
889
|
+
// Token mode: Generate token and return HTML page
|
|
890
|
+
if (this.#config.SESSION_MODE === SessionMode.TOKEN) {
|
|
891
|
+
const token = await this.#generateAndStoreToken(user);
|
|
892
|
+
this.#logger.debug('### CALLBACK TOKEN GENERATED ###');
|
|
893
|
+
const html = this.#renderTokenStorageHtml(token, user.attributes.expires_at, callbackRedirectUrl);
|
|
894
|
+
return res.send(html);
|
|
895
|
+
}
|
|
837
896
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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);
|
|
897
|
+
// Session mode: Save to session and redirect
|
|
898
|
+
await this.#saveSession(req, jwt, initUser);
|
|
899
|
+
return res.redirect(callbackRedirectUrl);
|
|
861
900
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
901
|
+
catch (error) {
|
|
902
|
+
this.#logger.error('### CALLBACK ERROR ###', error);
|
|
903
|
+
let errorMessage = error.message;
|
|
904
|
+
if (error.code === 'ERR_JWT_EXPIRED') {
|
|
905
|
+
errorMessage = 'Authentication token expired';
|
|
906
|
+
} else if (error.code === 'ERR_JWT_INVALID') {
|
|
907
|
+
errorMessage = 'Invalid authentication token';
|
|
908
|
+
}
|
|
909
|
+
return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(errorMessage)));
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
}
|
|
872
913
|
|
|
873
914
|
/**
|
|
874
915
|
* Get Identity Providers
|
|
@@ -896,14 +937,14 @@ export class SessionManager {
|
|
|
896
937
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
897
938
|
*/
|
|
898
939
|
refresh(initUser) {
|
|
899
|
-
const
|
|
940
|
+
const idpRefreshUrl = '/auth/refresh'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
|
|
900
941
|
return async (req, res, next) => {
|
|
901
942
|
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
902
|
-
|
|
943
|
+
|
|
903
944
|
if (mode === SessionMode.TOKEN) {
|
|
904
|
-
return this.#refreshToken(req, res, next, initUser,
|
|
945
|
+
return this.#refreshToken(req, res, next, initUser, idpRefreshUrl);
|
|
905
946
|
} else {
|
|
906
|
-
return this.#refreshSession(req, res, next, initUser,
|
|
947
|
+
return this.#refreshSession(req, res, next, initUser, idpRefreshUrl);
|
|
907
948
|
}
|
|
908
949
|
};
|
|
909
950
|
}
|
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 '
|
|
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 '
|
|
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 '
|
|
165
|
+
* @default 'session_expires_at'
|
|
163
166
|
*/
|
|
164
167
|
SESSION_EXPIRY_KEY?: string;
|
|
165
168
|
|
|
@@ -311,41 +314,98 @@ export class SessionManager {
|
|
|
311
314
|
redisManager(): RedisManager;
|
|
312
315
|
|
|
313
316
|
/**
|
|
314
|
-
*
|
|
317
|
+
* Get authenticated user data (works for both SESSION and TOKEN modes)
|
|
318
|
+
* @param req Express request object
|
|
319
|
+
* @returns Promise resolving to full user data object
|
|
320
|
+
* @throws CustomError If user is not authenticated
|
|
321
|
+
* @example
|
|
322
|
+
* ```javascript
|
|
323
|
+
* // Use in custom middleware
|
|
324
|
+
* app.use(async (req, res, next) => {
|
|
325
|
+
* try {
|
|
326
|
+
* const user = await sessionManager.getUser(req);
|
|
327
|
+
* req.customUser = user;
|
|
328
|
+
* next();
|
|
329
|
+
* } catch (error) {
|
|
330
|
+
* next(error);
|
|
331
|
+
* }
|
|
332
|
+
* });
|
|
333
|
+
* ```
|
|
334
|
+
*/
|
|
335
|
+
getUser(req: Request): Promise<SessionUser>;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Initialize the session configurations and middleware
|
|
315
339
|
* @param app Express application
|
|
316
|
-
* @param config Session configurations
|
|
317
|
-
* @param updateUser Process user object to compute attributes like permissions, avatar URL, etc.
|
|
318
340
|
*/
|
|
319
|
-
setup(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
341
|
+
setup(app: Application): Promise<void>;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Middleware to load full user data into req.user
|
|
345
|
+
* - SESSION mode: Loads user from session store (req.session[SESSION_KEY])
|
|
346
|
+
* - TOKEN mode: Loads user from Redis using JWT token
|
|
347
|
+
* - Provides request-level caching to avoid redundant lookups
|
|
348
|
+
* - Should be used after authenticate() middleware
|
|
349
|
+
* @returns Returns express Request Handler
|
|
350
|
+
* @example
|
|
351
|
+
* ```javascript
|
|
352
|
+
* app.get('/api/profile',
|
|
353
|
+
* session.authenticate(), // Verifies authentication
|
|
354
|
+
* session.requireUser(), // Loads user data into req.user
|
|
355
|
+
* (req, res) => {
|
|
356
|
+
* res.json({ user: req.user }); // User data available here
|
|
357
|
+
* }
|
|
358
|
+
* );
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
requireUser(): RequestHandler;
|
|
323
362
|
|
|
324
363
|
/**
|
|
325
364
|
* Resource protection middleware based on configured SESSION_MODE
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
365
|
+
* - SESSION mode: Verifies user exists in session store and is authorized (checks req.session data)
|
|
366
|
+
* - TOKEN mode: Validates JWT token from Authorization header (lightweight validation)
|
|
367
|
+
*
|
|
368
|
+
* Note: This method verifies authentication only and does NOT populate req.user.
|
|
369
|
+
* Use requireUser() after this middleware to load user data into req.user.
|
|
370
|
+
*
|
|
371
|
+
* @param errorRedirectUrl Redirect URL on authentication failure (default: '')
|
|
329
372
|
* @returns Returns express Request Handler
|
|
373
|
+
* @example
|
|
374
|
+
* ```javascript
|
|
375
|
+
* // Option 1: Just verify authentication (user data remains in req.session or token)
|
|
376
|
+
* app.get('/api/check',
|
|
377
|
+
* session.authenticate(),
|
|
378
|
+
* (req, res) => {
|
|
379
|
+
* res.json({ authenticated: true });
|
|
380
|
+
* }
|
|
381
|
+
* );
|
|
382
|
+
*
|
|
383
|
+
* // Option 2: Verify authentication AND populate req.user (recommended for most use cases)
|
|
384
|
+
* app.get('/api/profile',
|
|
385
|
+
* session.authenticate(), // Verifies session/token validity
|
|
386
|
+
* session.requireUser(), // Loads user data into req.user
|
|
387
|
+
* (req, res) => {
|
|
388
|
+
* res.json({ user: req.user }); // User data now available
|
|
389
|
+
* }
|
|
390
|
+
* );
|
|
391
|
+
* ```
|
|
330
392
|
*/
|
|
331
|
-
authenticate(
|
|
393
|
+
authenticate(errorRedirectUrl?: string): RequestHandler;
|
|
332
394
|
|
|
333
395
|
/**
|
|
334
396
|
* Resource protection by token (explicit token verification)
|
|
335
397
|
* Requires Authorization: Bearer {token} header
|
|
336
|
-
* @param
|
|
337
|
-
* @param redirectUrl Redirect URL (default: '')
|
|
398
|
+
* @param errorRedirectUrl Redirect URL (default: '')
|
|
338
399
|
* @returns Returns express Request Handler
|
|
339
400
|
*/
|
|
340
|
-
verifyToken(
|
|
401
|
+
verifyToken(errorRedirectUrl?: string): RequestHandler;
|
|
341
402
|
|
|
342
403
|
/**
|
|
343
404
|
* Resource protection by session (explicit session verification)
|
|
344
|
-
* @param
|
|
345
|
-
* @param redirectUrl Redirect URL (default: '')
|
|
405
|
+
* @param errorRedirectUrl Redirect URL (default: '')
|
|
346
406
|
* @returns Returns express Request Handler
|
|
347
407
|
*/
|
|
348
|
-
verifySession(
|
|
408
|
+
verifySession(errorRedirectUrl?: string): RequestHandler;
|
|
349
409
|
|
|
350
410
|
/**
|
|
351
411
|
* SSO callback for successful login
|