dms-middleware-auth 1.1.3 → 1.1.5
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 +20 -15
- package/dist/auth.middleware.js +235 -206
- package/package.json +1 -1
- package/src/auth.middleware.ts +295 -220
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NestMiddleware, OnModuleInit } from '@nestjs/common';
|
|
2
2
|
import { Response, NextFunction } from 'express';
|
|
3
|
-
import { Cache } from 'cache-manager';
|
|
3
|
+
import type { Cache } from 'cache-manager';
|
|
4
4
|
interface AuthMiddlewareOptions {
|
|
5
5
|
publicKey: string;
|
|
6
6
|
keycloakUrl: string;
|
|
@@ -9,35 +9,40 @@ interface AuthMiddlewareOptions {
|
|
|
9
9
|
clientSecret: string;
|
|
10
10
|
clientUuid: string;
|
|
11
11
|
bypassURL: string;
|
|
12
|
+
licenceServiceUrl?: string;
|
|
12
13
|
}
|
|
13
14
|
export declare class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
14
|
-
private cacheManager;
|
|
15
|
+
private readonly cacheManager;
|
|
15
16
|
private readonly options;
|
|
17
|
+
private readonly logger;
|
|
16
18
|
private static licenceExpired;
|
|
17
|
-
private static shutdownTimer;
|
|
18
|
-
private static licenceExpiryTimer;
|
|
19
19
|
private static licenceValidatedUntilMs;
|
|
20
20
|
private static licenceValidationPromise;
|
|
21
21
|
private static shutdownInitiated;
|
|
22
|
+
private static shutdownTimer;
|
|
23
|
+
private static licenceExpiryInterval;
|
|
22
24
|
private static readonly licenceExpiredMessage;
|
|
23
25
|
private static readonly CLOCK_TOLERANCE_MS;
|
|
24
|
-
private readonly
|
|
26
|
+
private static readonly LIC_EXPIRY_CHECK_INTERVAL_MS;
|
|
27
|
+
private static readonly LIC_CACHE_TTL_SECONDS;
|
|
28
|
+
private static readonly CLIENT_TOKEN_TTL_MAX_SECONDS;
|
|
29
|
+
private static readonly CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
25
30
|
constructor(cacheManager: Cache, options: AuthMiddlewareOptions);
|
|
26
31
|
onModuleInit(): Promise<void>;
|
|
27
32
|
use(req: any, res: Response, next: NextFunction): Promise<void | Response<any, Record<string, any>>>;
|
|
28
|
-
private
|
|
29
|
-
private getUserDetails;
|
|
30
|
-
private getClientRoleAttributes;
|
|
31
|
-
private checkLicenceAndValidate;
|
|
32
|
-
private getLicencingDetails;
|
|
33
|
-
private validateLicence;
|
|
33
|
+
private enforceLicenceExpiry;
|
|
34
34
|
private markLicenceExpired;
|
|
35
|
-
private scheduleLicenceShutdown;
|
|
36
35
|
private stopServer;
|
|
37
|
-
private
|
|
38
|
-
private
|
|
39
|
-
private
|
|
36
|
+
private checkLicenceAndValidate;
|
|
37
|
+
private validateLicence;
|
|
38
|
+
private getLicencingTokenFromService;
|
|
39
|
+
private getOrFetchClientAccessToken;
|
|
40
|
+
private clientLogin;
|
|
41
|
+
private getClientRoleAttributes;
|
|
42
|
+
private verifyLicenceJwt;
|
|
40
43
|
private decodeAccessToken;
|
|
41
44
|
private extractBearerToken;
|
|
45
|
+
private normalizeEpochMs;
|
|
46
|
+
private cacheSetSeconds;
|
|
42
47
|
}
|
|
43
48
|
export {};
|
package/dist/auth.middleware.js
CHANGED
|
@@ -48,8 +48,8 @@ var AuthMiddleware_1;
|
|
|
48
48
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
49
|
exports.AuthMiddleware = void 0;
|
|
50
50
|
const common_1 = require("@nestjs/common");
|
|
51
|
-
const jwt = __importStar(require("jsonwebtoken"));
|
|
52
51
|
const axios_1 = __importStar(require("axios"));
|
|
52
|
+
const jwt = __importStar(require("jsonwebtoken"));
|
|
53
53
|
const cache_manager_1 = require("@nestjs/cache-manager");
|
|
54
54
|
const process = __importStar(require("node:process"));
|
|
55
55
|
let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
@@ -58,139 +58,162 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
58
58
|
this.options = options;
|
|
59
59
|
this.logger = new common_1.Logger('AuthMiddleware');
|
|
60
60
|
}
|
|
61
|
+
// -------------------------
|
|
62
|
+
// Startup: validate licence once, then run a small periodic expiry check
|
|
63
|
+
// -------------------------
|
|
61
64
|
async onModuleInit() {
|
|
62
|
-
const {
|
|
63
|
-
|
|
65
|
+
const { realm, publicKey } = this.options;
|
|
66
|
+
// Validate once on boot
|
|
67
|
+
await this.checkLicenceAndValidate(realm, publicKey);
|
|
68
|
+
// Periodic expiry check (safe: small interval only)
|
|
69
|
+
if (!AuthMiddleware_1.licenceExpiryInterval) {
|
|
70
|
+
AuthMiddleware_1.licenceExpiryInterval = setInterval(() => {
|
|
71
|
+
try {
|
|
72
|
+
this.enforceLicenceExpiry();
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
this.logger.warn(`Licence expiry interval error: ${e?.message || e}`);
|
|
76
|
+
}
|
|
77
|
+
}, AuthMiddleware_1.LIC_EXPIRY_CHECK_INTERVAL_MS);
|
|
78
|
+
}
|
|
64
79
|
}
|
|
80
|
+
// -------------------------
|
|
81
|
+
// Main middleware
|
|
82
|
+
// -------------------------
|
|
65
83
|
async use(req, res, next) {
|
|
66
|
-
const { publicKey, clientId, realm, bypassURL } = this.options;
|
|
67
84
|
try {
|
|
85
|
+
// ✅ Production-safe: enforce expiry by timestamp compare, not long setTimeout
|
|
86
|
+
this.enforceLicenceExpiry();
|
|
68
87
|
if (AuthMiddleware_1.licenceExpired) {
|
|
88
|
+
// In EKS, letting the pod terminate is fine; K8s restarts/rolls
|
|
69
89
|
this.stopServer();
|
|
70
90
|
return res.status(axios_1.HttpStatusCode.Forbidden).json({
|
|
71
91
|
message: AuthMiddleware_1.licenceExpiredMessage,
|
|
72
92
|
server_time: new Date().toISOString(),
|
|
73
|
-
licence_expiry: AuthMiddleware_1.licenceValidatedUntilMs
|
|
74
|
-
|
|
93
|
+
licence_expiry: AuthMiddleware_1.licenceValidatedUntilMs
|
|
94
|
+
? new Date(AuthMiddleware_1.licenceValidatedUntilMs).toISOString()
|
|
95
|
+
: 'Unknown'
|
|
75
96
|
});
|
|
76
97
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (req.originalUrl == bypassURL) {
|
|
98
|
+
// Allow bypass URL
|
|
99
|
+
if (req.originalUrl === this.options.bypassURL) {
|
|
81
100
|
return next();
|
|
82
101
|
}
|
|
102
|
+
// Authorization header
|
|
83
103
|
const authHeader = req.headers['authorization'];
|
|
84
104
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
85
105
|
return res.status(401).json({ message: 'Bearer token required' });
|
|
86
106
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
// Decode + verify caller token
|
|
108
|
+
const decoded = this.decodeAccessToken(authHeader, this.options.publicKey);
|
|
109
|
+
// Fetch (or cache) Keycloak client token
|
|
110
|
+
const clientToken = await this.getOrFetchClientAccessToken();
|
|
111
|
+
// Fetch (or cache) role attributes
|
|
112
|
+
const role = decoded?.resource_access?.[this.options.clientId]?.roles?.[0];
|
|
113
|
+
if (!role) {
|
|
114
|
+
return res.status(403).json({ message: 'Role not found for client' });
|
|
95
115
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const clientRoleName = `${clientId + role}`;
|
|
99
|
-
let clientAttributes = await this.cacheManager.get(clientRoleName);
|
|
116
|
+
const clientRoleCacheKey = `${this.options.clientId}${role}`;
|
|
117
|
+
let clientAttributes = await this.cacheManager.get(clientRoleCacheKey);
|
|
100
118
|
if (!clientAttributes) {
|
|
101
|
-
|
|
102
|
-
clientAttributes = JSON.stringify(
|
|
103
|
-
|
|
119
|
+
const roleObj = await this.getClientRoleAttributes(role, clientToken);
|
|
120
|
+
clientAttributes = JSON.stringify(roleObj.attributes ?? {});
|
|
121
|
+
// store indefinitely (or you can choose a short TTL)
|
|
122
|
+
await this.cacheSetSeconds(clientRoleCacheKey, clientAttributes, 0);
|
|
104
123
|
}
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
if (!
|
|
124
|
+
// Route access check
|
|
125
|
+
const attrs = JSON.parse(clientAttributes);
|
|
126
|
+
if (!attrs[req.originalUrl]) {
|
|
108
127
|
return res.status(403).json({ message: 'Access denied for this route' });
|
|
109
128
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
129
|
+
// Optional event/params check
|
|
130
|
+
const apiPermission = JSON.parse(attrs[req.originalUrl]);
|
|
131
|
+
if (apiPermission?.params === 'true') {
|
|
132
|
+
const event = req?.body?.event;
|
|
133
|
+
const url = `${req.originalUrl}?action=${event}`;
|
|
134
|
+
if (!attrs[url]) {
|
|
135
|
+
return res.status(403).json({ message: 'Access denied for event' });
|
|
118
136
|
}
|
|
119
137
|
}
|
|
120
138
|
req['user'] = {
|
|
121
|
-
role
|
|
122
|
-
userName: decoded
|
|
139
|
+
role,
|
|
140
|
+
userName: decoded?.preferred_username
|
|
123
141
|
};
|
|
124
142
|
return next();
|
|
125
143
|
}
|
|
126
144
|
catch (error) {
|
|
127
|
-
return res.status(500).json({ message: error
|
|
145
|
+
return res.status(500).json({ message: error?.message || 'Internal error' });
|
|
128
146
|
}
|
|
129
147
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
throw new Error(`Failed to obtain client token: ${error.response?.data?.error_description || error.message}`);
|
|
148
|
+
// -------------------------
|
|
149
|
+
// Licence expiry enforcement (NO LONG TIMERS)
|
|
150
|
+
// -------------------------
|
|
151
|
+
enforceLicenceExpiry() {
|
|
152
|
+
if (AuthMiddleware_1.licenceExpired)
|
|
153
|
+
return;
|
|
154
|
+
if (!AuthMiddleware_1.licenceValidatedUntilMs)
|
|
155
|
+
return;
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
if (now >= AuthMiddleware_1.licenceValidatedUntilMs + AuthMiddleware_1.CLOCK_TOLERANCE_MS) {
|
|
158
|
+
this.markLicenceExpired();
|
|
142
159
|
}
|
|
143
160
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
catch (error) {
|
|
151
|
-
throw new Error(`Failed to fetch user details: ${error.response?.data?.error_description || error.message}`);
|
|
161
|
+
markLicenceExpired() {
|
|
162
|
+
if (!AuthMiddleware_1.licenceExpired) {
|
|
163
|
+
AuthMiddleware_1.licenceExpired = true;
|
|
164
|
+
this.logger.error(`Licence expired at ${AuthMiddleware_1.licenceValidatedUntilMs
|
|
165
|
+
? new Date(AuthMiddleware_1.licenceValidatedUntilMs).toISOString()
|
|
166
|
+
: 'unknown'}`);
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
// -------------------------
|
|
170
|
+
// Graceful shutdown for EKS
|
|
171
|
+
// -------------------------
|
|
172
|
+
stopServer() {
|
|
173
|
+
if (AuthMiddleware_1.shutdownInitiated)
|
|
174
|
+
return;
|
|
175
|
+
AuthMiddleware_1.shutdownInitiated = true;
|
|
176
|
+
// immediate SIGTERM, then force exit after 5s
|
|
177
|
+
setTimeout(() => {
|
|
178
|
+
try {
|
|
179
|
+
process.kill(process.pid, 'SIGTERM');
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
if (!AuthMiddleware_1.shutdownTimer) {
|
|
185
|
+
AuthMiddleware_1.shutdownTimer = setTimeout(() => process.exit(1), 5000);
|
|
186
|
+
}
|
|
187
|
+
}, 0);
|
|
163
188
|
}
|
|
189
|
+
// -------------------------
|
|
190
|
+
// Licence validate flow (called at boot / can be called again if needed)
|
|
191
|
+
// -------------------------
|
|
164
192
|
async checkLicenceAndValidate(realm, publicKey) {
|
|
165
193
|
try {
|
|
166
|
-
if (AuthMiddleware_1.licenceExpired)
|
|
194
|
+
if (AuthMiddleware_1.licenceExpired)
|
|
167
195
|
return false;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (cachedToken) {
|
|
172
|
-
const validate = await this.validateLicence(cachedToken, publicKey, false);
|
|
173
|
-
if (validate.status) {
|
|
174
|
-
return true;
|
|
175
|
-
}
|
|
176
|
-
// If cached token is invalid/expired, we'll try to get a new one from the server.
|
|
177
|
-
}
|
|
178
|
-
if (AuthMiddleware_1.licenceValidatedUntilMs && AuthMiddleware_1.licenceValidatedUntilMs > this.getUtcNowMs()) {
|
|
196
|
+
// If we already have an expiry and it's still valid, no need to fetch again
|
|
197
|
+
if (AuthMiddleware_1.licenceValidatedUntilMs &&
|
|
198
|
+
Date.now() < AuthMiddleware_1.licenceValidatedUntilMs + AuthMiddleware_1.CLOCK_TOLERANCE_MS) {
|
|
179
199
|
return true;
|
|
180
200
|
}
|
|
201
|
+
// De-duplicate concurrent validations
|
|
181
202
|
if (AuthMiddleware_1.licenceValidationPromise) {
|
|
182
203
|
return await AuthMiddleware_1.licenceValidationPromise;
|
|
183
204
|
}
|
|
184
|
-
// 2. if the licence details not available in cache get the data from another server and validate.
|
|
185
205
|
AuthMiddleware_1.licenceValidationPromise = (async () => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return validate.status;
|
|
206
|
+
// 1) Try cache first (short TTL)
|
|
207
|
+
const cachedToken = (await this.cacheManager.get('client_Licence_token'));
|
|
208
|
+
if (cachedToken) {
|
|
209
|
+
const ok = await this.validateLicence(cachedToken, publicKey, false);
|
|
210
|
+
if (ok)
|
|
211
|
+
return true;
|
|
193
212
|
}
|
|
213
|
+
// 2) Fetch from licensing service
|
|
214
|
+
const token = await this.getLicencingTokenFromService(realm);
|
|
215
|
+
const ok = await this.validateLicence(token, publicKey, true);
|
|
216
|
+
return ok;
|
|
194
217
|
})();
|
|
195
218
|
try {
|
|
196
219
|
return await AuthMiddleware_1.licenceValidationPromise;
|
|
@@ -199,142 +222,105 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
199
222
|
AuthMiddleware_1.licenceValidationPromise = null;
|
|
200
223
|
}
|
|
201
224
|
}
|
|
202
|
-
catch (
|
|
225
|
+
catch (e) {
|
|
226
|
+
this.logger.warn(`checkLicenceAndValidate failed: ${e?.message || e}`);
|
|
203
227
|
return false;
|
|
204
228
|
}
|
|
205
229
|
}
|
|
206
|
-
async
|
|
230
|
+
async validateLicence(tokenJwt, publicKey, updateCache) {
|
|
207
231
|
try {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
return {
|
|
219
|
-
code: axios_1.HttpStatusCode.InternalServerError,
|
|
220
|
-
message: "InternalServer error"
|
|
221
|
-
};
|
|
232
|
+
const payload = this.verifyLicenceJwt(tokenJwt, publicKey);
|
|
233
|
+
// Your custom claim: lic_end
|
|
234
|
+
const licEndMs = this.normalizeEpochMs(payload?.lic_end);
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
if (!licEndMs) {
|
|
237
|
+
this.logger.error('Licence token missing lic_end');
|
|
238
|
+
this.markLicenceExpired();
|
|
239
|
+
return false;
|
|
222
240
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
code: axios_1.HttpStatusCode.InternalServerError,
|
|
227
|
-
data: error?.response?.data || error?.message || 'Licencing service error'
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
async validateLicence(lic_data, publicKey, updateCache = true) {
|
|
232
|
-
try {
|
|
233
|
-
const token = await this.verifyJwt(lic_data, publicKey);
|
|
234
|
-
const licEndMs = this.normalizeEpochMs(token?.lic_end);
|
|
235
|
-
const now = this.getUtcNowMs();
|
|
236
|
-
// Reject when licence end (epoch) is missing or already in the past (with tolerance).
|
|
237
|
-
if (!licEndMs || (licEndMs + AuthMiddleware_1.CLOCK_TOLERANCE_MS) <= now) {
|
|
238
|
-
this.logger.error(`Licence expired. Expiry: ${new Date(licEndMs || 0).toISOString()}, Server Time: ${new Date(now).toISOString()}`);
|
|
241
|
+
if (now >= licEndMs + AuthMiddleware_1.CLOCK_TOLERANCE_MS) {
|
|
242
|
+
this.logger.error(`Licence already expired. lic_end=${new Date(licEndMs).toISOString()} now=${new Date(now).toISOString()}`);
|
|
239
243
|
this.markLicenceExpired();
|
|
240
|
-
return
|
|
244
|
+
return false;
|
|
241
245
|
}
|
|
242
|
-
//
|
|
246
|
+
// ✅ Save only the timestamp (supports 50 days / 500 days / years)
|
|
243
247
|
AuthMiddleware_1.licenceValidatedUntilMs = licEndMs;
|
|
244
|
-
|
|
248
|
+
// ✅ Cache only briefly (avoid long TTL -> avoids TimeoutOverflowWarning)
|
|
245
249
|
if (updateCache) {
|
|
246
|
-
|
|
247
|
-
const MAX_TTL = 2147483647;
|
|
248
|
-
const calculatedTtl = licEndMs - now;
|
|
249
|
-
const ttl = Math.min(Math.max(0, calculatedTtl), MAX_TTL);
|
|
250
|
-
await this.cacheManager.set('client_Licence_token', lic_data, ttl);
|
|
250
|
+
await this.cacheSetSeconds('client_Licence_token', tokenJwt, AuthMiddleware_1.LIC_CACHE_TTL_SECONDS);
|
|
251
251
|
}
|
|
252
|
-
return
|
|
253
|
-
status: true,
|
|
254
|
-
};
|
|
252
|
+
return true;
|
|
255
253
|
}
|
|
256
|
-
catch (
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
catch (e) {
|
|
255
|
+
// If JWT invalid, expire
|
|
256
|
+
const name = e?.name || '';
|
|
257
|
+
if (name === 'JsonWebTokenError' || name === 'TokenExpiredError' || name === 'NotBeforeError') {
|
|
258
|
+
this.logger.error(`Invalid licence token: ${e?.message || e}`);
|
|
259
259
|
this.markLicenceExpired();
|
|
260
|
+
return false;
|
|
260
261
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return { status: false };
|
|
262
|
+
// transient failures should not hard-expire immediately
|
|
263
|
+
this.logger.warn(`Transient licence validation error: ${e?.message || e}`);
|
|
264
|
+
return false;
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
async getLicencingTokenFromService(realm) {
|
|
268
|
+
const url = this.options.licenceServiceUrl ??
|
|
269
|
+
'https://iiuuckued274sisadalbpf7ivu0eiblk.lambda-url.ap-south-1.on.aws/';
|
|
270
|
+
const body = { client_name: realm };
|
|
271
|
+
const resp = await axios_1.default.post(url, body, { timeout: 15_000 });
|
|
272
|
+
if (resp.status !== axios_1.HttpStatusCode.Ok) {
|
|
273
|
+
throw new Error(`Licencing service returned status ${resp.status}`);
|
|
270
274
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return;
|
|
275
|
+
const token = resp?.data?.token;
|
|
276
|
+
if (!token || typeof token !== 'string') {
|
|
277
|
+
throw new Error('Licencing service response missing token');
|
|
275
278
|
}
|
|
276
|
-
|
|
277
|
-
const delay = (licEndMs + AuthMiddleware_1.CLOCK_TOLERANCE_MS) - now;
|
|
278
|
-
if (delay <= 0) {
|
|
279
|
-
this.logger.warn('Licence expired immediately (including tolerance).');
|
|
280
|
-
this.markLicenceExpired();
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
if (AuthMiddleware_1.licenceExpiryTimer) {
|
|
284
|
-
clearTimeout(AuthMiddleware_1.licenceExpiryTimer);
|
|
285
|
-
}
|
|
286
|
-
// setTimeout uses a 32-bit signed integer. Max value is 2147483647 ms (approx 24.8 days).
|
|
287
|
-
// If the delay is larger than this, setTimeout will fire immediately (overflow).
|
|
288
|
-
// so we cap the delay and re-check when it fires.
|
|
289
|
-
const MAX_TIMEOUT_VAL = 2147483647;
|
|
290
|
-
const safeDelay = Math.min(delay, MAX_TIMEOUT_VAL);
|
|
291
|
-
AuthMiddleware_1.licenceExpiryTimer = setTimeout(() => {
|
|
292
|
-
const remaining = (licEndMs + AuthMiddleware_1.CLOCK_TOLERANCE_MS) - this.getUtcNowMs();
|
|
293
|
-
if (remaining <= 0) {
|
|
294
|
-
this.markLicenceExpired();
|
|
295
|
-
}
|
|
296
|
-
else {
|
|
297
|
-
// Reschedule if we still have time left
|
|
298
|
-
this.scheduleLicenceShutdown(licEndMs);
|
|
299
|
-
}
|
|
300
|
-
}, safeDelay);
|
|
279
|
+
return token;
|
|
301
280
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
281
|
+
// -------------------------
|
|
282
|
+
// Keycloak client token caching (safe TTL)
|
|
283
|
+
// -------------------------
|
|
284
|
+
async getOrFetchClientAccessToken() {
|
|
285
|
+
let token = (await this.cacheManager.get('client_access_token'));
|
|
286
|
+
if (token)
|
|
287
|
+
return token;
|
|
288
|
+
token = await this.clientLogin();
|
|
289
|
+
const decoded = jwt.decode(token);
|
|
290
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
291
|
+
const expSec = Number(decoded?.exp || 0);
|
|
292
|
+
// ttl seconds: refresh early, clamp to [60, 3600]
|
|
293
|
+
let ttlSec = expSec - nowSec - AuthMiddleware_1.CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
294
|
+
if (!Number.isFinite(ttlSec) || ttlSec <= 0)
|
|
295
|
+
ttlSec = 60;
|
|
296
|
+
ttlSec = Math.min(Math.max(60, ttlSec), AuthMiddleware_1.CLIENT_TOKEN_TTL_MAX_SECONDS);
|
|
297
|
+
await this.cacheSetSeconds('client_access_token', token, ttlSec);
|
|
298
|
+
return token;
|
|
320
299
|
}
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
300
|
+
async clientLogin() {
|
|
301
|
+
const { keycloakUrl, realm, clientId, clientSecret } = this.options;
|
|
302
|
+
const resp = await axios_1.default.post(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`, new URLSearchParams({
|
|
303
|
+
grant_type: 'client_credentials',
|
|
304
|
+
client_id: clientId,
|
|
305
|
+
client_secret: clientSecret
|
|
306
|
+
}).toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15_000 });
|
|
307
|
+
const token = resp?.data?.access_token;
|
|
308
|
+
if (!token) {
|
|
309
|
+
throw new Error('Keycloak did not return access_token');
|
|
328
310
|
}
|
|
329
|
-
return
|
|
311
|
+
return token;
|
|
330
312
|
}
|
|
331
|
-
|
|
332
|
-
|
|
313
|
+
async getClientRoleAttributes(role, token) {
|
|
314
|
+
const { keycloakUrl, realm, clientUuid } = this.options;
|
|
315
|
+
const resp = await axios_1.default.get(`${keycloakUrl}/admin/realms/${realm}/clients/${clientUuid}/roles/${role}`, { headers: { Authorization: `Bearer ${token}` }, timeout: 15_000 });
|
|
316
|
+
return resp.data;
|
|
333
317
|
}
|
|
334
|
-
|
|
318
|
+
// -------------------------
|
|
319
|
+
// JWT helpers
|
|
320
|
+
// -------------------------
|
|
321
|
+
verifyLicenceJwt(token, publicKey) {
|
|
335
322
|
const publicKeys = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
|
|
336
|
-
// We ignore
|
|
337
|
-
// Also include a small clock tolerance for the library itself.
|
|
323
|
+
// We ignore standard 'exp' and rely on custom 'lic_end'
|
|
338
324
|
return jwt.verify(token, publicKeys, {
|
|
339
325
|
algorithms: ['RS256'],
|
|
340
326
|
ignoreExpiration: true,
|
|
@@ -343,21 +329,64 @@ let AuthMiddleware = AuthMiddleware_1 = class AuthMiddleware {
|
|
|
343
329
|
}
|
|
344
330
|
decodeAccessToken(authHeader, publicKey) {
|
|
345
331
|
const token = this.extractBearerToken(authHeader);
|
|
346
|
-
|
|
332
|
+
const publicKeys = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
|
|
333
|
+
return jwt.verify(token, publicKeys, {
|
|
334
|
+
algorithms: ['RS256'],
|
|
335
|
+
clockTolerance: 60
|
|
336
|
+
});
|
|
347
337
|
}
|
|
348
338
|
extractBearerToken(authHeader) {
|
|
349
339
|
return authHeader.split(' ')[1];
|
|
350
340
|
}
|
|
341
|
+
// -------------------------
|
|
342
|
+
// Time helpers
|
|
343
|
+
// -------------------------
|
|
344
|
+
normalizeEpochMs(epoch) {
|
|
345
|
+
const n = Number(epoch);
|
|
346
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
347
|
+
return null;
|
|
348
|
+
// If it's seconds (10 digits), convert to ms
|
|
349
|
+
// If it's ms (13 digits), keep
|
|
350
|
+
if (n < 1_000_000_000_000)
|
|
351
|
+
return n * 1000;
|
|
352
|
+
return n;
|
|
353
|
+
}
|
|
354
|
+
// -------------------------
|
|
355
|
+
// Cache helper (seconds-based, safe across stores)
|
|
356
|
+
// - Using seconds avoids ms/sec mismatch across cache-manager stores
|
|
357
|
+
// - TTL is kept small so no overflow even if a store uses Node timers
|
|
358
|
+
// -------------------------
|
|
359
|
+
async cacheSetSeconds(key, value, ttlSeconds) {
|
|
360
|
+
// ttlSeconds=0 => store forever (supported by many stores); if not, we just set without ttl
|
|
361
|
+
if (!ttlSeconds || ttlSeconds <= 0) {
|
|
362
|
+
await this.cacheManager.set(key, value);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// cache-manager / Nest stores vary: some accept third param number, others accept { ttl }
|
|
366
|
+
// We use { ttl } for better compatibility, and TTL is small anyway.
|
|
367
|
+
await this.cacheManager.set(key, value, { ttl: ttlSeconds });
|
|
368
|
+
}
|
|
351
369
|
};
|
|
352
370
|
exports.AuthMiddleware = AuthMiddleware;
|
|
371
|
+
// ---- Global-ish state (process-wide) ----
|
|
353
372
|
AuthMiddleware.licenceExpired = false;
|
|
354
|
-
AuthMiddleware.shutdownTimer = null;
|
|
355
|
-
AuthMiddleware.licenceExpiryTimer = null;
|
|
356
373
|
AuthMiddleware.licenceValidatedUntilMs = null;
|
|
357
374
|
AuthMiddleware.licenceValidationPromise = null;
|
|
358
375
|
AuthMiddleware.shutdownInitiated = false;
|
|
359
|
-
AuthMiddleware.
|
|
360
|
-
AuthMiddleware.
|
|
376
|
+
AuthMiddleware.shutdownTimer = null;
|
|
377
|
+
AuthMiddleware.licenceExpiryInterval = null;
|
|
378
|
+
// ---- Constants ----
|
|
379
|
+
AuthMiddleware.licenceExpiredMessage = 'server Licence is expired!. please renew';
|
|
380
|
+
// allow small clock drift
|
|
381
|
+
AuthMiddleware.CLOCK_TOLERANCE_MS = 60_000; // 1 min
|
|
382
|
+
// IMPORTANT:
|
|
383
|
+
// Node timers overflow above ~2,147,483,647ms (~24.8 days).
|
|
384
|
+
// We avoid scheduling long timeouts completely.
|
|
385
|
+
AuthMiddleware.LIC_EXPIRY_CHECK_INTERVAL_MS = 60_000; // 1 min
|
|
386
|
+
// Cache TTLs (short by design; safe for any store, avoids timer overflow)
|
|
387
|
+
AuthMiddleware.LIC_CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
|
|
388
|
+
AuthMiddleware.CLIENT_TOKEN_TTL_MAX_SECONDS = 60 * 60; // 1 hour cap
|
|
389
|
+
AuthMiddleware.CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS = 30; // refresh a bit early
|
|
361
390
|
exports.AuthMiddleware = AuthMiddleware = AuthMiddleware_1 = __decorate([
|
|
362
391
|
(0, common_1.Injectable)(),
|
|
363
392
|
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
package/package.json
CHANGED
package/src/auth.middleware.ts
CHANGED
|
@@ -1,194 +1,248 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Inject,
|
|
3
|
+
Injectable,
|
|
4
|
+
Logger,
|
|
5
|
+
NestMiddleware,
|
|
6
|
+
OnModuleInit
|
|
7
|
+
} from '@nestjs/common';
|
|
2
8
|
import { Response, NextFunction } from 'express';
|
|
3
|
-
import * as jwt from 'jsonwebtoken';
|
|
4
9
|
import axios, { HttpStatusCode } from 'axios';
|
|
10
|
+
import * as jwt from 'jsonwebtoken';
|
|
5
11
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
6
|
-
import { Cache } from 'cache-manager';
|
|
7
|
-
import * as process from
|
|
12
|
+
import type { Cache } from 'cache-manager';
|
|
13
|
+
import * as process from 'node:process';
|
|
8
14
|
|
|
9
15
|
interface AuthMiddlewareOptions {
|
|
10
16
|
publicKey: string;
|
|
17
|
+
|
|
11
18
|
keycloakUrl: string;
|
|
12
19
|
realm: string;
|
|
20
|
+
|
|
13
21
|
clientId: string;
|
|
14
22
|
clientSecret: string;
|
|
15
23
|
clientUuid: string;
|
|
24
|
+
|
|
16
25
|
bypassURL: string;
|
|
26
|
+
|
|
27
|
+
// Optional: licensing service URL (better than hardcoding in code)
|
|
28
|
+
licenceServiceUrl?: string;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
@Injectable()
|
|
20
32
|
export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
33
|
+
private readonly logger = new Logger('AuthMiddleware');
|
|
34
|
+
|
|
35
|
+
// ---- Global-ish state (process-wide) ----
|
|
21
36
|
private static licenceExpired = false;
|
|
22
|
-
private static shutdownTimer: NodeJS.Timeout | null = null;
|
|
23
|
-
private static licenceExpiryTimer: NodeJS.Timeout | null = null;
|
|
24
37
|
private static licenceValidatedUntilMs: number | null = null;
|
|
25
38
|
private static licenceValidationPromise: Promise<boolean> | null = null;
|
|
39
|
+
|
|
26
40
|
private static shutdownInitiated = false;
|
|
27
|
-
private static
|
|
28
|
-
private static readonly CLOCK_TOLERANCE_MS = 60000; // 1 minute grace period
|
|
29
|
-
private readonly logger = new Logger('AuthMiddleware');
|
|
41
|
+
private static shutdownTimer: NodeJS.Timeout | null = null;
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
private static licenceExpiryInterval: NodeJS.Timeout | null = null;
|
|
44
|
+
|
|
45
|
+
// ---- Constants ----
|
|
46
|
+
private static readonly licenceExpiredMessage =
|
|
47
|
+
'server Licence is expired!. please renew';
|
|
48
|
+
|
|
49
|
+
// allow small clock drift
|
|
50
|
+
private static readonly CLOCK_TOLERANCE_MS = 60_000; // 1 min
|
|
51
|
+
|
|
52
|
+
// IMPORTANT:
|
|
53
|
+
// Node timers overflow above ~2,147,483,647ms (~24.8 days).
|
|
54
|
+
// We avoid scheduling long timeouts completely.
|
|
55
|
+
private static readonly LIC_EXPIRY_CHECK_INTERVAL_MS = 60_000; // 1 min
|
|
56
|
+
|
|
57
|
+
// Cache TTLs (short by design; safe for any store, avoids timer overflow)
|
|
58
|
+
private static readonly LIC_CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
|
|
59
|
+
private static readonly CLIENT_TOKEN_TTL_MAX_SECONDS = 60 * 60; // 1 hour cap
|
|
60
|
+
private static readonly CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS = 30; // refresh a bit early
|
|
35
61
|
|
|
62
|
+
constructor(
|
|
63
|
+
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
|
64
|
+
@Inject('AUTH_MIDDLEWARE_OPTIONS')
|
|
65
|
+
private readonly options: AuthMiddlewareOptions
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
// -------------------------
|
|
69
|
+
// Startup: validate licence once, then run a small periodic expiry check
|
|
70
|
+
// -------------------------
|
|
36
71
|
async onModuleInit() {
|
|
37
|
-
const {
|
|
38
|
-
|
|
72
|
+
const { realm, publicKey } = this.options;
|
|
73
|
+
|
|
74
|
+
// Validate once on boot
|
|
75
|
+
await this.checkLicenceAndValidate(realm, publicKey);
|
|
76
|
+
|
|
77
|
+
// Periodic expiry check (safe: small interval only)
|
|
78
|
+
if (!AuthMiddleware.licenceExpiryInterval) {
|
|
79
|
+
AuthMiddleware.licenceExpiryInterval = setInterval(() => {
|
|
80
|
+
try {
|
|
81
|
+
this.enforceLicenceExpiry();
|
|
82
|
+
} catch (e: any) {
|
|
83
|
+
this.logger.warn(`Licence expiry interval error: ${e?.message || e}`);
|
|
84
|
+
}
|
|
85
|
+
}, AuthMiddleware.LIC_EXPIRY_CHECK_INTERVAL_MS);
|
|
86
|
+
}
|
|
39
87
|
}
|
|
40
88
|
|
|
89
|
+
// -------------------------
|
|
90
|
+
// Main middleware
|
|
91
|
+
// -------------------------
|
|
41
92
|
async use(req: any, res: Response, next: NextFunction) {
|
|
42
|
-
const { publicKey, clientId, realm, bypassURL } = this.options;
|
|
43
|
-
|
|
44
93
|
try {
|
|
94
|
+
// ✅ Production-safe: enforce expiry by timestamp compare, not long setTimeout
|
|
95
|
+
this.enforceLicenceExpiry();
|
|
96
|
+
|
|
45
97
|
if (AuthMiddleware.licenceExpired) {
|
|
98
|
+
// In EKS, letting the pod terminate is fine; K8s restarts/rolls
|
|
46
99
|
this.stopServer();
|
|
47
100
|
return res.status(HttpStatusCode.Forbidden).json({
|
|
48
101
|
message: AuthMiddleware.licenceExpiredMessage,
|
|
49
102
|
server_time: new Date().toISOString(),
|
|
50
|
-
licence_expiry: AuthMiddleware.licenceValidatedUntilMs
|
|
51
|
-
|
|
103
|
+
licence_expiry: AuthMiddleware.licenceValidatedUntilMs
|
|
104
|
+
? new Date(AuthMiddleware.licenceValidatedUntilMs).toISOString()
|
|
105
|
+
: 'Unknown'
|
|
52
106
|
});
|
|
53
107
|
}
|
|
54
108
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (req.originalUrl == bypassURL) {
|
|
109
|
+
// Allow bypass URL
|
|
110
|
+
if (req.originalUrl === this.options.bypassURL) {
|
|
60
111
|
return next();
|
|
61
112
|
}
|
|
62
113
|
|
|
114
|
+
// Authorization header
|
|
63
115
|
const authHeader = req.headers['authorization'];
|
|
64
116
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
65
117
|
return res.status(401).json({ message: 'Bearer token required' });
|
|
66
118
|
}
|
|
67
119
|
|
|
68
|
-
|
|
120
|
+
// Decode + verify caller token
|
|
121
|
+
const decoded: any = this.decodeAccessToken(authHeader, this.options.publicKey);
|
|
69
122
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
123
|
+
// Fetch (or cache) Keycloak client token
|
|
124
|
+
const clientToken = await this.getOrFetchClientAccessToken();
|
|
125
|
+
|
|
126
|
+
// Fetch (or cache) role attributes
|
|
127
|
+
const role = decoded?.resource_access?.[this.options.clientId]?.roles?.[0];
|
|
128
|
+
if (!role) {
|
|
129
|
+
return res.status(403).json({ message: 'Role not found for client' });
|
|
77
130
|
}
|
|
78
131
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let clientAttributes: any = await this.cacheManager.get(clientRoleName);
|
|
132
|
+
const clientRoleCacheKey = `${this.options.clientId}${role}`;
|
|
133
|
+
let clientAttributes: any = await this.cacheManager.get(clientRoleCacheKey);
|
|
134
|
+
|
|
83
135
|
if (!clientAttributes) {
|
|
84
|
-
|
|
85
|
-
clientAttributes = JSON.stringify(
|
|
86
|
-
|
|
136
|
+
const roleObj = await this.getClientRoleAttributes(role, clientToken);
|
|
137
|
+
clientAttributes = JSON.stringify(roleObj.attributes ?? {});
|
|
138
|
+
// store indefinitely (or you can choose a short TTL)
|
|
139
|
+
await this.cacheSetSeconds(clientRoleCacheKey, clientAttributes, 0);
|
|
87
140
|
}
|
|
88
141
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
if (!
|
|
142
|
+
// Route access check
|
|
143
|
+
const attrs = JSON.parse(clientAttributes);
|
|
144
|
+
if (!attrs[req.originalUrl]) {
|
|
92
145
|
return res.status(403).json({ message: 'Access denied for this route' });
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Optional event/params check
|
|
149
|
+
const apiPermission = JSON.parse(attrs[req.originalUrl]);
|
|
150
|
+
if (apiPermission?.params === 'true') {
|
|
151
|
+
const event = req?.body?.event;
|
|
152
|
+
const url = `${req.originalUrl}?action=${event}`;
|
|
153
|
+
if (!attrs[url]) {
|
|
154
|
+
return res.status(403).json({ message: 'Access denied for event' });
|
|
101
155
|
}
|
|
102
156
|
}
|
|
103
157
|
|
|
104
158
|
req['user'] = {
|
|
105
|
-
role
|
|
106
|
-
userName: decoded
|
|
159
|
+
role,
|
|
160
|
+
userName: decoded?.preferred_username
|
|
107
161
|
};
|
|
108
162
|
|
|
109
163
|
return next();
|
|
110
164
|
} catch (error: any) {
|
|
111
|
-
return res.status(500).json({ message: error
|
|
165
|
+
return res.status(500).json({ message: error?.message || 'Internal error' });
|
|
112
166
|
}
|
|
113
167
|
}
|
|
114
168
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
grant_type: 'client_credentials',
|
|
122
|
-
client_id: clientId,
|
|
123
|
-
client_secret: clientSecret,
|
|
124
|
-
}).toString(),
|
|
125
|
-
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
126
|
-
);
|
|
127
|
-
return response.data.access_token;
|
|
128
|
-
} catch (error: any) {
|
|
129
|
-
throw new Error(`Failed to obtain client token: ${error.response?.data?.error_description || error.message}`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
169
|
+
// -------------------------
|
|
170
|
+
// Licence expiry enforcement (NO LONG TIMERS)
|
|
171
|
+
// -------------------------
|
|
172
|
+
private enforceLicenceExpiry() {
|
|
173
|
+
if (AuthMiddleware.licenceExpired) return;
|
|
174
|
+
if (!AuthMiddleware.licenceValidatedUntilMs) return;
|
|
132
175
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const response = await axios.get(
|
|
137
|
-
`${keycloakUrl}/admin/realms/${realm}/users?username=${username}`,
|
|
138
|
-
{ headers: { Authorization: `Bearer ${token}` } }
|
|
139
|
-
);
|
|
140
|
-
return response.data[0];
|
|
141
|
-
} catch (error: any) {
|
|
142
|
-
throw new Error(`Failed to fetch user details: ${error.response?.data?.error_description || error.message}`);
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
if (now >= AuthMiddleware.licenceValidatedUntilMs + AuthMiddleware.CLOCK_TOLERANCE_MS) {
|
|
178
|
+
this.markLicenceExpired();
|
|
143
179
|
}
|
|
144
180
|
}
|
|
145
181
|
|
|
146
|
-
private
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
182
|
+
private markLicenceExpired() {
|
|
183
|
+
if (!AuthMiddleware.licenceExpired) {
|
|
184
|
+
AuthMiddleware.licenceExpired = true;
|
|
185
|
+
this.logger.error(
|
|
186
|
+
`Licence expired at ${AuthMiddleware.licenceValidatedUntilMs
|
|
187
|
+
? new Date(AuthMiddleware.licenceValidatedUntilMs).toISOString()
|
|
188
|
+
: 'unknown'}`
|
|
152
189
|
);
|
|
153
|
-
return response.data;
|
|
154
|
-
} catch (error: any) {
|
|
155
|
-
throw new Error(`Failed to fetch client role attributes: ${error.response?.data?.error_description || error.message}`);
|
|
156
190
|
}
|
|
157
191
|
}
|
|
158
192
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
193
|
+
// -------------------------
|
|
194
|
+
// Graceful shutdown for EKS
|
|
195
|
+
// -------------------------
|
|
196
|
+
private stopServer() {
|
|
197
|
+
if (AuthMiddleware.shutdownInitiated) return;
|
|
198
|
+
AuthMiddleware.shutdownInitiated = true;
|
|
199
|
+
|
|
200
|
+
// immediate SIGTERM, then force exit after 5s
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
try {
|
|
203
|
+
process.kill(process.pid, 'SIGTERM');
|
|
204
|
+
} catch {
|
|
205
|
+
process.exit(1);
|
|
163
206
|
}
|
|
164
207
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (cachedToken) {
|
|
168
|
-
const validate = await this.validateLicence(cachedToken, publicKey, false);
|
|
169
|
-
if (validate.status) {
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
// If cached token is invalid/expired, we'll try to get a new one from the server.
|
|
208
|
+
if (!AuthMiddleware.shutdownTimer) {
|
|
209
|
+
AuthMiddleware.shutdownTimer = setTimeout(() => process.exit(1), 5000);
|
|
173
210
|
}
|
|
211
|
+
}, 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// -------------------------
|
|
215
|
+
// Licence validate flow (called at boot / can be called again if needed)
|
|
216
|
+
// -------------------------
|
|
217
|
+
private async checkLicenceAndValidate(realm: string, publicKey: string): Promise<boolean> {
|
|
218
|
+
try {
|
|
219
|
+
if (AuthMiddleware.licenceExpired) return false;
|
|
174
220
|
|
|
175
|
-
|
|
221
|
+
// If we already have an expiry and it's still valid, no need to fetch again
|
|
222
|
+
if (
|
|
223
|
+
AuthMiddleware.licenceValidatedUntilMs &&
|
|
224
|
+
Date.now() < AuthMiddleware.licenceValidatedUntilMs + AuthMiddleware.CLOCK_TOLERANCE_MS
|
|
225
|
+
) {
|
|
176
226
|
return true;
|
|
177
227
|
}
|
|
178
228
|
|
|
229
|
+
// De-duplicate concurrent validations
|
|
179
230
|
if (AuthMiddleware.licenceValidationPromise) {
|
|
180
231
|
return await AuthMiddleware.licenceValidationPromise;
|
|
181
232
|
}
|
|
182
233
|
|
|
183
|
-
// 2. if the licence details not available in cache get the data from another server and validate.
|
|
184
234
|
AuthMiddleware.licenceValidationPromise = (async () => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return validate.status;
|
|
235
|
+
// 1) Try cache first (short TTL)
|
|
236
|
+
const cachedToken = (await this.cacheManager.get('client_Licence_token')) as string | undefined;
|
|
237
|
+
if (cachedToken) {
|
|
238
|
+
const ok = await this.validateLicence(cachedToken, publicKey, false);
|
|
239
|
+
if (ok) return true;
|
|
191
240
|
}
|
|
241
|
+
|
|
242
|
+
// 2) Fetch from licensing service
|
|
243
|
+
const token = await this.getLicencingTokenFromService(realm);
|
|
244
|
+
const ok = await this.validateLicence(token, publicKey, true);
|
|
245
|
+
return ok;
|
|
192
246
|
})();
|
|
193
247
|
|
|
194
248
|
try {
|
|
@@ -196,152 +250,138 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
196
250
|
} finally {
|
|
197
251
|
AuthMiddleware.licenceValidationPromise = null;
|
|
198
252
|
}
|
|
199
|
-
} catch (
|
|
253
|
+
} catch (e: any) {
|
|
254
|
+
this.logger.warn(`checkLicenceAndValidate failed: ${e?.message || e}`);
|
|
200
255
|
return false;
|
|
201
256
|
}
|
|
202
257
|
}
|
|
203
258
|
|
|
204
|
-
private async
|
|
259
|
+
private async validateLicence(tokenJwt: string, publicKey: string, updateCache: boolean): Promise<boolean> {
|
|
205
260
|
try {
|
|
206
|
-
const
|
|
207
|
-
const body = { "client_name": realm };
|
|
208
|
-
const response = await axios.post(url, body);
|
|
209
|
-
if (response.status === HttpStatusCode.Ok) {
|
|
210
|
-
return {
|
|
211
|
-
code: HttpStatusCode.Ok,
|
|
212
|
-
data: response.data.token
|
|
213
|
-
};
|
|
214
|
-
} else {
|
|
215
|
-
return {
|
|
216
|
-
code: HttpStatusCode.InternalServerError,
|
|
217
|
-
message: "InternalServer error"
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
} catch (error: any) {
|
|
221
|
-
return {
|
|
222
|
-
code: HttpStatusCode.InternalServerError,
|
|
223
|
-
data: error?.response?.data || error?.message || 'Licencing service error'
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
}
|
|
261
|
+
const payload: any = this.verifyLicenceJwt(tokenJwt, publicKey);
|
|
227
262
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
const licEndMs = this.normalizeEpochMs(token?.lic_end);
|
|
232
|
-
const now = this.getUtcNowMs();
|
|
263
|
+
// Your custom claim: lic_end
|
|
264
|
+
const licEndMs = this.normalizeEpochMs(payload?.lic_end);
|
|
265
|
+
const now = Date.now();
|
|
233
266
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
this.logger.error(`Licence expired. Expiry: ${new Date(licEndMs || 0).toISOString()}, Server Time: ${new Date(now).toISOString()}`);
|
|
267
|
+
if (!licEndMs) {
|
|
268
|
+
this.logger.error('Licence token missing lic_end');
|
|
237
269
|
this.markLicenceExpired();
|
|
238
|
-
return
|
|
270
|
+
return false;
|
|
239
271
|
}
|
|
240
272
|
|
|
241
|
-
|
|
273
|
+
if (now >= licEndMs + AuthMiddleware.CLOCK_TOLERANCE_MS) {
|
|
274
|
+
this.logger.error(
|
|
275
|
+
`Licence already expired. lic_end=${new Date(licEndMs).toISOString()} now=${new Date(now).toISOString()}`
|
|
276
|
+
);
|
|
277
|
+
this.markLicenceExpired();
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ✅ Save only the timestamp (supports 50 days / 500 days / years)
|
|
242
282
|
AuthMiddleware.licenceValidatedUntilMs = licEndMs;
|
|
243
|
-
this.scheduleLicenceShutdown(licEndMs);
|
|
244
283
|
|
|
284
|
+
// ✅ Cache only briefly (avoid long TTL -> avoids TimeoutOverflowWarning)
|
|
245
285
|
if (updateCache) {
|
|
246
|
-
|
|
247
|
-
const MAX_TTL = 2147483647;
|
|
248
|
-
const calculatedTtl = licEndMs - now;
|
|
249
|
-
const ttl = Math.min(Math.max(0, calculatedTtl), MAX_TTL);
|
|
250
|
-
await this.cacheManager.set('client_Licence_token', lic_data, ttl);
|
|
286
|
+
await this.cacheSetSeconds('client_Licence_token', tokenJwt, AuthMiddleware.LIC_CACHE_TTL_SECONDS);
|
|
251
287
|
}
|
|
252
288
|
|
|
253
|
-
return
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
this.logger.error(`Invalid
|
|
289
|
+
return true;
|
|
290
|
+
} catch (e: any) {
|
|
291
|
+
// If JWT invalid, expire
|
|
292
|
+
const name = e?.name || '';
|
|
293
|
+
if (name === 'JsonWebTokenError' || name === 'TokenExpiredError' || name === 'NotBeforeError') {
|
|
294
|
+
this.logger.error(`Invalid licence token: ${e?.message || e}`);
|
|
259
295
|
this.markLicenceExpired();
|
|
260
|
-
|
|
261
|
-
this.logger.warn(`Transient error during licence validation: ${error.message}`);
|
|
296
|
+
return false;
|
|
262
297
|
}
|
|
263
|
-
return { status: false };
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
298
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
299
|
+
// transient failures should not hard-expire immediately
|
|
300
|
+
this.logger.warn(`Transient licence validation error: ${e?.message || e}`);
|
|
301
|
+
return false;
|
|
270
302
|
}
|
|
271
303
|
}
|
|
272
304
|
|
|
273
|
-
private
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const now = this.getUtcNowMs();
|
|
278
|
-
const delay = (licEndMs + AuthMiddleware.CLOCK_TOLERANCE_MS) - now;
|
|
305
|
+
private async getLicencingTokenFromService(realm: string): Promise<string> {
|
|
306
|
+
const url =
|
|
307
|
+
this.options.licenceServiceUrl ??
|
|
308
|
+
'https://iiuuckued274sisadalbpf7ivu0eiblk.lambda-url.ap-south-1.on.aws/';
|
|
279
309
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
this.markLicenceExpired();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
310
|
+
const body = { client_name: realm };
|
|
311
|
+
const resp = await axios.post(url, body, { timeout: 15_000 });
|
|
285
312
|
|
|
286
|
-
if (
|
|
287
|
-
|
|
313
|
+
if (resp.status !== HttpStatusCode.Ok) {
|
|
314
|
+
throw new Error(`Licencing service returned status ${resp.status}`);
|
|
288
315
|
}
|
|
289
316
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const safeDelay = Math.min(delay, MAX_TIMEOUT_VAL);
|
|
317
|
+
const token = resp?.data?.token;
|
|
318
|
+
if (!token || typeof token !== 'string') {
|
|
319
|
+
throw new Error('Licencing service response missing token');
|
|
320
|
+
}
|
|
295
321
|
|
|
296
|
-
|
|
297
|
-
const remaining = (licEndMs + AuthMiddleware.CLOCK_TOLERANCE_MS) - this.getUtcNowMs();
|
|
298
|
-
if (remaining <= 0) {
|
|
299
|
-
this.markLicenceExpired();
|
|
300
|
-
} else {
|
|
301
|
-
// Reschedule if we still have time left
|
|
302
|
-
this.scheduleLicenceShutdown(licEndMs);
|
|
303
|
-
}
|
|
304
|
-
}, safeDelay);
|
|
322
|
+
return token;
|
|
305
323
|
}
|
|
306
324
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
325
|
+
// -------------------------
|
|
326
|
+
// Keycloak client token caching (safe TTL)
|
|
327
|
+
// -------------------------
|
|
328
|
+
private async getOrFetchClientAccessToken(): Promise<string> {
|
|
329
|
+
let token = (await this.cacheManager.get('client_access_token')) as string | undefined;
|
|
330
|
+
if (token) return token;
|
|
331
|
+
|
|
332
|
+
token = await this.clientLogin();
|
|
333
|
+
|
|
334
|
+
const decoded: any = jwt.decode(token);
|
|
335
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
336
|
+
const expSec = Number(decoded?.exp || 0);
|
|
337
|
+
|
|
338
|
+
// ttl seconds: refresh early, clamp to [60, 3600]
|
|
339
|
+
let ttlSec = expSec - nowSec - AuthMiddleware.CLIENT_TOKEN_TTL_SAFETY_SKEW_SECONDS;
|
|
340
|
+
if (!Number.isFinite(ttlSec) || ttlSec <= 0) ttlSec = 60;
|
|
341
|
+
ttlSec = Math.min(Math.max(60, ttlSec), AuthMiddleware.CLIENT_TOKEN_TTL_MAX_SECONDS);
|
|
342
|
+
|
|
343
|
+
await this.cacheSetSeconds('client_access_token', token, ttlSec);
|
|
344
|
+
return token;
|
|
324
345
|
}
|
|
325
346
|
|
|
326
|
-
private
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
347
|
+
private async clientLogin(): Promise<string> {
|
|
348
|
+
const { keycloakUrl, realm, clientId, clientSecret } = this.options;
|
|
349
|
+
|
|
350
|
+
const resp = await axios.post(
|
|
351
|
+
`${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`,
|
|
352
|
+
new URLSearchParams({
|
|
353
|
+
grant_type: 'client_credentials',
|
|
354
|
+
client_id: clientId,
|
|
355
|
+
client_secret: clientSecret
|
|
356
|
+
}).toString(),
|
|
357
|
+
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15_000 }
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const token = resp?.data?.access_token;
|
|
361
|
+
if (!token) {
|
|
362
|
+
throw new Error('Keycloak did not return access_token');
|
|
333
363
|
}
|
|
334
|
-
return
|
|
364
|
+
return token;
|
|
335
365
|
}
|
|
336
366
|
|
|
337
|
-
private
|
|
338
|
-
|
|
367
|
+
private async getClientRoleAttributes(role: string, token: string) {
|
|
368
|
+
const { keycloakUrl, realm, clientUuid } = this.options;
|
|
369
|
+
|
|
370
|
+
const resp = await axios.get(
|
|
371
|
+
`${keycloakUrl}/admin/realms/${realm}/clients/${clientUuid}/roles/${role}`,
|
|
372
|
+
{ headers: { Authorization: `Bearer ${token}` }, timeout: 15_000 }
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
return resp.data;
|
|
339
376
|
}
|
|
340
377
|
|
|
341
|
-
|
|
378
|
+
// -------------------------
|
|
379
|
+
// JWT helpers
|
|
380
|
+
// -------------------------
|
|
381
|
+
private verifyLicenceJwt(token: string, publicKey: string) {
|
|
342
382
|
const publicKeys = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
|
|
343
|
-
|
|
344
|
-
//
|
|
383
|
+
|
|
384
|
+
// We ignore standard 'exp' and rely on custom 'lic_end'
|
|
345
385
|
return jwt.verify(token, publicKeys, {
|
|
346
386
|
algorithms: ['RS256'],
|
|
347
387
|
ignoreExpiration: true,
|
|
@@ -351,10 +391,45 @@ export class AuthMiddleware implements NestMiddleware, OnModuleInit {
|
|
|
351
391
|
|
|
352
392
|
private decodeAccessToken(authHeader: string, publicKey: string) {
|
|
353
393
|
const token = this.extractBearerToken(authHeader);
|
|
354
|
-
|
|
394
|
+
const publicKeys = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
|
|
395
|
+
|
|
396
|
+
return jwt.verify(token, publicKeys, {
|
|
397
|
+
algorithms: ['RS256'],
|
|
398
|
+
clockTolerance: 60
|
|
399
|
+
});
|
|
355
400
|
}
|
|
356
401
|
|
|
357
402
|
private extractBearerToken(authHeader: string) {
|
|
358
403
|
return authHeader.split(' ')[1];
|
|
359
404
|
}
|
|
405
|
+
|
|
406
|
+
// -------------------------
|
|
407
|
+
// Time helpers
|
|
408
|
+
// -------------------------
|
|
409
|
+
private normalizeEpochMs(epoch: string | number): number | null {
|
|
410
|
+
const n = Number(epoch);
|
|
411
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
412
|
+
|
|
413
|
+
// If it's seconds (10 digits), convert to ms
|
|
414
|
+
// If it's ms (13 digits), keep
|
|
415
|
+
if (n < 1_000_000_000_000) return n * 1000;
|
|
416
|
+
return n;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// -------------------------
|
|
420
|
+
// Cache helper (seconds-based, safe across stores)
|
|
421
|
+
// - Using seconds avoids ms/sec mismatch across cache-manager stores
|
|
422
|
+
// - TTL is kept small so no overflow even if a store uses Node timers
|
|
423
|
+
// -------------------------
|
|
424
|
+
private async cacheSetSeconds(key: string, value: any, ttlSeconds: number) {
|
|
425
|
+
// ttlSeconds=0 => store forever (supported by many stores); if not, we just set without ttl
|
|
426
|
+
if (!ttlSeconds || ttlSeconds <= 0) {
|
|
427
|
+
await (this.cacheManager.set as any)(key, value);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// cache-manager / Nest stores vary: some accept third param number, others accept { ttl }
|
|
432
|
+
// We use { ttl } for better compatibility, and TTL is small anyway.
|
|
433
|
+
await (this.cacheManager.set as any)(key, value, { ttl: ttlSeconds });
|
|
434
|
+
}
|
|
360
435
|
}
|