@igxjs/node-components 1.0.11 → 1.0.13
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 +19 -19
- package/components/assets/template.html +111 -0
- package/components/http-handlers.js +46 -33
- package/components/jwt.js +14 -9
- package/components/logger.js +131 -0
- package/components/redis.js +38 -11
- package/components/router.js +13 -1
- package/components/session.js +217 -135
- package/index.d.ts +385 -44
- package/index.js +1 -0
- package/package.json +29 -5
package/components/session.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
2
6
|
import axios from 'axios';
|
|
3
7
|
import session from 'express-session';
|
|
4
8
|
import memStore from 'memorystore';
|
|
@@ -7,6 +11,10 @@ import { RedisStore } from 'connect-redis';
|
|
|
7
11
|
import { CustomError, httpCodes, httpHelper, httpMessages } from './http-handlers.js';
|
|
8
12
|
import { JwtManager } from './jwt.js';
|
|
9
13
|
import { RedisManager } from './redis.js';
|
|
14
|
+
import { Logger } from './logger.js';
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Session authentication mode constants
|
|
@@ -21,13 +29,25 @@ export const SessionMode = {
|
|
|
21
29
|
*/
|
|
22
30
|
export class SessionConfig {
|
|
23
31
|
/**
|
|
24
|
-
* @type {string}
|
|
25
|
-
*
|
|
26
|
-
* Supported values: SessionMode.SESSION | SessionMode.TOKEN
|
|
32
|
+
* @type {string} Authentication mode for protected routes
|
|
33
|
+
* - Supported values: SessionMode.SESSION | SessionMode.TOKEN
|
|
27
34
|
* @default SessionMode.SESSION
|
|
28
35
|
*/
|
|
29
36
|
SESSION_MODE;
|
|
30
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* @type {string} Identity Provider microservice endpoint URL
|
|
39
|
+
*
|
|
40
|
+
* This is a fully customized, independent microservice that provides SSO authentication.
|
|
41
|
+
* The endpoint serves multiple applications and provides the following APIs:
|
|
42
|
+
* - GET /auth/providers - List supported identity providers
|
|
43
|
+
* - POST /auth/login/:idp - Generate login URL for specific identity provider
|
|
44
|
+
* - POST /auth/verify - Verify JWT token validity
|
|
45
|
+
* - GET /auth/callback/:idp - Validate authentication and return user data
|
|
46
|
+
* - POST /auth/refresh - Refresh access tokens
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* SSO_ENDPOINT_URL: 'https://idp.example.com/open/api/v1'
|
|
50
|
+
*/
|
|
31
51
|
SSO_ENDPOINT_URL;
|
|
32
52
|
/** @type {string} */
|
|
33
53
|
SSO_CLIENT_ID;
|
|
@@ -38,35 +58,69 @@ export class SessionConfig {
|
|
|
38
58
|
/** @type {string} */
|
|
39
59
|
SSO_FAILURE_URL;
|
|
40
60
|
|
|
41
|
-
/** @type {number} */
|
|
61
|
+
/** @type {number} Session age in milliseconds */
|
|
42
62
|
SESSION_AGE;
|
|
43
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* @type {string} Session cookie path
|
|
65
|
+
* @default '/'
|
|
66
|
+
*/
|
|
44
67
|
SESSION_COOKIE_PATH;
|
|
45
|
-
/** @type {string} */
|
|
68
|
+
/** @type {string} Session secret */
|
|
46
69
|
SESSION_SECRET;
|
|
47
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* @type {string}
|
|
72
|
+
* @default 'ibmid:'
|
|
73
|
+
*/
|
|
48
74
|
SESSION_PREFIX;
|
|
49
|
-
|
|
50
|
-
|
|
75
|
+
/**
|
|
76
|
+
* @type {string} Session key
|
|
77
|
+
* - In the `SessionMode.SESSION` mode, this is the key used to store the user in the session.
|
|
78
|
+
* - In the `SessionMode.TOKEN` mode, this is the key of localStorage where the user is stored.
|
|
79
|
+
* @default 'session_token'
|
|
80
|
+
*/
|
|
81
|
+
SESSION_KEY;
|
|
82
|
+
/**
|
|
83
|
+
* @type {string} Session expiry key
|
|
84
|
+
* - In the `SessionMode.TOKEN` mode, this is the key of localStorage where the session expiry timestamp is stored.
|
|
85
|
+
* @default 'session_expires_at'
|
|
86
|
+
*/
|
|
87
|
+
SESSION_EXPIRY_KEY;
|
|
88
|
+
/**
|
|
89
|
+
* @type {string} Path to custom HTML template for TOKEN mode callback
|
|
90
|
+
* - Used to customize the redirect page that stores JWT token and expiry in localStorage
|
|
91
|
+
* - Supports placeholders: {{SESSION_DATA_KEY}}, {{SESSION_DATA_VALUE}}, {{SESSION_EXPIRY_KEY}}, {{SESSION_EXPIRY_VALUE}}, {{SSO_SUCCESS_URL}}, {{SSO_FAILURE_URL}}
|
|
92
|
+
* - If not provided, uses default template
|
|
93
|
+
*/
|
|
94
|
+
TOKEN_STORAGE_TEMPLATE_PATH;
|
|
95
|
+
/** @type {string} Redis URL */
|
|
51
96
|
REDIS_URL;
|
|
52
|
-
/** @type {string} */
|
|
97
|
+
/** @type {string} Redis certificate path */
|
|
53
98
|
REDIS_CERT_PATH;
|
|
54
|
-
|
|
55
|
-
|
|
99
|
+
/**
|
|
100
|
+
* @type {string} Algorithm used to encrypt the JWT
|
|
101
|
+
* @default 'dir'
|
|
102
|
+
*/
|
|
56
103
|
JWT_ALGORITHM;
|
|
57
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* @type {string} Encryption algorithm used to encrypt the JWT
|
|
106
|
+
* @default 'A256GCM'
|
|
107
|
+
*/
|
|
58
108
|
JWT_ENCRYPTION;
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
109
|
+
/**
|
|
110
|
+
* @type {number} Clock tolerance in seconds
|
|
111
|
+
* @default 30
|
|
112
|
+
*/
|
|
62
113
|
JWT_CLOCK_TOLERANCE;
|
|
63
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* @type {string} Hash algorithm used to hash the JWT secret
|
|
116
|
+
* @default 'SHA-256'
|
|
117
|
+
*/
|
|
64
118
|
JWT_SECRET_HASH_ALGORITHM;
|
|
65
|
-
/** @type {string} */
|
|
119
|
+
/** @type {string?} JWT issuer claim */
|
|
66
120
|
JWT_ISSUER;
|
|
67
|
-
/** @type {string} */
|
|
121
|
+
/** @type {string?} JWT audience claim */
|
|
68
122
|
JWT_AUDIENCE;
|
|
69
|
-
/** @type {string} */
|
|
123
|
+
/** @type {string?} JWT subject claim */
|
|
70
124
|
JWT_SUBJECT;
|
|
71
125
|
}
|
|
72
126
|
|
|
@@ -81,12 +135,49 @@ export class SessionManager {
|
|
|
81
135
|
#idpRequest = null;
|
|
82
136
|
/** @type {import('./jwt.js').JwtManager} */
|
|
83
137
|
#jwtManager = null;
|
|
138
|
+
/** @type {import('./logger.js').Logger} */
|
|
139
|
+
#logger = Logger.getInstance('SessionManager');
|
|
84
140
|
|
|
85
141
|
/**
|
|
86
142
|
* Create a new session manager
|
|
87
143
|
* @param {SessionConfig} config Session configuration
|
|
144
|
+
* @throws {TypeError} If config is not an object
|
|
145
|
+
* @throws {Error} If required configuration fields are missing
|
|
88
146
|
*/
|
|
89
147
|
constructor(config) {
|
|
148
|
+
// Validate config is an object
|
|
149
|
+
if (!config || typeof config !== 'object') {
|
|
150
|
+
throw new TypeError('SessionManager configuration must be an object');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate required fields based on SESSION_MODE
|
|
154
|
+
const mode = config.SESSION_MODE || SessionMode.SESSION;
|
|
155
|
+
|
|
156
|
+
// SESSION_SECRET is always required for both modes
|
|
157
|
+
if (!config.SESSION_SECRET) {
|
|
158
|
+
throw new Error('SESSION_SECRET is required for SessionManager');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Validate SSO configuration if SSO endpoints are used
|
|
162
|
+
if (config.SSO_ENDPOINT_URL) {
|
|
163
|
+
if (!config.SSO_CLIENT_ID) {
|
|
164
|
+
throw new Error('SSO_CLIENT_ID is required when SSO_ENDPOINT_URL is provided');
|
|
165
|
+
}
|
|
166
|
+
if (!config.SSO_CLIENT_SECRET) {
|
|
167
|
+
throw new Error('SSO_CLIENT_SECRET is required when SSO_ENDPOINT_URL is provided');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Validate TOKEN mode specific requirements
|
|
172
|
+
if (mode === SessionMode.TOKEN) {
|
|
173
|
+
if (!config.SSO_SUCCESS_URL) {
|
|
174
|
+
throw new Error('SSO_SUCCESS_URL is required for TOKEN authentication mode');
|
|
175
|
+
}
|
|
176
|
+
if (!config.SSO_FAILURE_URL) {
|
|
177
|
+
throw new Error('SSO_FAILURE_URL is required for TOKEN authentication mode');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
90
181
|
this.#config = {
|
|
91
182
|
// Session Mode
|
|
92
183
|
SESSION_MODE: config.SESSION_MODE || SessionMode.SESSION,
|
|
@@ -95,6 +186,10 @@ export class SessionManager {
|
|
|
95
186
|
SESSION_COOKIE_PATH: config.SESSION_COOKIE_PATH || '/',
|
|
96
187
|
SESSION_SECRET: config.SESSION_SECRET,
|
|
97
188
|
SESSION_PREFIX: config.SESSION_PREFIX || 'ibmid:',
|
|
189
|
+
SESSION_KEY: config.SESSION_KEY || 'session_token',
|
|
190
|
+
SESSION_EXPIRY_KEY: config.SESSION_EXPIRY_KEY || 'session_expires_at',
|
|
191
|
+
TOKEN_STORAGE_TEMPLATE_PATH: config.TOKEN_STORAGE_TEMPLATE_PATH,
|
|
192
|
+
|
|
98
193
|
// Identity Provider
|
|
99
194
|
SSO_ENDPOINT_URL: config.SSO_ENDPOINT_URL,
|
|
100
195
|
SSO_CLIENT_ID: config.SSO_CLIENT_ID,
|
|
@@ -104,10 +199,10 @@ export class SessionManager {
|
|
|
104
199
|
// Redis
|
|
105
200
|
REDIS_URL: config.REDIS_URL,
|
|
106
201
|
REDIS_CERT_PATH: config.REDIS_CERT_PATH,
|
|
202
|
+
|
|
107
203
|
// JWT Manager
|
|
108
204
|
JWT_ALGORITHM: config.JWT_ALGORITHM || 'dir',
|
|
109
205
|
JWT_ENCRYPTION: config.JWT_ENCRYPTION || 'A256GCM',
|
|
110
|
-
JWT_EXPIRATION_TIME: config.JWT_EXPIRATION_TIME || '10m',
|
|
111
206
|
JWT_CLOCK_TOLERANCE: config.JWT_CLOCK_TOLERANCE ?? 30,
|
|
112
207
|
JWT_SECRET_HASH_ALGORITHM: config.JWT_SECRET_HASH_ALGORITHM || 'SHA-256',
|
|
113
208
|
JWT_ISSUER: config.JWT_ISSUER,
|
|
@@ -155,18 +250,18 @@ export class SessionManager {
|
|
|
155
250
|
* @returns {string} Returns the session key
|
|
156
251
|
*/
|
|
157
252
|
#getSessionKey() {
|
|
158
|
-
return
|
|
253
|
+
return this.#config.SESSION_KEY;
|
|
159
254
|
}
|
|
160
255
|
|
|
161
256
|
/**
|
|
162
257
|
* Get Redis key for token storage
|
|
163
258
|
* @param {string} email User email
|
|
164
|
-
* @param {string}
|
|
259
|
+
* @param {string} tid Token ID
|
|
165
260
|
* @returns {string} Returns the Redis key for token storage
|
|
166
261
|
* @private
|
|
167
262
|
*/
|
|
168
|
-
#getTokenRedisKey(email,
|
|
169
|
-
return `${this.#config.
|
|
263
|
+
#getTokenRedisKey(email, tid) {
|
|
264
|
+
return `${this.#config.SESSION_KEY}:t:${email}:${tid}`;
|
|
170
265
|
}
|
|
171
266
|
|
|
172
267
|
/**
|
|
@@ -176,7 +271,7 @@ export class SessionManager {
|
|
|
176
271
|
* @private
|
|
177
272
|
*/
|
|
178
273
|
#getTokenRedisPattern(email) {
|
|
179
|
-
return `${this.#config.
|
|
274
|
+
return `${this.#config.SESSION_KEY}:t:${email}:*`;
|
|
180
275
|
}
|
|
181
276
|
|
|
182
277
|
/**
|
|
@@ -189,47 +284,54 @@ export class SessionManager {
|
|
|
189
284
|
|
|
190
285
|
/**
|
|
191
286
|
* Generate and store JWT token in Redis
|
|
192
|
-
*
|
|
287
|
+
* - JWT payload contains only { email, tid } for minimal size
|
|
288
|
+
* - Full user data is stored in Redis as single source of truth
|
|
289
|
+
* @param {object} user User object with email and attributes
|
|
193
290
|
* @returns {Promise<string>} Returns the generated JWT token
|
|
291
|
+
* @throws {Error} If JWT encryption fails
|
|
292
|
+
* @throws {Error} If Redis storage fails
|
|
194
293
|
* @private
|
|
294
|
+
* @example
|
|
295
|
+
* const token = await this.#generateAndStoreToken({
|
|
296
|
+
* email: 'user@example.com',
|
|
297
|
+
* attributes: { /* user data * / }
|
|
298
|
+
* });
|
|
195
299
|
*/
|
|
196
300
|
async #generateAndStoreToken(user) {
|
|
197
301
|
// Generate unique token ID for this device/session
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
// Create JWT token with email and
|
|
302
|
+
const tid = crypto.randomUUID();
|
|
303
|
+
const ttlSeconds = Math.floor(this.#config.SESSION_AGE / 1000);
|
|
304
|
+
// Create JWT token with only email and tid (minimal payload)
|
|
201
305
|
const token = await this.#jwtManager.encrypt(
|
|
202
|
-
{
|
|
203
|
-
email: user.email,
|
|
204
|
-
tokenId
|
|
205
|
-
},
|
|
306
|
+
{ email: user.email, tid },
|
|
206
307
|
this.#config.SESSION_SECRET,
|
|
207
|
-
{ expirationTime:
|
|
308
|
+
{ expirationTime: ttlSeconds }
|
|
208
309
|
);
|
|
209
|
-
|
|
310
|
+
|
|
210
311
|
// Store user data in Redis with TTL
|
|
211
|
-
const redisKey = this.#getTokenRedisKey(user.email,
|
|
212
|
-
|
|
213
|
-
|
|
312
|
+
const redisKey = this.#getTokenRedisKey(user.email, tid);
|
|
313
|
+
|
|
214
314
|
await this.#redisManager.getClient().setEx(
|
|
215
315
|
redisKey,
|
|
216
316
|
ttlSeconds,
|
|
217
317
|
JSON.stringify(user)
|
|
218
318
|
);
|
|
219
|
-
|
|
220
|
-
console.debug(`### TOKEN GENERATED: ${user.email} ###`);
|
|
221
|
-
|
|
319
|
+
this.#logger.debug(`### TOKEN GENERATED: ${user.email} ###`);
|
|
222
320
|
return token;
|
|
223
321
|
}
|
|
224
322
|
|
|
225
323
|
/**
|
|
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
|
|
231
|
-
* @param {string} redirectUrl
|
|
324
|
+
* Verify token authentication - extracts and validates JWT from Authorization header
|
|
325
|
+
* @param {import('@types/express').Request} req Request with Authorization header
|
|
326
|
+
* @param {import('@types/express').Response} res Response object
|
|
327
|
+
* @param {import('@types/express').NextFunction} next Next middleware function
|
|
328
|
+
* @param {boolean} isDebugging If true, allows unauthenticated requests
|
|
329
|
+
* @param {string} redirectUrl URL to redirect to on authentication failure
|
|
330
|
+
* @throws {CustomError} If token is missing, invalid, or expired
|
|
232
331
|
* @private
|
|
332
|
+
* @example
|
|
333
|
+
* // Authorization header format: "Bearer {jwt_token}"
|
|
334
|
+
* await this.#verifyToken(req, res, next, false, '/login');
|
|
233
335
|
*/
|
|
234
336
|
async #verifyToken(req, res, next, isDebugging, redirectUrl) {
|
|
235
337
|
try {
|
|
@@ -247,14 +349,14 @@ export class SessionManager {
|
|
|
247
349
|
this.#config.SESSION_SECRET
|
|
248
350
|
);
|
|
249
351
|
|
|
250
|
-
// Extract email and
|
|
251
|
-
const { email,
|
|
252
|
-
if (!email || !
|
|
352
|
+
// Extract email and token ID
|
|
353
|
+
const { email, tid } = payload;
|
|
354
|
+
if (!email || !tid) {
|
|
253
355
|
throw new CustomError(httpCodes.UNAUTHORIZED, 'Invalid token payload');
|
|
254
356
|
}
|
|
255
357
|
|
|
256
358
|
// Lookup user in Redis
|
|
257
|
-
const redisKey = this.#getTokenRedisKey(email,
|
|
359
|
+
const redisKey = this.#getTokenRedisKey(email, tid);
|
|
258
360
|
const userData = await this.#redisManager.getClient().get(redisKey);
|
|
259
361
|
|
|
260
362
|
if (!userData) {
|
|
@@ -263,7 +365,6 @@ export class SessionManager {
|
|
|
263
365
|
|
|
264
366
|
// Parse and attach user to request
|
|
265
367
|
req.user = JSON.parse(userData);
|
|
266
|
-
res.locals.user = req.user;
|
|
267
368
|
|
|
268
369
|
// Validate authorization
|
|
269
370
|
const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
|
|
@@ -275,7 +376,7 @@ export class SessionManager {
|
|
|
275
376
|
|
|
276
377
|
} catch (error) {
|
|
277
378
|
if (isDebugging) {
|
|
278
|
-
|
|
379
|
+
this.#logger.warn('### TOKEN VERIFICATION FAILED (debugging mode) ###', error.message);
|
|
279
380
|
return next();
|
|
280
381
|
}
|
|
281
382
|
|
|
@@ -294,12 +395,13 @@ export class SessionManager {
|
|
|
294
395
|
}
|
|
295
396
|
|
|
296
397
|
/**
|
|
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
|
|
302
|
-
* @param {string} redirectUrl
|
|
398
|
+
* Verify session authentication - checks if user is authorized in session
|
|
399
|
+
* @param {import('@types/express').Request} req Request with session data
|
|
400
|
+
* @param {import('@types/express').Response} res Response object
|
|
401
|
+
* @param {import('@types/express').NextFunction} next Next middleware function
|
|
402
|
+
* @param {boolean} isDebugging If true, allows unauthorized users
|
|
403
|
+
* @param {string} redirectUrl URL to redirect to if user is unauthorized
|
|
404
|
+
* @throws {CustomError} If user is not authorized
|
|
303
405
|
* @private
|
|
304
406
|
*/
|
|
305
407
|
async #verifySession(req, res, next, isDebugging, redirectUrl) {
|
|
@@ -314,13 +416,18 @@ export class SessionManager {
|
|
|
314
416
|
}
|
|
315
417
|
|
|
316
418
|
/**
|
|
317
|
-
* Refresh token authentication
|
|
318
|
-
*
|
|
319
|
-
* @param {import('@types/express').
|
|
320
|
-
* @param {import('@types/express').
|
|
321
|
-
* @param {(
|
|
322
|
-
* @param {
|
|
419
|
+
* Refresh token authentication - exchanges refresh token for new JWT
|
|
420
|
+
* Invalidates old token and generates a new one with updated user data
|
|
421
|
+
* @param {import('@types/express').Request} req Request with Authorization header
|
|
422
|
+
* @param {import('@types/express').Response} res Response object
|
|
423
|
+
* @param {import('@types/express').NextFunction} next Next middleware function
|
|
424
|
+
* @param {(user: object) => object} initUser Function to initialize/transform user object
|
|
425
|
+
* @param {string} idpUrl Identity provider refresh endpoint URL
|
|
426
|
+
* @throws {CustomError} If refresh lock is active or SSO refresh fails
|
|
323
427
|
* @private
|
|
428
|
+
* @example
|
|
429
|
+
* // Response format:
|
|
430
|
+
* // { token: "new_jwt", user: {...}, expiresIn: 64800, tokenType: "Bearer" }
|
|
324
431
|
*/
|
|
325
432
|
async #refreshToken(req, res, next, initUser, idpUrl) {
|
|
326
433
|
try {
|
|
@@ -331,11 +438,11 @@ export class SessionManager {
|
|
|
331
438
|
throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
|
|
332
439
|
}
|
|
333
440
|
|
|
334
|
-
// Extract
|
|
441
|
+
// Extract Token ID from current token
|
|
335
442
|
const authHeader = req.headers.authorization;
|
|
336
443
|
const token = authHeader?.substring(7);
|
|
337
444
|
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
338
|
-
const oldTokenId = payload
|
|
445
|
+
const { tid: oldTokenId } = payload;
|
|
339
446
|
|
|
340
447
|
// Check refresh lock
|
|
341
448
|
if (this.hasLock(email)) {
|
|
@@ -376,7 +483,7 @@ export class SessionManager {
|
|
|
376
483
|
const oldRedisKey = this.#getTokenRedisKey(email, oldTokenId);
|
|
377
484
|
await this.#redisManager.getClient().del(oldRedisKey);
|
|
378
485
|
|
|
379
|
-
|
|
486
|
+
this.#logger.debug('### TOKEN REFRESHED SUCCESSFULLY ###');
|
|
380
487
|
|
|
381
488
|
// Return new token
|
|
382
489
|
return res.json({
|
|
@@ -450,7 +557,7 @@ export class SessionManager {
|
|
|
450
557
|
await this.#redisManager.getClient().del(keys);
|
|
451
558
|
}
|
|
452
559
|
|
|
453
|
-
|
|
560
|
+
this.#logger.info(`### ALL TOKENS LOGGED OUT: ${email} (${keys.length} tokens) ###`);
|
|
454
561
|
|
|
455
562
|
if (isRedirect) {
|
|
456
563
|
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
@@ -461,7 +568,7 @@ export class SessionManager {
|
|
|
461
568
|
redirect_url: this.#config.SSO_SUCCESS_URL
|
|
462
569
|
});
|
|
463
570
|
} catch (error) {
|
|
464
|
-
|
|
571
|
+
this.#logger.error('### LOGOUT ALL TOKENS ERROR ###', error);
|
|
465
572
|
if (isRedirect) {
|
|
466
573
|
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
467
574
|
}
|
|
@@ -487,7 +594,7 @@ export class SessionManager {
|
|
|
487
594
|
}
|
|
488
595
|
|
|
489
596
|
try {
|
|
490
|
-
// Extract
|
|
597
|
+
// Extract Token ID from current token
|
|
491
598
|
const authHeader = req.headers.authorization;
|
|
492
599
|
const token = authHeader?.substring(7);
|
|
493
600
|
|
|
@@ -496,13 +603,17 @@ export class SessionManager {
|
|
|
496
603
|
}
|
|
497
604
|
|
|
498
605
|
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
499
|
-
const { email,
|
|
606
|
+
const { email, tid } = payload;
|
|
607
|
+
|
|
608
|
+
if (!email || !tid) {
|
|
609
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid token payload');
|
|
610
|
+
}
|
|
500
611
|
|
|
501
612
|
// Remove token from Redis
|
|
502
|
-
const redisKey = this.#getTokenRedisKey(email,
|
|
613
|
+
const redisKey = this.#getTokenRedisKey(email, tid);
|
|
503
614
|
await this.#redisManager.getClient().del(redisKey);
|
|
504
615
|
|
|
505
|
-
|
|
616
|
+
this.#logger.info('### TOKEN LOGOUT SUCCESSFULLY ###');
|
|
506
617
|
|
|
507
618
|
if (isRedirect) {
|
|
508
619
|
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
@@ -513,7 +624,7 @@ export class SessionManager {
|
|
|
513
624
|
});
|
|
514
625
|
|
|
515
626
|
} catch (error) {
|
|
516
|
-
|
|
627
|
+
this.#logger.error('### TOKEN LOGOUT ERROR ###', error);
|
|
517
628
|
if (isRedirect) {
|
|
518
629
|
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
519
630
|
}
|
|
@@ -535,16 +646,16 @@ export class SessionManager {
|
|
|
535
646
|
try {
|
|
536
647
|
res.clearCookie('connect.sid');
|
|
537
648
|
} catch (error) {
|
|
538
|
-
|
|
539
|
-
|
|
649
|
+
this.#logger.error('### CLEAR COOKIE ERROR ###');
|
|
650
|
+
this.#logger.error(error);
|
|
540
651
|
}
|
|
541
652
|
return req.session.destroy((sessionError) => {
|
|
542
653
|
if (sessionError) {
|
|
543
|
-
|
|
544
|
-
|
|
654
|
+
this.#logger.error('### SESSION DESTROY CALLBACK ERROR ###');
|
|
655
|
+
this.#logger.error(sessionError);
|
|
545
656
|
return callback(sessionError);
|
|
546
657
|
}
|
|
547
|
-
|
|
658
|
+
this.#logger.info('### SESSION LOGOUT SUCCESSFULLY ###');
|
|
548
659
|
return callback(null);
|
|
549
660
|
});
|
|
550
661
|
}
|
|
@@ -573,7 +684,7 @@ export class SessionManager {
|
|
|
573
684
|
*/
|
|
574
685
|
#redisSession() {
|
|
575
686
|
// Redis Session
|
|
576
|
-
|
|
687
|
+
this.#logger.log('### Using Redis as the Session Store ###');
|
|
577
688
|
return session({
|
|
578
689
|
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
579
690
|
store: new RedisStore({ client: this.#redisManager.getClient(), prefix: this.#config.SESSION_PREFIX, disableTouch: true }),
|
|
@@ -588,7 +699,7 @@ export class SessionManager {
|
|
|
588
699
|
*/
|
|
589
700
|
#memorySession() {
|
|
590
701
|
// Memory Session
|
|
591
|
-
|
|
702
|
+
this.#logger.log('### Using Memory as the Session Store ###');
|
|
592
703
|
const MemoryStore = memStore(session);
|
|
593
704
|
return session({
|
|
594
705
|
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
@@ -675,13 +786,13 @@ export class SessionManager {
|
|
|
675
786
|
/** @type {{ payload: { user: import('../models/types/user').UserModel, redirect_url: string } }} */
|
|
676
787
|
const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
677
788
|
if (payload?.user) {
|
|
678
|
-
|
|
789
|
+
this.#logger.debug('### CALLBACK USER ###');
|
|
679
790
|
request.session[this.#getSessionKey()] = initUser(payload.user);
|
|
680
791
|
return new Promise((resolve, reject) => {
|
|
681
792
|
request.session.touch().save((err) => {
|
|
682
793
|
if (err) {
|
|
683
|
-
|
|
684
|
-
|
|
794
|
+
this.#logger.error('### SESSION SAVE ERROR ###');
|
|
795
|
+
this.#logger.error(err);
|
|
685
796
|
return reject(new CustomError(httpCodes.SYSTEM_FAILURE, 'Session failed to save', err));
|
|
686
797
|
}
|
|
687
798
|
return resolve(payload);
|
|
@@ -711,7 +822,9 @@ export class SessionManager {
|
|
|
711
822
|
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
|
|
712
823
|
}
|
|
713
824
|
|
|
825
|
+
/** @type {import('../index.js').SessionUser} */
|
|
714
826
|
const user = initUser(payload.user);
|
|
827
|
+
/** @type {string} */
|
|
715
828
|
const redirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
|
|
716
829
|
|
|
717
830
|
// Check SESSION_MODE to determine response type
|
|
@@ -719,57 +832,26 @@ export class SessionManager {
|
|
|
719
832
|
// Token-based: Generate token and return HTML page that stores it
|
|
720
833
|
const token = await this.#generateAndStoreToken(user);
|
|
721
834
|
|
|
722
|
-
|
|
835
|
+
this.#logger.debug('### CALLBACK TOKEN GENERATED ###');
|
|
723
836
|
|
|
837
|
+
const templatePath = this.#config.TOKEN_STORAGE_TEMPLATE_PATH || path.resolve(__dirname, 'assets', 'template.html');
|
|
724
838
|
// Return HTML page that stores token in localStorage and redirects
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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);
|
|
839
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
840
|
+
const html = template
|
|
841
|
+
.replaceAll('{{SESSION_DATA_KEY}}', this.#config.SESSION_KEY)
|
|
842
|
+
.replaceAll('{{SESSION_DATA_VALUE}}', token)
|
|
843
|
+
.replaceAll('{{SESSION_EXPIRY_KEY}}', this.#config.SESSION_EXPIRY_KEY)
|
|
844
|
+
.replaceAll('{{SESSION_EXPIRY_VALUE}}', user.attributes.expires_at)
|
|
845
|
+
.replaceAll('{{SSO_SUCCESS_URL}}', redirectUrl)
|
|
846
|
+
.replaceAll('{{SSO_FAILURE_URL}}', this.#config.SSO_FAILURE_URL);
|
|
847
|
+
return res.send(html);
|
|
769
848
|
}
|
|
849
|
+
// Session-based: Save to session and redirect
|
|
850
|
+
await this.#saveSession(req, jwt, initUser);
|
|
851
|
+
return res.redirect(redirectUrl);
|
|
770
852
|
}
|
|
771
853
|
catch (error) {
|
|
772
|
-
|
|
854
|
+
this.#logger.error('### CALLBACK ERROR ###', error);
|
|
773
855
|
return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(error.message)));
|
|
774
856
|
}
|
|
775
857
|
};
|
|
@@ -832,8 +914,8 @@ export class SessionManager {
|
|
|
832
914
|
// Session-based authentication is already single-instance per cookie
|
|
833
915
|
return this.#logoutSession(req, res, (error) => {
|
|
834
916
|
if (error) {
|
|
835
|
-
|
|
836
|
-
|
|
917
|
+
this.#logger.error('### LOGOUT CALLBACK ERROR ###');
|
|
918
|
+
this.#logger.error(error);
|
|
837
919
|
if (isRedirect) {
|
|
838
920
|
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
839
921
|
}
|