@igxjs/node-components 1.0.11 → 1.0.12
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 +8 -6
- package/components/logger.js +131 -0
- package/components/redis.js +18 -10
- package/components/session.js +132 -114
- package/index.d.ts +76 -13
- package/index.js +1 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ npm install @igxjs/node-components
|
|
|
13
13
|
| Component | Description | Documentation |
|
|
14
14
|
|-----------|-------------|---------------|
|
|
15
15
|
| **SessionManager** | SSO session management with Redis/memory storage, supporting both session and token-based authentication | [View docs](./docs/session-manager.md) |
|
|
16
|
+
| **Logger** | High-performance logging utility with zero dependencies and smart color detection | [View docs](./docs/logger.md) |
|
|
16
17
|
| **FlexRouter** | Flexible routing with context paths and middleware | [View docs](./docs/flex-router.md) |
|
|
17
18
|
| **RedisManager** | Redis connection management with TLS support | [View docs](./docs/redis-manager.md) |
|
|
18
19
|
| **JWT Manager** | Secure JWT encryption/decryption with JWE | [View docs](./docs/jwt-manager.md) |
|
|
@@ -76,10 +77,11 @@ flexRouter.mount(app, '');
|
|
|
76
77
|
```javascript
|
|
77
78
|
import { JwtManager } from '@igxjs/node-components';
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
// Constructor uses UPPERCASE naming with JWT_ prefix
|
|
81
|
+
const jwt = new JwtManager({ SESSION_AGE: 64800000 });
|
|
80
82
|
const SECRET = process.env.JWT_SECRET;
|
|
81
83
|
|
|
82
|
-
// Create token
|
|
84
|
+
// Create token (encrypt method uses camelCase for per-call options)
|
|
83
85
|
const token = await jwt.encrypt({ userId: '123', email: 'user@example.com' }, SECRET);
|
|
84
86
|
|
|
85
87
|
// Verify token
|
|
@@ -143,7 +145,6 @@ Uses JWT bearer tokens instead of session cookies. When a user authenticates via
|
|
|
143
145
|
- `SSO_FAILURE_URL`: Redirect URL after failed SSO login
|
|
144
146
|
- `JWT_ALGORITHM`: JWT algorithm (default: `'dir'`)
|
|
145
147
|
- `JWT_ENCRYPTION`: Encryption algorithm (default: `'A256GCM'`)
|
|
146
|
-
- `JWT_EXPIRATION_TIME`: Token expiration time (default: `'10m'`)
|
|
147
148
|
- `JWT_CLOCK_TOLERANCE`: Clock skew tolerance in seconds (default: 30)
|
|
148
149
|
|
|
149
150
|
**Auth Methods:**
|
|
@@ -154,23 +155,23 @@ Uses JWT bearer tokens instead of session cookies. When a user authenticates via
|
|
|
154
155
|
|
|
155
156
|
**Token Storage (Client-Side):**
|
|
156
157
|
|
|
157
|
-
When using token-based authentication, the
|
|
158
|
-
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
window.location.replace(redirectUrl);
|
|
171
|
-
</script>
|
|
158
|
+
When using token-based authentication, the SSO callback returns an HTML page that stores the token in `localStorage` and redirects the user:
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
// The token is automatically stored in localStorage by the callback HTML page
|
|
162
|
+
// Default keys (customizable via SESSION_KEY and SESSION_EXPIRY_KEY config):
|
|
163
|
+
localStorage.getItem('session_token'); // JWT token
|
|
164
|
+
localStorage.getItem('session_expires_at'); // Expiry timestamp
|
|
165
|
+
|
|
166
|
+
// Making authenticated requests from the client:
|
|
167
|
+
const token = localStorage.getItem('session_token');
|
|
168
|
+
fetch('/api/protected', {
|
|
169
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
170
|
+
});
|
|
172
171
|
```
|
|
173
172
|
|
|
173
|
+
**Note:** The actual localStorage keys used are determined by the `SESSION_KEY` and `SESSION_EXPIRY_KEY` configuration options (defaults shown above).
|
|
174
|
+
|
|
174
175
|
## SessionManager Configuration Options
|
|
175
176
|
|
|
176
177
|
| Option | Type | Default | Description |
|
|
@@ -189,7 +190,6 @@ When using token-based authentication, the client-side HTML page stores the toke
|
|
|
189
190
|
| `REDIS_CERT_PATH` | string | - | Path to Redis TLS certificate |
|
|
190
191
|
| `JWT_ALGORITHM` | string | `'dir'` | JWT signing algorithm |
|
|
191
192
|
| `JWT_ENCRYPTION` | string | `'A256GCM'` | JWE encryption algorithm |
|
|
192
|
-
| `JWT_EXPIRATION_TIME` | string | `'10m'` | Token expiration duration |
|
|
193
193
|
| `JWT_CLOCK_TOLERANCE` | number | 30 | Clock skew tolerance in seconds |
|
|
194
194
|
| `JWT_SECRET_HASH_ALGORITHM` | string | `'SHA-256'` | Algorithm for hashing secrets |
|
|
195
195
|
| `JWT_ISSUER` | string | - | JWT issuer identifier |
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Sign in IBM Garage</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no,viewport-fit=cover,minimum-scale=1,maximum-scale=1,user-scalable=no">
|
|
6
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--foreground-color-primary: #444;
|
|
10
|
+
--font-size: 0.8rem;
|
|
11
|
+
--background-color-primary: #f1f1f199;
|
|
12
|
+
--gap-global: 0.4rem;
|
|
13
|
+
}
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root {
|
|
16
|
+
--background-color-primary: black;
|
|
17
|
+
--foreground-color-primary: #bbb;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
html, body {
|
|
21
|
+
padding: 0px;
|
|
22
|
+
margin: 0px auto;
|
|
23
|
+
height: 100%;
|
|
24
|
+
width: 100%;
|
|
25
|
+
}
|
|
26
|
+
body {
|
|
27
|
+
background-image: radial-gradient(circle at 20% 80%, rgba(255, 140, 140, 0.5), transparent 42%),
|
|
28
|
+
radial-gradient(circle at 80% 20%, rgba(140, 200, 255, 0.55), transparent 42%),
|
|
29
|
+
radial-gradient(circle at 40% 40%, rgba(255, 230, 140, 0.45), transparent 32%),
|
|
30
|
+
linear-gradient(135deg, rgba(160, 120, 240, 0.95), rgba(120, 160, 240, 0.95));
|
|
31
|
+
background-size: cover;
|
|
32
|
+
background-repeat: no-repeat;
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
font-family: Verdana, Helvetica, Arial, sans-serif;
|
|
37
|
+
font-size: var(--font-size);
|
|
38
|
+
color: var(--foreground-color-primary);
|
|
39
|
+
flex-flow: column;
|
|
40
|
+
position: relative;
|
|
41
|
+
z-index: 10;
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
}
|
|
44
|
+
body::before {
|
|
45
|
+
background: var(--background-color-primary);
|
|
46
|
+
content: "";
|
|
47
|
+
position: absolute;
|
|
48
|
+
top: 0;
|
|
49
|
+
left: 0;
|
|
50
|
+
right: 0;
|
|
51
|
+
bottom: 0;
|
|
52
|
+
z-index: -1;
|
|
53
|
+
backdrop-filter: blur(10px);
|
|
54
|
+
-webkit-backdrop-filter: blur(10px);
|
|
55
|
+
}
|
|
56
|
+
.wrapper {
|
|
57
|
+
width: 100%;
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: space-between;
|
|
61
|
+
box-sizing: border-box;
|
|
62
|
+
row-gap: var(--gap-global);
|
|
63
|
+
flex-direction: column;
|
|
64
|
+
padding: var(--gap-global);
|
|
65
|
+
}
|
|
66
|
+
.wrapper #failure {
|
|
67
|
+
display: none;
|
|
68
|
+
}
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<hr />
|
|
73
|
+
<div class="wrapper">
|
|
74
|
+
<div id="success">Redirecting... <a href="{{SSO_SUCCESS_URL}}" target="_blank" style="color: blue; text-decoration: underline;">(click here if not redirected)</a></div>
|
|
75
|
+
<div id="failure">
|
|
76
|
+
<p>Authentication failed. Please try again.</p>
|
|
77
|
+
<a href="{{SSO_FAILURE_URL}}">Return to login</a>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<script>
|
|
81
|
+
(function() {
|
|
82
|
+
let success = true;
|
|
83
|
+
|
|
84
|
+
// Check localStorage support first
|
|
85
|
+
if (typeof localStorage !== 'object' || typeof localStorage.setItem !== 'function') {
|
|
86
|
+
console.warn('localStorage not supported, falling back to session cookie');
|
|
87
|
+
success = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Try localStorage first
|
|
92
|
+
if (localStorage.setItem) {
|
|
93
|
+
localStorage.setItem('{{SESSION_DATA_KEY}}', '{{SESSION_DATA_VALUE}}');
|
|
94
|
+
localStorage.setItem('{{SESSION_EXPIRY_KEY}}', '{{SESSION_EXPIRY_VALUE}}');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fall back to simple navigation
|
|
98
|
+
location.href = '{{SSO_SUCCESS_URL}}';
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error('Redirect failed:', e);
|
|
101
|
+
success = false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!success) {
|
|
105
|
+
document.getElementById('success').style.display = 'none';
|
|
106
|
+
document.getElementById('failure').style.display = 'block';
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
</script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import { STATUS_CODES } from 'node:http';
|
|
2
|
+
import { Logger } from './logger.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
OK: 'OK',
|
|
5
|
-
CREATED: 'Created',
|
|
6
|
-
NO_CONTENT: 'No Content',
|
|
7
|
-
BAD_REQUEST: 'Bad Request',
|
|
8
|
-
UNAUTHORIZED: 'Unauthorized',
|
|
9
|
-
FORBIDDEN: 'Forbidden',
|
|
10
|
-
NOT_FOUND: 'Not Found',
|
|
11
|
-
NOT_ACCEPTABLE: 'Not Acceptable',
|
|
12
|
-
CONFLICT: 'Conflict',
|
|
13
|
-
LOCKED: 'Locked',
|
|
14
|
-
SYSTEM_FAILURE: 'System Error',
|
|
15
|
-
NOT_IMPLEMENTED: 'Not Implemented',
|
|
16
|
-
};
|
|
4
|
+
const logger = Logger.getInstance('httpError');
|
|
17
5
|
|
|
6
|
+
/**
|
|
7
|
+
* HTTP status codes
|
|
8
|
+
*/
|
|
18
9
|
export const httpCodes = {
|
|
19
10
|
OK: 200,
|
|
20
11
|
CREATED: 201,
|
|
@@ -30,23 +21,45 @@ export const httpCodes = {
|
|
|
30
21
|
NOT_IMPLEMENTED: 501,
|
|
31
22
|
};
|
|
32
23
|
|
|
24
|
+
/**
|
|
25
|
+
* HTTP status messages
|
|
26
|
+
*/
|
|
27
|
+
export const httpMessages = {
|
|
28
|
+
OK: 'OK',
|
|
29
|
+
CREATED: 'Created',
|
|
30
|
+
NO_CONTENT: 'No Content',
|
|
31
|
+
BAD_REQUEST: 'Bad Request',
|
|
32
|
+
UNAUTHORIZED: 'Unauthorized',
|
|
33
|
+
FORBIDDEN: 'Forbidden',
|
|
34
|
+
NOT_FOUND: 'Not Found',
|
|
35
|
+
NOT_ACCEPTABLE: 'Not Acceptable',
|
|
36
|
+
CONFLICT: 'Conflict',
|
|
37
|
+
LOCKED: 'Locked',
|
|
38
|
+
SYSTEM_FAILURE: 'Internal Server Error',
|
|
39
|
+
NOT_IMPLEMENTED: 'Not Implemented',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Custom Error class
|
|
44
|
+
*/
|
|
33
45
|
export class CustomError extends Error {
|
|
34
|
-
/** @type {number} */
|
|
35
|
-
code;
|
|
36
|
-
/** @type {object} */
|
|
37
|
-
data;
|
|
38
|
-
/** @type {object} */
|
|
39
|
-
error;
|
|
40
46
|
/**
|
|
41
|
-
*
|
|
42
|
-
* @param {
|
|
43
|
-
* @param {
|
|
47
|
+
* @param {number} code HTTP status code
|
|
48
|
+
* @param {string} message Error message
|
|
49
|
+
* @param {Error} [error] Original error object
|
|
50
|
+
* @param {object} [data] Additional error data
|
|
44
51
|
*/
|
|
45
|
-
constructor(code, message, error
|
|
52
|
+
constructor(code, message, error, data) {
|
|
46
53
|
super(message);
|
|
54
|
+
this.name = 'CustomError';
|
|
47
55
|
this.code = code;
|
|
48
56
|
this.error = error;
|
|
49
57
|
this.data = data;
|
|
58
|
+
|
|
59
|
+
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
60
|
+
if (Error.captureStackTrace) {
|
|
61
|
+
Error.captureStackTrace(this, CustomError);
|
|
62
|
+
}
|
|
50
63
|
}
|
|
51
64
|
}
|
|
52
65
|
|
|
@@ -85,21 +98,21 @@ export const httpErrorHandler = (err, req, res, next) => {
|
|
|
85
98
|
res.status(responseBody.status).json(responseBody);
|
|
86
99
|
|
|
87
100
|
// Log error details
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
logger.error('### ERROR ###');
|
|
102
|
+
logger.error(`${req.method} ${req.path}`);
|
|
90
103
|
|
|
91
104
|
// Log based on error type
|
|
92
105
|
if ([httpCodes.UNAUTHORIZED, httpCodes.FORBIDDEN, httpCodes.NOT_FOUND].includes(err.code)) {
|
|
93
|
-
|
|
106
|
+
logger.error('>>> Auth Error:', err.message);
|
|
94
107
|
} else {
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
logger.error('>>> Name:', err.name);
|
|
109
|
+
logger.error('>>> Message:', err.message);
|
|
97
110
|
if (err.stack) {
|
|
98
|
-
|
|
111
|
+
logger.error('>>> Stack:', err.stack);
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
|
|
115
|
+
logger.error('### /ERROR ###');
|
|
103
116
|
};
|
|
104
117
|
|
|
105
118
|
/**
|
|
@@ -177,7 +190,7 @@ export const httpHelper = {
|
|
|
177
190
|
* @returns {CustomError} Returns CustomError instance
|
|
178
191
|
*/
|
|
179
192
|
handleAxiosError(error, defaultMessage = 'An error occurred') {
|
|
180
|
-
|
|
193
|
+
logger.warn(`### TRY ERROR: ${defaultMessage} ###`);
|
|
181
194
|
// Extract error details
|
|
182
195
|
const errorCode = _getErrorCode(error);
|
|
183
196
|
const errorMessage = _getErrorMessage(error, defaultMessage);
|
|
@@ -196,4 +209,4 @@ export const httpHelper = {
|
|
|
196
209
|
*/
|
|
197
210
|
export const httpError = (code, message, error, data) => {
|
|
198
211
|
return new CustomError(code, message, error, data);
|
|
199
|
-
};
|
|
212
|
+
};
|
package/components/jwt.js
CHANGED
|
@@ -13,7 +13,7 @@ export class JwtManager {
|
|
|
13
13
|
/** @type {string} Encryption method */
|
|
14
14
|
encryption;
|
|
15
15
|
|
|
16
|
-
/** @type {
|
|
16
|
+
/** @type {number} Token expiration time */
|
|
17
17
|
expirationTime;
|
|
18
18
|
|
|
19
19
|
/** @type {number} Clock tolerance in seconds */
|
|
@@ -37,7 +37,7 @@ export class JwtManager {
|
|
|
37
37
|
* @typedef {Object} JwtManagerOptions JwtManager configuration options
|
|
38
38
|
* @property {string} [JWT_ALGORITHM='dir'] JWE algorithm (default: 'dir')
|
|
39
39
|
* @property {string} [JWT_ENCRYPTION='A256GCM'] Encryption method (default: 'A256GCM')
|
|
40
|
-
* @property {
|
|
40
|
+
* @property {number} [SESSION_AGE=64800000] Token expiration time in milliseconds (default: 64800000 = 18 hours)
|
|
41
41
|
* @property {string} [JWT_SECRET_HASH_ALGORITHM='SHA-256'] Hash algorithm (default: 'SHA-256')
|
|
42
42
|
* @property {string?} [JWT_ISSUER] Optional JWT issuer claim
|
|
43
43
|
* @property {string?} [JWT_AUDIENCE] Optional JWT audience claim
|
|
@@ -48,7 +48,8 @@ export class JwtManager {
|
|
|
48
48
|
constructor(options = {}) {
|
|
49
49
|
this.algorithm = options.JWT_ALGORITHM || 'dir';
|
|
50
50
|
this.encryption = options.JWT_ENCRYPTION || 'A256GCM';
|
|
51
|
-
|
|
51
|
+
// SESSION_AGE is in milliseconds, convert to seconds for expirationTime
|
|
52
|
+
this.expirationTime = Math.floor((options.SESSION_AGE || 64800000) / 1000);
|
|
52
53
|
this.secretHashAlgorithm = options.JWT_SECRET_HASH_ALGORITHM || 'SHA-256';
|
|
53
54
|
this.issuer = options.JWT_ISSUER;
|
|
54
55
|
this.audience = options.JWT_AUDIENCE;
|
|
@@ -62,7 +63,7 @@ export class JwtManager {
|
|
|
62
63
|
* @typedef {Object} JwtEncryptOptions Encryption method options
|
|
63
64
|
* @property {string} [algorithm='dir'] JWE algorithm (overrides instance JWT_ALGORITHM)
|
|
64
65
|
* @property {string} [encryption='A256GCM'] Encryption method (overrides instance JWT_ENCRYPTION)
|
|
65
|
-
* @property {
|
|
66
|
+
* @property {number} [expirationTime] Token expiration time in seconds (overrides instance expirationTime derived from SESSION_AGE)
|
|
66
67
|
* @property {string} [secretHashAlgorithm='SHA-256'] Hash algorithm for secret derivation (overrides instance JWT_SECRET_HASH_ALGORITHM)
|
|
67
68
|
* @property {string?} [issuer] Optional JWT issuer claim (overrides instance JWT_ISSUER)
|
|
68
69
|
* @property {string?} [audience] Optional JWT audience claim (overrides instance JWT_AUDIENCE)
|
|
@@ -93,10 +94,11 @@ export class JwtManager {
|
|
|
93
94
|
const jwt = new EncryptJWT(data)
|
|
94
95
|
.setProtectedHeader({
|
|
95
96
|
alg: algorithm,
|
|
96
|
-
enc: encryption
|
|
97
|
+
enc: encryption,
|
|
98
|
+
typ: 'JWT',
|
|
97
99
|
})
|
|
98
100
|
.setIssuedAt()
|
|
99
|
-
.setExpirationTime(expirationTime);
|
|
101
|
+
.setExpirationTime(`${expirationTime}s`); // Pass as string with 's' suffix for seconds
|
|
100
102
|
|
|
101
103
|
// Add optional claims if provided
|
|
102
104
|
if (issuer) jwt.setIssuer(issuer);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utility for node-components
|
|
3
|
+
* Provides configurable logging with enable/disable functionality
|
|
4
|
+
* Uses singleton pattern to manage logger instances per component
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { Logger } from './logger.js';
|
|
8
|
+
*
|
|
9
|
+
* // Recommended: Get logger instance (singleton pattern)
|
|
10
|
+
* const logger = Logger.getInstance('ComponentName');
|
|
11
|
+
*
|
|
12
|
+
* // With explicit enable/disable
|
|
13
|
+
* const logger = Logger.getInstance('ComponentName', true); // Always enabled
|
|
14
|
+
* const logger = Logger.getInstance('ComponentName', false); // Always disabled
|
|
15
|
+
*
|
|
16
|
+
* // Backward compatibility: Constructor still works
|
|
17
|
+
* const logger = new Logger('ComponentName');
|
|
18
|
+
*
|
|
19
|
+
* // Use logger
|
|
20
|
+
* logger.info('Operation completed');
|
|
21
|
+
* logger.error('Error occurred', error);
|
|
22
|
+
*/
|
|
23
|
+
export class Logger {
|
|
24
|
+
/** @type {Map<string, Logger>} */
|
|
25
|
+
static #instances = new Map();
|
|
26
|
+
|
|
27
|
+
/** @type {boolean} - Global flag to enable/disable colors */
|
|
28
|
+
static #colorsEnabled = true;
|
|
29
|
+
|
|
30
|
+
/** ANSI color codes for different log levels */
|
|
31
|
+
static #colors = {
|
|
32
|
+
reset: '\x1b[0m',
|
|
33
|
+
dim: '\x1b[2m', // debug - dim/gray
|
|
34
|
+
cyan: '\x1b[36m', // info - cyan
|
|
35
|
+
yellow: '\x1b[33m', // warn - yellow
|
|
36
|
+
red: '\x1b[31m', // error - red
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** @type {boolean} */
|
|
40
|
+
#enabled;
|
|
41
|
+
/** @type {string} */
|
|
42
|
+
#prefix;
|
|
43
|
+
/** @type {boolean} */
|
|
44
|
+
#useColors;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get or create a Logger instance (singleton pattern)
|
|
48
|
+
* @param {string} componentName Component name for log prefix
|
|
49
|
+
* @param {boolean} [enableLogging] Enable/disable logging. Defaults to NODE_ENV !== 'production'
|
|
50
|
+
* @returns {Logger} Logger instance
|
|
51
|
+
*/
|
|
52
|
+
static getInstance(componentName, enableLogging) {
|
|
53
|
+
const key = `${componentName}:${enableLogging ?? 'default'}`;
|
|
54
|
+
|
|
55
|
+
if (!Logger.#instances.has(key)) {
|
|
56
|
+
Logger.#instances.set(key, new Logger(componentName, enableLogging));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Logger.#instances.get(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Clear all logger instances (useful for testing)
|
|
64
|
+
*/
|
|
65
|
+
static clearInstances() {
|
|
66
|
+
Logger.#instances.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Disable colors globally for all logger instances
|
|
71
|
+
*/
|
|
72
|
+
static disableColors() {
|
|
73
|
+
Logger.#colorsEnabled = false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Enable colors globally for all logger instances
|
|
78
|
+
*/
|
|
79
|
+
static enableColors() {
|
|
80
|
+
Logger.#colorsEnabled = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a new Logger instance
|
|
85
|
+
* @param {string} componentName Component name for log prefix
|
|
86
|
+
* @param {boolean} [enableLogging] Enable/disable logging. Defaults to NODE_ENV !== 'production'
|
|
87
|
+
*/
|
|
88
|
+
constructor(componentName, enableLogging) {
|
|
89
|
+
// If enableLogging is explicitly set (true/false), use it
|
|
90
|
+
// Otherwise, default to enabled in development, disabled in production
|
|
91
|
+
this.#enabled = enableLogging ?? (process.env.NODE_ENV !== 'production');
|
|
92
|
+
this.#prefix = `[${componentName}]`;
|
|
93
|
+
|
|
94
|
+
// Determine if colors should be used:
|
|
95
|
+
// - Colors are globally enabled
|
|
96
|
+
// - Output is a terminal (TTY)
|
|
97
|
+
// - NO_COLOR environment variable is not set
|
|
98
|
+
this.#useColors = Logger.#colorsEnabled &&
|
|
99
|
+
process.stdout.isTTY &&
|
|
100
|
+
!process.env.NO_COLOR;
|
|
101
|
+
|
|
102
|
+
// Assign methods based on enabled flag to eliminate runtime checks
|
|
103
|
+
// This improves performance by avoiding conditional checks on every log call
|
|
104
|
+
if (this.#enabled) {
|
|
105
|
+
const colors = Logger.#colors;
|
|
106
|
+
|
|
107
|
+
if (this.#useColors) {
|
|
108
|
+
// Logging enabled with colors: colorize the prefix
|
|
109
|
+
this.debug = (...args) => console.debug(colors.dim + this.#prefix + colors.reset, ...args);
|
|
110
|
+
this.info = (...args) => console.info(colors.cyan + this.#prefix + colors.reset, ...args);
|
|
111
|
+
this.warn = (...args) => console.warn(colors.yellow + this.#prefix + colors.reset, ...args);
|
|
112
|
+
this.error = (...args) => console.error(colors.red + this.#prefix + colors.reset, ...args);
|
|
113
|
+
this.log = (...args) => console.log(this.#prefix, ...args);
|
|
114
|
+
} else {
|
|
115
|
+
// Logging enabled without colors: plain text
|
|
116
|
+
this.debug = (...args) => console.debug(this.#prefix, ...args);
|
|
117
|
+
this.info = (...args) => console.info(this.#prefix, ...args);
|
|
118
|
+
this.warn = (...args) => console.warn(this.#prefix, ...args);
|
|
119
|
+
this.error = (...args) => console.error(this.#prefix, ...args);
|
|
120
|
+
this.log = (...args) => console.log(this.#prefix, ...args);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
// Logging disabled: assign no-op functions that do nothing
|
|
124
|
+
this.debug = () => {};
|
|
125
|
+
this.info = () => {};
|
|
126
|
+
this.warn = () => {};
|
|
127
|
+
this.error = () => {};
|
|
128
|
+
this.log = () => {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/components/redis.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
|
|
3
3
|
import { createClient } from '@redis/client';
|
|
4
|
+
import { Logger } from './logger.js';
|
|
4
5
|
|
|
5
6
|
export class RedisManager {
|
|
7
|
+
/** @type {Logger} */
|
|
8
|
+
#logger = Logger.getInstance('RedisManager');
|
|
9
|
+
|
|
6
10
|
/** @type {import('@redis/client').RedisClientType} */
|
|
7
11
|
#client = null;
|
|
8
12
|
/**
|
|
@@ -22,21 +26,23 @@ export class RedisManager {
|
|
|
22
26
|
options.socket.ca = caCert;
|
|
23
27
|
}
|
|
24
28
|
this.#client = createClient(options);
|
|
29
|
+
this.#logger.info('### REDIS CONNECTING ###');
|
|
25
30
|
this.#client.on('ready', () => {
|
|
26
|
-
|
|
31
|
+
this.#logger.info('### REDIS READY ###');
|
|
27
32
|
});
|
|
28
33
|
this.#client.on('reconnecting', (_res) => {
|
|
29
|
-
|
|
34
|
+
this.#logger.warn('### REDIS RECONNECTING ###');
|
|
30
35
|
});
|
|
31
36
|
this.#client.on('error', (error) => {
|
|
32
|
-
|
|
37
|
+
this.#logger.error(`### REDIS ERROR: ${error.message} ###`);
|
|
33
38
|
});
|
|
34
39
|
await this.#client.connect();
|
|
40
|
+
this.#logger.info('### REDIS CONNECTED SUCCESSFULLY ###');
|
|
35
41
|
return true;
|
|
36
42
|
}
|
|
37
43
|
catch (error) {
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
this.#logger.error('### REDIS CONNECT ERROR ###', error);
|
|
45
|
+
return false;
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
return false;
|
|
@@ -60,8 +66,8 @@ export class RedisManager {
|
|
|
60
66
|
const pongMessage = await this.#client.ping();
|
|
61
67
|
return 'PONG' === pongMessage;
|
|
62
68
|
} catch (error) {
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
this.#logger.error(`### REDIS PING ERROR: ${error.message} ###`);
|
|
70
|
+
return false;
|
|
65
71
|
}
|
|
66
72
|
return false;
|
|
67
73
|
}
|
|
@@ -70,7 +76,9 @@ export class RedisManager {
|
|
|
70
76
|
* Disconnect with Redis
|
|
71
77
|
* @returns {Promise<void>} Returns nothing
|
|
72
78
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
async disconnect() {
|
|
80
|
+
this.#logger.info('### REDIS DISCONNECTING ###');
|
|
81
|
+
await this.#client.quit();
|
|
82
|
+
this.#logger.info('### REDIS DISCONNECTED ###');
|
|
75
83
|
}
|
|
76
|
-
}
|
|
84
|
+
}
|
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,9 +29,8 @@ 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;
|
|
@@ -38,35 +45,69 @@ export class SessionConfig {
|
|
|
38
45
|
/** @type {string} */
|
|
39
46
|
SSO_FAILURE_URL;
|
|
40
47
|
|
|
41
|
-
/** @type {number} */
|
|
48
|
+
/** @type {number} Session age in milliseconds */
|
|
42
49
|
SESSION_AGE;
|
|
43
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* @type {string} Session cookie path
|
|
52
|
+
* @default '/'
|
|
53
|
+
*/
|
|
44
54
|
SESSION_COOKIE_PATH;
|
|
45
|
-
/** @type {string} */
|
|
55
|
+
/** @type {string} Session secret */
|
|
46
56
|
SESSION_SECRET;
|
|
47
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* @type {string}
|
|
59
|
+
* @default 'ibmid:'
|
|
60
|
+
*/
|
|
48
61
|
SESSION_PREFIX;
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
/**
|
|
63
|
+
* @type {string} Session key
|
|
64
|
+
* - In the `SessionMode.SESSION` mode, this is the key used to store the user in the session.
|
|
65
|
+
* - In the `SessionMode.TOKEN` mode, this is the key of localStorage where the user is stored.
|
|
66
|
+
* @default 'session_token'
|
|
67
|
+
*/
|
|
68
|
+
SESSION_KEY;
|
|
69
|
+
/**
|
|
70
|
+
* @type {string} Session expiry key
|
|
71
|
+
* - In the `SessionMode.TOKEN` mode, this is the key of localStorage where the session expiry timestamp is stored.
|
|
72
|
+
* @default 'session_expires_at'
|
|
73
|
+
*/
|
|
74
|
+
SESSION_EXPIRY_KEY;
|
|
75
|
+
/**
|
|
76
|
+
* @type {string} Path to custom HTML template for TOKEN mode callback
|
|
77
|
+
* - Used to customize the redirect page that stores JWT token and expiry in localStorage
|
|
78
|
+
* - Supports placeholders: {{SESSION_DATA_KEY}}, {{SESSION_DATA_VALUE}}, {{SESSION_EXPIRY_KEY}}, {{SESSION_EXPIRY_VALUE}}, {{SSO_SUCCESS_URL}}, {{SSO_FAILURE_URL}}
|
|
79
|
+
* - If not provided, uses default template
|
|
80
|
+
*/
|
|
81
|
+
TOKEN_STORAGE_TEMPLATE_PATH;
|
|
82
|
+
/** @type {string} Redis URL */
|
|
51
83
|
REDIS_URL;
|
|
52
|
-
/** @type {string} */
|
|
84
|
+
/** @type {string} Redis certificate path */
|
|
53
85
|
REDIS_CERT_PATH;
|
|
54
|
-
|
|
55
|
-
|
|
86
|
+
/**
|
|
87
|
+
* @type {string} Algorithm used to encrypt the JWT
|
|
88
|
+
* @default 'dir'
|
|
89
|
+
*/
|
|
56
90
|
JWT_ALGORITHM;
|
|
57
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* @type {string} Encryption algorithm used to encrypt the JWT
|
|
93
|
+
* @default 'A256GCM'
|
|
94
|
+
*/
|
|
58
95
|
JWT_ENCRYPTION;
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
96
|
+
/**
|
|
97
|
+
* @type {number} Clock tolerance in seconds
|
|
98
|
+
* @default 30
|
|
99
|
+
*/
|
|
62
100
|
JWT_CLOCK_TOLERANCE;
|
|
63
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* @type {string} Hash algorithm used to hash the JWT secret
|
|
103
|
+
* @default 'SHA-256'
|
|
104
|
+
*/
|
|
64
105
|
JWT_SECRET_HASH_ALGORITHM;
|
|
65
|
-
/** @type {string} */
|
|
106
|
+
/** @type {string?} JWT issuer claim */
|
|
66
107
|
JWT_ISSUER;
|
|
67
|
-
/** @type {string} */
|
|
108
|
+
/** @type {string?} JWT audience claim */
|
|
68
109
|
JWT_AUDIENCE;
|
|
69
|
-
/** @type {string} */
|
|
110
|
+
/** @type {string?} JWT subject claim */
|
|
70
111
|
JWT_SUBJECT;
|
|
71
112
|
}
|
|
72
113
|
|
|
@@ -81,6 +122,8 @@ export class SessionManager {
|
|
|
81
122
|
#idpRequest = null;
|
|
82
123
|
/** @type {import('./jwt.js').JwtManager} */
|
|
83
124
|
#jwtManager = null;
|
|
125
|
+
/** @type {import('./logger.js').Logger} */
|
|
126
|
+
#logger = Logger.getInstance('SessionManager');
|
|
84
127
|
|
|
85
128
|
/**
|
|
86
129
|
* Create a new session manager
|
|
@@ -95,6 +138,10 @@ export class SessionManager {
|
|
|
95
138
|
SESSION_COOKIE_PATH: config.SESSION_COOKIE_PATH || '/',
|
|
96
139
|
SESSION_SECRET: config.SESSION_SECRET,
|
|
97
140
|
SESSION_PREFIX: config.SESSION_PREFIX || 'ibmid:',
|
|
141
|
+
SESSION_KEY: config.SESSION_KEY || 'session_token',
|
|
142
|
+
SESSION_EXPIRY_KEY: config.SESSION_EXPIRY_KEY || 'session_expires_at',
|
|
143
|
+
TOKEN_STORAGE_TEMPLATE_PATH: config.TOKEN_STORAGE_TEMPLATE_PATH,
|
|
144
|
+
|
|
98
145
|
// Identity Provider
|
|
99
146
|
SSO_ENDPOINT_URL: config.SSO_ENDPOINT_URL,
|
|
100
147
|
SSO_CLIENT_ID: config.SSO_CLIENT_ID,
|
|
@@ -104,10 +151,10 @@ export class SessionManager {
|
|
|
104
151
|
// Redis
|
|
105
152
|
REDIS_URL: config.REDIS_URL,
|
|
106
153
|
REDIS_CERT_PATH: config.REDIS_CERT_PATH,
|
|
154
|
+
|
|
107
155
|
// JWT Manager
|
|
108
156
|
JWT_ALGORITHM: config.JWT_ALGORITHM || 'dir',
|
|
109
157
|
JWT_ENCRYPTION: config.JWT_ENCRYPTION || 'A256GCM',
|
|
110
|
-
JWT_EXPIRATION_TIME: config.JWT_EXPIRATION_TIME || '10m',
|
|
111
158
|
JWT_CLOCK_TOLERANCE: config.JWT_CLOCK_TOLERANCE ?? 30,
|
|
112
159
|
JWT_SECRET_HASH_ALGORITHM: config.JWT_SECRET_HASH_ALGORITHM || 'SHA-256',
|
|
113
160
|
JWT_ISSUER: config.JWT_ISSUER,
|
|
@@ -155,18 +202,18 @@ export class SessionManager {
|
|
|
155
202
|
* @returns {string} Returns the session key
|
|
156
203
|
*/
|
|
157
204
|
#getSessionKey() {
|
|
158
|
-
return
|
|
205
|
+
return this.#config.SESSION_KEY;
|
|
159
206
|
}
|
|
160
207
|
|
|
161
208
|
/**
|
|
162
209
|
* Get Redis key for token storage
|
|
163
210
|
* @param {string} email User email
|
|
164
|
-
* @param {string}
|
|
211
|
+
* @param {string} tid Token ID
|
|
165
212
|
* @returns {string} Returns the Redis key for token storage
|
|
166
213
|
* @private
|
|
167
214
|
*/
|
|
168
|
-
#getTokenRedisKey(email,
|
|
169
|
-
return `${this.#config.
|
|
215
|
+
#getTokenRedisKey(email, tid) {
|
|
216
|
+
return `${this.#config.SESSION_KEY}:t:${email}:${tid}`;
|
|
170
217
|
}
|
|
171
218
|
|
|
172
219
|
/**
|
|
@@ -176,7 +223,7 @@ export class SessionManager {
|
|
|
176
223
|
* @private
|
|
177
224
|
*/
|
|
178
225
|
#getTokenRedisPattern(email) {
|
|
179
|
-
return `${this.#config.
|
|
226
|
+
return `${this.#config.SESSION_KEY}:t:${email}:*`;
|
|
180
227
|
}
|
|
181
228
|
|
|
182
229
|
/**
|
|
@@ -189,36 +236,32 @@ export class SessionManager {
|
|
|
189
236
|
|
|
190
237
|
/**
|
|
191
238
|
* Generate and store JWT token in Redis
|
|
239
|
+
* - JWT payload contains only { email, tid } for minimal size
|
|
240
|
+
* - Full user data is stored in Redis as single source of truth
|
|
192
241
|
* @param {object} user User object
|
|
193
242
|
* @returns {Promise<string>} Returns the generated JWT token
|
|
194
243
|
* @private
|
|
195
244
|
*/
|
|
196
245
|
async #generateAndStoreToken(user) {
|
|
197
246
|
// Generate unique token ID for this device/session
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
// Create JWT token with email and
|
|
247
|
+
const tid = crypto.randomUUID();
|
|
248
|
+
const ttlSeconds = Math.floor(this.#config.SESSION_AGE / 1000);
|
|
249
|
+
// Create JWT token with only email and tid (minimal payload)
|
|
201
250
|
const token = await this.#jwtManager.encrypt(
|
|
202
|
-
{
|
|
203
|
-
email: user.email,
|
|
204
|
-
tokenId
|
|
205
|
-
},
|
|
251
|
+
{ email: user.email, tid },
|
|
206
252
|
this.#config.SESSION_SECRET,
|
|
207
|
-
{ expirationTime:
|
|
253
|
+
{ expirationTime: ttlSeconds }
|
|
208
254
|
);
|
|
209
|
-
|
|
255
|
+
|
|
210
256
|
// Store user data in Redis with TTL
|
|
211
|
-
const redisKey = this.#getTokenRedisKey(user.email,
|
|
212
|
-
|
|
213
|
-
|
|
257
|
+
const redisKey = this.#getTokenRedisKey(user.email, tid);
|
|
258
|
+
|
|
214
259
|
await this.#redisManager.getClient().setEx(
|
|
215
260
|
redisKey,
|
|
216
261
|
ttlSeconds,
|
|
217
262
|
JSON.stringify(user)
|
|
218
263
|
);
|
|
219
|
-
|
|
220
|
-
console.debug(`### TOKEN GENERATED: ${user.email} ###`);
|
|
221
|
-
|
|
264
|
+
this.#logger.debug(`### TOKEN GENERATED: ${user.email} ###`);
|
|
222
265
|
return token;
|
|
223
266
|
}
|
|
224
267
|
|
|
@@ -247,14 +290,14 @@ export class SessionManager {
|
|
|
247
290
|
this.#config.SESSION_SECRET
|
|
248
291
|
);
|
|
249
292
|
|
|
250
|
-
// Extract email and
|
|
251
|
-
const { email,
|
|
252
|
-
if (!email || !
|
|
293
|
+
// Extract email and token ID
|
|
294
|
+
const { email, tid } = payload;
|
|
295
|
+
if (!email || !tid) {
|
|
253
296
|
throw new CustomError(httpCodes.UNAUTHORIZED, 'Invalid token payload');
|
|
254
297
|
}
|
|
255
298
|
|
|
256
299
|
// Lookup user in Redis
|
|
257
|
-
const redisKey = this.#getTokenRedisKey(email,
|
|
300
|
+
const redisKey = this.#getTokenRedisKey(email, tid);
|
|
258
301
|
const userData = await this.#redisManager.getClient().get(redisKey);
|
|
259
302
|
|
|
260
303
|
if (!userData) {
|
|
@@ -275,7 +318,7 @@ export class SessionManager {
|
|
|
275
318
|
|
|
276
319
|
} catch (error) {
|
|
277
320
|
if (isDebugging) {
|
|
278
|
-
|
|
321
|
+
this.#logger.warn('### TOKEN VERIFICATION FAILED (debugging mode) ###', error.message);
|
|
279
322
|
return next();
|
|
280
323
|
}
|
|
281
324
|
|
|
@@ -331,11 +374,11 @@ export class SessionManager {
|
|
|
331
374
|
throw new CustomError(httpCodes.UNAUTHORIZED, 'User not authenticated');
|
|
332
375
|
}
|
|
333
376
|
|
|
334
|
-
// Extract
|
|
377
|
+
// Extract Token ID from current token
|
|
335
378
|
const authHeader = req.headers.authorization;
|
|
336
379
|
const token = authHeader?.substring(7);
|
|
337
380
|
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
338
|
-
const oldTokenId = payload
|
|
381
|
+
const { tid: oldTokenId } = payload;
|
|
339
382
|
|
|
340
383
|
// Check refresh lock
|
|
341
384
|
if (this.hasLock(email)) {
|
|
@@ -376,7 +419,7 @@ export class SessionManager {
|
|
|
376
419
|
const oldRedisKey = this.#getTokenRedisKey(email, oldTokenId);
|
|
377
420
|
await this.#redisManager.getClient().del(oldRedisKey);
|
|
378
421
|
|
|
379
|
-
|
|
422
|
+
this.#logger.debug('### TOKEN REFRESHED SUCCESSFULLY ###');
|
|
380
423
|
|
|
381
424
|
// Return new token
|
|
382
425
|
return res.json({
|
|
@@ -450,7 +493,7 @@ export class SessionManager {
|
|
|
450
493
|
await this.#redisManager.getClient().del(keys);
|
|
451
494
|
}
|
|
452
495
|
|
|
453
|
-
|
|
496
|
+
this.#logger.info(`### ALL TOKENS LOGGED OUT: ${email} (${keys.length} tokens) ###`);
|
|
454
497
|
|
|
455
498
|
if (isRedirect) {
|
|
456
499
|
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
@@ -461,7 +504,7 @@ export class SessionManager {
|
|
|
461
504
|
redirect_url: this.#config.SSO_SUCCESS_URL
|
|
462
505
|
});
|
|
463
506
|
} catch (error) {
|
|
464
|
-
|
|
507
|
+
this.#logger.error('### LOGOUT ALL TOKENS ERROR ###', error);
|
|
465
508
|
if (isRedirect) {
|
|
466
509
|
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
467
510
|
}
|
|
@@ -487,7 +530,7 @@ export class SessionManager {
|
|
|
487
530
|
}
|
|
488
531
|
|
|
489
532
|
try {
|
|
490
|
-
// Extract
|
|
533
|
+
// Extract Token ID from current token
|
|
491
534
|
const authHeader = req.headers.authorization;
|
|
492
535
|
const token = authHeader?.substring(7);
|
|
493
536
|
|
|
@@ -496,13 +539,17 @@ export class SessionManager {
|
|
|
496
539
|
}
|
|
497
540
|
|
|
498
541
|
const { payload } = await this.#jwtManager.decrypt(token, this.#config.SESSION_SECRET);
|
|
499
|
-
const { email,
|
|
542
|
+
const { email, tid } = payload;
|
|
543
|
+
|
|
544
|
+
if (!email || !tid) {
|
|
545
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid token payload');
|
|
546
|
+
}
|
|
500
547
|
|
|
501
548
|
// Remove token from Redis
|
|
502
|
-
const redisKey = this.#getTokenRedisKey(email,
|
|
549
|
+
const redisKey = this.#getTokenRedisKey(email, tid);
|
|
503
550
|
await this.#redisManager.getClient().del(redisKey);
|
|
504
551
|
|
|
505
|
-
|
|
552
|
+
this.#logger.info('### TOKEN LOGOUT SUCCESSFULLY ###');
|
|
506
553
|
|
|
507
554
|
if (isRedirect) {
|
|
508
555
|
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
@@ -513,7 +560,7 @@ export class SessionManager {
|
|
|
513
560
|
});
|
|
514
561
|
|
|
515
562
|
} catch (error) {
|
|
516
|
-
|
|
563
|
+
this.#logger.error('### TOKEN LOGOUT ERROR ###', error);
|
|
517
564
|
if (isRedirect) {
|
|
518
565
|
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
519
566
|
}
|
|
@@ -535,16 +582,16 @@ export class SessionManager {
|
|
|
535
582
|
try {
|
|
536
583
|
res.clearCookie('connect.sid');
|
|
537
584
|
} catch (error) {
|
|
538
|
-
|
|
539
|
-
|
|
585
|
+
this.#logger.error('### CLEAR COOKIE ERROR ###');
|
|
586
|
+
this.#logger.error(error);
|
|
540
587
|
}
|
|
541
588
|
return req.session.destroy((sessionError) => {
|
|
542
589
|
if (sessionError) {
|
|
543
|
-
|
|
544
|
-
|
|
590
|
+
this.#logger.error('### SESSION DESTROY CALLBACK ERROR ###');
|
|
591
|
+
this.#logger.error(sessionError);
|
|
545
592
|
return callback(sessionError);
|
|
546
593
|
}
|
|
547
|
-
|
|
594
|
+
this.#logger.info('### SESSION LOGOUT SUCCESSFULLY ###');
|
|
548
595
|
return callback(null);
|
|
549
596
|
});
|
|
550
597
|
}
|
|
@@ -573,7 +620,7 @@ export class SessionManager {
|
|
|
573
620
|
*/
|
|
574
621
|
#redisSession() {
|
|
575
622
|
// Redis Session
|
|
576
|
-
|
|
623
|
+
this.#logger.log('### Using Redis as the Session Store ###');
|
|
577
624
|
return session({
|
|
578
625
|
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
579
626
|
store: new RedisStore({ client: this.#redisManager.getClient(), prefix: this.#config.SESSION_PREFIX, disableTouch: true }),
|
|
@@ -588,7 +635,7 @@ export class SessionManager {
|
|
|
588
635
|
*/
|
|
589
636
|
#memorySession() {
|
|
590
637
|
// Memory Session
|
|
591
|
-
|
|
638
|
+
this.#logger.log('### Using Memory as the Session Store ###');
|
|
592
639
|
const MemoryStore = memStore(session);
|
|
593
640
|
return session({
|
|
594
641
|
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
@@ -675,13 +722,13 @@ export class SessionManager {
|
|
|
675
722
|
/** @type {{ payload: { user: import('../models/types/user').UserModel, redirect_url: string } }} */
|
|
676
723
|
const { payload } = await this.#jwtManager.decrypt(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
677
724
|
if (payload?.user) {
|
|
678
|
-
|
|
725
|
+
this.#logger.debug('### CALLBACK USER ###');
|
|
679
726
|
request.session[this.#getSessionKey()] = initUser(payload.user);
|
|
680
727
|
return new Promise((resolve, reject) => {
|
|
681
728
|
request.session.touch().save((err) => {
|
|
682
729
|
if (err) {
|
|
683
|
-
|
|
684
|
-
|
|
730
|
+
this.#logger.error('### SESSION SAVE ERROR ###');
|
|
731
|
+
this.#logger.error(err);
|
|
685
732
|
return reject(new CustomError(httpCodes.SYSTEM_FAILURE, 'Session failed to save', err));
|
|
686
733
|
}
|
|
687
734
|
return resolve(payload);
|
|
@@ -711,7 +758,9 @@ export class SessionManager {
|
|
|
711
758
|
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
|
|
712
759
|
}
|
|
713
760
|
|
|
761
|
+
/** @type {import('../index.js').SessionUser} */
|
|
714
762
|
const user = initUser(payload.user);
|
|
763
|
+
/** @type {string} */
|
|
715
764
|
const redirectUrl = payload.redirect_url || this.#config.SSO_SUCCESS_URL;
|
|
716
765
|
|
|
717
766
|
// Check SESSION_MODE to determine response type
|
|
@@ -719,57 +768,26 @@ export class SessionManager {
|
|
|
719
768
|
// Token-based: Generate token and return HTML page that stores it
|
|
720
769
|
const token = await this.#generateAndStoreToken(user);
|
|
721
770
|
|
|
722
|
-
|
|
771
|
+
this.#logger.debug('### CALLBACK TOKEN GENERATED ###');
|
|
723
772
|
|
|
773
|
+
const templatePath = this.#config.TOKEN_STORAGE_TEMPLATE_PATH || path.resolve(__dirname, 'assets', 'template.html');
|
|
724
774
|
// 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);
|
|
775
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
776
|
+
const html = template
|
|
777
|
+
.replaceAll('{{SESSION_DATA_KEY}}', this.#config.SESSION_KEY)
|
|
778
|
+
.replaceAll('{{SESSION_DATA_VALUE}}', token)
|
|
779
|
+
.replaceAll('{{SESSION_EXPIRY_KEY}}', this.#config.SESSION_EXPIRY_KEY)
|
|
780
|
+
.replaceAll('{{SESSION_EXPIRY_VALUE}}', user.attributes.expires_at)
|
|
781
|
+
.replaceAll('{{SSO_SUCCESS_URL}}', redirectUrl)
|
|
782
|
+
.replaceAll('{{SSO_FAILURE_URL}}', this.#config.SSO_FAILURE_URL);
|
|
783
|
+
return res.send(html);
|
|
769
784
|
}
|
|
785
|
+
// Session-based: Save to session and redirect
|
|
786
|
+
await this.#saveSession(req, jwt, initUser);
|
|
787
|
+
return res.redirect(redirectUrl);
|
|
770
788
|
}
|
|
771
789
|
catch (error) {
|
|
772
|
-
|
|
790
|
+
this.#logger.error('### CALLBACK ERROR ###', error);
|
|
773
791
|
return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(error.message)));
|
|
774
792
|
}
|
|
775
793
|
};
|
|
@@ -832,8 +850,8 @@ export class SessionManager {
|
|
|
832
850
|
// Session-based authentication is already single-instance per cookie
|
|
833
851
|
return this.#logoutSession(req, res, (error) => {
|
|
834
852
|
if (error) {
|
|
835
|
-
|
|
836
|
-
|
|
853
|
+
this.#logger.error('### LOGOUT CALLBACK ERROR ###');
|
|
854
|
+
this.#logger.error(error);
|
|
837
855
|
if (isRedirect) {
|
|
838
856
|
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
839
857
|
}
|
package/index.d.ts
CHANGED
|
@@ -7,6 +7,70 @@ import { Application, RequestHandler, Request, Response, NextFunction, Router }
|
|
|
7
7
|
|
|
8
8
|
export { JWTPayload } from 'jose';
|
|
9
9
|
|
|
10
|
+
// Logger class for configurable logging
|
|
11
|
+
export class Logger {
|
|
12
|
+
/**
|
|
13
|
+
* Get or create a Logger instance (singleton pattern)
|
|
14
|
+
* @param componentName Component name for log prefix
|
|
15
|
+
* @param enableLogging Enable/disable logging (defaults to NODE_ENV !== 'production')
|
|
16
|
+
* @returns Logger instance
|
|
17
|
+
*/
|
|
18
|
+
static getInstance(componentName: string, enableLogging?: boolean): Logger;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Clear all logger instances (useful for testing)
|
|
22
|
+
*/
|
|
23
|
+
static clearInstances(): void;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Disable colors globally for all logger instances
|
|
27
|
+
*/
|
|
28
|
+
static disableColors(): void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Enable colors globally for all logger instances
|
|
32
|
+
*/
|
|
33
|
+
static enableColors(): void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a new Logger instance (backward compatibility)
|
|
37
|
+
* Note: Use Logger.getInstance() for singleton pattern
|
|
38
|
+
* @param componentName Component name for log prefix
|
|
39
|
+
* @param enableLogging Enable/disable logging (defaults to NODE_ENV !== 'production')
|
|
40
|
+
*/
|
|
41
|
+
constructor(componentName: string, enableLogging?: boolean);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Log debug message
|
|
45
|
+
* @param args Arguments to log
|
|
46
|
+
*/
|
|
47
|
+
debug(...args: any[]): void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Log info message
|
|
51
|
+
* @param args Arguments to log
|
|
52
|
+
*/
|
|
53
|
+
info(...args: any[]): void;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Log warning message
|
|
57
|
+
* @param args Arguments to log
|
|
58
|
+
*/
|
|
59
|
+
warn(...args: any[]): void;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Log error message
|
|
63
|
+
* @param args Arguments to log
|
|
64
|
+
*/
|
|
65
|
+
error(...args: any[]): void;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Log general message
|
|
69
|
+
* @param args Arguments to log
|
|
70
|
+
*/
|
|
71
|
+
log(...args: any[]): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
10
74
|
// Session Mode constants
|
|
11
75
|
export const SessionMode: {
|
|
12
76
|
SESSION: string;
|
|
@@ -29,13 +93,15 @@ export interface SessionConfig {
|
|
|
29
93
|
SESSION_COOKIE_PATH?: string;
|
|
30
94
|
SESSION_SECRET?: string;
|
|
31
95
|
SESSION_PREFIX?: string;
|
|
96
|
+
SESSION_KEY?: string;
|
|
97
|
+
SESSION_EXPIRY_KEY?: string;
|
|
98
|
+
TOKEN_STORAGE_TEMPLATE_PATH?: string;
|
|
32
99
|
|
|
33
100
|
REDIS_URL?: string;
|
|
34
101
|
REDIS_CERT_PATH?: string;
|
|
35
102
|
|
|
36
103
|
JWT_ALGORITHM?: string;
|
|
37
104
|
JWT_ENCRYPTION?: string;
|
|
38
|
-
JWT_EXPIRATION_TIME?: string;
|
|
39
105
|
JWT_CLOCK_TOLERANCE?: number;
|
|
40
106
|
JWT_SECRET_HASH_ALGORITHM?: string;
|
|
41
107
|
JWT_ISSUER?: string;
|
|
@@ -241,32 +307,29 @@ export class RedisManager {
|
|
|
241
307
|
* Disconnect from Redis
|
|
242
308
|
* @returns Returns nothing
|
|
243
309
|
*/
|
|
244
|
-
|
|
310
|
+
disconnect(): Promise<void>;
|
|
245
311
|
}
|
|
246
312
|
|
|
247
313
|
// JWT Manager Configuration - uses strict UPPERCASE naming convention with JWT_ prefix for all property names
|
|
248
314
|
export interface JwtManagerOptions {
|
|
249
315
|
/** JWE algorithm (default: 'dir') */
|
|
250
316
|
JWT_ALGORITHM?: string;
|
|
251
|
-
|
|
317
|
+
|
|
252
318
|
/** JWE encryption method (default: 'A256GCM') */
|
|
253
319
|
JWT_ENCRYPTION?: string;
|
|
254
|
-
|
|
255
|
-
/** Token expiration time (default: '10m') */
|
|
256
|
-
JWT_EXPIRATION_TIME?: string;
|
|
257
|
-
|
|
320
|
+
|
|
258
321
|
/** Clock tolerance in seconds for token validation (default: 30) */
|
|
259
322
|
JWT_CLOCK_TOLERANCE?: number;
|
|
260
|
-
|
|
323
|
+
|
|
261
324
|
/** Hash algorithm for secret derivation (default: 'SHA-256') */
|
|
262
325
|
JWT_SECRET_HASH_ALGORITHM?: string;
|
|
263
|
-
|
|
326
|
+
|
|
264
327
|
/** Optional JWT issuer claim */
|
|
265
328
|
JWT_ISSUER?: string;
|
|
266
|
-
|
|
329
|
+
|
|
267
330
|
/** Optional JWT audience claim */
|
|
268
331
|
JWT_AUDIENCE?: string;
|
|
269
|
-
|
|
332
|
+
|
|
270
333
|
/** Optional JWT subject claim */
|
|
271
334
|
JWT_SUBJECT?: string;
|
|
272
335
|
}
|
|
@@ -282,7 +345,7 @@ export interface JwtEncryptOptions {
|
|
|
282
345
|
encryption?: string;
|
|
283
346
|
|
|
284
347
|
/** Override default expiration time */
|
|
285
|
-
expirationTime?:
|
|
348
|
+
expirationTime?: number;
|
|
286
349
|
|
|
287
350
|
/** Override default hash algorithm */
|
|
288
351
|
secretHashAlgorithm?: string;
|
|
@@ -323,7 +386,7 @@ export type JwtDecryptResult = JWTDecryptResult<EncryptJWT>;
|
|
|
323
386
|
export class JwtManager {
|
|
324
387
|
algorithm: string;
|
|
325
388
|
encryption: string;
|
|
326
|
-
expirationTime:
|
|
389
|
+
expirationTime: number;
|
|
327
390
|
clockTolerance: number;
|
|
328
391
|
secretHashAlgorithm: string;
|
|
329
392
|
issuer?: string;
|
package/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { httpCodes, httpMessages, httpErrorHandler, httpNotFoundHandler, CustomE
|
|
|
3
3
|
export { RedisManager } from './components/redis.js';
|
|
4
4
|
export { FlexRouter } from './components/router.js';
|
|
5
5
|
export { JwtManager } from './components/jwt.js';
|
|
6
|
+
export { Logger } from './components/logger.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igxjs/node-components",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Node components for igxjs",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"axios": "^1.13.6",
|
|
27
27
|
"connect-redis": "^9.0.0",
|
|
28
28
|
"express-session": "^1.19.0",
|
|
29
|
-
"jose": "^6.2.
|
|
29
|
+
"jose": "^6.2.1",
|
|
30
30
|
"memorystore": "^1.6.7"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"chai": "^6.2.2",
|
|
34
34
|
"express": "^5.2.1",
|
|
35
35
|
"mocha": "^12.0.0-beta-10",
|
|
36
|
-
"sinon": "^21.0.
|
|
36
|
+
"sinon": "^21.0.3",
|
|
37
37
|
"supertest": "^7.0.0"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|