@igxjs/node-components 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -4
- package/components/http-handlers.js +6 -0
- package/components/jwt.js +89 -45
- package/components/session.js +571 -101
- package/index.d.ts +114 -44
- package/index.js +1 -1
- package/package.json +10 -3
- package/.github/workflows/node.js.yml +0 -31
- package/.github/workflows/npm-publish.yml +0 -33
- package/docs/README.md +0 -54
- package/docs/flex-router.md +0 -167
- package/docs/http-handlers.md +0 -302
- package/docs/jwt-manager.md +0 -124
- package/docs/redis-manager.md +0 -210
- package/docs/session-manager.md +0 -160
- package/tests/http-handlers.test.js +0 -21
- package/tests/jwt.test.js +0 -345
- package/tests/redis.test.js +0 -50
- package/tests/router.test.js +0 -50
- package/tests/session.test.js +0 -116
package/components/session.js
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import axios from 'axios';
|
|
2
3
|
import session from 'express-session';
|
|
3
4
|
import memStore from 'memorystore';
|
|
4
5
|
import { RedisStore } from 'connect-redis';
|
|
5
|
-
import { jwtDecrypt } from 'jose';
|
|
6
6
|
|
|
7
7
|
import { CustomError, httpCodes, httpHelper, httpMessages } from './http-handlers.js';
|
|
8
|
+
import { JwtManager } from './jwt.js';
|
|
8
9
|
import { RedisManager } from './redis.js';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Session authentication mode constants
|
|
13
|
+
*/
|
|
14
|
+
export const SessionMode = {
|
|
15
|
+
SESSION: 'session',
|
|
16
|
+
TOKEN: 'token'
|
|
17
|
+
};
|
|
11
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Session configuration options
|
|
21
|
+
*/
|
|
12
22
|
export class SessionConfig {
|
|
23
|
+
/**
|
|
24
|
+
* @type {string}
|
|
25
|
+
* Authentication mode for protected routes
|
|
26
|
+
* Supported values: SessionMode.SESSION | SessionMode.TOKEN
|
|
27
|
+
* @default SessionMode.SESSION
|
|
28
|
+
*/
|
|
29
|
+
SESSION_MODE;
|
|
13
30
|
/** @type {string} */
|
|
14
31
|
SSO_ENDPOINT_URL;
|
|
15
32
|
/** @type {string} */
|
|
@@ -34,6 +51,23 @@ export class SessionConfig {
|
|
|
34
51
|
REDIS_URL;
|
|
35
52
|
/** @type {string} */
|
|
36
53
|
REDIS_CERT_PATH;
|
|
54
|
+
|
|
55
|
+
/** @type {string} */
|
|
56
|
+
JWT_ALGORITHM;
|
|
57
|
+
/** @type {string} */
|
|
58
|
+
JWT_ENCRYPTION;
|
|
59
|
+
/** @type {string} */
|
|
60
|
+
JWT_EXPIRATION_TIME;
|
|
61
|
+
/** @type {number} */
|
|
62
|
+
JWT_CLOCK_TOLERANCE;
|
|
63
|
+
/** @type {string} */
|
|
64
|
+
JWT_SECRET_HASH_ALGORITHM;
|
|
65
|
+
/** @type {string} */
|
|
66
|
+
JWT_ISSUER;
|
|
67
|
+
/** @type {string} */
|
|
68
|
+
JWT_AUDIENCE;
|
|
69
|
+
/** @type {string} */
|
|
70
|
+
JWT_SUBJECT;
|
|
37
71
|
}
|
|
38
72
|
|
|
39
73
|
export class SessionManager {
|
|
@@ -45,6 +79,8 @@ export class SessionManager {
|
|
|
45
79
|
#redisManager = null;
|
|
46
80
|
/** @type {import('axios').AxiosInstance} */
|
|
47
81
|
#idpRequest = null;
|
|
82
|
+
/** @type {import('./jwt.js').JwtManager} */
|
|
83
|
+
#jwtManager = null;
|
|
48
84
|
|
|
49
85
|
/**
|
|
50
86
|
* Create a new session manager
|
|
@@ -52,6 +88,8 @@ export class SessionManager {
|
|
|
52
88
|
*/
|
|
53
89
|
constructor(config) {
|
|
54
90
|
this.#config = {
|
|
91
|
+
// Session Mode
|
|
92
|
+
SESSION_MODE: config.SESSION_MODE || SessionMode.SESSION,
|
|
55
93
|
// Session
|
|
56
94
|
SESSION_AGE: config.SESSION_AGE || 64800000,
|
|
57
95
|
SESSION_COOKIE_PATH: config.SESSION_COOKIE_PATH || '/',
|
|
@@ -66,6 +104,15 @@ export class SessionManager {
|
|
|
66
104
|
// Redis
|
|
67
105
|
REDIS_URL: config.REDIS_URL,
|
|
68
106
|
REDIS_CERT_PATH: config.REDIS_CERT_PATH,
|
|
107
|
+
// JWT Manager
|
|
108
|
+
JWT_ALGORITHM: config.JWT_ALGORITHM || 'dir',
|
|
109
|
+
JWT_ENCRYPTION: config.JWT_ENCRYPTION || 'A256GCM',
|
|
110
|
+
JWT_EXPIRATION_TIME: config.JWT_EXPIRATION_TIME || '10m',
|
|
111
|
+
JWT_CLOCK_TOLERANCE: config.JWT_CLOCK_TOLERANCE ?? 30,
|
|
112
|
+
JWT_SECRET_HASH_ALGORITHM: config.JWT_SECRET_HASH_ALGORITHM || 'SHA-256',
|
|
113
|
+
JWT_ISSUER: config.JWT_ISSUER,
|
|
114
|
+
JWT_AUDIENCE: config.JWT_AUDIENCE,
|
|
115
|
+
JWT_SUBJECT: config.JWT_SUBJECT,
|
|
69
116
|
};
|
|
70
117
|
}
|
|
71
118
|
|
|
@@ -111,6 +158,27 @@ export class SessionManager {
|
|
|
111
158
|
return 'user';
|
|
112
159
|
}
|
|
113
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Get Redis key for token storage
|
|
163
|
+
* @param {string} email User email
|
|
164
|
+
* @param {string} tokenId Token ID
|
|
165
|
+
* @returns {string} Returns the Redis key for token storage
|
|
166
|
+
* @private
|
|
167
|
+
*/
|
|
168
|
+
#getTokenRedisKey(email, tokenId) {
|
|
169
|
+
return `${this.#config.SESSION_PREFIX}token:${email}:${tokenId}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get Redis key pattern for all user tokens
|
|
174
|
+
* @param {string} email User email
|
|
175
|
+
* @returns {string} Returns the Redis key pattern for all user tokens
|
|
176
|
+
* @private
|
|
177
|
+
*/
|
|
178
|
+
#getTokenRedisPattern(email) {
|
|
179
|
+
return `${this.#config.SESSION_PREFIX}token:${email}:*`;
|
|
180
|
+
}
|
|
181
|
+
|
|
114
182
|
/**
|
|
115
183
|
* Get RedisManager instance
|
|
116
184
|
* @returns {import('./redis.js').RedisManager} Returns the RedisManager instance
|
|
@@ -119,6 +187,368 @@ export class SessionManager {
|
|
|
119
187
|
return this.#redisManager;
|
|
120
188
|
}
|
|
121
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Generate and store JWT token in Redis
|
|
192
|
+
* @param {object} user User object
|
|
193
|
+
* @returns {Promise<string>} Returns the generated JWT token
|
|
194
|
+
* @private
|
|
195
|
+
*/
|
|
196
|
+
async #generateAndStoreToken(user) {
|
|
197
|
+
// Generate unique token ID for this device/session
|
|
198
|
+
const tokenId = crypto.randomUUID();
|
|
199
|
+
|
|
200
|
+
// Create JWT token with email and tokenId
|
|
201
|
+
const token = await this.#jwtManager.encrypt(
|
|
202
|
+
{
|
|
203
|
+
email: user.email,
|
|
204
|
+
tokenId
|
|
205
|
+
},
|
|
206
|
+
this.#config.SESSION_SECRET,
|
|
207
|
+
{ expirationTime: this.#config.JWT_EXPIRATION_TIME }
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Store user data in Redis with TTL
|
|
211
|
+
const redisKey = this.#getTokenRedisKey(user.email, tokenId);
|
|
212
|
+
const ttlSeconds = Math.floor(this.#config.SESSION_AGE / 1000);
|
|
213
|
+
|
|
214
|
+
await this.#redisManager.getClient().setEx(
|
|
215
|
+
redisKey,
|
|
216
|
+
ttlSeconds,
|
|
217
|
+
JSON.stringify(user)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
console.debug(`### TOKEN GENERATED: ${user.email} ###`);
|
|
221
|
+
|
|
222
|
+
return token;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Verify token authentication
|
|
227
|
+
* @param {import('@types/express').Request} req Request
|
|
228
|
+
* @param {import('@types/express').Response} res Response
|
|
229
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
230
|
+
* @param {boolean} isDebugging Debugging flag
|
|
231
|
+
* @param {string} redirectUrl Redirect URL
|
|
232
|
+
* @private
|
|
233
|
+
*/
|
|
234
|
+
async #verifyToken(req, res, next, isDebugging, redirectUrl) {
|
|
235
|
+
try {
|
|
236
|
+
// Extract token from Authorization header
|
|
237
|
+
const authHeader = req.headers.authorization;
|
|
238
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
239
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'Missing or invalid authorization header');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
243
|
+
|
|
244
|
+
// Decrypt JWT token
|
|
245
|
+
const { payload } = await this.#jwtManager.decrypt(
|
|
246
|
+
token,
|
|
247
|
+
this.#config.SESSION_SECRET
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Extract email and tokenId
|
|
251
|
+
const { email, tokenId } = payload;
|
|
252
|
+
if (!email || !tokenId) {
|
|
253
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'Invalid token payload');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Lookup user in Redis
|
|
257
|
+
const redisKey = this.#getTokenRedisKey(email, tokenId);
|
|
258
|
+
const userData = await this.#redisManager.getClient().get(redisKey);
|
|
259
|
+
|
|
260
|
+
if (!userData) {
|
|
261
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'Token not found or expired');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Parse and attach user to request
|
|
265
|
+
req.user = JSON.parse(userData);
|
|
266
|
+
res.locals.user = req.user;
|
|
267
|
+
|
|
268
|
+
// Validate authorization
|
|
269
|
+
const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
|
|
270
|
+
if (!authorized && !isDebugging) {
|
|
271
|
+
throw new CustomError(httpCodes.FORBIDDEN, 'User is not authorized');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return next();
|
|
275
|
+
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (isDebugging) {
|
|
278
|
+
console.warn('### TOKEN VERIFICATION FAILED (debugging mode) ###', error.message);
|
|
279
|
+
return next();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (redirectUrl) {
|
|
283
|
+
return res.redirect(redirectUrl);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Handle specific JWT errors
|
|
287
|
+
if (error.code === 'ERR_JWT_EXPIRED') {
|
|
288
|
+
return next(new CustomError(httpCodes.UNAUTHORIZED, 'Token expired'));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return next(error instanceof CustomError ? error :
|
|
292
|
+
new CustomError(httpCodes.UNAUTHORIZED, 'Token verification failed'));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Verify session authentication
|
|
298
|
+
* @param {import('@types/express').Request} req Request
|
|
299
|
+
* @param {import('@types/express').Response} res Response
|
|
300
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
301
|
+
* @param {boolean} isDebugging Debugging flag
|
|
302
|
+
* @param {string} redirectUrl Redirect URL
|
|
303
|
+
* @private
|
|
304
|
+
*/
|
|
305
|
+
async #verifySession(req, res, next, isDebugging, redirectUrl) {
|
|
306
|
+
const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
|
|
307
|
+
if (authorized) {
|
|
308
|
+
return next();
|
|
309
|
+
}
|
|
310
|
+
if (redirectUrl) {
|
|
311
|
+
return res.redirect(redirectUrl);
|
|
312
|
+
}
|
|
313
|
+
return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Refresh token authentication
|
|
318
|
+
* @param {import('@types/express').Request} req Request
|
|
319
|
+
* @param {import('@types/express').Response} res Response
|
|
320
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
321
|
+
* @param {(user: object) => object} initUser Initialize user function
|
|
322
|
+
* @param {string} idpUrl Identity provider URL
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
async #refreshToken(req, res, next, initUser, idpUrl) {
|
|
326
|
+
try {
|
|
327
|
+
// Get current user from verifyToken middleware
|
|
328
|
+
const { email, attributes } = req.user || {};
|
|
329
|
+
|
|
330
|
+
if (!email) {
|
|
331
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Extract tokenId from current token
|
|
335
|
+
const authHeader = req.headers.authorization;
|
|
336
|
+
const token = authHeader?.substring(7);
|
|
337
|
+
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
338
|
+
const oldTokenId = payload.tokenId;
|
|
339
|
+
|
|
340
|
+
// Check refresh lock
|
|
341
|
+
if (this.hasLock(email)) {
|
|
342
|
+
throw new CustomError(httpCodes.CONFLICT, 'Token refresh is locked');
|
|
343
|
+
}
|
|
344
|
+
this.lock(email);
|
|
345
|
+
|
|
346
|
+
// Call SSO refresh endpoint
|
|
347
|
+
const response = await this.#idpRequest.post(idpUrl, {
|
|
348
|
+
user: {
|
|
349
|
+
email,
|
|
350
|
+
attributes: {
|
|
351
|
+
idp: attributes?.idp,
|
|
352
|
+
refresh_token: attributes?.refresh_token
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (response.status !== httpCodes.OK) {
|
|
358
|
+
throw new CustomError(response.status, response.statusText);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Decrypt new user data from SSO
|
|
362
|
+
const { jwt } = response.data;
|
|
363
|
+
const { payload: newPayload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
364
|
+
|
|
365
|
+
if (!newPayload?.user) {
|
|
366
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload from SSO');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Initialize user with new data
|
|
370
|
+
const user = initUser(newPayload.user);
|
|
371
|
+
|
|
372
|
+
// Generate new token
|
|
373
|
+
const newToken = await this.#generateAndStoreToken(user);
|
|
374
|
+
|
|
375
|
+
// Remove old token from Redis
|
|
376
|
+
const oldRedisKey = this.#getTokenRedisKey(email, oldTokenId);
|
|
377
|
+
await this.#redisManager.getClient().del(oldRedisKey);
|
|
378
|
+
|
|
379
|
+
console.debug('### TOKEN REFRESHED SUCCESSFULLY ###');
|
|
380
|
+
|
|
381
|
+
// Return new token
|
|
382
|
+
return res.json({
|
|
383
|
+
token: newToken,
|
|
384
|
+
user,
|
|
385
|
+
expiresIn: Math.floor(this.#config.SESSION_AGE / 1000),
|
|
386
|
+
tokenType: 'Bearer'
|
|
387
|
+
});
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return next(httpHelper.handleAxiosError(error));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Refresh session authentication
|
|
395
|
+
* @param {import('@types/express').Request} req Request
|
|
396
|
+
* @param {import('@types/express').Response} res Response
|
|
397
|
+
* @param {import('@types/express').NextFunction} next Next function
|
|
398
|
+
* @param {(user: object) => object} initUser Initialize user function
|
|
399
|
+
* @param {string} idpUrl Identity provider URL
|
|
400
|
+
* @private
|
|
401
|
+
*/
|
|
402
|
+
async #refreshSession(req, res, next, initUser, idpUrl) {
|
|
403
|
+
try {
|
|
404
|
+
const { email, attributes } = req.user || { email: '', attributes: {} };
|
|
405
|
+
if (this.hasLock(email)) {
|
|
406
|
+
throw new CustomError(httpCodes.CONFLICT, 'User refresh is locked');
|
|
407
|
+
}
|
|
408
|
+
this.lock(email);
|
|
409
|
+
const response = await this.#idpRequest.post(idpUrl, {
|
|
410
|
+
user: {
|
|
411
|
+
email,
|
|
412
|
+
attributes: {
|
|
413
|
+
idp: attributes?.idp,
|
|
414
|
+
refresh_token: attributes?.refresh_token
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
if (response.status === httpCodes.OK) {
|
|
419
|
+
const { jwt } = response.data;
|
|
420
|
+
const payload = await this.#saveSession(req, jwt, initUser);
|
|
421
|
+
return res.json(payload);
|
|
422
|
+
}
|
|
423
|
+
throw new CustomError(response.status, response.statusText);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return next(httpHelper.handleAxiosError(error));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Logout all tokens for a user
|
|
431
|
+
* @param {import('@types/express').Request} req Request
|
|
432
|
+
* @param {import('@types/express').Response} res Response
|
|
433
|
+
* @param {boolean} isRedirect Whether to redirect
|
|
434
|
+
* @private
|
|
435
|
+
*/
|
|
436
|
+
async #logoutAllTokens(req, res, isRedirect) {
|
|
437
|
+
try {
|
|
438
|
+
const { email } = req.user || {};
|
|
439
|
+
|
|
440
|
+
if (!email) {
|
|
441
|
+
throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Find all tokens for this user
|
|
445
|
+
const pattern = this.#getTokenRedisPattern(email);
|
|
446
|
+
const keys = await this.#redisManager.getClient().keys(pattern);
|
|
447
|
+
|
|
448
|
+
// Delete all tokens
|
|
449
|
+
if (keys.length > 0) {
|
|
450
|
+
await this.#redisManager.getClient().del(keys);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
console.info(`### ALL TOKENS LOGGED OUT: ${email} (${keys.length} tokens) ###`);
|
|
454
|
+
|
|
455
|
+
if (isRedirect) {
|
|
456
|
+
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
457
|
+
}
|
|
458
|
+
return res.json({
|
|
459
|
+
message: 'All tokens logged out successfully',
|
|
460
|
+
tokensRemoved: keys.length,
|
|
461
|
+
redirect_url: this.#config.SSO_SUCCESS_URL
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
console.error('### LOGOUT ALL TOKENS ERROR ###', error);
|
|
465
|
+
if (isRedirect) {
|
|
466
|
+
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
467
|
+
}
|
|
468
|
+
return res.status(httpCodes.SYSTEM_FAILURE).json({
|
|
469
|
+
error: 'Logout all failed',
|
|
470
|
+
redirect_url: this.#config.SSO_FAILURE_URL
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Logout token authentication
|
|
477
|
+
* @param {import('@types/express').Request} req Request
|
|
478
|
+
* @param {import('@types/express').Response} res Response
|
|
479
|
+
* @param {boolean} isRedirect Whether to redirect
|
|
480
|
+
* @param {boolean} logoutAll Whether to logout all tokens
|
|
481
|
+
* @private
|
|
482
|
+
*/
|
|
483
|
+
async #logoutToken(req, res, isRedirect, logoutAll = false) {
|
|
484
|
+
// If logoutAll is true, delegate to the all tokens logout method
|
|
485
|
+
if (logoutAll) {
|
|
486
|
+
return this.#logoutAllTokens(req, res, isRedirect);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
// Extract tokenId from current token
|
|
491
|
+
const authHeader = req.headers.authorization;
|
|
492
|
+
const token = authHeader?.substring(7);
|
|
493
|
+
|
|
494
|
+
if (!token) {
|
|
495
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'No token provided');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
499
|
+
const { email, tokenId } = payload;
|
|
500
|
+
|
|
501
|
+
// Remove token from Redis
|
|
502
|
+
const redisKey = this.#getTokenRedisKey(email, tokenId);
|
|
503
|
+
await this.#redisManager.getClient().del(redisKey);
|
|
504
|
+
|
|
505
|
+
console.info('### TOKEN LOGOUT SUCCESSFULLY ###');
|
|
506
|
+
|
|
507
|
+
if (isRedirect) {
|
|
508
|
+
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
509
|
+
}
|
|
510
|
+
return res.json({
|
|
511
|
+
message: 'Logout successful',
|
|
512
|
+
redirect_url: this.#config.SSO_SUCCESS_URL
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
} catch (error) {
|
|
516
|
+
console.error('### TOKEN LOGOUT ERROR ###', error);
|
|
517
|
+
if (isRedirect) {
|
|
518
|
+
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
519
|
+
}
|
|
520
|
+
return res.status(httpCodes.SYSTEM_FAILURE).json({
|
|
521
|
+
error: 'Logout failed',
|
|
522
|
+
redirect_url: this.#config.SSO_FAILURE_URL
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Logout session authentication
|
|
529
|
+
* @param {import('@types/express').Request} req Request
|
|
530
|
+
* @param {import('@types/express').Response} res Response
|
|
531
|
+
* @param {Function} callback Callback function
|
|
532
|
+
* @private
|
|
533
|
+
*/
|
|
534
|
+
#logoutSession(req, res, callback) {
|
|
535
|
+
try {
|
|
536
|
+
res.clearCookie('connect.sid');
|
|
537
|
+
} catch (error) {
|
|
538
|
+
console.error('### CLEAR COOKIE ERROR ###');
|
|
539
|
+
console.error(error);
|
|
540
|
+
}
|
|
541
|
+
return req.session.destroy((sessionError) => {
|
|
542
|
+
if (sessionError) {
|
|
543
|
+
console.error('### SESSION DESTROY CALLBACK ERROR ###');
|
|
544
|
+
console.error(sessionError);
|
|
545
|
+
return callback(sessionError);
|
|
546
|
+
}
|
|
547
|
+
console.info('### SESSION LOGOUT SUCCESSFULLY ###');
|
|
548
|
+
return callback(null);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
122
552
|
/**
|
|
123
553
|
* Setup the session/user handlers with configurations
|
|
124
554
|
* @param {import('@types/express').Application} app Express application
|
|
@@ -126,6 +556,7 @@ export class SessionManager {
|
|
|
126
556
|
*/
|
|
127
557
|
async setup(app, updateUser) {
|
|
128
558
|
this.#redisManager = new RedisManager();
|
|
559
|
+
this.#jwtManager = new JwtManager(this.#config);
|
|
129
560
|
// Identity Provider Request
|
|
130
561
|
this.#idpRequest = axios.create({
|
|
131
562
|
baseURL: this.#config.SSO_ENDPOINT_URL,
|
|
@@ -158,6 +589,7 @@ export class SessionManager {
|
|
|
158
589
|
#memorySession() {
|
|
159
590
|
// Memory Session
|
|
160
591
|
console.log('### Using Memory as the Session Store ###');
|
|
592
|
+
const MemoryStore = memStore(session);
|
|
161
593
|
return session({
|
|
162
594
|
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
163
595
|
store: new MemoryStore({}),
|
|
@@ -193,24 +625,44 @@ export class SessionManager {
|
|
|
193
625
|
}
|
|
194
626
|
|
|
195
627
|
/**
|
|
196
|
-
* Resource protection
|
|
628
|
+
* Resource protection based on configured SESSION_MODE
|
|
197
629
|
* @param {boolean} [isDebugging=false] Debugging flag
|
|
198
|
-
* @param {
|
|
630
|
+
* @param {string} [redirectUrl=''] Redirect URL
|
|
199
631
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
200
632
|
*/
|
|
201
|
-
authenticate
|
|
633
|
+
authenticate(isDebugging = false, redirectUrl = '') {
|
|
202
634
|
return async (req, res, next) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return next();
|
|
635
|
+
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
636
|
+
if (mode === SessionMode.TOKEN) {
|
|
637
|
+
return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
|
|
207
638
|
}
|
|
208
|
-
|
|
209
|
-
return res.redirect(redirectUrl);
|
|
210
|
-
}
|
|
211
|
-
return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
|
|
639
|
+
return this.#verifySession(req, res, next, isDebugging, redirectUrl);
|
|
212
640
|
};
|
|
213
|
-
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Resource protection by token (explicit token verification)
|
|
645
|
+
* @param {boolean} [isDebugging=false] Debugging flag
|
|
646
|
+
* @param {string} [redirectUrl=''] Redirect URL
|
|
647
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
648
|
+
*/
|
|
649
|
+
verifyToken(isDebugging = false, redirectUrl = '') {
|
|
650
|
+
return async (req, res, next) => {
|
|
651
|
+
return this.#verifyToken(req, res, next, isDebugging, redirectUrl);
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Resource protection by session (explicit session verification)
|
|
657
|
+
* @param {boolean} [isDebugging=false] Debugging flag
|
|
658
|
+
* @param {string} [redirectUrl=''] Redirect URL
|
|
659
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
660
|
+
*/
|
|
661
|
+
verifySession(isDebugging = false, redirectUrl = '') {
|
|
662
|
+
return async (req, res, next) => {
|
|
663
|
+
return this.#verifySession(req, res, next, isDebugging, redirectUrl);
|
|
664
|
+
};
|
|
665
|
+
}
|
|
214
666
|
|
|
215
667
|
/**
|
|
216
668
|
* Save session
|
|
@@ -221,7 +673,7 @@ export class SessionManager {
|
|
|
221
673
|
*/
|
|
222
674
|
#saveSession = async (request, jwt, initUser) => {
|
|
223
675
|
/** @type {{ payload: { user: import('../models/types/user').UserModel, redirect_url: string } }} */
|
|
224
|
-
const { payload } = await this.#
|
|
676
|
+
const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
225
677
|
if (payload?.user) {
|
|
226
678
|
console.debug('### CALLBACK USER ###');
|
|
227
679
|
request.session[this.#getSessionKey()] = initUser(payload.user);
|
|
@@ -247,16 +699,77 @@ export class SessionManager {
|
|
|
247
699
|
callback(initUser) {
|
|
248
700
|
return async (req, res, next) => {
|
|
249
701
|
const { jwt = '' } = req.query;
|
|
250
|
-
if(!jwt) {
|
|
702
|
+
if (!jwt) {
|
|
251
703
|
return next(new CustomError(httpCodes.BAD_REQUEST, 'Missing `jwt` in query parameters'));
|
|
252
704
|
}
|
|
705
|
+
|
|
253
706
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
707
|
+
// Decrypt JWT from Identity Adapter
|
|
708
|
+
const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
709
|
+
|
|
710
|
+
if (!payload?.user) {
|
|
711
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const user = initUser(payload.user);
|
|
715
|
+
const redirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
|
|
716
|
+
|
|
717
|
+
// Check SESSION_MODE to determine response type
|
|
718
|
+
if (this.#config.SESSION_MODE === SessionMode.TOKEN) {
|
|
719
|
+
// Token-based: Generate token and return HTML page that stores it
|
|
720
|
+
const token = await this.#generateAndStoreToken(user);
|
|
721
|
+
|
|
722
|
+
console.debug('### CALLBACK TOKEN GENERATED ###');
|
|
723
|
+
|
|
724
|
+
// Return HTML page that stores token in localStorage and redirects
|
|
725
|
+
return res.send(`
|
|
726
|
+
<!DOCTYPE html>
|
|
727
|
+
<html>
|
|
728
|
+
<head>
|
|
729
|
+
<meta charset="UTF-8">
|
|
730
|
+
<title>Authentication Complete</title>
|
|
731
|
+
<script>
|
|
732
|
+
(function() {
|
|
733
|
+
try {
|
|
734
|
+
// Store auth data in localStorage
|
|
735
|
+
localStorage.setItem('authToken', ${JSON.stringify(token)});
|
|
736
|
+
localStorage.setItem('tokenExpiry', ${Date.now() + this.#config.SESSION_AGE});
|
|
737
|
+
localStorage.setItem('user', ${JSON.stringify({
|
|
738
|
+
email: user.email,
|
|
739
|
+
name: user.name,
|
|
740
|
+
})});
|
|
741
|
+
|
|
742
|
+
// Redirect to original destination
|
|
743
|
+
window.location.replace(${JSON.stringify(redirectUrl)});
|
|
744
|
+
} catch (error) {
|
|
745
|
+
console.error('Failed to store authentication:', error);
|
|
746
|
+
document.getElementById('error').style.display = 'block';
|
|
747
|
+
}
|
|
748
|
+
})();
|
|
749
|
+
</script>
|
|
750
|
+
<style>
|
|
751
|
+
body { font-family: system-ui, sans-serif; text-align: center; padding: 50px; }
|
|
752
|
+
#error { display: none; color: #d32f2f; margin-top: 20px; }
|
|
753
|
+
</style>
|
|
754
|
+
</head>
|
|
755
|
+
<body>
|
|
756
|
+
<p>Completing authentication...</p>
|
|
757
|
+
<div id="error">
|
|
758
|
+
<p>Authentication failed. Please try again.</p>
|
|
759
|
+
<a href="${this.#config.SSO_FAILURE_URL}">Return to login</a>
|
|
760
|
+
</div>
|
|
761
|
+
</body>
|
|
762
|
+
</html>
|
|
763
|
+
`);
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
// Session-based: Save to session and redirect
|
|
767
|
+
await this.#saveSession(req, jwt, initUser);
|
|
768
|
+
return res.redirect(redirectUrl);
|
|
769
|
+
}
|
|
256
770
|
}
|
|
257
771
|
catch (error) {
|
|
258
|
-
console.error('###
|
|
259
|
-
console.error(error);
|
|
772
|
+
console.error('### CALLBACK ERROR ###', error);
|
|
260
773
|
return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(error.message)));
|
|
261
774
|
}
|
|
262
775
|
};
|
|
@@ -283,100 +796,57 @@ export class SessionManager {
|
|
|
283
796
|
}
|
|
284
797
|
|
|
285
798
|
/**
|
|
286
|
-
*
|
|
287
|
-
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
288
|
-
*/
|
|
289
|
-
logout() {
|
|
290
|
-
return (req, res) => {
|
|
291
|
-
const { redirect = false } = req.query;
|
|
292
|
-
const isRedirect = (redirect === 'true' || redirect === true);
|
|
293
|
-
return this.#logout(req, res, (error => {
|
|
294
|
-
if (error) {
|
|
295
|
-
console.error('### LOGOUT CALLBACK ERROR ###');
|
|
296
|
-
console.error(error);
|
|
297
|
-
if (isRedirect)
|
|
298
|
-
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
299
|
-
return res.status(httpCodes.AUTHORIZATION_FAILED).send({ redirect_url: this.#config.SSO_FAILURE_URL });
|
|
300
|
-
}
|
|
301
|
-
if (isRedirect)
|
|
302
|
-
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
303
|
-
return res.send({ redirect_url: this.#config.SSO_SUCCESS_URL });
|
|
304
|
-
}));
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Refresh user session
|
|
799
|
+
* Refresh user authentication based on configured SESSION_MODE
|
|
310
800
|
* @param {(user: object) => object} initUser Initialize user object function
|
|
311
801
|
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
312
802
|
*/
|
|
313
803
|
refresh(initUser) {
|
|
314
804
|
const idpUrl = '/auth/refresh'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
|
|
315
805
|
return async (req, res, next) => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this
|
|
322
|
-
const response = await this.#idpRequest.post(idpUrl, {
|
|
323
|
-
user: {
|
|
324
|
-
email,
|
|
325
|
-
attributes: {
|
|
326
|
-
idp: attributes?.idp,
|
|
327
|
-
refresh_token: attributes?.refresh_token
|
|
328
|
-
},
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
if(response.status === httpCodes.OK) {
|
|
332
|
-
/** @type {{ jwt: string }} */
|
|
333
|
-
const { jwt } = response.data;
|
|
334
|
-
const payload = await this.#saveSession(req, jwt, initUser);
|
|
335
|
-
return res.json(payload);
|
|
336
|
-
}
|
|
337
|
-
throw new CustomError(response.status, response.statusText);
|
|
338
|
-
}
|
|
339
|
-
catch(error) {
|
|
340
|
-
return next(httpHelper.handleAxiosError(error));
|
|
806
|
+
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
807
|
+
|
|
808
|
+
if (mode === SessionMode.TOKEN) {
|
|
809
|
+
return this.#refreshToken(req, res, next, initUser, idpUrl);
|
|
810
|
+
} else {
|
|
811
|
+
return this.#refreshSession(req, res, next, initUser, idpUrl);
|
|
341
812
|
}
|
|
342
813
|
};
|
|
343
814
|
}
|
|
344
815
|
|
|
345
816
|
/**
|
|
346
|
-
*
|
|
347
|
-
* @
|
|
348
|
-
* @param {import('@types/express').Response} res Response
|
|
349
|
-
* @param {(error: Error)} callback Callback
|
|
817
|
+
* Application logout based on configured SESSION_MODE (NOT SSO)
|
|
818
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
350
819
|
*/
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
console.error('### SESSION DESTROY CALLBACK ERROR ###');
|
|
361
|
-
console.error(sessionError);
|
|
362
|
-
return callback(sessionError);
|
|
820
|
+
logout() {
|
|
821
|
+
return async (req, res) => {
|
|
822
|
+
const { redirect = false, all = false } = req.query;
|
|
823
|
+
const isRedirect = (redirect === 'true' || redirect === true);
|
|
824
|
+
const logoutAll = (all === 'true' || all === true);
|
|
825
|
+
const mode = this.#config.SESSION_MODE || SessionMode.SESSION;
|
|
826
|
+
|
|
827
|
+
if (mode === SessionMode.TOKEN) {
|
|
828
|
+
return this.#logoutToken(req, res, isRedirect, logoutAll);
|
|
363
829
|
}
|
|
364
|
-
console.info('### LOGOUT SUCCESSFULLY ###');
|
|
365
|
-
return callback(null);
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
830
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
831
|
+
// Note: 'all' parameter is only applicable for token-based authentication
|
|
832
|
+
// Session-based authentication is already single-instance per cookie
|
|
833
|
+
return this.#logoutSession(req, res, (error) => {
|
|
834
|
+
if (error) {
|
|
835
|
+
console.error('### LOGOUT CALLBACK ERROR ###');
|
|
836
|
+
console.error(error);
|
|
837
|
+
if (isRedirect) {
|
|
838
|
+
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
839
|
+
}
|
|
840
|
+
return res.status(httpCodes.AUTHORIZATION_FAILED).send({
|
|
841
|
+
redirect_url: this.#config.SSO_FAILURE_URL
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
if (isRedirect) {
|
|
845
|
+
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
846
|
+
}
|
|
847
|
+
return res.send({ redirect_url: this.#config.SSO_SUCCESS_URL });
|
|
848
|
+
});
|
|
849
|
+
};
|
|
381
850
|
}
|
|
851
|
+
|
|
382
852
|
}
|