@rodit/rodit-auth-be 9.11.14

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.
@@ -0,0 +1,1302 @@
1
+ const { ulid } = require('ulid');
2
+ const logger = require("../../services/logger");
3
+ const { createLogContext, logErrorWithMetrics } = logger;
4
+ const config = require('../../services/configsdk');
5
+ const SESSION_CLEANUP_INTERVAL = config.get('SESSION_CLEANUP_INTERVAL'); // 1 hour in milliseconds
6
+ const SESSION_TOKEN_RETENTION_PERIOD = config.get('SESSION_TOKEN_RETENTION_PERIOD'); // 7 days in seconds
7
+
8
+ // Try to load express-session if available in host app
9
+ let sessionLib = null;
10
+ try {
11
+ // eslint-disable-next-line import/no-extraneous-dependencies
12
+ sessionLib = require('express-session');
13
+ logger.infoWithContext('express-session detected; SessionManager can use express-compatible stores', {
14
+ component: 'SessionManager',
15
+ operation: 'session.storage.detect'
16
+ });
17
+ } catch (e) {
18
+ // Optional dependency — we gracefully fall back to internal memory storage
19
+ sessionLib = null;
20
+ }
21
+
22
+ /**
23
+ * Adapter to wrap an express-session Store and expose the SDK's storage interface
24
+ * Required SDK interface: { get, set, delete, keys, size, clear, getAll? }
25
+ */
26
+ class ExpressSessionStoreAdapter {
27
+ constructor(store) {
28
+ if (!store || typeof store !== 'object') {
29
+ throw new Error('ExpressSessionStoreAdapter requires a valid store instance');
30
+ }
31
+ // Minimal express-session Store methods we rely on
32
+ const required = ['get', 'set', 'destroy'];
33
+ const missing = required.filter((m) => typeof store[m] !== 'function');
34
+ if (missing.length) {
35
+ throw new Error(`Provided express-session store is missing methods: ${missing.join(', ')}`);
36
+ }
37
+ this.store = store;
38
+ }
39
+
40
+ async get(sessionId) {
41
+ return new Promise((resolve) => {
42
+ this.store.get(sessionId, (err, sess) => {
43
+ if (err) return resolve(null);
44
+ // Allow storing our own session objects directly
45
+ resolve(sess || null);
46
+ });
47
+ });
48
+ }
49
+
50
+ async set(sessionId, session) {
51
+ // Help express-session stores compute TTL by providing cookie.maxAge when possible
52
+ try {
53
+ if (session && typeof session === 'object' && session.expiresAt) {
54
+ const nowSec = Math.floor(Date.now() / 1000);
55
+ const ttlMs = Math.max(0, (session.expiresAt - nowSec) * 1000);
56
+ if (!session.cookie || typeof session.cookie !== 'object') {
57
+ session.cookie = {};
58
+ }
59
+ if (typeof session.cookie.maxAge !== 'number') {
60
+ session.cookie.maxAge = ttlMs; // Many stores read cookie.maxAge for TTL
61
+ }
62
+ if (typeof session.cookie.originalMaxAge !== 'number') {
63
+ session.cookie.originalMaxAge = ttlMs;
64
+ }
65
+ }
66
+ } catch (_) {
67
+ // Non-fatal: continue without cookie hints
68
+ }
69
+
70
+ return new Promise((resolve) => {
71
+ this.store.set(sessionId, session, (err) => {
72
+ if (err) return resolve(false);
73
+ resolve(true);
74
+ });
75
+ });
76
+ }
77
+
78
+ async delete(sessionId) {
79
+ return new Promise((resolve) => {
80
+ this.store.destroy(sessionId, (err) => {
81
+ if (err) return resolve(false);
82
+ resolve(true);
83
+ });
84
+ });
85
+ }
86
+
87
+ async keys() {
88
+ // Non-standard; best-effort using .all() if available
89
+ if (typeof this.store.all === 'function') {
90
+ return new Promise((resolve) => {
91
+ this.store.all((err, sessions) => {
92
+ if (err || !sessions) return resolve([]);
93
+ if (Array.isArray(sessions)) {
94
+ // MemoryStore typically returns array of session objects without ids; not standardized
95
+ // Some stores return array of session records with an id property
96
+ const ids = sessions
97
+ .map((s) => s?.id || s?.sessionId || s?.sid)
98
+ .filter(Boolean);
99
+ return resolve(ids);
100
+ }
101
+ // Some stores return an object map { sid: session }
102
+ resolve(Object.keys(sessions));
103
+ });
104
+ });
105
+ }
106
+ // If not supported, return empty (SDK falls back where possible)
107
+ return [];
108
+ }
109
+
110
+ async size() {
111
+ if (typeof this.store.length === 'function') {
112
+ return new Promise((resolve) => {
113
+ this.store.length((err, length) => {
114
+ if (err) return resolve(0);
115
+ resolve(typeof length === 'number' ? length : 0);
116
+ });
117
+ });
118
+ }
119
+ if (typeof this.store.all === 'function') {
120
+ const all = await this.getAll();
121
+ return Array.isArray(all) ? all.length : (all ? Object.keys(all).length : 0);
122
+ }
123
+ return 0;
124
+ }
125
+
126
+ async clear() {
127
+ if (typeof this.store.clear === 'function') {
128
+ return new Promise((resolve) => {
129
+ this.store.clear((err) => resolve(!err));
130
+ });
131
+ }
132
+ // Fallback: enumerate keys and destroy
133
+ const ids = await this.keys();
134
+ let ok = true;
135
+ for (const id of ids) {
136
+ // eslint-disable-next-line no-await-in-loop
137
+ const res = await this.delete(id);
138
+ ok = ok && res;
139
+ }
140
+ return ok;
141
+ }
142
+
143
+ async getAll() {
144
+ if (typeof this.store.all === 'function') {
145
+ return new Promise((resolve) => {
146
+ this.store.all((err, sessions) => {
147
+ if (err) return resolve([]);
148
+ resolve(sessions || []);
149
+ });
150
+ });
151
+ }
152
+ // Not supported; reconstruct is not feasible without keys -> return empty
153
+ return [];
154
+ }
155
+
156
+ async getStorageInfo() {
157
+ const info = {
158
+ type: 'ExpressSessionStoreAdapter',
159
+ storeType: this.store?.constructor?.name || 'UnknownStore',
160
+ features: {
161
+ hasAll: typeof this.store.all === 'function',
162
+ hasLength: typeof this.store.length === 'function',
163
+ hasClear: typeof this.store.clear === 'function',
164
+ },
165
+ timestamp: new Date().toISOString(),
166
+ };
167
+ try {
168
+ const count = await this.size();
169
+ info.sessionCount = count;
170
+ } catch (_) {
171
+ info.sessionCount = undefined;
172
+ }
173
+ return info;
174
+ }
175
+ }
176
+ class InMemorySessionStorage {
177
+ constructor() {
178
+ this.sessions = new Map();
179
+ }
180
+
181
+ async get(sessionId) {
182
+ const session = this.sessions.get(sessionId);
183
+ if (!session) return null;
184
+
185
+ // Check if session is expired and auto-cleanup
186
+ const now = Math.floor(Date.now() / 1000);
187
+ if (session.expiresAt && session.expiresAt < now) {
188
+ // Session is expired, remove it
189
+ this.sessions.delete(sessionId);
190
+ return null;
191
+ }
192
+
193
+ return session;
194
+ }
195
+
196
+ async set(sessionId, session) {
197
+ if (!sessionId || typeof sessionId !== 'string') {
198
+ throw new Error('sessionId must be a non-empty string');
199
+ }
200
+
201
+ // Add timestamp for tracking
202
+ if (session && typeof session === 'object') {
203
+ session.updatedAt = Math.floor(Date.now() / 1000);
204
+ }
205
+
206
+ this.sessions.set(sessionId, session);
207
+ return true;
208
+ }
209
+
210
+ async delete(sessionId) {
211
+ return this.sessions.delete(sessionId);
212
+ }
213
+
214
+ async keys() {
215
+ return Array.from(this.sessions.keys());
216
+ }
217
+
218
+ async size() {
219
+ return this.sessions.size;
220
+ }
221
+
222
+ async clear() {
223
+ this.sessions.clear();
224
+ return true;
225
+ }
226
+
227
+ async getAll() {
228
+ // Return an array of all session objects
229
+ return Array.from(this.sessions.values());
230
+ }
231
+
232
+
233
+ async getStorageInfo() {
234
+ return {
235
+ type: 'InMemorySessionStorage',
236
+ sessionCount: this.sessions.size,
237
+ memoryUsage: process.memoryUsage ? process.memoryUsage() : 'unavailable',
238
+ timestamp: new Date().toISOString()
239
+ };
240
+ }
241
+
242
+ // Validate session structure
243
+ _validateSession(session) {
244
+ if (!session || typeof session !== 'object') {
245
+ return false;
246
+ }
247
+ const required = ['id', 'status', 'createdAt'];
248
+ return required.every(prop => prop in session);
249
+ }
250
+ }
251
+
252
+ // Default storage: standalone in-memory store (no express-session dependency)
253
+ // If applications want Redis/DB/etc they must provide an express-session Store via setExpressSessionStore()
254
+ const defaultStorage = new InMemorySessionStorage();
255
+
256
+ let currentStorage = defaultStorage;
257
+
258
+ function setStorage(customStorage) {
259
+ if (!customStorage || typeof customStorage !== 'object') {
260
+ throw new Error('setStorage(customStorage) requires a storage object');
261
+ }
262
+
263
+ const required = ['get', 'set', 'delete', 'keys', 'size', 'clear'];
264
+ const missing = required.filter(method => typeof customStorage[method] !== 'function');
265
+
266
+ if (missing.length) {
267
+ throw new Error(`Injected storage is missing methods: ${missing.join(', ')}`);
268
+ }
269
+
270
+
271
+
272
+ currentStorage = customStorage;
273
+ }
274
+
275
+ // Allow direct injection of an express-session Store and wrap it with our adapter
276
+ function setExpressSessionStore(expressSessionStore) {
277
+ if (!sessionLib) {
278
+ throw new Error('express-session is not installed; cannot set express-session store');
279
+ }
280
+ const adapter = new ExpressSessionStoreAdapter(expressSessionStore);
281
+ currentStorage = adapter;
282
+ logger.infoWithContext('Session storage set via express-session store', {
283
+ component: 'SessionManager',
284
+ operation: 'session.storage.set',
285
+ storeType: expressSessionStore?.constructor?.name,
286
+ });
287
+ }
288
+
289
+ function configureStorageFromConfig() {
290
+ let storageType;
291
+ try {
292
+ storageType = config.get('SESSION_STORAGE_TYPE');
293
+ } catch (_) {
294
+ // Keep default storage
295
+ return;
296
+ }
297
+
298
+ const type = String(storageType || '').toLowerCase();
299
+
300
+ switch (type) {
301
+ case 'memory':
302
+ // Use standalone in-memory store
303
+ currentStorage = new InMemorySessionStorage();
304
+ logger.infoWithContext('Configured session storage: standalone InMemorySessionStorage', {
305
+ component: 'SessionManager',
306
+ operation: 'session.storage.configure',
307
+ storageType: 'memory'
308
+ });
309
+ return;
310
+
311
+ case 'express':
312
+ case 'express-session':
313
+ if (!sessionLib || !sessionLib.MemoryStore) {
314
+ logger.warnWithContext('express-session not installed. Falling back to standalone InMemorySessionStorage', {
315
+ component: 'SessionManager',
316
+ operation: 'session.storage.configure',
317
+ storageType: 'express-session'
318
+ });
319
+ currentStorage = new InMemorySessionStorage();
320
+ return;
321
+ }
322
+ currentStorage = new ExpressSessionStoreAdapter(new sessionLib.MemoryStore());
323
+ logger.infoWithContext('Configured session storage: express-session MemoryStore (override with setExpressSessionStore for Redis/DB/etc)', {
324
+ component: 'SessionManager',
325
+ operation: 'session.storage.configure',
326
+ storageType: 'express-session'
327
+ });
328
+ return;
329
+
330
+ default:
331
+ logger.warnWithContext(`Unknown SESSION_STORAGE_TYPE='${storageType}'. Using standalone InMemorySessionStorage.`, {
332
+ component: 'SessionManager',
333
+ operation: 'session.storage.configure',
334
+ storageType: String(storageType)
335
+ });
336
+ currentStorage = new InMemorySessionStorage();
337
+ return;
338
+ }
339
+ }
340
+
341
+ // Optional helper to create a real express-session middleware using the current store
342
+ function createExpressSessionMiddleware(options = {}) {
343
+ if (!sessionLib) {
344
+ logger.warnWithContext('express-session not installed; returning no-op session middleware', {
345
+ component: 'SessionManager',
346
+ operation: 'session.middleware.create'
347
+ });
348
+ return (req, res, next) => next();
349
+ }
350
+ const secret = config.get('SECURITY_OPTIONS.SESSION_SECRET');
351
+ const store = (currentStorage instanceof ExpressSessionStoreAdapter)
352
+ ? currentStorage.store
353
+ : new sessionLib.MemoryStore();
354
+
355
+ return sessionLib({
356
+ saveUninitialized: false,
357
+ resave: false,
358
+ ...options,
359
+ secret,
360
+ store,
361
+ });
362
+ }
363
+
364
+ class SessionManager {
365
+ constructor() {
366
+ const instanceId = ulid();
367
+ const baseContext = createLogContext("SessionManager", "constructor", { instanceId });
368
+
369
+ logger.infoWithContext("SessionManager instance created", {
370
+ ...baseContext,
371
+ instanceId,
372
+ timestamp: new Date().toISOString(),
373
+ isSingleton: true
374
+ });
375
+
376
+ // Note: Token invalidation is now handled via session state checking
377
+ // No separate invalidatedTokens Map needed - tokens are invalid when their session is closed
378
+
379
+ // Session validation cache - trades security for performance
380
+ // Cache stores validation results with TTL to reduce storage lookups
381
+ this._validationCache = new Map();
382
+ this._validationCacheTTL = config.get('SESSION_VALIDATION_CACHE_TTL', 5000);
383
+
384
+ // Cleanup interval reference
385
+ this.cleanupInterval = null;
386
+
387
+ this._instanceId = instanceId;
388
+
389
+ logger.infoWithContext("Token validation cache initialized", {
390
+ ...baseContext,
391
+ cacheTTL: this._validationCacheTTL,
392
+ cacheEnabled: this._validationCacheTTL > 0
393
+ });
394
+ }
395
+
396
+ // Storage facade - delegates to current storage with proper binding
397
+ get storage() {
398
+ return {
399
+ get: currentStorage.get.bind(currentStorage),
400
+ set: currentStorage.set.bind(currentStorage),
401
+ delete: currentStorage.delete.bind(currentStorage),
402
+ keys: currentStorage.keys.bind(currentStorage),
403
+ size: currentStorage.size.bind(currentStorage),
404
+ getAll: currentStorage.getAll ? currentStorage.getAll.bind(currentStorage) : undefined
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Get cached validation result for a token
410
+ * @private
411
+ */
412
+ _getCachedValidation(token) {
413
+ if (this._validationCacheTTL <= 0) return undefined;
414
+
415
+ const entry = this._validationCache.get(token);
416
+ if (!entry) return undefined;
417
+
418
+ // Check if entry has expired
419
+ if (entry.expiresAt && entry.expiresAt <= Date.now()) {
420
+ this._validationCache.delete(token);
421
+ return undefined;
422
+ }
423
+
424
+ return entry.value;
425
+ }
426
+
427
+ /**
428
+ * Cache validation result for a token
429
+ * @private
430
+ */
431
+ _setCachedValidation(token, isInvalidated, sessionId = null) {
432
+ if (this._validationCacheTTL <= 0) return; // Caching disabled
433
+
434
+ const expiresAt = this._validationCacheTTL > 0 ? Date.now() + this._validationCacheTTL : 0;
435
+ this._validationCache.set(token, {
436
+ value: isInvalidated,
437
+ expiresAt,
438
+ sessionId,
439
+ cachedAt: Date.now()
440
+ });
441
+ }
442
+
443
+ /**
444
+ * Invalidate cache entry for a token (called when session is closed)
445
+ * @private
446
+ */
447
+ _invalidateCachedValidation(token) {
448
+ this._validationCache.delete(token);
449
+ }
450
+
451
+ /**
452
+ * Invalidate all cache entries for a specific session
453
+ * This is called when a session is closed to immediately invalidate all tokens for that session
454
+ * @private
455
+ */
456
+ _invalidateCachedValidationBySession(sessionId) {
457
+ if (!sessionId) return;
458
+
459
+ let invalidatedCount = 0;
460
+ for (const [token, entry] of this._validationCache.entries()) {
461
+ if (entry.sessionId === sessionId) {
462
+ this._validationCache.delete(token);
463
+ invalidatedCount++;
464
+ }
465
+ }
466
+
467
+ if (invalidatedCount > 0) {
468
+ logger.debugWithContext("Invalidated cached validations for closed session", {
469
+ component: "SessionManager",
470
+ method: "_invalidateCachedValidationBySession",
471
+ sessionId,
472
+ invalidatedCount
473
+ });
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Get cache statistics for monitoring
479
+ */
480
+ getValidationCacheStats() {
481
+ const now = Date.now();
482
+ let validEntries = 0;
483
+ let expiredEntries = 0;
484
+
485
+ for (const [, entry] of this._validationCache.entries()) {
486
+ if (entry.expiresAt && entry.expiresAt <= now) {
487
+ expiredEntries++;
488
+ } else {
489
+ validEntries++;
490
+ }
491
+ }
492
+
493
+ return {
494
+ totalEntries: this._validationCache.size,
495
+ validEntries,
496
+ expiredEntries,
497
+ cacheTTL: this._validationCacheTTL,
498
+ cacheEnabled: this._validationCacheTTL > 0
499
+ };
500
+ }
501
+
502
+
503
+ _generateSessionId(roditId) {
504
+ const requestId = ulid();
505
+ const startTime = Date.now();
506
+ const baseContext = createLogContext("SessionManager", "_generateSessionId", { requestId, roditId });
507
+
508
+ const sessionId = `sess_${roditId}_${ulid()}`;
509
+
510
+ logger.debugWithContext("Generated session ID", {
511
+ ...baseContext,
512
+ sessionId,
513
+ roditId
514
+ });
515
+
516
+ return sessionId;
517
+ }
518
+
519
+ async createSession(sessionData) {
520
+ const requestId = ulid();
521
+ const startTime = Date.now();
522
+ const baseContext = createLogContext("SessionManager", "createSession", { requestId });
523
+
524
+ // Get current active session count for metrics
525
+ const activeSessionCount = await this.getActiveSessionCount();
526
+
527
+ try {
528
+ if (!sessionData || !sessionData.roditId) {
529
+ throw new Error('Missing required session data');
530
+ }
531
+
532
+ const sessionId = this._generateSessionId(sessionData.roditId);
533
+ const now = Math.floor(Date.now() / 1000);
534
+
535
+ // Create the session object
536
+ const session = {
537
+ id: sessionId,
538
+ roditId: sessionData.roditId,
539
+ ownerId: sessionData.ownerId,
540
+ createdAt: sessionData.createdAt || now,
541
+ expiresAt: sessionData.expiresAt,
542
+ lastAccessedAt: now,
543
+ status: 'active',
544
+ metadata: sessionData.metadata || {},
545
+ };
546
+
547
+ // Store the session
548
+ await this.storage.set(sessionId, session);
549
+
550
+ const duration = Date.now() - startTime;
551
+
552
+ // Verify the session was stored correctly
553
+ const storedSession = await this.storage.get(sessionId);
554
+ const verificationSuccess = !!storedSession;
555
+
556
+ logger.infoWithContext("Session created and stored", {
557
+ ...baseContext,
558
+ sessionId,
559
+ sessionStatus: session.status,
560
+ expiresAt: session.expiresAt,
561
+ createdAt: session.createdAt,
562
+ verificationSuccess,
563
+ sessionManagerInstanceId: this._instanceId,
564
+ storageBackend: currentStorage?.store ? currentStorage.store.constructor.name : currentStorage.constructor.name,
565
+ duration,
566
+ sessionObjectValid: !!session,
567
+ sessionIdValid: !!session?.id,
568
+ sessionIdType: typeof session?.id
569
+ });
570
+
571
+ // Validate session object before returning
572
+ if (!session || !session.id) {
573
+ logger.errorWithContext("Created session is invalid", {
574
+ ...baseContext,
575
+ sessionObject: session,
576
+ sessionId,
577
+ sessionManagerInstanceId: this._instanceId
578
+ });
579
+ throw new Error("Session creation resulted in invalid session object");
580
+ }
581
+
582
+ return session;
583
+ } catch (error) {
584
+ const duration = Date.now() - startTime;
585
+
586
+ throw error;
587
+ }
588
+ }
589
+
590
+
591
+ async getSession(sessionId) {
592
+ const requestId = ulid();
593
+ const startTime = Date.now();
594
+ const baseContext = createLogContext("SessionManager", "getSession", {
595
+ requestId,
596
+ sessionId
597
+ });
598
+
599
+ try {
600
+ const session = await this.storage.get(sessionId);
601
+ const now = Math.floor(Date.now() / 1000);
602
+
603
+ if (!session) {
604
+ const duration = Date.now() - startTime;
605
+
606
+ logger.warnWithContext("Session not found in storage", {
607
+ ...baseContext,
608
+ sessionId,
609
+ sessionManagerInstanceId: this._instanceId,
610
+ storageBackend: currentStorage?.store ? currentStorage.store.constructor.name : currentStorage.constructor.name,
611
+ duration,
612
+ currentTimestamp: now
613
+ });
614
+
615
+ return null;
616
+ }
617
+
618
+ // Update last accessed time
619
+ session.lastAccessedAt = now;
620
+ // Persist updated access time back to storage
621
+ await this.storage.set(sessionId, session);
622
+
623
+ const duration = Date.now() - startTime;
624
+
625
+ logger.debugWithContext("Session retrieved successfully", {
626
+ ...baseContext,
627
+ sessionId,
628
+ sessionStatus: session.status,
629
+ expiresAt: session.expiresAt,
630
+ lastAccessedAt: session.lastAccessedAt,
631
+ sessionManagerInstanceId: this._instanceId,
632
+ storageBackend: currentStorage?.store ? currentStorage.store.constructor.name : currentStorage.constructor.name,
633
+ duration
634
+ });
635
+
636
+ return session;
637
+ } catch (error) {
638
+ const duration = Date.now() - startTime;
639
+
640
+ logger.errorWithContext("Error retrieving session", {
641
+ ...baseContext,
642
+ sessionId,
643
+ error: error.message,
644
+ sessionManagerInstanceId: this._instanceId,
645
+ duration
646
+ });
647
+
648
+ return null;
649
+ }
650
+ }
651
+
652
+
653
+ async updateSession(sessionId, updates) {
654
+ const requestId = ulid();
655
+ const startTime = Date.now();
656
+ const baseContext = createLogContext("SessionManager", "updateSession", {
657
+ requestId,
658
+ sessionId,
659
+ updatedFields: updates ? Object.keys(updates) : []
660
+ });
661
+
662
+
663
+
664
+ try {
665
+ // Load session from configured storage
666
+ const session = await this.storage.get(sessionId);
667
+
668
+ if (!session) {
669
+ return null;
670
+ }
671
+
672
+ // Apply updates except id which should be immutable
673
+ Object.entries(updates).forEach(([key, value]) => {
674
+ if (key !== 'id') {
675
+ session[key] = value;
676
+ }
677
+ });
678
+
679
+ // Update last accessed time
680
+ session.lastAccessedAt = Math.floor(Date.now() / 1000);
681
+
682
+ // Store updated session in storage
683
+ await this.storage.set(sessionId, session);
684
+
685
+ const duration = Date.now() - startTime;
686
+
687
+
688
+ return session;
689
+ } catch (error) {
690
+ const duration = Date.now() - startTime;
691
+
692
+
693
+ return null;
694
+ }
695
+ }
696
+
697
+ async closeSession(sessionId, reason = 'user_logout', token = null) {
698
+ const requestId = ulid();
699
+ const startTime = Date.now();
700
+ const baseContext = createLogContext("SessionManager", "closeSession", {
701
+ requestId,
702
+ sessionId,
703
+ reason,
704
+ hasToken: !!token
705
+ });
706
+
707
+ try {
708
+ const session = await this.storage.get(sessionId);
709
+
710
+ if (!session) {
711
+ // Enhanced debugging for session not found
712
+ const allSessionIds = await this.storage.keys();
713
+ const sessionCount = await this.storage.size();
714
+
715
+
716
+ return true; // Changed to true - allow logout to succeed
717
+ }
718
+
719
+ // Update session status
720
+ session.status = 'closed';
721
+ session.closedAt = Math.floor(Date.now() / 1000);
722
+ session.closeReason = reason;
723
+
724
+ // Store updated session
725
+ await this.storage.set(sessionId, session);
726
+
727
+ // CRITICAL: Immediately invalidate all cached validations for this session
728
+ // This ensures tokens are rejected immediately after logout, not after cache TTL
729
+ this._invalidateCachedValidationBySession(sessionId);
730
+
731
+ // Also invalidate the specific token if provided
732
+ if (token) {
733
+ this._invalidateCachedValidation(token);
734
+ }
735
+
736
+ logger.debugWithContext("Session closed and cache invalidated", {
737
+ ...baseContext,
738
+ sessionId,
739
+ cacheInvalidated: true
740
+ });
741
+
742
+ const duration = Date.now() - startTime;
743
+
744
+ return true;
745
+ } catch (error) {
746
+ const duration = Date.now() - startTime;
747
+
748
+ return false;
749
+ }
750
+ }
751
+
752
+
753
+ async invalidateToken(token, reason = 'user_logout', sessionId = null) {
754
+ const requestId = ulid();
755
+ const startTime = Date.now();
756
+
757
+ const baseContext = createLogContext("SessionManager", "invalidateToken", {
758
+ requestId,
759
+ reason,
760
+ sessionId: sessionId || 'will_extract_from_token'
761
+ });
762
+
763
+ try {
764
+ // If sessionId not provided, extract it from the token
765
+ let targetSessionId = sessionId;
766
+ if (!targetSessionId && token) {
767
+ try {
768
+ const tokenParts = token.split('.');
769
+ if (tokenParts.length === 3) {
770
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
771
+ targetSessionId = payload.session_id;
772
+ }
773
+ } catch (decodeError) {
774
+
775
+ }
776
+ }
777
+
778
+ if (!targetSessionId) {
779
+ return false;
780
+ }
781
+
782
+ // Close the session - this will invalidate the token
783
+ const sessionClosed = await this.closeSession(targetSessionId, reason, null); // Don't pass token to avoid recursion
784
+
785
+ const duration = Date.now() - startTime;
786
+
787
+
788
+ return sessionClosed;
789
+ } catch (error) {
790
+ const duration = Date.now() - startTime;
791
+
792
+
793
+ return false;
794
+ }
795
+ }
796
+
797
+
798
+ async isTokenInvalidated(token) {
799
+ const requestId = ulid();
800
+ const startTime = Date.now();
801
+ const baseContext = createLogContext("SessionManager", "isTokenInvalidated", { requestId });
802
+
803
+ if (!token) {
804
+ return true; // No token = invalidated
805
+ }
806
+
807
+ try {
808
+ // Check cache first for performance
809
+ const cachedResult = this._getCachedValidation(token);
810
+ if (cachedResult !== undefined) {
811
+ const duration = Date.now() - startTime;
812
+ logger.debugWithContext("Token validation cache hit", {
813
+ ...baseContext,
814
+ isInvalidated: cachedResult,
815
+ cacheHit: true,
816
+ duration,
817
+ tokenLength: token?.length || 0
818
+ });
819
+ return cachedResult;
820
+ }
821
+
822
+ // Cache miss - perform full validation
823
+ // Decode JWT token to extract session_id
824
+ const tokenParts = token.split('.');
825
+ if (tokenParts.length !== 3) {
826
+ return true; // Invalid format = invalidated
827
+ }
828
+
829
+ // Decode the payload (second part) using base64url
830
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
831
+ const sessionId = payload.session_id;
832
+
833
+ if (!sessionId) {
834
+ return true; // No session ID = invalidated
835
+ }
836
+
837
+ // Check if session exists and is active
838
+ const session = await this.storage.get(sessionId);
839
+ const now = Math.floor(Date.now() / 1000);
840
+
841
+ let isInvalidated = false;
842
+ let reason = null;
843
+
844
+ if (!session) {
845
+ isInvalidated = true;
846
+ reason = "session_not_found";
847
+ } else if (session.status !== 'active') {
848
+ isInvalidated = true;
849
+ reason = `session_status_${session.status}`;
850
+ } else if (session.expiresAt && session.expiresAt < now) {
851
+ isInvalidated = true;
852
+ reason = "session_expired";
853
+ }
854
+
855
+ // Cache the result for future requests
856
+ this._setCachedValidation(token, isInvalidated, sessionId);
857
+
858
+ const duration = Date.now() - startTime;
859
+
860
+ logger.infoWithContext("Token invalidation check completed", {
861
+ ...baseContext,
862
+ sessionId,
863
+ isInvalidated,
864
+ reason,
865
+ sessionFound: !!session,
866
+ sessionStatus: session?.status,
867
+ sessionExpiresAt: session?.expiresAt,
868
+ currentTimestamp: now,
869
+ sessionManagerInstanceId: this._instanceId,
870
+ tokenLength: token?.length || 0,
871
+ cacheHit: false,
872
+ cacheTTL: this._validationCacheTTL,
873
+ duration
874
+ });
875
+
876
+ return isInvalidated;
877
+ } catch (error) {
878
+ const duration = Date.now() - startTime;
879
+
880
+ // If we can't check due to error, assume it's invalidated for security
881
+ return true;
882
+ }
883
+ }
884
+
885
+
886
+ async getTokenInvalidationInfo(token) {
887
+ const requestId = ulid();
888
+ const startTime = Date.now();
889
+ const baseContext = createLogContext("SessionManager", "getTokenInvalidationInfo", { requestId });
890
+
891
+
892
+ if (!token) {
893
+ return {
894
+ reason: "no_token_provided",
895
+ invalidatedAt: Math.floor(Date.now() / 1000),
896
+ timestamp: new Date().toISOString(),
897
+ sessionId: null
898
+ };
899
+ }
900
+
901
+ try {
902
+ // Decode JWT token to extract session_id
903
+ const tokenParts = token.split('.');
904
+ if (tokenParts.length !== 3) {
905
+ return {
906
+ reason: "invalid_jwt_format",
907
+ invalidatedAt: Math.floor(Date.now() / 1000),
908
+ timestamp: new Date().toISOString(),
909
+ sessionId: null
910
+ };
911
+ }
912
+
913
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString());
914
+ const sessionId = payload.session_id;
915
+
916
+ if (!sessionId) {
917
+ return {
918
+ reason: "no_session_id_in_token",
919
+ invalidatedAt: Math.floor(Date.now() / 1000),
920
+ timestamp: new Date().toISOString(),
921
+ sessionId: null
922
+ };
923
+ }
924
+
925
+ // Check session state
926
+ const session = await this.storage.get(sessionId);
927
+ const now = Math.floor(Date.now() / 1000);
928
+
929
+ let invalidationInfo = null;
930
+
931
+ if (!session) {
932
+ invalidationInfo = {
933
+ reason: "session_not_found",
934
+ invalidatedAt: now,
935
+ timestamp: new Date().toISOString(),
936
+ sessionId
937
+ };
938
+ } else if (session.status !== 'active') {
939
+ invalidationInfo = {
940
+ reason: `session_status_${session.status}`,
941
+ invalidatedAt: session.closedAt || now,
942
+ timestamp: session.closedAt ? new Date(session.closedAt * 1000).toISOString() : new Date().toISOString(),
943
+ sessionId,
944
+ closeReason: session.closeReason
945
+ };
946
+ } else if (session.expiresAt && session.expiresAt < now) {
947
+ invalidationInfo = {
948
+ reason: "session_expired",
949
+ invalidatedAt: session.expiresAt,
950
+ timestamp: new Date(session.expiresAt * 1000).toISOString(),
951
+ sessionId
952
+ };
953
+ }
954
+
955
+ const duration = Date.now() - startTime;
956
+ return invalidationInfo;
957
+ } catch (error) {
958
+ const duration = Date.now() - startTime;
959
+
960
+
961
+ // Return error info
962
+ return {
963
+ reason: "error_checking_session",
964
+ invalidatedAt: Math.floor(Date.now() / 1000),
965
+ timestamp: new Date().toISOString(),
966
+ sessionId: null,
967
+ error: error.message
968
+ };
969
+ }
970
+ }
971
+
972
+
973
+ hashToken(token) {
974
+ const requestId = ulid();
975
+ const startTime = Date.now();
976
+ const baseContext = createLogContext("SessionManager", "hashToken", { requestId });
977
+
978
+ try {
979
+ const crypto = require('crypto');
980
+ const hash = crypto.createHash('sha256').update(token).digest('hex');
981
+
982
+ const duration = Date.now() - startTime;
983
+
984
+
985
+ return hash;
986
+ } catch (error) {
987
+ const duration = Date.now() - startTime;
988
+
989
+
990
+ throw error; // Rethrow as this is a critical operation
991
+ }
992
+ }
993
+
994
+
995
+ async isSessionActive(sessionId) {
996
+ if (!sessionId) return false;
997
+ const session = await this.getSession(sessionId);
998
+ // Session is active if it exists, isn't closed or expired
999
+ return !!(session && session.status === 'active');
1000
+ }
1001
+
1002
+ async cleanupExpiredSessions() {
1003
+ const requestId = ulid();
1004
+ const startTime = Date.now();
1005
+ const now = Math.floor(Date.now() / 1000);
1006
+ let removedCount = 0;
1007
+ const baseContext = createLogContext("SessionManager", "cleanupExpiredSessions", { requestId });
1008
+
1009
+ try {
1010
+ // Get all sessions from storage (support backends without getAll)
1011
+ let allSessions = [];
1012
+ if (typeof this.storage.getAll === 'function') {
1013
+ allSessions = await this.storage.getAll();
1014
+ } else {
1015
+ const ids = await this.storage.keys();
1016
+ for (const id of ids) {
1017
+ const s = await this.storage.get(id);
1018
+ if (s) allSessions.push(s);
1019
+ }
1020
+ }
1021
+
1022
+ logger.infoWithContext("Starting session cleanup", {
1023
+ ...baseContext,
1024
+ totalSessions: allSessions.length,
1025
+ currentTimestamp: now,
1026
+ sessionManagerInstanceId: this._instanceId
1027
+ });
1028
+
1029
+ // Find expired sessions
1030
+ for (const session of allSessions) {
1031
+ const sessionId = session.id || session.sessionId;
1032
+ if (!sessionId) {
1033
+ continue;
1034
+ }
1035
+
1036
+ const isExpired = session.expiresAt && session.expiresAt < now;
1037
+ const isOldClosed = session.status === 'closed' && session.closedAt < now - 86400;
1038
+
1039
+ if (isExpired || isOldClosed) {
1040
+ logger.infoWithContext("Removing expired/old session", {
1041
+ ...baseContext,
1042
+ sessionId,
1043
+ sessionStatus: session.status,
1044
+ expiresAt: session.expiresAt,
1045
+ closedAt: session.closedAt,
1046
+ currentTimestamp: now,
1047
+ reason: isExpired ? 'expired' : 'old_closed',
1048
+ sessionManagerInstanceId: this._instanceId
1049
+ });
1050
+
1051
+ await this.storage.delete(sessionId);
1052
+ removedCount++;
1053
+ } else {
1054
+ logger.debugWithContext("Session kept during cleanup", {
1055
+ ...baseContext,
1056
+ sessionId,
1057
+ sessionStatus: session.status,
1058
+ expiresAt: session.expiresAt,
1059
+ currentTimestamp: now,
1060
+ sessionManagerInstanceId: this._instanceId
1061
+ });
1062
+ }
1063
+ }
1064
+
1065
+ const duration = Date.now() - startTime;
1066
+
1067
+ logger.infoWithContext("Session cleanup completed", {
1068
+ ...baseContext,
1069
+ removedCount,
1070
+ totalSessionsBefore: allSessions.length,
1071
+ remainingSessions: allSessions.length - removedCount,
1072
+ sessionManagerInstanceId: this._instanceId,
1073
+ duration
1074
+ });
1075
+
1076
+ return removedCount;
1077
+ } catch (error) {
1078
+
1079
+
1080
+ return 0;
1081
+ }
1082
+ }
1083
+
1084
+
1085
+ async findSessionsByRoditId(roditId) {
1086
+ const requestId = ulid();
1087
+ const startTime = Date.now();
1088
+ const baseContext = createLogContext("SessionManager", "findSessionsByRoditId", { requestId, roditId });
1089
+
1090
+
1091
+ try {
1092
+ let result = [];
1093
+ // Get sessions from storage
1094
+ let allSessions = [];
1095
+ if (typeof this.storage.getAll === 'function') {
1096
+ allSessions = await this.storage.getAll();
1097
+ } else {
1098
+ const ids = await this.storage.keys();
1099
+ for (const id of ids) {
1100
+ const s = await this.storage.get(id);
1101
+ if (s) allSessions.push(s);
1102
+ }
1103
+ }
1104
+ result = allSessions.filter(s => s && s.roditId === roditId);
1105
+
1106
+ const duration = Date.now() - startTime;
1107
+
1108
+
1109
+ return result;
1110
+ } catch (error) {
1111
+ const duration = Date.now() - startTime;
1112
+
1113
+
1114
+ return [];
1115
+ }
1116
+ }
1117
+
1118
+
1119
+ async getActiveSessionCount() {
1120
+ const requestId = ulid();
1121
+ const startTime = Date.now();
1122
+ const baseContext = createLogContext("SessionManager", "getActiveSessionCount", { requestId });
1123
+
1124
+ try {
1125
+ let count = 0;
1126
+ const now = Math.floor(Date.now() / 1000);
1127
+
1128
+ // Get all sessions from storage with fallback mechanisms
1129
+ let allSessions = [];
1130
+ try {
1131
+ // First try to use getAll if available
1132
+ if (this.storage.getAll) {
1133
+ allSessions = await this.storage.getAll();
1134
+ } else {
1135
+ // Fall back to getting keys and fetching each one
1136
+ const ids = await this.storage.keys();
1137
+ for (const id of ids) {
1138
+ try {
1139
+ const s = await this.storage.get(id);
1140
+ if (s) allSessions.push(s);
1141
+ } catch (err) {
1142
+ // Skip any invalid sessions
1143
+ continue;
1144
+ }
1145
+ }
1146
+ }
1147
+ } catch (err) {
1148
+ // If we can't get sessions, return 0
1149
+ logger.errorWithContext('Error getting sessions', baseContext, err);
1150
+ return 0;
1151
+ }
1152
+
1153
+ // Count active sessions
1154
+ for (const session of allSessions) {
1155
+ try {
1156
+ if (session &&
1157
+ typeof session === 'object' &&
1158
+ session.status === 'active' &&
1159
+ (!session.expiresAt || session.expiresAt > now)) {
1160
+ count++;
1161
+ }
1162
+ } catch (err) {
1163
+ // Skip any invalid session objects
1164
+ continue;
1165
+ }
1166
+ }
1167
+
1168
+ const duration = Date.now() - startTime;
1169
+ logger.debug('Active session count calculated', { ...baseContext, count, duration });
1170
+
1171
+ return count;
1172
+ } catch (error) {
1173
+ const duration = Date.now() - startTime;
1174
+ logger.errorWithContext('Error in getActiveSessionCount', {
1175
+ ...baseContext,
1176
+ duration
1177
+ }, error);
1178
+
1179
+ // Return 0 on any error to ensure the application remains available
1180
+ return 0;
1181
+ }
1182
+ }
1183
+
1184
+ startCleanupJob(interval = SESSION_CLEANUP_INTERVAL) {
1185
+ const requestId = ulid();
1186
+
1187
+ const baseContext = createLogContext(
1188
+ "SessionManager",
1189
+ "startCleanupJob",
1190
+ {
1191
+ requestId,
1192
+ intervalMs: interval,
1193
+ intervalSeconds: interval / 1000
1194
+ }
1195
+ );
1196
+
1197
+ // Clear any existing interval
1198
+ if (this.cleanupInterval) {
1199
+ clearInterval(this.cleanupInterval);
1200
+ }
1201
+
1202
+ // Schedule the cleanup job
1203
+ this.cleanupInterval = setInterval(async () => {
1204
+ const cleanupRequestId = ulid();
1205
+ const startTime = Date.now();
1206
+
1207
+ const cleanupContext = createLogContext(
1208
+ "SessionManager",
1209
+ "scheduledCleanup",
1210
+ {
1211
+ requestId: cleanupRequestId,
1212
+ intervalSeconds: interval / 1000
1213
+ }
1214
+ );
1215
+
1216
+
1217
+ try {
1218
+ const removedCount = await this.cleanupExpiredSessions();
1219
+
1220
+ const duration = Date.now() - startTime;
1221
+
1222
+ } catch (error) {
1223
+ const duration = Date.now() - startTime;
1224
+
1225
+ }
1226
+ }, interval);
1227
+
1228
+ }
1229
+
1230
+
1231
+ stopCleanupJob() {
1232
+ const requestId = ulid();
1233
+
1234
+ const baseContext = createLogContext(
1235
+ "SessionManager",
1236
+ "stopCleanupJob",
1237
+ { requestId }
1238
+ );
1239
+
1240
+ if (this.cleanupInterval) {
1241
+ clearInterval(this.cleanupInterval);
1242
+ this.cleanupInterval = null;
1243
+
1244
+ } else {
1245
+ }
1246
+ }
1247
+
1248
+ async runManualCleanup(tokenRetentionPeriod = SESSION_TOKEN_RETENTION_PERIOD) {
1249
+ const requestId = ulid();
1250
+ const startTime = Date.now();
1251
+
1252
+ const baseContext = createLogContext(
1253
+ "SessionManager",
1254
+ "runManualCleanup",
1255
+ { requestId }
1256
+ );
1257
+
1258
+
1259
+ try {
1260
+ const removedSessionsCount = await this.cleanupExpiredSessions();
1261
+
1262
+ const duration = Date.now() - startTime;
1263
+
1264
+ const resultContext = {
1265
+ ...baseContext,
1266
+ duration,
1267
+ removedSessionsCount,
1268
+ remainingSessions: await this.getActiveSessionCount()
1269
+ };
1270
+
1271
+ return {
1272
+ removedSessionsCount,
1273
+ remainingSessions: await this.getActiveSessionCount(),
1274
+ duration
1275
+ };
1276
+ } catch (error) {
1277
+ const duration = Date.now() - startTime;
1278
+ throw error;
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ const sessionManager = new SessionManager();
1284
+
1285
+ logger.infoWithContext("SessionManager singleton created and exported", {
1286
+ component: "SessionManager",
1287
+ event: "singleton_export",
1288
+ instanceId: sessionManager._instanceId,
1289
+ timestamp: new Date().toISOString(),
1290
+ storageBackend: currentStorage?.store ? currentStorage.store.constructor.name : currentStorage.constructor.name
1291
+ });
1292
+
1293
+ module.exports = {
1294
+ sessionManager,
1295
+ InMemorySessionStorage,
1296
+ setStorage,
1297
+ setExpressSessionStore,
1298
+ configureStorageFromConfig,
1299
+ createExpressSessionMiddleware,
1300
+ SESSION_CLEANUP_INTERVAL,
1301
+ SESSION_TOKEN_RETENTION_PERIOD
1302
+ };