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