@igxjs/node-components 1.0.0
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/.github/workflows/node.js.yml +31 -0
- package/.github/workflows/npm-publish.yml +33 -0
- package/LICENSE +201 -0
- package/README.md +378 -0
- package/components/http-handlers.js +167 -0
- package/components/redis.js +76 -0
- package/components/router.js +54 -0
- package/components/session.js +376 -0
- package/index.d.ts +289 -0
- package/index.js +8 -0
- package/package.json +43 -0
- package/tests/http-handlers.test.js +21 -0
- package/tests/redis.test.js +50 -0
- package/tests/router.test.js +50 -0
- package/tests/session.test.js +116 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { STATUS_CODES } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export const httpMessages = {
|
|
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
|
+
SYSTEM_FAILURE: 'System Error',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const httpCodes = {
|
|
17
|
+
OK: 200,
|
|
18
|
+
CREATED: 201,
|
|
19
|
+
NO_CONTENT: 201,
|
|
20
|
+
BAD_REQUEST: 400,
|
|
21
|
+
UNAUTHORIZED: 401,
|
|
22
|
+
FORBIDDEN: 403,
|
|
23
|
+
NOT_FOUND: 404,
|
|
24
|
+
NOT_ACCEPTABLE: 406,
|
|
25
|
+
CONFLICT: 409,
|
|
26
|
+
SYSTEM_FAILURE: 500,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class CustomError extends Error {
|
|
30
|
+
/** @type {number} */
|
|
31
|
+
code;
|
|
32
|
+
/** @type {object} */
|
|
33
|
+
data;
|
|
34
|
+
/** @type {object} */
|
|
35
|
+
error;
|
|
36
|
+
/**
|
|
37
|
+
* Construct a custom error
|
|
38
|
+
* @param {number} code Error code
|
|
39
|
+
* @param {string} message Message
|
|
40
|
+
*/
|
|
41
|
+
constructor(code, message, error = {}, data = {}) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.error = error;
|
|
45
|
+
this.data = data;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Custom error handler
|
|
51
|
+
*
|
|
52
|
+
* @param {CustomError} err Error
|
|
53
|
+
* @param {import('@types/express').Request} req Request
|
|
54
|
+
* @param {import('@types/express').Response} res Response
|
|
55
|
+
* @param {import('@types/express').NextFunction} next Next
|
|
56
|
+
*/
|
|
57
|
+
export const httpErrorHandler = (err, req, res, next) => {
|
|
58
|
+
// If no error, pass to next middleware
|
|
59
|
+
if (!err) {
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build response object with defaults
|
|
64
|
+
const responseBody = {
|
|
65
|
+
status: err.code || httpCodes.BAD_REQUEST,
|
|
66
|
+
message: err.message || httpMessages.BAD_REQUEST,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Merge additional error data if present
|
|
70
|
+
if (err.data && typeof err.data === 'object') {
|
|
71
|
+
Object.entries(err.data).forEach(([key, value]) => {
|
|
72
|
+
responseBody[key] = value;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Set CORS and custom headers
|
|
77
|
+
res.header('Access-Control-Expose-Headers', '*');
|
|
78
|
+
res.header('X-IBM-Override-Error-Pages', 'not-found, client-error, server-error');
|
|
79
|
+
|
|
80
|
+
// Send error response
|
|
81
|
+
res.status(responseBody.status).json(responseBody);
|
|
82
|
+
|
|
83
|
+
// Log error details
|
|
84
|
+
console.error('### ERROR ###');
|
|
85
|
+
console.error(`${req.method} ${req.path}`);
|
|
86
|
+
|
|
87
|
+
// Log based on error type
|
|
88
|
+
if ([httpCodes.UNAUTHORIZED, httpCodes.FORBIDDEN, httpCodes.NOT_FOUND].includes(err.code)) {
|
|
89
|
+
console.error('>>> Auth Error:', err.message);
|
|
90
|
+
} else {
|
|
91
|
+
console.error('>>> Name:', err.name);
|
|
92
|
+
console.error('>>> Message:', err.message);
|
|
93
|
+
if (err.stack) {
|
|
94
|
+
console.error('>>> Stack:', err.stack);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.error('### /ERROR ###');
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract HTTP status code from error
|
|
103
|
+
* @param {Error} error Error object
|
|
104
|
+
* @returns {number} HTTP status code
|
|
105
|
+
*/
|
|
106
|
+
const _getErrorCode = (error) => {
|
|
107
|
+
const statusCode = error.response?.status;
|
|
108
|
+
// Validate it's a valid HTTP status code
|
|
109
|
+
if (statusCode && typeof statusCode === 'number' && Object.hasOwn(STATUS_CODES, statusCode)) {
|
|
110
|
+
return statusCode;
|
|
111
|
+
}
|
|
112
|
+
return httpCodes.SYSTEM_FAILURE;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract error message from error
|
|
117
|
+
* @param {Error} error Error object
|
|
118
|
+
* @param {string} defaultMessage Default message
|
|
119
|
+
* @returns {string} Error message
|
|
120
|
+
*/
|
|
121
|
+
const _getErrorMessage = (error, defaultMessage) => {
|
|
122
|
+
// Priority: response.data.message > response.statusText > error.message > default
|
|
123
|
+
return error.response?.data?.message
|
|
124
|
+
|| error.response?.statusText
|
|
125
|
+
|| error.message
|
|
126
|
+
|| defaultMessage;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const httpHelper = {
|
|
130
|
+
format (str, ...args) {
|
|
131
|
+
const matched = str.match(/{\d}/ig);
|
|
132
|
+
matched.forEach((element, index) => {
|
|
133
|
+
if(args.length > index)
|
|
134
|
+
str = str.replace(element, args[index]);
|
|
135
|
+
});
|
|
136
|
+
return str;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate friendly Zod message
|
|
141
|
+
* @param {Error} error Zod error
|
|
142
|
+
* @returns {string} Returns friendly Zod message
|
|
143
|
+
*/
|
|
144
|
+
toZodMessage(error) {
|
|
145
|
+
return error.issues
|
|
146
|
+
.map(issue => {
|
|
147
|
+
const path = issue.path.join('.');
|
|
148
|
+
return (path ? '['.concat(path).concat('] ').concat('') : '').concat(issue.message);
|
|
149
|
+
})
|
|
150
|
+
.join('; ');
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Try to analyze axios Error
|
|
155
|
+
* @param {Error | import('axios').AxiosError} error Error object
|
|
156
|
+
* @param {string} defaultMessage Default error message
|
|
157
|
+
* @returns {CustomError} Returns CustomError instance
|
|
158
|
+
*/
|
|
159
|
+
handleAxiosError(error, defaultMessage = 'An error occurred') {
|
|
160
|
+
console.warn(`### TRY ERROR: ${defaultMessage} ###`);
|
|
161
|
+
// Extract error details
|
|
162
|
+
const errorCode = _getErrorCode(error);
|
|
163
|
+
const errorMessage = _getErrorMessage(error, defaultMessage);
|
|
164
|
+
const errorData = error.response?.data || {};
|
|
165
|
+
return new CustomError(errorCode, errorMessage, error, errorData);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { createClient } from '@redis/client';
|
|
4
|
+
|
|
5
|
+
export class RedisManager {
|
|
6
|
+
/** @type {import('@redis/client').RedisClientType} */
|
|
7
|
+
#client = null;
|
|
8
|
+
/**
|
|
9
|
+
* Connect with Redis
|
|
10
|
+
* @returns {Promise<boolean>} Returns `true` if Redis server is connected
|
|
11
|
+
*/
|
|
12
|
+
async connect(redisUrl, certPath) {
|
|
13
|
+
if (redisUrl?.length > 0) {
|
|
14
|
+
try {
|
|
15
|
+
/** @type {import('@redis/client').RedisClientOptions} */
|
|
16
|
+
const options = {
|
|
17
|
+
url: redisUrl,
|
|
18
|
+
socket: { tls: redisUrl.includes('rediss:'), ca: null },
|
|
19
|
+
};
|
|
20
|
+
if(options.socket.tls) {
|
|
21
|
+
const caCert = fs.readFileSync(certPath);
|
|
22
|
+
options.socket.ca = caCert;
|
|
23
|
+
}
|
|
24
|
+
this.#client = createClient(options);
|
|
25
|
+
this.#client.on('ready', () => {
|
|
26
|
+
console.info('### REDIS READY ###');
|
|
27
|
+
});
|
|
28
|
+
this.#client.on('reconnecting', (_res) => {
|
|
29
|
+
console.warn('### REDIS RECONNECTING ###');
|
|
30
|
+
});
|
|
31
|
+
this.#client.on('error', (error) => {
|
|
32
|
+
console.error(`### REDIS ERROR: ${error.message} ###`);
|
|
33
|
+
});
|
|
34
|
+
await this.#client.connect();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error('### REDIS CONNECT ERROR ###');
|
|
39
|
+
console.error(error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get Redis client
|
|
47
|
+
* @returns {import('@redis/client').RedisClientType} Returns Redis client
|
|
48
|
+
*/
|
|
49
|
+
getClient() {
|
|
50
|
+
return this.#client;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Determine if the Redis server is connected
|
|
55
|
+
* @returns {boolean} Returns `true` if Redis server is connected
|
|
56
|
+
*/
|
|
57
|
+
async isConnected() {
|
|
58
|
+
if (this.#client)
|
|
59
|
+
try {
|
|
60
|
+
const pongMessage = await this.#client.ping();
|
|
61
|
+
return 'PONG' === pongMessage;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(`### REDIS PING ERROR ###`);
|
|
64
|
+
console.error(error);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Disconnect with Redis
|
|
71
|
+
* @returns {Promise<void>} Returns nothing
|
|
72
|
+
*/
|
|
73
|
+
disConnect() {
|
|
74
|
+
return this.#client.quit();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexRouter for expressjs
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import { Router } from 'express';
|
|
6
|
+
* import { FlexRouter } from '../models/router.js';
|
|
7
|
+
* import { authenticate } from '../middlewares/common.js';
|
|
8
|
+
*
|
|
9
|
+
* export const publicRouter = Router();
|
|
10
|
+
* export const privateRouter = Router();
|
|
11
|
+
*
|
|
12
|
+
* publicRouter.get('/health', (req, res) => {
|
|
13
|
+
* res.send('OK');
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* privateRouter.get('/', (req, res) => {
|
|
17
|
+
* res.send('Hello World!');
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* export const routers = [
|
|
21
|
+
* new FlexRouter('/api/v1/protected', privateRouter, [authenticate]),
|
|
22
|
+
* new FlexRouter('/api/v1/public', healthRouter),
|
|
23
|
+
* ];
|
|
24
|
+
*/
|
|
25
|
+
export class FlexRouter {
|
|
26
|
+
/** @type {string} */
|
|
27
|
+
context = '';
|
|
28
|
+
/** @type {import('@types/express').Router} */
|
|
29
|
+
router = null;
|
|
30
|
+
/** @type {import('@types/express').RequestHandler[]} */
|
|
31
|
+
handlers = [];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Constructor
|
|
35
|
+
* @param {string} context Context path
|
|
36
|
+
* @param {import('@types/express').Router} router Router instance
|
|
37
|
+
* @param {import('@types/express').RequestHandler[]} handlers Request handlers
|
|
38
|
+
*/
|
|
39
|
+
constructor(context, router, handlers = []) {
|
|
40
|
+
this.context = context;
|
|
41
|
+
this.router = router;
|
|
42
|
+
this.handlers = handlers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mount router
|
|
47
|
+
* @param {import('@types/express').Express} app Express app
|
|
48
|
+
* @param {string} basePath Base path
|
|
49
|
+
*/
|
|
50
|
+
mount(app, basePath) {
|
|
51
|
+
const path = basePath.concat(this.context);
|
|
52
|
+
app.use(path, this.handlers, this.router);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import session from 'express-session';
|
|
3
|
+
import { jwtDecrypt } from 'jose';
|
|
4
|
+
import { RedisStore } from 'connect-redis';
|
|
5
|
+
import memStore from 'memorystore';
|
|
6
|
+
|
|
7
|
+
import { CustomError, httpCodes, httpHelper, httpMessages } from './http-handlers.js';
|
|
8
|
+
import { RedisManager } from './redis.js';
|
|
9
|
+
|
|
10
|
+
const MemoryStore = memStore(session);
|
|
11
|
+
|
|
12
|
+
export class SessionConfig {
|
|
13
|
+
/** @type {string} */
|
|
14
|
+
SSO_ENDPOINT_URL;
|
|
15
|
+
/** @type {string} */
|
|
16
|
+
SSO_CLIENT_ID;
|
|
17
|
+
/** @type {string} */
|
|
18
|
+
SSO_CLIENT_SECRET;
|
|
19
|
+
/** @type {string} */
|
|
20
|
+
SSO_SUCCESS_URL;
|
|
21
|
+
/** @type {string} */
|
|
22
|
+
SSO_FAILURE_URL;
|
|
23
|
+
|
|
24
|
+
/** @type {number} */
|
|
25
|
+
SESSION_AGE;
|
|
26
|
+
/** @type {string} */
|
|
27
|
+
SESSION_COOKIE_PATH;
|
|
28
|
+
/** @type {string} */
|
|
29
|
+
SESSION_SECRET;
|
|
30
|
+
/** @type {string} */
|
|
31
|
+
SESSION_PREFIX;
|
|
32
|
+
|
|
33
|
+
/** @type {string} */
|
|
34
|
+
REDIS_URL;
|
|
35
|
+
/** @type {string} */
|
|
36
|
+
REDIS_CERT_PATH;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class SessionManager {
|
|
40
|
+
/** @type {SessionConfig} */
|
|
41
|
+
#config = null;
|
|
42
|
+
/** @type {Map<string, number>} */
|
|
43
|
+
#sessionRefreshLocks = new Map();
|
|
44
|
+
/** @type {import('./redis.js').RedisManager} */
|
|
45
|
+
#redisManager = null;
|
|
46
|
+
/** @type {import('axios').AxiosInstance} */
|
|
47
|
+
#idpRequest = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if the email has a session refresh lock
|
|
51
|
+
* @param {string} email Email address
|
|
52
|
+
* @returns {boolean} Returns true if the email has a session refresh lock
|
|
53
|
+
*/
|
|
54
|
+
hasLock(email) {
|
|
55
|
+
return this.#sessionRefreshLocks.has(email) && this.#sessionRefreshLocks.get(email) > Date.now();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Lock the email for session refresh
|
|
60
|
+
* @param {string} email Email address
|
|
61
|
+
*/
|
|
62
|
+
lock(email) {
|
|
63
|
+
if (email) {
|
|
64
|
+
this.#sessionRefreshLocks.set(email, Date.now() + 60000);
|
|
65
|
+
}
|
|
66
|
+
this.clearLocks();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clear session refresh locks
|
|
71
|
+
*/
|
|
72
|
+
clearLocks() {
|
|
73
|
+
return setTimeout(() => {
|
|
74
|
+
for (const email of this.#sessionRefreshLocks.keys()) {
|
|
75
|
+
if (this.hasLock(email)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
this.#sessionRefreshLocks.delete(email);
|
|
79
|
+
}
|
|
80
|
+
}, 1000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get session key
|
|
85
|
+
* @returns {string} Returns the session key
|
|
86
|
+
*/
|
|
87
|
+
#getSessionKey() {
|
|
88
|
+
return 'user';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get RedisManager instance
|
|
93
|
+
* @returns {import('./redis.js').RedisManager} Returns the RedisManager instance
|
|
94
|
+
*/
|
|
95
|
+
redisManager() {
|
|
96
|
+
return this.#redisManager;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Setup the session/user handlers with configurations
|
|
101
|
+
* @param {import('@types/express').Application} app Express application
|
|
102
|
+
* @param {SessionConfig} config Redis configurations
|
|
103
|
+
* @param {(user: object) => object} updateUser Update user object if user should have proper attributes, e.g. permissions, avatar URL
|
|
104
|
+
*/
|
|
105
|
+
async setup(app, config, updateUser) {
|
|
106
|
+
this.#redisManager = new RedisManager();
|
|
107
|
+
this.#config = {
|
|
108
|
+
// Session
|
|
109
|
+
SESSION_AGE: config.SESSION_AGE || 64800000,
|
|
110
|
+
SESSION_COOKIE_PATH: config.SESSION_COOKIE_PATH || '/',
|
|
111
|
+
SESSION_SECRET: config.SESSION_SECRET,
|
|
112
|
+
SESSION_PREFIX: config.SESSION_PREFIX || 'ibmid:',
|
|
113
|
+
// Identity Provider
|
|
114
|
+
SSO_ENDPOINT_URL: config.SSO_ENDPOINT_URL,
|
|
115
|
+
SSO_CLIENT_ID: config.SSO_CLIENT_ID,
|
|
116
|
+
SSO_CLIENT_SECRET: config.SSO_CLIENT_SECRET,
|
|
117
|
+
SSO_SUCCESS_URL: config.SSO_SUCCESS_URL,
|
|
118
|
+
SSO_FAILURE_URL: config.SSO_FAILURE_URL,
|
|
119
|
+
// Redis
|
|
120
|
+
REDIS_URL: config.REDIS_URL,
|
|
121
|
+
REDIS_CERT_PATH: config.REDIS_CERT_PATH,
|
|
122
|
+
};
|
|
123
|
+
// Identity Provider Request
|
|
124
|
+
this.#idpRequest = axios.create({
|
|
125
|
+
baseURL: this.#config.SSO_ENDPOINT_URL,
|
|
126
|
+
timeout: 30000,
|
|
127
|
+
});
|
|
128
|
+
app.set('trust proxy', 1);
|
|
129
|
+
app.use(await this.sessionHandler());
|
|
130
|
+
app.use(this.#userHandler(updateUser));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get Redis session RequestHandler
|
|
135
|
+
* @returns {import('@types/express').RequestHandler} Returns RequestHandler instance of Express
|
|
136
|
+
*/
|
|
137
|
+
#redisSession() {
|
|
138
|
+
// Redis Session
|
|
139
|
+
console.log('### Using Redis as the Session Store ###');
|
|
140
|
+
return session({
|
|
141
|
+
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
142
|
+
store: new RedisStore({ client: this.#redisManager.getClient(), prefix: this.#config.SESSION_PREFIX, disableTouch: true }),
|
|
143
|
+
resave: false, saveUninitialized: false,
|
|
144
|
+
secret: this.#config.SESSION_SECRET,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get Memory session RequestHandler
|
|
150
|
+
* @returns {import('@types/express').RequestHandler} Returns RequestHandler instance of Express
|
|
151
|
+
*/
|
|
152
|
+
#memorySession() {
|
|
153
|
+
// Memory Session
|
|
154
|
+
console.log('### Using Memory as the Session Store ###');
|
|
155
|
+
return session({
|
|
156
|
+
cookie: { maxAge: this.#config.SESSION_AGE, path: this.#config.SESSION_COOKIE_PATH, sameSite: false },
|
|
157
|
+
store: new MemoryStore({}),
|
|
158
|
+
resave: false, saveUninitialized: false,
|
|
159
|
+
secret: this.#config.SESSION_SECRET,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get session RequestHandler
|
|
165
|
+
* @returns {Promise<import('@types/express').RequestHandler>} Returns RequestHandler instance of Express
|
|
166
|
+
*/
|
|
167
|
+
async sessionHandler() {
|
|
168
|
+
if(this.#config.REDIS_URL?.length > 0) {
|
|
169
|
+
await this.#redisManager.connect(this.#config.REDIS_URL, this.#config.REDIS_CERT_PATH);
|
|
170
|
+
return this.#redisSession();
|
|
171
|
+
}
|
|
172
|
+
return this.#memorySession();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* User HTTP Handler
|
|
177
|
+
* @param {(user: object) => object} updateUser User wrapper
|
|
178
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
179
|
+
*/
|
|
180
|
+
#userHandler (updateUser) {
|
|
181
|
+
return (req, res, next) => {
|
|
182
|
+
req.user = req.session[this.#getSessionKey()];
|
|
183
|
+
/** @type {import('@types/express').Request & { user: object }} Session user */
|
|
184
|
+
res.locals.user = updateUser(req.user);
|
|
185
|
+
return next();
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resource protection
|
|
191
|
+
* @param {boolean} [isDebugging=false] Debugging flag
|
|
192
|
+
* @param {boolean} [redirectUrl=''] Redirect flag
|
|
193
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
194
|
+
*/
|
|
195
|
+
authenticate (isDebugging = false, redirectUrl = '') {
|
|
196
|
+
return async (req, res, next) => {
|
|
197
|
+
/** @type {{ authorized: boolean }} */
|
|
198
|
+
const { authorized = isDebugging } = req.user ?? { authorized: isDebugging };
|
|
199
|
+
if (authorized) {
|
|
200
|
+
return next();
|
|
201
|
+
}
|
|
202
|
+
if(redirectUrl) {
|
|
203
|
+
return res.redirect(redirectUrl);
|
|
204
|
+
}
|
|
205
|
+
return next(new CustomError(httpCodes.UNAUTHORIZED, httpMessages.UNAUTHORIZED));
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Save session
|
|
211
|
+
* @param {import('@types/express').Request} request Request object
|
|
212
|
+
* @param {string} jwt JWT
|
|
213
|
+
* @param {(user: object) => object} initUser Redirect URL
|
|
214
|
+
* @returns {Promise<{ user: import('../models/types/user').UserModel, redirect_url: string }>} Promise
|
|
215
|
+
*/
|
|
216
|
+
#saveSession = async (request, jwt, initUser) => {
|
|
217
|
+
/** @type {{ payload: { user: import('../models/types/user').UserModel, redirect_url: string } }} */
|
|
218
|
+
const { payload } = await this.#decryptJWT(jwt, this.#config.SSO_CLIENT_SECRET);
|
|
219
|
+
if (payload?.user) {
|
|
220
|
+
console.debug('### CALLBACK USER ###');
|
|
221
|
+
request.session[this.#getSessionKey()] = initUser(payload.user);
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
request.session.touch().save((err) => {
|
|
224
|
+
if (err) {
|
|
225
|
+
console.error('### SESSION SAVE ERROR ###');
|
|
226
|
+
console.error(err);
|
|
227
|
+
return reject(new CustomError(httpCodes.SYSTEM_FAILURE, 'Session failed to save', err));
|
|
228
|
+
}
|
|
229
|
+
return resolve(payload);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
throw new CustomError(httpCodes.BAD_REQUEST, 'Invalid JWT payload');
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* SSO callback for successful login
|
|
238
|
+
* @param {(user: object) => object} initUser Initialize user object function
|
|
239
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
240
|
+
*/
|
|
241
|
+
callback(initUser) {
|
|
242
|
+
return async (req, res, next) => {
|
|
243
|
+
const { jwt = '' } = req.query;
|
|
244
|
+
if(!jwt) {
|
|
245
|
+
return next(new CustomError(httpCodes.BAD_REQUEST, 'Missing `jwt` in query parameters'));
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const payload = await this.#saveSession(req, jwt, initUser);
|
|
249
|
+
return res.redirect(payload?.redirect_url ? payload.redirect_url : this.#config.SSO_SUCCESS_URL);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
console.error('### LOGIN ERROR ###');
|
|
253
|
+
console.error(error);
|
|
254
|
+
return res.redirect(this.#config.SSO_FAILURE_URL.concat('?message=').concat(encodeURIComponent(error.message)));
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get Identity Providers
|
|
261
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
262
|
+
*/
|
|
263
|
+
identityProviders() {
|
|
264
|
+
const idpUrl = '/auth/providers'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
|
|
265
|
+
return async (_req, res, next) => {
|
|
266
|
+
try {
|
|
267
|
+
const response = await this.#idpRequest.get(idpUrl);
|
|
268
|
+
if(response.status === httpCodes.OK) {
|
|
269
|
+
return res.json(response.data);
|
|
270
|
+
}
|
|
271
|
+
throw new CustomError(response.status, response.statusText);
|
|
272
|
+
}
|
|
273
|
+
catch(error) {
|
|
274
|
+
return next(httpHelper.handleAxiosError(error));
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Application logout (NOT SSO)
|
|
281
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
282
|
+
*/
|
|
283
|
+
logout() {
|
|
284
|
+
return (req, res) => {
|
|
285
|
+
const { redirect = false } = req.query;
|
|
286
|
+
const isRedirect = (redirect === 'true' || redirect === true);
|
|
287
|
+
return this.#logout(req, res, (error => {
|
|
288
|
+
if (error) {
|
|
289
|
+
console.error('### LOGOUT CALLBACK ERROR ###');
|
|
290
|
+
console.error(error);
|
|
291
|
+
if (isRedirect)
|
|
292
|
+
return res.redirect(this.#config.SSO_FAILURE_URL);
|
|
293
|
+
return res.status(httpCodes.AUTHORIZATION_FAILED).send({ redirect_url: this.#config.SSO_FAILURE_URL });
|
|
294
|
+
}
|
|
295
|
+
if (isRedirect)
|
|
296
|
+
return res.redirect(this.#config.SSO_SUCCESS_URL);
|
|
297
|
+
return res.send({ redirect_url: this.#config.SSO_SUCCESS_URL });
|
|
298
|
+
}));
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Refresh user session
|
|
304
|
+
* @param {(user: object) => object} initUser Initialize user object function
|
|
305
|
+
* @returns {import('@types/express').RequestHandler} Returns express Request Handler
|
|
306
|
+
*/
|
|
307
|
+
refresh(initUser) {
|
|
308
|
+
const idpUrl = '/auth/refresh'.concat('?client_id=').concat(this.#config.SSO_CLIENT_ID);
|
|
309
|
+
return async (req, res, next) => {
|
|
310
|
+
try {
|
|
311
|
+
const { email, attributes } = req.user || { email: '', attributes: {} };
|
|
312
|
+
if (this.hasLock(email)) {
|
|
313
|
+
throw new CustomError(httpCodes.CONFLICT, 'User refresh is locked');
|
|
314
|
+
}
|
|
315
|
+
this.lock(email);
|
|
316
|
+
const response = await this.#idpRequest.post(idpUrl, {
|
|
317
|
+
user: {
|
|
318
|
+
email,
|
|
319
|
+
attributes: {
|
|
320
|
+
idp: attributes?.idp,
|
|
321
|
+
refresh_token: attributes?.refresh_token
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
if(response.status === httpCodes.OK) {
|
|
326
|
+
/** @type {{ jwt: string }} */
|
|
327
|
+
const { jwt } = response.data;
|
|
328
|
+
const payload = await this.#saveSession(req, jwt, initUser);
|
|
329
|
+
return res.json(payload);
|
|
330
|
+
}
|
|
331
|
+
throw new CustomError(response.status, response.statusText);
|
|
332
|
+
}
|
|
333
|
+
catch(error) {
|
|
334
|
+
return next(httpHelper.handleAxiosError(error));
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Logout
|
|
341
|
+
* @param {import('@types/express').Request} req Request
|
|
342
|
+
* @param {import('@types/express').Response} res Response
|
|
343
|
+
* @param {(error: Error)} callback Callback
|
|
344
|
+
*/
|
|
345
|
+
#logout(req, res, callback) {
|
|
346
|
+
try {
|
|
347
|
+
res.clearCookie('connect.sid');
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.error('### CLEAR COOKIE ERROR ###');
|
|
350
|
+
console.error(error);
|
|
351
|
+
}
|
|
352
|
+
return req.session.destroy((sessionError) => {
|
|
353
|
+
if (sessionError) {
|
|
354
|
+
console.error('### SESSION DESTROY CALLBACK ERROR ###');
|
|
355
|
+
console.error(sessionError);
|
|
356
|
+
return callback(sessionError);
|
|
357
|
+
}
|
|
358
|
+
console.info('### LOGOUT SUCCESSFULLY ###');
|
|
359
|
+
return callback(null);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Decrypt JWT data for user session
|
|
365
|
+
* @param {string} data JWT data
|
|
366
|
+
* @param {string} input Input string for encryption
|
|
367
|
+
* @returns {Promise<import('jose').JWTDecryptResult<import('jose').EncryptJWT>>} Returns decrypted JWT payload
|
|
368
|
+
*/
|
|
369
|
+
async #decryptJWT(data, input) {
|
|
370
|
+
const secret = await crypto.subtle.digest(
|
|
371
|
+
'SHA-256',
|
|
372
|
+
new TextEncoder().encode(input)
|
|
373
|
+
);
|
|
374
|
+
return await jwtDecrypt(data, new Uint8Array(secret), { clockTolerance: 30 });
|
|
375
|
+
}
|
|
376
|
+
}
|