dms-middleware-auth 1.2.0 → 1.2.3
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/dist/auth.middleware.d.ts +6 -2
- package/dist/auth.middleware.js +142 -66
- package/package.json +1 -1
- package/src/auth.middleware.ts +174 -80
|
@@ -22,7 +22,8 @@ export declare class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
22
22
|
private static shutdownTimer;
|
|
23
23
|
private static licenceExpiryInterval;
|
|
24
24
|
private static readonly licenceExpiredMessage;
|
|
25
|
-
private static readonly
|
|
25
|
+
private static readonly licenceValidationFailedMessage;
|
|
26
|
+
private static readonly LICENCE_MONITOR_INTERVAL_MS;
|
|
26
27
|
private static readonly LIC_CACHE_TTL_SECONDS;
|
|
27
28
|
private static readonly CLIENT_TOKEN_TTL_MAX_SECONDS;
|
|
28
29
|
private static readonly CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
@@ -32,12 +33,12 @@ export declare class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
32
33
|
onModuleInit(): Promise<void>;
|
|
33
34
|
use(req: any, res: Response, next: NextFunction): Promise<void | Response<any, Record<string, any>>>;
|
|
34
35
|
private enforceLicenceExpiry;
|
|
36
|
+
private monitorLicenceRuntime;
|
|
35
37
|
private markLicenceExpired;
|
|
36
38
|
private stopServer;
|
|
37
39
|
private checkLicenceAndValidate;
|
|
38
40
|
private validateLicence;
|
|
39
41
|
private getLicencingTokenFromService;
|
|
40
|
-
private getOrFetchClientAccessToken;
|
|
41
42
|
private clientLogin;
|
|
42
43
|
private getClientRoleAttributes;
|
|
43
44
|
private verifyLicenceJwt;
|
|
@@ -45,5 +46,8 @@ export declare class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
45
46
|
private extractBearerToken;
|
|
46
47
|
private normalizeEpochMs;
|
|
47
48
|
private cacheSetSeconds;
|
|
49
|
+
private cacheGetIfFresh;
|
|
50
|
+
private isCacheEnvelope;
|
|
51
|
+
private normalizeTtlSeconds;
|
|
48
52
|
}
|
|
49
53
|
export {};
|
package/dist/auth.middleware.js
CHANGED
|
@@ -65,16 +65,17 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
65
65
|
const { realm, publicKey } = this.options;
|
|
66
66
|
// Validate once on boot
|
|
67
67
|
await this.checkLicenceAndValidate(realm, publicKey);
|
|
68
|
-
|
|
68
|
+
this.monitorLicenceRuntime();
|
|
69
|
+
// Periodic expiry check (safe: short interval, no long timers)
|
|
69
70
|
if (!AuthMiddleware_1.licenceExpiryInterval) {
|
|
70
71
|
AuthMiddleware_1.licenceExpiryInterval = setInterval(() => {
|
|
71
72
|
try {
|
|
72
|
-
this.
|
|
73
|
+
this.monitorLicenceRuntime();
|
|
73
74
|
}
|
|
74
75
|
catch (e) {
|
|
75
76
|
this.logger.warn(`Licence expiry interval error: ${e?.message || e}`);
|
|
76
77
|
}
|
|
77
|
-
},
|
|
78
|
+
}, AuthMiddleware_1.LICENCE_MONITOR_INTERVAL_MS);
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
// -------------------------
|
|
@@ -82,7 +83,9 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
82
83
|
// -------------------------
|
|
83
84
|
async use(req, res, next) {
|
|
84
85
|
try {
|
|
85
|
-
//
|
|
86
|
+
// Validate licence for this request; fast path returns from in-memory state.
|
|
87
|
+
const licenceOk = await this.checkLicenceAndValidate(this.options.realm, this.options.publicKey);
|
|
88
|
+
// Production-safe: enforce expiry by timestamp compare, not long setTimeout
|
|
86
89
|
this.enforceLicenceExpiry();
|
|
87
90
|
if (AuthMiddleware_1.licenceExpired) {
|
|
88
91
|
// In EKS, letting the pod terminate is fine; K8s restarts/rolls
|
|
@@ -95,39 +98,64 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
95
98
|
: 'Unknown',
|
|
96
99
|
});
|
|
97
100
|
}
|
|
101
|
+
// Fail closed when licence could not be validated for this request.
|
|
102
|
+
if (!licenceOk || !AuthMiddleware_1.licenceValidatedUntilMs) {
|
|
103
|
+
return res.status(axios_1.HttpStatusCode.Forbidden).json({
|
|
104
|
+
message: AuthMiddleware_1.licenceValidationFailedMessage,
|
|
105
|
+
server_time: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (req.originalUrl == this.options.bypassURL) {
|
|
109
|
+
return next();
|
|
110
|
+
}
|
|
98
111
|
const authHeader = req.headers['authorization'];
|
|
99
112
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
100
113
|
return res.status(401).json({ message: 'Bearer token required' });
|
|
101
114
|
}
|
|
102
115
|
const decoded = this.decodeAccessToken(authHeader, this.options.publicKey);
|
|
103
|
-
|
|
116
|
+
// Read client token from app-managed cache envelope (no native store TTL timer).
|
|
117
|
+
let clientToken = await this.cacheGetIfFresh('client_access_token');
|
|
118
|
+
if (!clientToken) {
|
|
119
|
+
clientToken = await this.clientLogin();
|
|
120
|
+
const decodedToken = jwt.decode(clientToken);
|
|
121
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
122
|
+
const expSec = Number(decodedToken?.exp || 0);
|
|
123
|
+
// Clamp to 1 hour max to avoid stale token usage.
|
|
124
|
+
let ttlSec = expSec - nowSec - AuthMiddleware_1.CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
125
|
+
if (!Number.isFinite(ttlSec) || ttlSec <= 0)
|
|
126
|
+
ttlSec = 60;
|
|
127
|
+
ttlSec = Math.min(Math.max(60, ttlSec), AuthMiddleware_1.CLIENT_TOKEN_TTL_MAX_SECONDS);
|
|
128
|
+
await this.cacheSetSeconds('client_access_token', clientToken, ttlSec);
|
|
129
|
+
}
|
|
104
130
|
const role = decoded?.resource_access?.[this.options.clientId]?.roles?.[0];
|
|
105
131
|
if (!role) {
|
|
106
132
|
return res.status(403).json({ message: 'Role not found for client' });
|
|
107
133
|
}
|
|
108
|
-
const
|
|
109
|
-
let clientAttributes = await this.
|
|
134
|
+
const clientRoleName = `${this.options.clientId + role}`;
|
|
135
|
+
let clientAttributes = await this.cacheGetIfFresh(clientRoleName);
|
|
110
136
|
if (!clientAttributes) {
|
|
111
|
-
|
|
112
|
-
clientAttributes = JSON.stringify(
|
|
113
|
-
|
|
114
|
-
await this.cacheSetSeconds(clientRoleCacheKey, clientAttributes, AuthMiddleware_1.ROLE_ATTRIBUTES_TTL_SECONDS);
|
|
137
|
+
clientAttributes = await this.getClientRoleAttributes(role, clientToken);
|
|
138
|
+
clientAttributes = JSON.stringify(clientAttributes.attributes);
|
|
139
|
+
await this.cacheSetSeconds(clientRoleName, clientAttributes, AuthMiddleware_1.ROLE_ATTRIBUTES_TTL_SECONDS);
|
|
115
140
|
}
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
// Check route access
|
|
142
|
+
clientAttributes = JSON.parse(clientAttributes);
|
|
143
|
+
if (!clientAttributes[req.originalUrl]) {
|
|
118
144
|
return res.status(403).json({ message: 'Access denied for this route' });
|
|
119
145
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
else {
|
|
147
|
+
const apiPermission = JSON.parse(clientAttributes[req.originalUrl]);
|
|
148
|
+
if (apiPermission?.params === "true") {
|
|
149
|
+
const event = req?.body?.event;
|
|
150
|
+
const url = req?.originalUrl + `?action=${event}`;
|
|
151
|
+
if (!clientAttributes[url]) {
|
|
152
|
+
return res.status(403).json({ message: 'Access denied for event' });
|
|
153
|
+
}
|
|
126
154
|
}
|
|
127
155
|
}
|
|
128
156
|
req['user'] = {
|
|
129
|
-
role,
|
|
130
|
-
userName: decoded
|
|
157
|
+
role: role,
|
|
158
|
+
userName: decoded.preferred_username
|
|
131
159
|
};
|
|
132
160
|
return next();
|
|
133
161
|
}
|
|
@@ -145,16 +173,27 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
145
173
|
if (!AuthMiddleware_1.licenceValidatedUntilMs)
|
|
146
174
|
return;
|
|
147
175
|
const now = Date.now();
|
|
148
|
-
if (now >= AuthMiddleware_1.licenceValidatedUntilMs
|
|
149
|
-
this.markLicenceExpired();
|
|
176
|
+
if (now >= AuthMiddleware_1.licenceValidatedUntilMs) {
|
|
177
|
+
this.markLicenceExpired(AuthMiddleware_1.licenceValidatedUntilMs, 'Licence Expired');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
monitorLicenceRuntime() {
|
|
181
|
+
this.enforceLicenceExpiry();
|
|
182
|
+
if (AuthMiddleware_1.licenceExpired) {
|
|
183
|
+
this.stopServer();
|
|
150
184
|
}
|
|
151
185
|
}
|
|
152
|
-
markLicenceExpired() {
|
|
186
|
+
markLicenceExpired(expiredAtMs, reason) {
|
|
187
|
+
if (Number.isFinite(expiredAtMs) && Number(expiredAtMs) > 0) {
|
|
188
|
+
AuthMiddleware_1.licenceValidatedUntilMs = Number(expiredAtMs);
|
|
189
|
+
}
|
|
153
190
|
if (!AuthMiddleware_1.licenceExpired) {
|
|
154
191
|
AuthMiddleware_1.licenceExpired = true;
|
|
155
|
-
|
|
192
|
+
const expiryText = AuthMiddleware_1.licenceValidatedUntilMs
|
|
156
193
|
? new Date(AuthMiddleware_1.licenceValidatedUntilMs).toISOString()
|
|
157
|
-
: 'unknown'
|
|
194
|
+
: 'unknown';
|
|
195
|
+
const reasonText = reason ? ` (reason: ${reason})` : '';
|
|
196
|
+
this.logger.error(`Licence expired at ${expiryText}${reasonText}`);
|
|
158
197
|
}
|
|
159
198
|
}
|
|
160
199
|
// -------------------------
|
|
@@ -188,9 +227,13 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
188
227
|
try {
|
|
189
228
|
if (AuthMiddleware_1.licenceExpired)
|
|
190
229
|
return false;
|
|
191
|
-
// If we already have an expiry
|
|
192
|
-
if (AuthMiddleware_1.licenceValidatedUntilMs
|
|
193
|
-
Date.now()
|
|
230
|
+
// If we already have an expiry in memory, decide immediately.
|
|
231
|
+
if (AuthMiddleware_1.licenceValidatedUntilMs) {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
if (now >= AuthMiddleware_1.licenceValidatedUntilMs) {
|
|
234
|
+
this.markLicenceExpired(AuthMiddleware_1.licenceValidatedUntilMs, 'Licence Expired');
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
194
237
|
return true;
|
|
195
238
|
}
|
|
196
239
|
// De-duplicate concurrent validations
|
|
@@ -199,7 +242,7 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
199
242
|
}
|
|
200
243
|
AuthMiddleware_1.licenceValidationPromise = (async () => {
|
|
201
244
|
// 1) Try cache first (short TTL)
|
|
202
|
-
const cachedToken =
|
|
245
|
+
const cachedToken = await this.cacheGetIfFresh('client_Licence_token');
|
|
203
246
|
if (cachedToken) {
|
|
204
247
|
const ok = await this.validateLicence(cachedToken, publicKey, realm, this.options.keycloakUrl, false);
|
|
205
248
|
if (ok) {
|
|
@@ -230,18 +273,29 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
230
273
|
}
|
|
231
274
|
async validateLicence(tokenJwt, publicKey, realm, keycloakUrl, updateCache) {
|
|
232
275
|
try {
|
|
276
|
+
const isAuthoritativeToken = updateCache;
|
|
233
277
|
const payload = this.verifyLicenceJwt(tokenJwt, publicKey, realm, keycloakUrl);
|
|
234
278
|
// Your custom claim: lic_end
|
|
235
279
|
const licEndMs = this.normalizeEpochMs(payload?.lic_end);
|
|
236
280
|
const now = Date.now();
|
|
237
281
|
if (!licEndMs) {
|
|
238
|
-
|
|
239
|
-
|
|
282
|
+
if (isAuthoritativeToken) {
|
|
283
|
+
this.logger.error('Licence token missing lic_end');
|
|
284
|
+
this.markLicenceExpired(null, 'authoritative token missing lic_end');
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
this.logger.warn('Cached licence token missing lic_end, refetching from service');
|
|
288
|
+
}
|
|
240
289
|
return false;
|
|
241
290
|
}
|
|
242
|
-
if (now >= licEndMs
|
|
243
|
-
|
|
244
|
-
|
|
291
|
+
if (now >= licEndMs) {
|
|
292
|
+
if (isAuthoritativeToken) {
|
|
293
|
+
this.logger.error(`Licence already expired. lic_end=${new Date(licEndMs).toISOString()} now=${new Date(now).toISOString()}`);
|
|
294
|
+
this.markLicenceExpired(licEndMs, 'authoritative token already expired');
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
this.logger.warn(`Cached licence token expired. lic_end=${new Date(licEndMs).toISOString()} now=${new Date(now).toISOString()}`);
|
|
298
|
+
}
|
|
245
299
|
return false;
|
|
246
300
|
}
|
|
247
301
|
// ✅ Save only the timestamp (supports 50 days / 500 days / years)
|
|
@@ -255,11 +309,17 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
255
309
|
return true;
|
|
256
310
|
}
|
|
257
311
|
catch (e) {
|
|
312
|
+
const isAuthoritativeToken = updateCache;
|
|
258
313
|
// If JWT invalid, expire
|
|
259
314
|
const name = e?.name || '';
|
|
260
315
|
if (name === 'JsonWebTokenError' || name === 'TokenExpiredError' || name === 'NotBeforeError') {
|
|
261
|
-
|
|
262
|
-
|
|
316
|
+
if (isAuthoritativeToken) {
|
|
317
|
+
this.logger.error(`Invalid licence token: ${e?.message || e}`);
|
|
318
|
+
this.markLicenceExpired(null, `authoritative token invalid (${name})`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
this.logger.warn(`Cached licence token invalid, refetching from service: ${e?.message || e}`);
|
|
322
|
+
}
|
|
263
323
|
return false;
|
|
264
324
|
}
|
|
265
325
|
// transient failures should not hard-expire immediately
|
|
@@ -287,25 +347,6 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
287
347
|
throw e;
|
|
288
348
|
}
|
|
289
349
|
}
|
|
290
|
-
// -------------------------
|
|
291
|
-
// Keycloak client token caching (safe TTL)
|
|
292
|
-
// -------------------------
|
|
293
|
-
async getOrFetchClientAccessToken() {
|
|
294
|
-
let token = (await this.cacheManager.get('client_access_token'));
|
|
295
|
-
if (token)
|
|
296
|
-
return token;
|
|
297
|
-
token = await this.clientLogin();
|
|
298
|
-
const decoded = jwt.decode(token);
|
|
299
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
300
|
-
const expSec = Number(decoded?.exp || 0);
|
|
301
|
-
// ttl seconds: refresh early, clamp to [60, 3600]
|
|
302
|
-
let ttlSec = expSec - nowSec - AuthMiddleware_1.CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
303
|
-
if (!Number.isFinite(ttlSec) || ttlSec <= 0)
|
|
304
|
-
ttlSec = 60;
|
|
305
|
-
ttlSec = Math.min(Math.max(60, ttlSec), AuthMiddleware_1.CLIENT_TOKEN_TTL_MAX_SECONDS);
|
|
306
|
-
await this.cacheSetSeconds('client_access_token', token, ttlSec);
|
|
307
|
-
return token;
|
|
308
|
-
}
|
|
309
350
|
async clientLogin() {
|
|
310
351
|
const { keycloakUrl, realm, clientId, clientSecret } = this.options;
|
|
311
352
|
const resp = await axios_1.default.post(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`, new URLSearchParams({
|
|
@@ -329,10 +370,8 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
329
370
|
// -------------------------
|
|
330
371
|
verifyLicenceJwt(token, publicKey, realm, keycloakUrl) {
|
|
331
372
|
const publicKeys = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
|
|
332
|
-
const issuer = `${keycloakUrl}/realms/${realm}`;
|
|
333
373
|
return jwt.verify(token, publicKeys, {
|
|
334
374
|
algorithms: ['RS256'],
|
|
335
|
-
issuer,
|
|
336
375
|
clockTolerance: 60,
|
|
337
376
|
});
|
|
338
377
|
}
|
|
@@ -363,18 +402,54 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
363
402
|
// -------------------------
|
|
364
403
|
// Cache helper with overflow protection
|
|
365
404
|
// -------------------------
|
|
366
|
-
async cacheSetSeconds(key, value,
|
|
405
|
+
async cacheSetSeconds(key, value, ttlSeconds) {
|
|
367
406
|
try {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
407
|
+
const safeTtlSeconds = this.normalizeTtlSeconds(ttlSeconds);
|
|
408
|
+
const envelope = {
|
|
409
|
+
value,
|
|
410
|
+
expiresAtMs: Date.now() + safeTtlSeconds * 1000,
|
|
411
|
+
};
|
|
412
|
+
// Do not use native store TTL; some stores map TTL to long setTimeout internally.
|
|
413
|
+
await this.cacheManager.set(key, envelope);
|
|
414
|
+
this.logger.debug(`Cache set: ${key} (TTL: ${safeTtlSeconds}s)`);
|
|
373
415
|
}
|
|
374
416
|
catch (e) {
|
|
375
417
|
this.logger.error(`Cache set failed for key=${key}: ${e?.message || e}`, e?.stack);
|
|
376
418
|
}
|
|
377
419
|
}
|
|
420
|
+
async cacheGetIfFresh(key) {
|
|
421
|
+
try {
|
|
422
|
+
const cached = (await this.cacheManager.get(key));
|
|
423
|
+
if (!cached)
|
|
424
|
+
return undefined;
|
|
425
|
+
// Backward compatibility for entries written before envelope format.
|
|
426
|
+
if (!this.isCacheEnvelope(cached)) {
|
|
427
|
+
return cached;
|
|
428
|
+
}
|
|
429
|
+
if (Date.now() >= cached.expiresAtMs) {
|
|
430
|
+
await this.cacheManager.del(key);
|
|
431
|
+
this.logger.debug(`Cache expired: ${key}`);
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
return cached.value;
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
this.logger.warn(`Cache get failed for key=${key}: ${e?.message || e}`);
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
isCacheEnvelope(value) {
|
|
442
|
+
if (!value || typeof value !== 'object')
|
|
443
|
+
return false;
|
|
444
|
+
const v = value;
|
|
445
|
+
return Number.isFinite(v.expiresAtMs) && Object.prototype.hasOwnProperty.call(v, 'value');
|
|
446
|
+
}
|
|
447
|
+
normalizeTtlSeconds(ttlSeconds) {
|
|
448
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
|
|
449
|
+
return AuthMiddleware_1.ONE_DAY_SECONDS;
|
|
450
|
+
}
|
|
451
|
+
return Math.floor(ttlSeconds);
|
|
452
|
+
}
|
|
378
453
|
};
|
|
379
454
|
exports.AuthMiddleware = AuthMiddleware;
|
|
380
455
|
// ---- Global-ish state (process-wide) ----
|
|
@@ -386,8 +461,9 @@ AuthMiddleware.shutdownTimer = null;
|
|
|
386
461
|
AuthMiddleware.licenceExpiryInterval = null;
|
|
387
462
|
// ---- Constants ----
|
|
388
463
|
AuthMiddleware.licenceExpiredMessage = 'Server Licence is expired! Please renew';
|
|
389
|
-
|
|
390
|
-
|
|
464
|
+
AuthMiddleware.licenceValidationFailedMessage = 'Server licence validation failed. Please try again.';
|
|
465
|
+
// Runtime monitor cadence for strict licence end enforcement.
|
|
466
|
+
AuthMiddleware.LICENCE_MONITOR_INTERVAL_MS = 10_000; // 10 seconds
|
|
391
467
|
// Cache TTLs (safe by design; prevents timer overflow)
|
|
392
468
|
AuthMiddleware.LIC_CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours TTL for licence
|
|
393
469
|
AuthMiddleware.CLIENT_TOKEN_TTL_MAX_SECONDS = 60 * 60; // 1 hour cap
|
package/package.json
CHANGED
package/src/auth.middleware.ts
CHANGED
|
@@ -24,6 +24,11 @@ interface AuthMiddlewareOptions {
|
|
|
24
24
|
licenceServiceUrl?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
interface CacheEnvelope<T> {
|
|
28
|
+
value: T;
|
|
29
|
+
expiresAtMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
@Injectable()
|
|
28
33
|
export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
29
34
|
private readonly logger = new Logger('AuthMiddleware');
|
|
@@ -41,9 +46,11 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
41
46
|
// ---- Constants ----
|
|
42
47
|
private static readonly licenceExpiredMessage =
|
|
43
48
|
'Server Licence is expired! Please renew';
|
|
49
|
+
private static readonly licenceValidationFailedMessage =
|
|
50
|
+
'Server licence validation failed. Please try again.';
|
|
44
51
|
|
|
45
|
-
//
|
|
46
|
-
private static readonly
|
|
52
|
+
// Runtime monitor cadence for strict licence end enforcement.
|
|
53
|
+
private static readonly LICENCE_MONITOR_INTERVAL_MS = 10_000; // 10 seconds
|
|
47
54
|
|
|
48
55
|
// Cache TTLs (safe by design; prevents timer overflow)
|
|
49
56
|
private static readonly LIC_CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours TTL for licence
|
|
@@ -67,16 +74,17 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
67
74
|
|
|
68
75
|
// Validate once on boot
|
|
69
76
|
await this.checkLicenceAndValidate(realm, publicKey);
|
|
77
|
+
this.monitorLicenceRuntime();
|
|
70
78
|
|
|
71
|
-
// Periodic expiry check (safe:
|
|
79
|
+
// Periodic expiry check (safe: short interval, no long timers)
|
|
72
80
|
if (!AuthMiddleware.licenceExpiryInterval) {
|
|
73
81
|
AuthMiddleware.licenceExpiryInterval = setInterval(() => {
|
|
74
82
|
try {
|
|
75
|
-
this.
|
|
83
|
+
this.monitorLicenceRuntime();
|
|
76
84
|
} catch (e: any) {
|
|
77
85
|
this.logger.warn(`Licence expiry interval error: ${e?.message || e}`);
|
|
78
86
|
}
|
|
79
|
-
},
|
|
87
|
+
}, AuthMiddleware.LICENCE_MONITOR_INTERVAL_MS);
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
|
|
@@ -85,7 +93,13 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
85
93
|
// -------------------------
|
|
86
94
|
async use(req: any, res: Response, next: NextFunction) {
|
|
87
95
|
try {
|
|
88
|
-
//
|
|
96
|
+
// Validate licence for this request; fast path returns from in-memory state.
|
|
97
|
+
const licenceOk = await this.checkLicenceAndValidate(
|
|
98
|
+
this.options.realm,
|
|
99
|
+
this.options.publicKey
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Production-safe: enforce expiry by timestamp compare, not long setTimeout
|
|
89
103
|
this.enforceLicenceExpiry();
|
|
90
104
|
|
|
91
105
|
if (AuthMiddleware.licenceExpired) {
|
|
@@ -100,6 +114,17 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
100
114
|
});
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
// Fail closed when licence could not be validated for this request.
|
|
118
|
+
if (!licenceOk || !AuthMiddleware.licenceValidatedUntilMs) {
|
|
119
|
+
return res.status(HttpStatusCode.Forbidden).json({
|
|
120
|
+
message: AuthMiddleware.licenceValidationFailedMessage,
|
|
121
|
+
server_time: new Date().toISOString(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (req.originalUrl == this.options.bypassURL) {
|
|
126
|
+
return next();
|
|
127
|
+
}
|
|
103
128
|
const authHeader = req.headers['authorization'];
|
|
104
129
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
105
130
|
return res.status(401).json({ message: 'Bearer token required' });
|
|
@@ -107,48 +132,63 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
107
132
|
|
|
108
133
|
const decoded: any = this.decodeAccessToken(authHeader, this.options.publicKey);
|
|
109
134
|
|
|
110
|
-
|
|
135
|
+
// Read client token from app-managed cache envelope (no native store TTL timer).
|
|
136
|
+
let clientToken = await this.cacheGetIfFresh<string>('client_access_token');
|
|
137
|
+
if (!clientToken) {
|
|
138
|
+
clientToken = await this.clientLogin();
|
|
139
|
+
const decodedToken: any = jwt.decode(clientToken);
|
|
140
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
141
|
+
const expSec = Number(decodedToken?.exp || 0);
|
|
142
|
+
|
|
143
|
+
// Clamp to 1 hour max to avoid stale token usage.
|
|
144
|
+
let ttlSec = expSec - nowSec - AuthMiddleware.CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
145
|
+
if (!Number.isFinite(ttlSec) || ttlSec <= 0) ttlSec = 60;
|
|
146
|
+
ttlSec = Math.min(Math.max(60, ttlSec), AuthMiddleware.CLIENT_TOKEN_TTL_MAX_SECONDS);
|
|
147
|
+
|
|
148
|
+
await this.cacheSetSeconds('client_access_token', clientToken, ttlSec);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
111
153
|
const role = decoded?.resource_access?.[this.options.clientId]?.roles?.[0];
|
|
112
154
|
|
|
113
155
|
if (!role) {
|
|
114
156
|
return res.status(403).json({ message: 'Role not found for client' });
|
|
115
157
|
}
|
|
116
158
|
|
|
117
|
-
const
|
|
118
|
-
let clientAttributes: any = await this.
|
|
119
|
-
|
|
159
|
+
const clientRoleName = `${this.options.clientId + role}`;
|
|
160
|
+
let clientAttributes: any = await this.cacheGetIfFresh<string>(clientRoleName);
|
|
120
161
|
if (!clientAttributes) {
|
|
121
|
-
|
|
122
|
-
clientAttributes = JSON.stringify(
|
|
123
|
-
|
|
124
|
-
// ✅ Cache with 24-hour TTL
|
|
162
|
+
clientAttributes = await this.getClientRoleAttributes(role, clientToken);
|
|
163
|
+
clientAttributes = JSON.stringify(clientAttributes.attributes);
|
|
125
164
|
await this.cacheSetSeconds(
|
|
126
|
-
|
|
165
|
+
clientRoleName,
|
|
127
166
|
clientAttributes,
|
|
128
167
|
AuthMiddleware.ROLE_ATTRIBUTES_TTL_SECONDS
|
|
129
168
|
);
|
|
130
169
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!
|
|
170
|
+
// Check route access
|
|
171
|
+
clientAttributes = JSON.parse(clientAttributes);
|
|
172
|
+
if (!clientAttributes[req.originalUrl]) {
|
|
134
173
|
return res.status(403).json({ message: 'Access denied for this route' });
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
174
|
+
} else {
|
|
175
|
+
const apiPermission = JSON.parse(clientAttributes[req.originalUrl]);
|
|
176
|
+
if (apiPermission?.params === "true") {
|
|
177
|
+
const event = req?.body?.event;
|
|
178
|
+
const url = req?.originalUrl + `?action=${event}`;
|
|
179
|
+
if (!clientAttributes[url]) {
|
|
180
|
+
return res.status(403).json({ message: 'Access denied for event' });
|
|
181
|
+
}
|
|
143
182
|
}
|
|
144
183
|
}
|
|
145
|
-
|
|
184
|
+
|
|
146
185
|
req['user'] = {
|
|
147
|
-
role,
|
|
148
|
-
userName: decoded
|
|
186
|
+
role: role,
|
|
187
|
+
userName: decoded.preferred_username
|
|
149
188
|
};
|
|
150
189
|
|
|
151
190
|
return next();
|
|
191
|
+
|
|
152
192
|
} catch (error: any) {
|
|
153
193
|
this.logger.error(`Middleware error: ${error?.message || error}`, error?.stack);
|
|
154
194
|
return res.status(500).json({ message: error?.message || 'Internal error' });
|
|
@@ -163,18 +203,34 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
163
203
|
if (!AuthMiddleware.licenceValidatedUntilMs) return;
|
|
164
204
|
|
|
165
205
|
const now = Date.now();
|
|
166
|
-
if (now >= AuthMiddleware.licenceValidatedUntilMs
|
|
167
|
-
this.markLicenceExpired(
|
|
206
|
+
if (now >= AuthMiddleware.licenceValidatedUntilMs) {
|
|
207
|
+
this.markLicenceExpired(
|
|
208
|
+
AuthMiddleware.licenceValidatedUntilMs,
|
|
209
|
+
'Licence Expired'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private monitorLicenceRuntime() {
|
|
215
|
+
this.enforceLicenceExpiry();
|
|
216
|
+
if (AuthMiddleware.licenceExpired) {
|
|
217
|
+
this.stopServer();
|
|
168
218
|
}
|
|
169
219
|
}
|
|
170
220
|
|
|
171
|
-
private markLicenceExpired() {
|
|
221
|
+
private markLicenceExpired(expiredAtMs?: number | null, reason?: string) {
|
|
222
|
+
if (Number.isFinite(expiredAtMs) && Number(expiredAtMs) > 0) {
|
|
223
|
+
AuthMiddleware.licenceValidatedUntilMs = Number(expiredAtMs);
|
|
224
|
+
}
|
|
225
|
+
|
|
172
226
|
if (!AuthMiddleware.licenceExpired) {
|
|
173
227
|
AuthMiddleware.licenceExpired = true;
|
|
228
|
+
const expiryText = AuthMiddleware.licenceValidatedUntilMs
|
|
229
|
+
? new Date(AuthMiddleware.licenceValidatedUntilMs).toISOString()
|
|
230
|
+
: 'unknown';
|
|
231
|
+
const reasonText = reason ? ` (reason: ${reason})` : '';
|
|
174
232
|
this.logger.error(
|
|
175
|
-
`Licence expired at ${
|
|
176
|
-
? new Date(AuthMiddleware.licenceValidatedUntilMs).toISOString()
|
|
177
|
-
: 'unknown'}`
|
|
233
|
+
`Licence expired at ${expiryText}${reasonText}`
|
|
178
234
|
);
|
|
179
235
|
}
|
|
180
236
|
}
|
|
@@ -212,11 +268,16 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
212
268
|
try {
|
|
213
269
|
if (AuthMiddleware.licenceExpired) return false;
|
|
214
270
|
|
|
215
|
-
// If we already have an expiry
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
271
|
+
// If we already have an expiry in memory, decide immediately.
|
|
272
|
+
if (AuthMiddleware.licenceValidatedUntilMs) {
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
if (now >= AuthMiddleware.licenceValidatedUntilMs) {
|
|
275
|
+
this.markLicenceExpired(
|
|
276
|
+
AuthMiddleware.licenceValidatedUntilMs,
|
|
277
|
+
'Licence Expired'
|
|
278
|
+
);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
220
281
|
return true;
|
|
221
282
|
}
|
|
222
283
|
|
|
@@ -227,7 +288,7 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
227
288
|
|
|
228
289
|
AuthMiddleware.licenceValidationPromise = (async () => {
|
|
229
290
|
// 1) Try cache first (short TTL)
|
|
230
|
-
const cachedToken =
|
|
291
|
+
const cachedToken = await this.cacheGetIfFresh<string>('client_Licence_token');
|
|
231
292
|
if (cachedToken) {
|
|
232
293
|
const ok = await this.validateLicence(cachedToken, publicKey, realm, this.options.keycloakUrl, false);
|
|
233
294
|
if (ok) {
|
|
@@ -267,6 +328,7 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
267
328
|
updateCache: boolean
|
|
268
329
|
): Promise<boolean> {
|
|
269
330
|
try {
|
|
331
|
+
const isAuthoritativeToken = updateCache;
|
|
270
332
|
const payload: any = this.verifyLicenceJwt(tokenJwt, publicKey, realm, keycloakUrl);
|
|
271
333
|
|
|
272
334
|
// Your custom claim: lic_end
|
|
@@ -274,16 +336,26 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
274
336
|
const now = Date.now();
|
|
275
337
|
|
|
276
338
|
if (!licEndMs) {
|
|
277
|
-
|
|
278
|
-
|
|
339
|
+
if (isAuthoritativeToken) {
|
|
340
|
+
this.logger.error('Licence token missing lic_end');
|
|
341
|
+
this.markLicenceExpired(null, 'authoritative token missing lic_end');
|
|
342
|
+
} else {
|
|
343
|
+
this.logger.warn('Cached licence token missing lic_end, refetching from service');
|
|
344
|
+
}
|
|
279
345
|
return false;
|
|
280
346
|
}
|
|
281
347
|
|
|
282
|
-
if (now >= licEndMs
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
348
|
+
if (now >= licEndMs) {
|
|
349
|
+
if (isAuthoritativeToken) {
|
|
350
|
+
this.logger.error(
|
|
351
|
+
`Licence already expired. lic_end=${new Date(licEndMs).toISOString()} now=${new Date(now).toISOString()}`
|
|
352
|
+
);
|
|
353
|
+
this.markLicenceExpired(licEndMs, 'authoritative token already expired');
|
|
354
|
+
} else {
|
|
355
|
+
this.logger.warn(
|
|
356
|
+
`Cached licence token expired. lic_end=${new Date(licEndMs).toISOString()} now=${new Date(now).toISOString()}`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
287
359
|
return false;
|
|
288
360
|
}
|
|
289
361
|
|
|
@@ -302,11 +374,16 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
302
374
|
|
|
303
375
|
return true;
|
|
304
376
|
} catch (e: any) {
|
|
377
|
+
const isAuthoritativeToken = updateCache;
|
|
305
378
|
// If JWT invalid, expire
|
|
306
379
|
const name = e?.name || '';
|
|
307
380
|
if (name === 'JsonWebTokenError' || name === 'TokenExpiredError' || name === 'NotBeforeError') {
|
|
308
|
-
|
|
309
|
-
|
|
381
|
+
if (isAuthoritativeToken) {
|
|
382
|
+
this.logger.error(`Invalid licence token: ${e?.message || e}`);
|
|
383
|
+
this.markLicenceExpired(null, `authoritative token invalid (${name})`);
|
|
384
|
+
} else {
|
|
385
|
+
this.logger.warn(`Cached licence token invalid, refetching from service: ${e?.message || e}`);
|
|
386
|
+
}
|
|
310
387
|
return false;
|
|
311
388
|
}
|
|
312
389
|
|
|
@@ -342,27 +419,7 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
342
419
|
}
|
|
343
420
|
}
|
|
344
421
|
|
|
345
|
-
|
|
346
|
-
// Keycloak client token caching (safe TTL)
|
|
347
|
-
// -------------------------
|
|
348
|
-
private async getOrFetchClientAccessToken(): Promise<string> {
|
|
349
|
-
let token = (await this.cacheManager.get('client_access_token')) as string | undefined;
|
|
350
|
-
if (token) return token;
|
|
351
|
-
|
|
352
|
-
token = await this.clientLogin();
|
|
353
|
-
|
|
354
|
-
const decoded: any = jwt.decode(token);
|
|
355
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
356
|
-
const expSec = Number(decoded?.exp || 0);
|
|
357
|
-
|
|
358
|
-
// ttl seconds: refresh early, clamp to [60, 3600]
|
|
359
|
-
let ttlSec = expSec - nowSec - AuthMiddleware.CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
360
|
-
if (!Number.isFinite(ttlSec) || ttlSec <= 0) ttlSec = 60;
|
|
361
|
-
ttlSec = Math.min(Math.max(60, ttlSec), AuthMiddleware.CLIENT_TOKEN_TTL_MAX_SECONDS);
|
|
362
|
-
|
|
363
|
-
await this.cacheSetSeconds('client_access_token', token, ttlSec);
|
|
364
|
-
return token;
|
|
365
|
-
}
|
|
422
|
+
|
|
366
423
|
|
|
367
424
|
private async clientLogin(): Promise<string> {
|
|
368
425
|
const { keycloakUrl, realm, clientId, clientSecret } = this.options;
|
|
@@ -401,11 +458,8 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
401
458
|
private verifyLicenceJwt(token: string, publicKey: string, realm: string, keycloakUrl: string) {
|
|
402
459
|
const publicKeys = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
|
|
403
460
|
|
|
404
|
-
const issuer = `${keycloakUrl}/realms/${realm}`;
|
|
405
|
-
|
|
406
461
|
return jwt.verify(token, publicKeys, {
|
|
407
462
|
algorithms: ['RS256'],
|
|
408
|
-
issuer,
|
|
409
463
|
clockTolerance: 60,
|
|
410
464
|
});
|
|
411
465
|
}
|
|
@@ -440,15 +494,55 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
440
494
|
// -------------------------
|
|
441
495
|
// Cache helper with overflow protection
|
|
442
496
|
// -------------------------
|
|
443
|
-
private async cacheSetSeconds(key: string, value: any,
|
|
497
|
+
private async cacheSetSeconds(key: string, value: any, ttlSeconds: number) {
|
|
444
498
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
499
|
+
const safeTtlSeconds = this.normalizeTtlSeconds(ttlSeconds);
|
|
500
|
+
const envelope: CacheEnvelope<any> = {
|
|
501
|
+
value,
|
|
502
|
+
expiresAtMs: Date.now() + safeTtlSeconds * 1000,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// Do not use native store TTL; some stores map TTL to long setTimeout internally.
|
|
506
|
+
await this.cacheManager.set(key, envelope as any);
|
|
507
|
+
this.logger.debug(`Cache set: ${key} (TTL: ${safeTtlSeconds}s)`);
|
|
450
508
|
} catch (e: any) {
|
|
451
509
|
this.logger.error(`Cache set failed for key=${key}: ${e?.message || e}`, e?.stack);
|
|
452
510
|
}
|
|
453
511
|
}
|
|
512
|
+
|
|
513
|
+
private async cacheGetIfFresh<T>(key: string): Promise<T | undefined> {
|
|
514
|
+
try {
|
|
515
|
+
const cached = (await this.cacheManager.get(key)) as CacheEnvelope<T> | T | undefined;
|
|
516
|
+
if (!cached) return undefined;
|
|
517
|
+
|
|
518
|
+
// Backward compatibility for entries written before envelope format.
|
|
519
|
+
if (!this.isCacheEnvelope(cached)) {
|
|
520
|
+
return cached as T;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (Date.now() >= cached.expiresAtMs) {
|
|
524
|
+
await this.cacheManager.del(key);
|
|
525
|
+
this.logger.debug(`Cache expired: ${key}`);
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return cached.value;
|
|
530
|
+
} catch (e: any) {
|
|
531
|
+
this.logger.warn(`Cache get failed for key=${key}: ${e?.message || e}`);
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private isCacheEnvelope<T>(value: unknown): value is CacheEnvelope<T> {
|
|
537
|
+
if (!value || typeof value !== 'object') return false;
|
|
538
|
+
const v = value as CacheEnvelope<T>;
|
|
539
|
+
return Number.isFinite(v.expiresAtMs) && Object.prototype.hasOwnProperty.call(v, 'value');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private normalizeTtlSeconds(ttlSeconds: number): number {
|
|
543
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
|
|
544
|
+
return AuthMiddleware.ONE_DAY_SECONDS;
|
|
545
|
+
}
|
|
546
|
+
return Math.floor(ttlSeconds);
|
|
547
|
+
}
|
|
454
548
|
}
|