@focura/auth-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/index.d.ts +593 -0
- package/dist/index.js +1362 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import crypto3 from 'crypto';
|
|
3
|
+
import { generateSecret, generateURI, verify } from 'otplib';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name in all)
|
|
13
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// src/config.ts
|
|
17
|
+
function resolveConfig(raw) {
|
|
18
|
+
return {
|
|
19
|
+
...raw,
|
|
20
|
+
keyPrefix: raw.keyPrefix ?? DEFAULTS.keyPrefix,
|
|
21
|
+
issuer: raw.jwt.issuer ?? DEFAULTS.issuer,
|
|
22
|
+
audience: raw.jwt.audience ?? DEFAULTS.audience,
|
|
23
|
+
accessTokenExpiry: raw.jwt.accessTokenExpiry ?? DEFAULTS.accessTokenExpiry,
|
|
24
|
+
refreshTokenExpiry: raw.jwt.refreshTokenExpiry ?? DEFAULTS.refreshTokenExpiry,
|
|
25
|
+
sseTokenExpiry: raw.jwt.sseTokenExpiry ?? DEFAULTS.sseTokenExpiry,
|
|
26
|
+
currentVersion: raw.jwt.currentVersion ?? DEFAULTS.currentVersion,
|
|
27
|
+
maxConcurrentSessions: raw.session?.maxConcurrent ?? DEFAULTS.maxConcurrentSessions,
|
|
28
|
+
sessionMetadataTtl: raw.session?.metadataTtl ?? DEFAULTS.sessionMetadataTtl,
|
|
29
|
+
lockoutMaxFailures: raw.lockout?.maxFailures ?? DEFAULTS.lockoutMaxFailures,
|
|
30
|
+
lockoutSeconds: raw.lockout?.lockoutSeconds ?? DEFAULTS.lockoutSeconds,
|
|
31
|
+
lockoutWindowSeconds: raw.lockout?.windowSeconds ?? DEFAULTS.lockoutWindowSeconds,
|
|
32
|
+
inactivityTimeout: raw.session?.inactivityTimeout ?? 7 * 24 * 60 * 60,
|
|
33
|
+
absoluteTimeout: raw.session?.absoluteTimeout ?? 7 * 24 * 60 * 60
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
var DEFAULTS, AUDIT_SEVERITY;
|
|
37
|
+
var init_config = __esm({
|
|
38
|
+
"src/config.ts"() {
|
|
39
|
+
DEFAULTS = {
|
|
40
|
+
keyPrefix: "focura:",
|
|
41
|
+
issuer: "focura-app",
|
|
42
|
+
audience: "focura-backend",
|
|
43
|
+
accessTokenExpiry: "15m",
|
|
44
|
+
refreshTokenExpiry: "7d",
|
|
45
|
+
sseTokenExpiry: "30s",
|
|
46
|
+
currentVersion: 1,
|
|
47
|
+
maxConcurrentSessions: 5,
|
|
48
|
+
sessionMetadataTtl: 7 * 24 * 60 * 60,
|
|
49
|
+
lockoutMaxFailures: 10,
|
|
50
|
+
lockoutSeconds: 15 * 60,
|
|
51
|
+
lockoutWindowSeconds: 60 * 60,
|
|
52
|
+
refreshLockTtlSeconds: 45,
|
|
53
|
+
refreshDedupeTtlSeconds: 30,
|
|
54
|
+
revokedSessionTtl: 7 * 24 * 60 * 60,
|
|
55
|
+
inactivityTimeout: 7 * 24 * 60 * 60,
|
|
56
|
+
absoluteTimeout: 7 * 24 * 60 * 60
|
|
57
|
+
};
|
|
58
|
+
AUDIT_SEVERITY = {
|
|
59
|
+
LOGIN_SUCCESS: "info",
|
|
60
|
+
LOGIN_FAILED: "warn",
|
|
61
|
+
LOGIN_BLOCKED: "warn",
|
|
62
|
+
LOGOUT: "info",
|
|
63
|
+
LOGOUT_ALL_DEVICES: "info",
|
|
64
|
+
TOKEN_REFRESHED: "info",
|
|
65
|
+
TOKEN_REVOKED: "info",
|
|
66
|
+
TOKEN_EXPIRED: "info",
|
|
67
|
+
TOKEN_VERSION_MISMATCH: "warn",
|
|
68
|
+
TOKEN_REPLAY_DETECTED: "critical",
|
|
69
|
+
EXCHANGE_SUCCESS: "info",
|
|
70
|
+
EXCHANGE_FAILED: "warn",
|
|
71
|
+
SSE_CONNECTED: "info",
|
|
72
|
+
SSE_DISCONNECTED: "info",
|
|
73
|
+
ACCOUNT_LOCKED: "critical",
|
|
74
|
+
TOTP_VERIFIED: "info",
|
|
75
|
+
TOTP_FAILED: "warn",
|
|
76
|
+
PERMISSION_DENIED: "warn",
|
|
77
|
+
EMAIL_NOT_VERIFIED: "warn",
|
|
78
|
+
SESSION_HIJACK_DETECTED: "critical",
|
|
79
|
+
SESSION_BOUND: "info",
|
|
80
|
+
SESSION_REBOUND: "info",
|
|
81
|
+
SESSION_TIMEOUT: "info",
|
|
82
|
+
MAX_SESSIONS_REACHED: "warn",
|
|
83
|
+
SESSION_REVOKED: "info",
|
|
84
|
+
SESSIONS_REVOKED: "info",
|
|
85
|
+
DEVICE_MISMATCH: "warn",
|
|
86
|
+
SUSPICIOUS_IP_CHANGE: "warn",
|
|
87
|
+
CSRF_VALIDATION_FAILED: "warn",
|
|
88
|
+
UNAUTHORIZED_ACCESS: "warn",
|
|
89
|
+
RATE_LIMIT_EXCEEDED: "warn",
|
|
90
|
+
MALWARE_DETECTED: "critical",
|
|
91
|
+
SUSPICIOUS_ACTIVITY: "warn",
|
|
92
|
+
DATA_EXPORT: "info",
|
|
93
|
+
DATA_DELETION: "critical",
|
|
94
|
+
SENSITIVE_DATA_ACCESS: "warn",
|
|
95
|
+
WORKSPACE_CREATED: "info",
|
|
96
|
+
WORKSPACE_DELETED: "critical",
|
|
97
|
+
MEMBER_ADDED: "info",
|
|
98
|
+
MEMBER_REMOVED: "info",
|
|
99
|
+
ROLE_CHANGED: "info",
|
|
100
|
+
SUBSCRIPTION_CREATED: "info",
|
|
101
|
+
SUBSCRIPTION_CANCELLED: "info",
|
|
102
|
+
PAYMENT_FAILED: "warn"
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// src/middleware/sessionTimeout.ts
|
|
108
|
+
var sessionTimeout_exports = {};
|
|
109
|
+
__export(sessionTimeout_exports, {
|
|
110
|
+
SessionTimeoutManager: () => SessionTimeoutManager
|
|
111
|
+
});
|
|
112
|
+
var SessionTimeoutManager;
|
|
113
|
+
var init_sessionTimeout = __esm({
|
|
114
|
+
"src/middleware/sessionTimeout.ts"() {
|
|
115
|
+
init_config();
|
|
116
|
+
SessionTimeoutManager = class {
|
|
117
|
+
constructor(redis, config) {
|
|
118
|
+
this.redis = redis;
|
|
119
|
+
this.inactivityTimeout = config?.inactivityTimeout ?? DEFAULTS.inactivityTimeout;
|
|
120
|
+
this.absoluteTimeout = config?.absoluteTimeout ?? DEFAULTS.absoluteTimeout;
|
|
121
|
+
this.prefix = config?.prefix ?? DEFAULTS.keyPrefix;
|
|
122
|
+
}
|
|
123
|
+
redis;
|
|
124
|
+
inactivityTimeout;
|
|
125
|
+
absoluteTimeout;
|
|
126
|
+
prefix;
|
|
127
|
+
createdKey(sessionId) {
|
|
128
|
+
return `${this.prefix}session:created:${sessionId}`;
|
|
129
|
+
}
|
|
130
|
+
activityKey(sessionId) {
|
|
131
|
+
return `${this.prefix}session:activity:${sessionId}`;
|
|
132
|
+
}
|
|
133
|
+
async recordCreation(sessionId) {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
await this.redis.setex(this.createdKey(sessionId), this.absoluteTimeout, now.toString());
|
|
136
|
+
await this.redis.setex(this.activityKey(sessionId), this.inactivityTimeout, now.toString());
|
|
137
|
+
}
|
|
138
|
+
async invalidate(sessionId) {
|
|
139
|
+
await this.redis.del(this.createdKey(sessionId), this.activityKey(sessionId));
|
|
140
|
+
}
|
|
141
|
+
async isTracked(sessionId) {
|
|
142
|
+
return await this.redis.exists(this.createdKey(sessionId)) === 1;
|
|
143
|
+
}
|
|
144
|
+
async isInactive(sessionId) {
|
|
145
|
+
const exists = await this.redis.exists(this.activityKey(sessionId));
|
|
146
|
+
return exists !== 1;
|
|
147
|
+
}
|
|
148
|
+
async updateActivity(sessionId) {
|
|
149
|
+
try {
|
|
150
|
+
const result = await this.redis.setex(this.activityKey(sessionId), this.inactivityTimeout, Date.now().toString());
|
|
151
|
+
return result === "OK";
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// src/tokens/backendToken.ts
|
|
161
|
+
init_config();
|
|
162
|
+
var TokenManager = class _TokenManager {
|
|
163
|
+
privateKey;
|
|
164
|
+
publicKey;
|
|
165
|
+
issuer;
|
|
166
|
+
audience;
|
|
167
|
+
accessTokenExpiry;
|
|
168
|
+
refreshTokenExpiry;
|
|
169
|
+
sseTokenExpiry;
|
|
170
|
+
currentVersion;
|
|
171
|
+
constructor(config) {
|
|
172
|
+
this.privateKey = config.privateKey;
|
|
173
|
+
this.publicKey = config.publicKey;
|
|
174
|
+
this.issuer = config.issuer ?? DEFAULTS.issuer;
|
|
175
|
+
this.audience = config.audience ?? DEFAULTS.audience;
|
|
176
|
+
this.accessTokenExpiry = config.accessTokenExpiry ?? DEFAULTS.accessTokenExpiry;
|
|
177
|
+
this.refreshTokenExpiry = config.refreshTokenExpiry ?? DEFAULTS.refreshTokenExpiry;
|
|
178
|
+
this.sseTokenExpiry = config.sseTokenExpiry ?? DEFAULTS.sseTokenExpiry;
|
|
179
|
+
this.currentVersion = config.currentVersion ?? DEFAULTS.currentVersion;
|
|
180
|
+
}
|
|
181
|
+
createAccessToken(p) {
|
|
182
|
+
return jwt.sign(
|
|
183
|
+
{
|
|
184
|
+
sub: p.id,
|
|
185
|
+
email: p.email,
|
|
186
|
+
role: p.role,
|
|
187
|
+
type: "access",
|
|
188
|
+
version: this.currentVersion,
|
|
189
|
+
jti: crypto3.randomUUID(),
|
|
190
|
+
sessionId: p.sessionId
|
|
191
|
+
},
|
|
192
|
+
this.privateKey,
|
|
193
|
+
{
|
|
194
|
+
algorithm: "RS256",
|
|
195
|
+
expiresIn: this.accessTokenExpiry,
|
|
196
|
+
issuer: this.issuer,
|
|
197
|
+
audience: this.audience
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
createRefreshToken(p) {
|
|
202
|
+
return jwt.sign(
|
|
203
|
+
{
|
|
204
|
+
sub: p.id,
|
|
205
|
+
email: p.email,
|
|
206
|
+
role: p.role,
|
|
207
|
+
type: "refresh",
|
|
208
|
+
version: this.currentVersion,
|
|
209
|
+
jti: crypto3.randomUUID(),
|
|
210
|
+
sessionId: p.sessionId
|
|
211
|
+
},
|
|
212
|
+
this.privateKey,
|
|
213
|
+
{
|
|
214
|
+
algorithm: "RS256",
|
|
215
|
+
expiresIn: this.refreshTokenExpiry,
|
|
216
|
+
issuer: this.issuer,
|
|
217
|
+
audience: this.audience
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
createTokenPair(p) {
|
|
222
|
+
const sessionId = p.sessionId || crypto3.randomUUID();
|
|
223
|
+
const payload = { ...p, sessionId };
|
|
224
|
+
return {
|
|
225
|
+
accessToken: this.createAccessToken(payload),
|
|
226
|
+
refreshToken: this.createRefreshToken(payload),
|
|
227
|
+
accessTokenExpiry: Date.now() + _TokenManager.parseExpiry(this.accessTokenExpiry),
|
|
228
|
+
refreshTokenExpiry: Date.now() + _TokenManager.parseExpiry(this.refreshTokenExpiry)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
createSseToken(userId) {
|
|
232
|
+
return jwt.sign(
|
|
233
|
+
{
|
|
234
|
+
sub: userId,
|
|
235
|
+
type: "sse",
|
|
236
|
+
version: this.currentVersion,
|
|
237
|
+
jti: crypto3.randomUUID()
|
|
238
|
+
},
|
|
239
|
+
this.privateKey,
|
|
240
|
+
{
|
|
241
|
+
algorithm: "RS256",
|
|
242
|
+
expiresIn: this.sseTokenExpiry,
|
|
243
|
+
issuer: this.issuer,
|
|
244
|
+
audience: this.audience
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
verifyToken(token, expectedType) {
|
|
249
|
+
const decoded = jwt.verify(token, this.publicKey, {
|
|
250
|
+
algorithms: ["RS256"],
|
|
251
|
+
issuer: this.issuer,
|
|
252
|
+
audience: this.audience
|
|
253
|
+
});
|
|
254
|
+
if (decoded.version !== this.currentVersion) throw new Error("Token version mismatch");
|
|
255
|
+
if (expectedType && decoded.type !== expectedType) throw new Error(`Expected '${expectedType}', got '${decoded.type}'`);
|
|
256
|
+
return {
|
|
257
|
+
id: decoded.sub ?? "",
|
|
258
|
+
email: decoded.email,
|
|
259
|
+
role: decoded.role,
|
|
260
|
+
type: decoded.type,
|
|
261
|
+
version: decoded.version,
|
|
262
|
+
jti: decoded.jti ?? "",
|
|
263
|
+
sessionId: decoded.sessionId
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
getPublicKey() {
|
|
267
|
+
return this.publicKey;
|
|
268
|
+
}
|
|
269
|
+
getAccessTokenExpiry() {
|
|
270
|
+
return this.accessTokenExpiry;
|
|
271
|
+
}
|
|
272
|
+
getRefreshTokenExpiry() {
|
|
273
|
+
return this.refreshTokenExpiry;
|
|
274
|
+
}
|
|
275
|
+
getSseTokenExpiry() {
|
|
276
|
+
return this.sseTokenExpiry;
|
|
277
|
+
}
|
|
278
|
+
getCurrentVersion() {
|
|
279
|
+
return this.currentVersion;
|
|
280
|
+
}
|
|
281
|
+
getIssuer() {
|
|
282
|
+
return this.issuer;
|
|
283
|
+
}
|
|
284
|
+
getAudience() {
|
|
285
|
+
return this.audience;
|
|
286
|
+
}
|
|
287
|
+
static parseExpiry(expiry) {
|
|
288
|
+
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
289
|
+
if (!match) throw new Error(`Invalid expiry: ${expiry}`);
|
|
290
|
+
const unit = match[2];
|
|
291
|
+
const multipliers = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
|
|
292
|
+
return parseInt(match[1], 10) * multipliers[unit];
|
|
293
|
+
}
|
|
294
|
+
static extractJti(token) {
|
|
295
|
+
try {
|
|
296
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
|
297
|
+
return payload.jti ?? "";
|
|
298
|
+
} catch {
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// src/revocation/tokenRevocation.ts
|
|
305
|
+
init_config();
|
|
306
|
+
var TokenRevocation = class {
|
|
307
|
+
constructor(redis, prefix = DEFAULTS.keyPrefix) {
|
|
308
|
+
this.redis = redis;
|
|
309
|
+
this.prefix = prefix;
|
|
310
|
+
}
|
|
311
|
+
redis;
|
|
312
|
+
prefix;
|
|
313
|
+
refreshIndexKey(userId) {
|
|
314
|
+
return `${this.prefix}refresh:index:${userId}`;
|
|
315
|
+
}
|
|
316
|
+
refreshTokenKey(userId, jti) {
|
|
317
|
+
return `${this.prefix}refresh:${userId}:${jti}`;
|
|
318
|
+
}
|
|
319
|
+
revokedAccessKey(jti) {
|
|
320
|
+
return `${this.prefix}revoked:access:${jti}`;
|
|
321
|
+
}
|
|
322
|
+
sseKey(jti) {
|
|
323
|
+
return `${this.prefix}sse:${jti}`;
|
|
324
|
+
}
|
|
325
|
+
sessionRevokedKey(sessionId) {
|
|
326
|
+
return `${this.prefix}session:revoked:${sessionId}`;
|
|
327
|
+
}
|
|
328
|
+
async revokeAccessToken(jti, expiresInSeconds) {
|
|
329
|
+
try {
|
|
330
|
+
await this.redis.setex(this.revokedAccessKey(jti), expiresInSeconds, "1");
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error("[TokenRevocation] Failed to revoke access token:", err);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async isAccessTokenRevoked(jti) {
|
|
336
|
+
try {
|
|
337
|
+
const val = await this.redis.get(this.revokedAccessKey(jti));
|
|
338
|
+
return val === "1";
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error("[TokenRevocation] Failed to check revoked access token:", err);
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async storeRefreshToken(userId, jti, expiresInSeconds) {
|
|
345
|
+
try {
|
|
346
|
+
const tokenKey = this.refreshTokenKey(userId, jti);
|
|
347
|
+
await this.redis.setex(tokenKey, expiresInSeconds, JSON.stringify({ jti, createdAt: Date.now() }));
|
|
348
|
+
await this.redis.sadd(this.refreshIndexKey(userId), tokenKey);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.error("[TokenRevocation] Failed to store refresh token:", err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async isRefreshTokenValid(userId, jti) {
|
|
354
|
+
try {
|
|
355
|
+
const val = await this.redis.get(this.refreshTokenKey(userId, jti));
|
|
356
|
+
return val !== null;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.error("[TokenRevocation] Failed to check refresh token validity:", err);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async revokeRefreshToken(userId, jti) {
|
|
363
|
+
try {
|
|
364
|
+
const tokenKey = this.refreshTokenKey(userId, jti);
|
|
365
|
+
await this.redis.del(tokenKey);
|
|
366
|
+
await this.redis.srem(this.refreshIndexKey(userId), tokenKey);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.error("[TokenRevocation] Failed to revoke refresh token:", err);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async revokeAllRefreshTokens(userId) {
|
|
372
|
+
try {
|
|
373
|
+
const idxKey = this.refreshIndexKey(userId);
|
|
374
|
+
let keys = await this.redis.smembers(idxKey);
|
|
375
|
+
if (keys.length === 0) {
|
|
376
|
+
let cursor = "0";
|
|
377
|
+
const pattern = `${this.prefix}refresh:${userId}:*`;
|
|
378
|
+
do {
|
|
379
|
+
const [nextCursor, found] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", "100");
|
|
380
|
+
cursor = nextCursor;
|
|
381
|
+
keys.push(...found);
|
|
382
|
+
} while (cursor !== "0");
|
|
383
|
+
}
|
|
384
|
+
if (keys.length > 0) await this.redis.del(...keys);
|
|
385
|
+
await this.redis.del(idxKey);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error("[TokenRevocation] Failed to revoke all refresh tokens:", err);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async rotateRefreshToken(userId, oldJti, newJti, expiresInSeconds) {
|
|
391
|
+
const oldKey = this.refreshTokenKey(userId, oldJti);
|
|
392
|
+
const newKey = this.refreshTokenKey(userId, newJti);
|
|
393
|
+
const indexKey = this.refreshIndexKey(userId);
|
|
394
|
+
try {
|
|
395
|
+
const result = await this.redis.eval(
|
|
396
|
+
`if redis.call("EXISTS", KEYS[1]) == 1 then
|
|
397
|
+
redis.call("DEL", KEYS[1])
|
|
398
|
+
redis.call("SETEX", KEYS[2], ARGV[1], ARGV[2])
|
|
399
|
+
redis.call("SREM", KEYS[3], KEYS[1])
|
|
400
|
+
redis.call("SADD", KEYS[3], KEYS[2])
|
|
401
|
+
return 1
|
|
402
|
+
else
|
|
403
|
+
return 0
|
|
404
|
+
end`,
|
|
405
|
+
3,
|
|
406
|
+
oldKey,
|
|
407
|
+
newKey,
|
|
408
|
+
indexKey,
|
|
409
|
+
expiresInSeconds.toString(),
|
|
410
|
+
JSON.stringify({ jti: newJti, createdAt: Date.now() })
|
|
411
|
+
);
|
|
412
|
+
return result === 1;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
const err = error;
|
|
415
|
+
if (err?.name === "MaxRetriesPerRequestError") {
|
|
416
|
+
throw new Error("Redis service temporarily unavailable");
|
|
417
|
+
}
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async storeSseToken(jti, userId, ttlSeconds) {
|
|
422
|
+
try {
|
|
423
|
+
await this.redis.setex(this.sseKey(jti), ttlSeconds, userId);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const err = error;
|
|
426
|
+
if (err?.name === "MaxRetriesPerRequestError") {
|
|
427
|
+
throw new Error("Redis service temporarily unavailable");
|
|
428
|
+
}
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async consumeSseToken(jti) {
|
|
433
|
+
const key = this.sseKey(jti);
|
|
434
|
+
try {
|
|
435
|
+
const userId = await this.redis.eval(
|
|
436
|
+
`local v = redis.call("GET", KEYS[1])
|
|
437
|
+
if v then redis.call("DEL", KEYS[1]) end
|
|
438
|
+
return v`,
|
|
439
|
+
1,
|
|
440
|
+
key
|
|
441
|
+
);
|
|
442
|
+
return typeof userId === "string" ? userId : null;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
const err = error;
|
|
445
|
+
if (err?.name === "MaxRetriesPerRequestError") {
|
|
446
|
+
throw new Error("Redis service temporarily unavailable");
|
|
447
|
+
}
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async markSessionRevoked(sessionId) {
|
|
452
|
+
try {
|
|
453
|
+
await this.redis.setex(this.sessionRevokedKey(sessionId), DEFAULTS.revokedSessionTtl, "1");
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.error("[TokenRevocation] Failed to mark session revoked:", err);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async isSessionRevoked(sessionId) {
|
|
459
|
+
try {
|
|
460
|
+
const val = await this.redis.get(this.sessionRevokedKey(sessionId));
|
|
461
|
+
return val === "1";
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error("[TokenRevocation] Failed to check revoked session:", err);
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// src/refresh/refreshLock.ts
|
|
470
|
+
init_config();
|
|
471
|
+
var RefreshLock = class {
|
|
472
|
+
constructor(redis, prefix = DEFAULTS.keyPrefix) {
|
|
473
|
+
this.redis = redis;
|
|
474
|
+
this.prefix = prefix;
|
|
475
|
+
}
|
|
476
|
+
redis;
|
|
477
|
+
prefix;
|
|
478
|
+
lockKey(sessionId) {
|
|
479
|
+
return `${this.prefix}refresh:lock:${sessionId}`;
|
|
480
|
+
}
|
|
481
|
+
async acquire(sessionId) {
|
|
482
|
+
try {
|
|
483
|
+
const result = await this.redis.setnx(
|
|
484
|
+
this.lockKey(sessionId),
|
|
485
|
+
"1"
|
|
486
|
+
);
|
|
487
|
+
return result === 1;
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error("[RefreshLock] Failed to acquire lock:", err);
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async release(sessionId) {
|
|
494
|
+
try {
|
|
495
|
+
await this.redis.del(this.lockKey(sessionId));
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.error("[RefreshLock] Failed to release lock:", err);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async isLocked(sessionId) {
|
|
501
|
+
try {
|
|
502
|
+
const val = await this.redis.get(this.lockKey(sessionId));
|
|
503
|
+
return !!val;
|
|
504
|
+
} catch (err) {
|
|
505
|
+
console.error("[RefreshLock] Failed to check lock status:", err);
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// src/session/sessionManager.ts
|
|
512
|
+
init_config();
|
|
513
|
+
var SessionManager = class {
|
|
514
|
+
constructor(redis, tokenRevocation, auditLogger, prefix = DEFAULTS.keyPrefix, maxConcurrent) {
|
|
515
|
+
this.redis = redis;
|
|
516
|
+
this.tokenRevocation = tokenRevocation;
|
|
517
|
+
this.auditLogger = auditLogger;
|
|
518
|
+
this.prefix = prefix;
|
|
519
|
+
this.maxConcurrent = maxConcurrent ?? DEFAULTS.maxConcurrentSessions;
|
|
520
|
+
}
|
|
521
|
+
redis;
|
|
522
|
+
tokenRevocation;
|
|
523
|
+
auditLogger;
|
|
524
|
+
maxConcurrent;
|
|
525
|
+
prefix;
|
|
526
|
+
sessionKey(userId) {
|
|
527
|
+
return `${this.prefix}user:sessions:${userId}`;
|
|
528
|
+
}
|
|
529
|
+
metadataKey(sessionId) {
|
|
530
|
+
return `${this.prefix}session:metadata:${sessionId}`;
|
|
531
|
+
}
|
|
532
|
+
async trackUserSession(userId, sessionId) {
|
|
533
|
+
try {
|
|
534
|
+
const key = this.sessionKey(userId);
|
|
535
|
+
const members = await this.redis.smembers(key);
|
|
536
|
+
if (members.length >= this.maxConcurrent) {
|
|
537
|
+
this.auditLogger?.log("MAX_SESSIONS_REACHED", {
|
|
538
|
+
userId,
|
|
539
|
+
sessionCount: members.length,
|
|
540
|
+
limit: this.maxConcurrent
|
|
541
|
+
});
|
|
542
|
+
const evictedSession = await this.pickLeastActiveSession(members);
|
|
543
|
+
await this.redis.srem(key, evictedSession);
|
|
544
|
+
await this.redis.del(this.metadataKey(evictedSession));
|
|
545
|
+
await this.tokenRevocation.markSessionRevoked(evictedSession);
|
|
546
|
+
}
|
|
547
|
+
await this.redis.sadd(key, sessionId);
|
|
548
|
+
await this.redis.expire(key, DEFAULTS.sessionMetadataTtl);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
console.error(`[SessionManager] Failed to track session for user ${userId}:`, err);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async pickLeastActiveSession(members) {
|
|
554
|
+
let leastActive = members[0];
|
|
555
|
+
let leastActiveAt = Infinity;
|
|
556
|
+
for (const member of members) {
|
|
557
|
+
let lastActivity = 0;
|
|
558
|
+
try {
|
|
559
|
+
const meta = await this.redis.get(this.metadataKey(member));
|
|
560
|
+
if (meta) lastActivity = JSON.parse(meta).lastActivity ?? 0;
|
|
561
|
+
} catch {
|
|
562
|
+
lastActivity = 0;
|
|
563
|
+
}
|
|
564
|
+
if (lastActivity < leastActiveAt) {
|
|
565
|
+
leastActiveAt = lastActivity;
|
|
566
|
+
leastActive = member;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return leastActive;
|
|
570
|
+
}
|
|
571
|
+
async revokeUserSession(userId, sessionId) {
|
|
572
|
+
try {
|
|
573
|
+
await this.redis.srem(this.sessionKey(userId), sessionId);
|
|
574
|
+
await this.redis.del(this.metadataKey(sessionId));
|
|
575
|
+
} catch (err) {
|
|
576
|
+
console.error(`[SessionManager] Failed to revoke session for user ${userId}:`, err);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async getUserActiveSessions(userId) {
|
|
580
|
+
try {
|
|
581
|
+
const sessionIds = await this.redis.smembers(this.sessionKey(userId));
|
|
582
|
+
const sessions = [];
|
|
583
|
+
for (const sessionId of sessionIds) {
|
|
584
|
+
const metadata = await this.redis.get(this.metadataKey(sessionId));
|
|
585
|
+
if (metadata) {
|
|
586
|
+
try {
|
|
587
|
+
const parsed = JSON.parse(metadata);
|
|
588
|
+
sessions.push({
|
|
589
|
+
sessionId,
|
|
590
|
+
deviceInfo: parsed.userAgent ?? "",
|
|
591
|
+
ipAddress: parsed.ipAddress,
|
|
592
|
+
lastActivity: new Date(parsed.lastActivity).toISOString(),
|
|
593
|
+
createdAt: new Date(parsed.createdAt ?? parsed.lastActivity).toISOString()
|
|
594
|
+
});
|
|
595
|
+
} catch {
|
|
596
|
+
console.warn(`[SessionManager] Corrupted metadata for session ${sessionId}, skipping`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return sessions;
|
|
601
|
+
} catch (err) {
|
|
602
|
+
console.error(`[SessionManager] Failed to get sessions for user ${userId}:`, err);
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
var SERVER_TO_SERVER_UA = /^(node|undici|axios|curl|go-http-client|python-requests|java\/|okhttp|superagent)/i;
|
|
608
|
+
function looksLikeServerToServerUA(userAgent) {
|
|
609
|
+
return !userAgent || SERVER_TO_SERVER_UA.test(userAgent);
|
|
610
|
+
}
|
|
611
|
+
function looksLikeServerToServerRequest(req) {
|
|
612
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
613
|
+
if (looksLikeServerToServerUA(ua)) return true;
|
|
614
|
+
return !req.headers["accept-language"] && !req.headers["accept-encoding"];
|
|
615
|
+
}
|
|
616
|
+
function normalizeUserAgent(userAgent) {
|
|
617
|
+
const ua = userAgent || "";
|
|
618
|
+
const mobile = /Mobile|Android|iPhone|iPad|iPod/i.test(ua);
|
|
619
|
+
const browser = /Edg\//i.test(ua) ? "Edge" : /OPR\/|Opera/i.test(ua) ? "Opera" : /Firefox\/|FxiOS/i.test(ua) ? "Firefox" : /CriOS\/|Chrome\//i.test(ua) ? "Chrome" : /Safari\//i.test(ua) ? "Safari" : "Other";
|
|
620
|
+
const os = /Android/i.test(ua) ? "Android" : /iPhone|iPad|iPod/i.test(ua) ? "iOS" : /Windows/i.test(ua) ? "Windows" : /Macintosh|Mac OS X/i.test(ua) ? "macOS" : /Linux/i.test(ua) ? "Linux" : "Other";
|
|
621
|
+
return `${browser}|${os}|${mobile ? "mobile" : "desktop"}`;
|
|
622
|
+
}
|
|
623
|
+
function primaryLanguage(acceptLanguage) {
|
|
624
|
+
const first = (acceptLanguage || "").split(",")[0]?.trim() ?? "";
|
|
625
|
+
return first ? first.split("-")[0].toLowerCase() : "";
|
|
626
|
+
}
|
|
627
|
+
function generateDeviceFingerprint(req) {
|
|
628
|
+
const components = [
|
|
629
|
+
normalizeUserAgent(req.headers["user-agent"] || ""),
|
|
630
|
+
primaryLanguage(req.headers["accept-language"] || "")
|
|
631
|
+
].join("|");
|
|
632
|
+
return crypto3.createHash("sha256").update(components).digest("hex").substring(0, 32);
|
|
633
|
+
}
|
|
634
|
+
function getClientIp(req) {
|
|
635
|
+
if (typeof req.ip === "string" && req.ip.length > 0) {
|
|
636
|
+
return req.ip.startsWith("::ffff:") ? req.ip.slice(7) : req.ip;
|
|
637
|
+
}
|
|
638
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
639
|
+
if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
|
|
640
|
+
const ip = req.socket?.remoteAddress || "unknown";
|
|
641
|
+
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
642
|
+
}
|
|
643
|
+
function createSessionMetadata(req) {
|
|
644
|
+
return {
|
|
645
|
+
deviceId: null,
|
|
646
|
+
ipAddress: getClientIp(req),
|
|
647
|
+
userAgent: req.headers["user-agent"] || "unknown",
|
|
648
|
+
lastActivity: Date.now()
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function isPrivateIp(ip) {
|
|
652
|
+
if (!ip || ip === "unknown") return true;
|
|
653
|
+
const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
654
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(normalized)) {
|
|
655
|
+
const [a, b] = normalized.split(".").map(Number);
|
|
656
|
+
return a === 0 || a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
657
|
+
}
|
|
658
|
+
return normalized === "::1" || /^fe80:/i.test(normalized) || /^fc/i.test(normalized) || /^fd/i.test(normalized);
|
|
659
|
+
}
|
|
660
|
+
function validateSessionBinding(req, storedMetadata) {
|
|
661
|
+
const currentDeviceId = generateDeviceFingerprint(req);
|
|
662
|
+
const currentIp = getClientIp(req);
|
|
663
|
+
if (currentDeviceId !== storedMetadata.deviceId) {
|
|
664
|
+
return { valid: false, reason: "DEVICE_MISMATCH" };
|
|
665
|
+
}
|
|
666
|
+
if (currentIp !== storedMetadata.ipAddress) {
|
|
667
|
+
if (isPrivateIp(currentIp) || isPrivateIp(storedMetadata.ipAddress)) {
|
|
668
|
+
return { valid: true };
|
|
669
|
+
}
|
|
670
|
+
const timeSinceLastActivity = Date.now() - storedMetadata.lastActivity;
|
|
671
|
+
const maxIpChangeInterval = 5 * 60 * 1e3;
|
|
672
|
+
if (timeSinceLastActivity < maxIpChangeInterval) {
|
|
673
|
+
return { valid: false, reason: "SUSPICIOUS_IP_CHANGE" };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return { valid: true };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/lockout/accountLockout.ts
|
|
680
|
+
init_config();
|
|
681
|
+
var AccountLockout = class {
|
|
682
|
+
constructor(redis, config) {
|
|
683
|
+
this.redis = redis;
|
|
684
|
+
this.maxFailures = config?.maxFailures ?? DEFAULTS.lockoutMaxFailures;
|
|
685
|
+
this.lockoutSeconds = config?.lockoutSeconds ?? DEFAULTS.lockoutSeconds;
|
|
686
|
+
this.windowSeconds = config?.windowSeconds ?? DEFAULTS.lockoutWindowSeconds;
|
|
687
|
+
this.prefix = config?.prefix ?? `${DEFAULTS.keyPrefix}lockout`;
|
|
688
|
+
}
|
|
689
|
+
redis;
|
|
690
|
+
maxFailures;
|
|
691
|
+
lockoutSeconds;
|
|
692
|
+
windowSeconds;
|
|
693
|
+
prefix;
|
|
694
|
+
async recordFailedAttempt(identifier) {
|
|
695
|
+
try {
|
|
696
|
+
const failKey = `${this.prefix}:failures:${identifier}`;
|
|
697
|
+
const lockKey = `${this.prefix}:locked:${identifier}`;
|
|
698
|
+
const existingLock = await this.redis.get(lockKey);
|
|
699
|
+
if (existingLock) {
|
|
700
|
+
return { locked: true, unlocksAt: new Date(Number(existingLock)), attempts: this.maxFailures };
|
|
701
|
+
}
|
|
702
|
+
const pipe = this.redis.pipeline();
|
|
703
|
+
pipe.incr(failKey);
|
|
704
|
+
pipe.expire(failKey, this.windowSeconds);
|
|
705
|
+
const results = await pipe.exec();
|
|
706
|
+
const attempts = results?.[0]?.[1] ?? 1;
|
|
707
|
+
if (attempts >= this.maxFailures) {
|
|
708
|
+
const unlocksAt = Date.now() + this.lockoutSeconds * 1e3;
|
|
709
|
+
await this.redis.setex(lockKey, this.lockoutSeconds, String(unlocksAt));
|
|
710
|
+
return { locked: true, unlocksAt: new Date(unlocksAt), attempts };
|
|
711
|
+
}
|
|
712
|
+
return { locked: false, attempts };
|
|
713
|
+
} catch (err) {
|
|
714
|
+
console.error("[AccountLockout] Failed to record failed attempt:", err);
|
|
715
|
+
return { locked: false, attempts: 0 };
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async clearFailedAttempts(identifier) {
|
|
719
|
+
try {
|
|
720
|
+
await this.redis.del(`${this.prefix}:failures:${identifier}`, `${this.prefix}:locked:${identifier}`);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
console.error("[AccountLockout] Failed to clear failed attempts:", err);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async isAccountLocked(identifier) {
|
|
726
|
+
try {
|
|
727
|
+
const lockedUntil = await this.redis.get(`${this.prefix}:locked:${identifier}`);
|
|
728
|
+
if (!lockedUntil) return { locked: false };
|
|
729
|
+
return { locked: true, unlocksAt: new Date(Number(lockedUntil)) };
|
|
730
|
+
} catch (err) {
|
|
731
|
+
console.error("[AccountLockout] Failed to check account lock status:", err);
|
|
732
|
+
return { locked: false };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// src/audit/auditLog.ts
|
|
738
|
+
init_config();
|
|
739
|
+
var AuditLog = class {
|
|
740
|
+
constructor(logger) {
|
|
741
|
+
this.logger = logger;
|
|
742
|
+
}
|
|
743
|
+
logger;
|
|
744
|
+
log(event, data) {
|
|
745
|
+
const severity = AUDIT_SEVERITY[event] ?? "info";
|
|
746
|
+
const entry = { event, severity, timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...data };
|
|
747
|
+
const output = JSON.stringify(entry);
|
|
748
|
+
if (severity === "critical") {
|
|
749
|
+
console.error(`[AUDIT:CRITICAL] ${output}`);
|
|
750
|
+
} else if (severity === "warn") {
|
|
751
|
+
console.warn(`[AUDIT:WARN] ${output}`);
|
|
752
|
+
} else {
|
|
753
|
+
console.info(`[AUDIT:INFO] ${output}`);
|
|
754
|
+
}
|
|
755
|
+
if (this.logger) {
|
|
756
|
+
void this.logger.log(event, data).catch((err) => {
|
|
757
|
+
if (process.env.NODE_ENV !== "test") {
|
|
758
|
+
console.error("[AuditLog] Failed to persist audit event:", err?.message ?? err);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
var TotpManager = class {
|
|
765
|
+
issuer;
|
|
766
|
+
constructor(issuer = "Auth") {
|
|
767
|
+
this.issuer = issuer;
|
|
768
|
+
}
|
|
769
|
+
generateSecret() {
|
|
770
|
+
return generateSecret();
|
|
771
|
+
}
|
|
772
|
+
createUri(secret, email) {
|
|
773
|
+
return generateURI({ issuer: this.issuer, label: email, secret });
|
|
774
|
+
}
|
|
775
|
+
async verify(token, secret) {
|
|
776
|
+
try {
|
|
777
|
+
const result = verify({ token, secret });
|
|
778
|
+
const normalized = result;
|
|
779
|
+
if (typeof normalized === "boolean") return normalized;
|
|
780
|
+
return normalized.valid === true;
|
|
781
|
+
} catch {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// src/index.ts
|
|
788
|
+
init_sessionTimeout();
|
|
789
|
+
|
|
790
|
+
// src/middleware/middlewareFactory.ts
|
|
791
|
+
init_config();
|
|
792
|
+
|
|
793
|
+
// src/errors/index.ts
|
|
794
|
+
var UnauthorizedError = class extends Error {
|
|
795
|
+
code;
|
|
796
|
+
statusCode = 401;
|
|
797
|
+
constructor(message = "Unauthorized", code = "UNAUTHORIZED") {
|
|
798
|
+
super(message);
|
|
799
|
+
this.name = "UnauthorizedError";
|
|
800
|
+
this.code = code;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
var TokenExpiredError = class extends Error {
|
|
804
|
+
code = "TOKEN_EXPIRED";
|
|
805
|
+
statusCode = 401;
|
|
806
|
+
constructor(message = "Token expired") {
|
|
807
|
+
super(message);
|
|
808
|
+
this.name = "TokenExpiredError";
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
var InvalidTokenError = class extends Error {
|
|
812
|
+
code;
|
|
813
|
+
statusCode = 401;
|
|
814
|
+
constructor(code = "INVALID_TOKEN") {
|
|
815
|
+
super("Invalid token");
|
|
816
|
+
this.name = "InvalidTokenError";
|
|
817
|
+
this.code = code;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
var TokenRevokedError = class extends Error {
|
|
821
|
+
code = "TOKEN_REVOKED";
|
|
822
|
+
statusCode = 401;
|
|
823
|
+
constructor(message = "Token has been revoked") {
|
|
824
|
+
super(message);
|
|
825
|
+
this.name = "TokenRevokedError";
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
var EmailNotVerifiedError = class extends Error {
|
|
829
|
+
code = "EMAIL_NOT_VERIFIED";
|
|
830
|
+
statusCode = 403;
|
|
831
|
+
constructor(message = "Email not verified") {
|
|
832
|
+
super(message);
|
|
833
|
+
this.name = "EmailNotVerifiedError";
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
var AccountBannedError = class extends Error {
|
|
837
|
+
code = "ACCOUNT_BANNED";
|
|
838
|
+
statusCode = 403;
|
|
839
|
+
bannedAt;
|
|
840
|
+
constructor(reason, bannedAt) {
|
|
841
|
+
super(reason ?? "Account has been banned");
|
|
842
|
+
this.name = "AccountBannedError";
|
|
843
|
+
this.bannedAt = bannedAt ?? void 0;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
var ForbiddenError = class extends Error {
|
|
847
|
+
code = "FORBIDDEN";
|
|
848
|
+
statusCode = 403;
|
|
849
|
+
constructor(message = "Forbidden") {
|
|
850
|
+
super(message);
|
|
851
|
+
this.name = "ForbiddenError";
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
var SessionHijackError = class extends Error {
|
|
855
|
+
code = "SESSION_HIJACK_DETECTED";
|
|
856
|
+
statusCode = 401;
|
|
857
|
+
constructor(reason) {
|
|
858
|
+
super(reason ?? "Session hijack detected");
|
|
859
|
+
this.name = "SessionHijackError";
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
var BadRequestError = class extends Error {
|
|
863
|
+
code;
|
|
864
|
+
statusCode = 400;
|
|
865
|
+
constructor(message = "Bad request", code = "BAD_REQUEST") {
|
|
866
|
+
super(message);
|
|
867
|
+
this.name = "BadRequestError";
|
|
868
|
+
this.code = code;
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
var ValidationError = class extends Error {
|
|
872
|
+
code;
|
|
873
|
+
statusCode = 400;
|
|
874
|
+
details;
|
|
875
|
+
constructor(message = "Validation failed", details, code = "VALIDATION_ERROR") {
|
|
876
|
+
super(message);
|
|
877
|
+
this.name = "ValidationError";
|
|
878
|
+
this.code = code;
|
|
879
|
+
this.details = details;
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
var defaultErrors = {
|
|
883
|
+
UnauthorizedError: (msg, code) => new UnauthorizedError(msg, code),
|
|
884
|
+
TokenExpiredError: () => new TokenExpiredError(),
|
|
885
|
+
InvalidTokenError: (code) => new InvalidTokenError(code),
|
|
886
|
+
TokenRevokedError: () => new TokenRevokedError(),
|
|
887
|
+
EmailNotVerifiedError: () => new EmailNotVerifiedError(),
|
|
888
|
+
AccountBannedError: (reason, at) => new AccountBannedError(reason, at),
|
|
889
|
+
ForbiddenError: (msg) => new ForbiddenError(msg),
|
|
890
|
+
SessionHijackError: (reason) => new SessionHijackError(reason),
|
|
891
|
+
BadRequestError: (msg, code) => new BadRequestError(msg, code),
|
|
892
|
+
ValidationError: (msg, details, code) => new ValidationError(msg, details, code)
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/middleware/middlewareFactory.ts
|
|
896
|
+
var MiddlewareFactory = class {
|
|
897
|
+
config;
|
|
898
|
+
tokenManager;
|
|
899
|
+
tokenRevocation;
|
|
900
|
+
errors;
|
|
901
|
+
audit;
|
|
902
|
+
observability;
|
|
903
|
+
cache;
|
|
904
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
905
|
+
sessionTimeoutManager = null;
|
|
906
|
+
constructor(rawConfig) {
|
|
907
|
+
this.config = resolveConfig(rawConfig);
|
|
908
|
+
this.tokenManager = new TokenManager(rawConfig.jwt);
|
|
909
|
+
this.tokenRevocation = new TokenRevocation(rawConfig.redis, this.config.keyPrefix);
|
|
910
|
+
this.errors = rawConfig.errors ?? defaultErrors;
|
|
911
|
+
this.audit = new AuditLog(rawConfig.auditLogger);
|
|
912
|
+
this.observability = rawConfig.observability;
|
|
913
|
+
this.cache = rawConfig.cache;
|
|
914
|
+
}
|
|
915
|
+
getTokenManager() {
|
|
916
|
+
return this.tokenManager;
|
|
917
|
+
}
|
|
918
|
+
getTokenRevocation() {
|
|
919
|
+
return this.tokenRevocation;
|
|
920
|
+
}
|
|
921
|
+
getAuditLog() {
|
|
922
|
+
return this.audit;
|
|
923
|
+
}
|
|
924
|
+
getRedis() {
|
|
925
|
+
return this.config.redis;
|
|
926
|
+
}
|
|
927
|
+
getConfig() {
|
|
928
|
+
return this.config;
|
|
929
|
+
}
|
|
930
|
+
async enforceSessionBinding(req, decoded) {
|
|
931
|
+
if (!decoded.sessionId) return null;
|
|
932
|
+
const redis = this.config.redis;
|
|
933
|
+
const metadataKey = `${this.config.keyPrefix}session:metadata:${decoded.sessionId}`;
|
|
934
|
+
const storedMetadata = await redis.get(metadataKey);
|
|
935
|
+
if (!storedMetadata) return null;
|
|
936
|
+
let metadata;
|
|
937
|
+
try {
|
|
938
|
+
metadata = JSON.parse(storedMetadata);
|
|
939
|
+
} catch {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
if (looksLikeServerToServerRequest(req)) return null;
|
|
943
|
+
if (metadata.deviceId == null || looksLikeServerToServerUA(metadata.userAgent)) {
|
|
944
|
+
metadata.deviceId = generateDeviceFingerprint(req);
|
|
945
|
+
metadata.ipAddress = getClientIp(req);
|
|
946
|
+
metadata.userAgent = req.headers["user-agent"] || "unknown";
|
|
947
|
+
metadata.lastActivity = Date.now();
|
|
948
|
+
this.audit.log("SESSION_BOUND", {
|
|
949
|
+
userId: decoded.sub,
|
|
950
|
+
sessionId: decoded.sessionId,
|
|
951
|
+
ip: metadata.ipAddress,
|
|
952
|
+
userAgent: metadata.userAgent
|
|
953
|
+
});
|
|
954
|
+
} else {
|
|
955
|
+
const bindingValidation = validateSessionBinding(req, metadata);
|
|
956
|
+
const currentIp = getClientIp(req);
|
|
957
|
+
if (!bindingValidation.valid) {
|
|
958
|
+
const isSameIpDeviceChange = bindingValidation.reason === "DEVICE_MISMATCH" && (currentIp === metadata.ipAddress || isPrivateIp(currentIp) || isPrivateIp(metadata.ipAddress));
|
|
959
|
+
if (isSameIpDeviceChange) {
|
|
960
|
+
metadata.deviceId = generateDeviceFingerprint(req);
|
|
961
|
+
metadata.userAgent = req.headers["user-agent"] || "unknown";
|
|
962
|
+
this.audit.log("SESSION_REBOUND", {
|
|
963
|
+
userId: decoded.sub,
|
|
964
|
+
sessionId: decoded.sessionId,
|
|
965
|
+
ip: currentIp,
|
|
966
|
+
userAgent: metadata.userAgent
|
|
967
|
+
});
|
|
968
|
+
} else {
|
|
969
|
+
await this.tokenRevocation.revokeAllRefreshTokens(decoded.sub);
|
|
970
|
+
if (decoded.jti && this.cache) {
|
|
971
|
+
await this.cache.delete(`auth:result:${decoded.jti}`);
|
|
972
|
+
}
|
|
973
|
+
this.audit.log("SESSION_HIJACK_DETECTED", {
|
|
974
|
+
userId: decoded.sub,
|
|
975
|
+
sessionId: decoded.sessionId,
|
|
976
|
+
reason: bindingValidation.reason,
|
|
977
|
+
ip: currentIp,
|
|
978
|
+
userAgent: req.headers["user-agent"]
|
|
979
|
+
});
|
|
980
|
+
return this.errors.SessionHijackError(bindingValidation.reason);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
metadata.lastActivity = Date.now();
|
|
985
|
+
await redis.setex(metadataKey, this.config.sessionMetadataTtl, JSON.stringify(metadata));
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
createAuthenticateMiddleware() {
|
|
989
|
+
const self = this;
|
|
990
|
+
return async function authenticate(req, res, next) {
|
|
991
|
+
try {
|
|
992
|
+
const authHeader = req.headers.authorization;
|
|
993
|
+
if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
|
|
994
|
+
return next(self.errors.UnauthorizedError("Authentication required", "NO_TOKEN"));
|
|
995
|
+
}
|
|
996
|
+
const token = authHeader.slice(7).trim();
|
|
997
|
+
if (!token) {
|
|
998
|
+
return next(self.errors.UnauthorizedError("Authentication required", "NO_TOKEN"));
|
|
999
|
+
}
|
|
1000
|
+
let decoded;
|
|
1001
|
+
try {
|
|
1002
|
+
decoded = self.tokenManager.verifyToken(token);
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
if (err.name === "TokenExpiredError") {
|
|
1005
|
+
return next(self.errors.TokenExpiredError());
|
|
1006
|
+
}
|
|
1007
|
+
return next(self.errors.InvalidTokenError());
|
|
1008
|
+
}
|
|
1009
|
+
if (decoded.jti && self.cache) {
|
|
1010
|
+
const cacheKey = `auth:result:${decoded.jti}`;
|
|
1011
|
+
const cachedResult = await self.cache.get(cacheKey);
|
|
1012
|
+
if (cachedResult) {
|
|
1013
|
+
if (!cachedResult.valid) return next(self.errors.InvalidTokenError("INVALID_TOKEN_CACHED"));
|
|
1014
|
+
const bindingError2 = await self.enforceSessionBinding(req, decoded);
|
|
1015
|
+
if (bindingError2) return next(bindingError2);
|
|
1016
|
+
const user2 = await self.loadUser(decoded.sub);
|
|
1017
|
+
if (!user2) return next(self.errors.UnauthorizedError("User not found", "USER_NOT_FOUND"));
|
|
1018
|
+
if (!user2.emailVerified) return next(self.errors.EmailNotVerifiedError());
|
|
1019
|
+
if (user2.bannedAt) return next(self.errors.AccountBannedError(user2.banReason, user2.bannedAt));
|
|
1020
|
+
self.assignUser(req, user2, decoded);
|
|
1021
|
+
return next();
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (decoded.version !== self.config.currentVersion) {
|
|
1025
|
+
return next(self.errors.InvalidTokenError("TOKEN_VERSION_MISMATCH"));
|
|
1026
|
+
}
|
|
1027
|
+
if (decoded.type !== "access") {
|
|
1028
|
+
return next(self.errors.InvalidTokenError("INVALID_TOKEN_TYPE"));
|
|
1029
|
+
}
|
|
1030
|
+
const [isRevoked, sessionWasRevoked] = await Promise.all([
|
|
1031
|
+
decoded.jti ? self.tokenRevocation.isAccessTokenRevoked(decoded.jti) : Promise.resolve(false),
|
|
1032
|
+
decoded.sessionId ? self.tokenRevocation.isSessionRevoked(decoded.sessionId) : Promise.resolve(false)
|
|
1033
|
+
]);
|
|
1034
|
+
if (isRevoked) return next(self.errors.TokenRevokedError());
|
|
1035
|
+
if (sessionWasRevoked) return next(self.errors.TokenRevokedError());
|
|
1036
|
+
const [bindingError, cachedUser] = await Promise.all([
|
|
1037
|
+
self.enforceSessionBinding(req, decoded),
|
|
1038
|
+
self.cache?.get(`auth:user:${decoded.sub}`)
|
|
1039
|
+
]);
|
|
1040
|
+
if (bindingError) return next(bindingError);
|
|
1041
|
+
const user = cachedUser ?? await self.loadUser(decoded.sub);
|
|
1042
|
+
if (!user) return next(self.errors.UnauthorizedError("User not found", "USER_NOT_FOUND"));
|
|
1043
|
+
if (!user.emailVerified) return next(self.errors.EmailNotVerifiedError());
|
|
1044
|
+
if (user.bannedAt) return next(self.errors.AccountBannedError(user.banReason, user.bannedAt));
|
|
1045
|
+
if (!cachedUser && user && self.cache) {
|
|
1046
|
+
void self.cache.set(`auth:user:${decoded.sub}`, user, 30 * 60).catch(() => {
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
if (decoded.jti && self.cache) {
|
|
1050
|
+
void self.cache.set(`auth:result:${decoded.jti}`, { userId: decoded.sub, valid: true }, 5 * 60).catch(() => {
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
self.assignUser(req, user, decoded);
|
|
1054
|
+
self.observability?.setUserContext?.({ id: user.id, email: user.email });
|
|
1055
|
+
self.observability?.addBreadcrumb?.({
|
|
1056
|
+
message: "User authenticated successfully",
|
|
1057
|
+
data: { userId: user.id, email: user.email, role: user.role }
|
|
1058
|
+
});
|
|
1059
|
+
next();
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
console.error("Unexpected authentication error:", err);
|
|
1062
|
+
self.observability?.captureException?.(err, { context: "authentication" });
|
|
1063
|
+
next(err);
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
createAuthorizeMiddleware(...roles) {
|
|
1068
|
+
const self = this;
|
|
1069
|
+
return function authorize(req, res, next) {
|
|
1070
|
+
if (!req.user) {
|
|
1071
|
+
return next(self.errors.UnauthorizedError("Authentication required", "NOT_AUTHENTICATED"));
|
|
1072
|
+
}
|
|
1073
|
+
if (!roles.includes(req.user.role)) {
|
|
1074
|
+
return next(self.errors.ForbiddenError("Insufficient permissions"));
|
|
1075
|
+
}
|
|
1076
|
+
next();
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
createCsrfMiddleware() {
|
|
1080
|
+
const self = this;
|
|
1081
|
+
const CSRF_TOKEN_LENGTH = 32;
|
|
1082
|
+
const CSRF_TOKEN_TTL = 3600;
|
|
1083
|
+
return {
|
|
1084
|
+
generateToken: async (userId, sessionId) => {
|
|
1085
|
+
const token = crypto3.randomBytes(CSRF_TOKEN_LENGTH).toString("base64url");
|
|
1086
|
+
const key = `${self.config.keyPrefix}csrf:${userId}:${sessionId}`;
|
|
1087
|
+
await self.config.redis.setex(key, CSRF_TOKEN_TTL, token);
|
|
1088
|
+
return token;
|
|
1089
|
+
},
|
|
1090
|
+
validateToken: async (userId, sessionId, token) => {
|
|
1091
|
+
if (!token) return false;
|
|
1092
|
+
const key = `${self.config.keyPrefix}csrf:${userId}:${sessionId}`;
|
|
1093
|
+
const storedToken = await self.config.redis.get(key);
|
|
1094
|
+
if (!storedToken) return false;
|
|
1095
|
+
try {
|
|
1096
|
+
return crypto3.timingSafeEqual(Buffer.from(token, "base64url"), Buffer.from(storedToken, "base64url"));
|
|
1097
|
+
} catch {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
middleware() {
|
|
1102
|
+
return async (req, res, next) => {
|
|
1103
|
+
if (["GET", "HEAD", "OPTIONS"].includes(req.method ?? "")) return next();
|
|
1104
|
+
if (req.path?.startsWith("/webhooks/") || req.path?.includes("/callback")) return next();
|
|
1105
|
+
const csrfToken = req.headers["x-csrf-token"];
|
|
1106
|
+
if (!req.user?.id) {
|
|
1107
|
+
return res.status(401).json({ success: false, message: "Authentication required", code: "NOT_AUTHENTICATED" });
|
|
1108
|
+
}
|
|
1109
|
+
const sessionId = req.user.sessionId || "default";
|
|
1110
|
+
const stored = await self.config.redis.get(`${self.config.keyPrefix}csrf:${req.user.id}:${sessionId}`);
|
|
1111
|
+
if (!stored || !csrfToken) return res.status(403).json({ success: false, message: "Invalid CSRF token", code: "CSRF_VALIDATION_FAILED" });
|
|
1112
|
+
let isValid = false;
|
|
1113
|
+
try {
|
|
1114
|
+
isValid = crypto3.timingSafeEqual(Buffer.from(csrfToken, "base64url"), Buffer.from(stored, "base64url"));
|
|
1115
|
+
} catch {
|
|
1116
|
+
isValid = false;
|
|
1117
|
+
}
|
|
1118
|
+
if (!isValid) {
|
|
1119
|
+
return res.status(403).json({ success: false, message: "Invalid CSRF token", code: "CSRF_VALIDATION_FAILED" });
|
|
1120
|
+
}
|
|
1121
|
+
next();
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
createRateLimitMiddleware() {
|
|
1127
|
+
const self = this;
|
|
1128
|
+
return (max, windowSeconds, keyFn, options) => {
|
|
1129
|
+
return async (req, res, next) => {
|
|
1130
|
+
const ip = getClientIp(req);
|
|
1131
|
+
const userKey = keyFn?.(req);
|
|
1132
|
+
const key = userKey ? `${self.config.keyPrefix}rl:user:${userKey}` : `${self.config.keyPrefix}rl:backend:${ip}`;
|
|
1133
|
+
try {
|
|
1134
|
+
const redis = self.config.redis;
|
|
1135
|
+
const countKey = `${key}:count`;
|
|
1136
|
+
const count = await redis.incr(countKey);
|
|
1137
|
+
if (count === 1) await redis.expire(countKey, windowSeconds);
|
|
1138
|
+
if (count > max) {
|
|
1139
|
+
return res.status(429).json({ success: false, message: "Too many requests", code: "RATE_LIMIT_EXCEEDED", retryAfter: windowSeconds });
|
|
1140
|
+
}
|
|
1141
|
+
next();
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
if (options?.failOpen) return next();
|
|
1144
|
+
return res.status(429).json({ success: false, message: "Rate limit service unavailable", code: "RATE_LIMIT_SERVICE_UNAVAILABLE", retryAfter: windowSeconds });
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
createSessionTimeoutMiddleware() {
|
|
1150
|
+
const self = this;
|
|
1151
|
+
return async (req, res, next) => {
|
|
1152
|
+
const sessionId = req.user?.sessionId;
|
|
1153
|
+
if (!sessionId) return next();
|
|
1154
|
+
if (!self.sessionTimeoutManager) {
|
|
1155
|
+
const { SessionTimeoutManager: SessionTimeoutManager2 } = await Promise.resolve().then(() => (init_sessionTimeout(), sessionTimeout_exports));
|
|
1156
|
+
self.sessionTimeoutManager = new SessionTimeoutManager2(self.config.redis, {
|
|
1157
|
+
inactivityTimeout: self.config.inactivityTimeout,
|
|
1158
|
+
absoluteTimeout: self.config.absoluteTimeout,
|
|
1159
|
+
prefix: self.config.keyPrefix
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
const tracked = await self.sessionTimeoutManager.isTracked(sessionId);
|
|
1163
|
+
if (!tracked) return next();
|
|
1164
|
+
const inactive = await self.sessionTimeoutManager.isInactive(sessionId);
|
|
1165
|
+
if (inactive) {
|
|
1166
|
+
return res.status(401).json({ success: false, code: "SESSION_TIMEOUT", message: "Session expired" });
|
|
1167
|
+
}
|
|
1168
|
+
await self.sessionTimeoutManager.updateActivity(sessionId);
|
|
1169
|
+
next();
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
createExchangeHandler() {
|
|
1173
|
+
const self = this;
|
|
1174
|
+
return async (req, res) => {
|
|
1175
|
+
const ip = getClientIp(req);
|
|
1176
|
+
const { userId, email, role, sessionId, timestamp, signature } = req.body;
|
|
1177
|
+
const age = Date.now() - Number(timestamp);
|
|
1178
|
+
if (age > 6e4 || age < 0) {
|
|
1179
|
+
self.audit.log("EXCHANGE_FAILED", { userId, email, ip, reason: "Proof expired" });
|
|
1180
|
+
throw self.errors.UnauthorizedError("Exchange proof expired", "PROOF_EXPIRED");
|
|
1181
|
+
}
|
|
1182
|
+
const proofPayload = `${userId}${email}${role}${sessionId}${timestamp}`;
|
|
1183
|
+
const expected = crypto3.createHmac("sha256", self.config.hmacSecret).update(proofPayload).digest("hex");
|
|
1184
|
+
let valid = false;
|
|
1185
|
+
try {
|
|
1186
|
+
if (signature) {
|
|
1187
|
+
valid = crypto3.timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expected, "hex"));
|
|
1188
|
+
}
|
|
1189
|
+
} catch {
|
|
1190
|
+
valid = false;
|
|
1191
|
+
}
|
|
1192
|
+
if (!valid) {
|
|
1193
|
+
self.audit.log("EXCHANGE_FAILED", { userId, email, ip, reason: "Invalid signature" });
|
|
1194
|
+
throw self.errors.UnauthorizedError("Invalid exchange proof", "INVALID_SIGNATURE");
|
|
1195
|
+
}
|
|
1196
|
+
const idempotencyKey = `exchange:idempotent:${userId}:${sessionId}:${timestamp}`;
|
|
1197
|
+
const existing = await self.config.redis.get(idempotencyKey);
|
|
1198
|
+
if (existing) {
|
|
1199
|
+
return res.status(200).json(JSON.parse(existing));
|
|
1200
|
+
}
|
|
1201
|
+
const user = await self.config.userStore.findById(userId);
|
|
1202
|
+
if (!user) {
|
|
1203
|
+
self.audit.log("EXCHANGE_FAILED", { userId, email, ip, reason: "User not found" });
|
|
1204
|
+
throw self.errors.UnauthorizedError("User not found", "USER_NOT_FOUND");
|
|
1205
|
+
}
|
|
1206
|
+
if (user.email.toLowerCase() !== email.toLowerCase()) {
|
|
1207
|
+
self.audit.log("EXCHANGE_FAILED", { userId, email, ip, reason: "Email mismatch" });
|
|
1208
|
+
throw self.errors.UnauthorizedError("Email mismatch", "EMAIL_MISMATCH");
|
|
1209
|
+
}
|
|
1210
|
+
if (!user.emailVerified) {
|
|
1211
|
+
self.audit.log("EXCHANGE_FAILED", { userId, email, ip, reason: "Email not verified" });
|
|
1212
|
+
throw self.errors.UnauthorizedError("Email not verified", "EMAIL_NOT_VERIFIED");
|
|
1213
|
+
}
|
|
1214
|
+
const sid = sessionId || crypto3.randomUUID();
|
|
1215
|
+
const tokens = self.tokenManager.createTokenPair({
|
|
1216
|
+
id: user.id,
|
|
1217
|
+
email: user.email,
|
|
1218
|
+
role: user.role,
|
|
1219
|
+
sessionId: sid
|
|
1220
|
+
});
|
|
1221
|
+
const refreshTtlSeconds = self.tokenManager.getRefreshTokenExpiry() === "7d" ? 7 * 24 * 60 * 60 : TokenManager.parseExpiry(self.tokenManager.getRefreshTokenExpiry()) / 1e3;
|
|
1222
|
+
await self.tokenRevocation.storeRefreshToken(user.id, TokenManager.extractJti(tokens.refreshToken), refreshTtlSeconds);
|
|
1223
|
+
const sseToken = self.tokenManager.createSseToken(user.id);
|
|
1224
|
+
const sseTtlSeconds = TokenManager.parseExpiry(self.tokenManager.getSseTokenExpiry()) / 1e3;
|
|
1225
|
+
await self.tokenRevocation.storeSseToken(TokenManager.extractJti(sseToken), user.id, sseTtlSeconds);
|
|
1226
|
+
await self.config.redis.setex(
|
|
1227
|
+
`${self.config.keyPrefix}session:created:${sid}`,
|
|
1228
|
+
self.config.absoluteTimeout,
|
|
1229
|
+
Date.now().toString()
|
|
1230
|
+
);
|
|
1231
|
+
await self.config.redis.setex(
|
|
1232
|
+
`${self.config.keyPrefix}session:activity:${sid}`,
|
|
1233
|
+
self.config.inactivityTimeout,
|
|
1234
|
+
Date.now().toString()
|
|
1235
|
+
);
|
|
1236
|
+
await self.config.redis.setex(idempotencyKey, 90, JSON.stringify(tokens));
|
|
1237
|
+
self.audit.log("EXCHANGE_SUCCESS", { userId: user.id, email: user.email, ip });
|
|
1238
|
+
return res.status(200).json(tokens);
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
createRefreshHandler() {
|
|
1242
|
+
const self = this;
|
|
1243
|
+
return async (req, res) => {
|
|
1244
|
+
const { refreshToken } = req.body;
|
|
1245
|
+
if (!refreshToken) throw self.errors.BadRequestError("Refresh token required");
|
|
1246
|
+
let decoded;
|
|
1247
|
+
try {
|
|
1248
|
+
decoded = self.tokenManager.verifyToken(refreshToken, "refresh");
|
|
1249
|
+
} catch {
|
|
1250
|
+
throw self.errors.UnauthorizedError("Invalid refresh token", "INVALID_TOKEN");
|
|
1251
|
+
}
|
|
1252
|
+
const redis = self.config.redis;
|
|
1253
|
+
const lockKey = `${self.config.keyPrefix}refresh:lock:${decoded.sessionId}`;
|
|
1254
|
+
const lockResult = await redis.set(lockKey, "1", "EX", 45, "NX");
|
|
1255
|
+
if (lockResult !== "OK") {
|
|
1256
|
+
throw self.errors.BadRequestError("Refresh already in progress", "REFRESH_IN_PROGRESS");
|
|
1257
|
+
}
|
|
1258
|
+
try {
|
|
1259
|
+
const dedupeKey = `${self.config.keyPrefix}refresh:dedupe:${decoded.id}:${decoded.jti}`;
|
|
1260
|
+
const cached = await redis.get(dedupeKey);
|
|
1261
|
+
if (cached) return res.status(200).json(JSON.parse(cached));
|
|
1262
|
+
const sessionRevokedKey = `${self.config.keyPrefix}session:revoked:${decoded.sessionId}`;
|
|
1263
|
+
if (await redis.get(sessionRevokedKey) === "1") {
|
|
1264
|
+
throw self.errors.UnauthorizedError("Session revoked", "SESSION_REVOKED");
|
|
1265
|
+
}
|
|
1266
|
+
const createdKey = `${self.config.keyPrefix}session:created:${decoded.sessionId}`;
|
|
1267
|
+
if (await redis.exists(createdKey) !== 1) {
|
|
1268
|
+
throw self.errors.UnauthorizedError("Session expired", "SESSION_TIMEOUT");
|
|
1269
|
+
}
|
|
1270
|
+
const newTokens = self.tokenManager.createTokenPair({
|
|
1271
|
+
id: decoded.id,
|
|
1272
|
+
email: decoded.email,
|
|
1273
|
+
role: decoded.role,
|
|
1274
|
+
sessionId: decoded.sessionId
|
|
1275
|
+
});
|
|
1276
|
+
const refreshTtlSeconds = TokenManager.parseExpiry(self.tokenManager.getRefreshTokenExpiry()) / 1e3;
|
|
1277
|
+
await self.tokenRevocation.rotateRefreshToken(
|
|
1278
|
+
decoded.id,
|
|
1279
|
+
decoded.jti,
|
|
1280
|
+
TokenManager.extractJti(newTokens.refreshToken),
|
|
1281
|
+
refreshTtlSeconds
|
|
1282
|
+
);
|
|
1283
|
+
const newSseToken = self.tokenManager.createSseToken(decoded.id);
|
|
1284
|
+
const sseTtlSeconds = TokenManager.parseExpiry(self.tokenManager.getSseTokenExpiry()) / 1e3;
|
|
1285
|
+
await self.tokenRevocation.storeSseToken(TokenManager.extractJti(newSseToken), decoded.id, sseTtlSeconds);
|
|
1286
|
+
const response = { ...newTokens, sseToken: newSseToken };
|
|
1287
|
+
await redis.setex(dedupeKey, 30, JSON.stringify(response));
|
|
1288
|
+
self.audit.log("TOKEN_REFRESHED", { userId: decoded.id, sessionId: decoded.sessionId });
|
|
1289
|
+
return res.status(200).json(response);
|
|
1290
|
+
} finally {
|
|
1291
|
+
await redis.del(lockKey);
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
createLogoutHandler() {
|
|
1296
|
+
const self = this;
|
|
1297
|
+
return async (req, res) => {
|
|
1298
|
+
const { logoutAll } = req.body;
|
|
1299
|
+
const userId = req.user?.id;
|
|
1300
|
+
const sessionId = req.user?.sessionId;
|
|
1301
|
+
if (req.user?.tokenJti) {
|
|
1302
|
+
const token = req.headers.authorization?.slice(7);
|
|
1303
|
+
if (token) {
|
|
1304
|
+
const expiry = self.tokenManager.getAccessTokenExpiry();
|
|
1305
|
+
const ttlSeconds = TokenManager.parseExpiry(expiry) / 1e3;
|
|
1306
|
+
await self.tokenRevocation.revokeAccessToken(req.user.tokenJti, ttlSeconds);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (logoutAll && userId) {
|
|
1310
|
+
await self.tokenRevocation.revokeAllRefreshTokens(userId);
|
|
1311
|
+
await self.config.redis.setex(
|
|
1312
|
+
`${self.config.keyPrefix}session:revoked:${sessionId}`,
|
|
1313
|
+
DEFAULTS.revokedSessionTtl,
|
|
1314
|
+
"1"
|
|
1315
|
+
);
|
|
1316
|
+
self.audit.log("LOGOUT_ALL_DEVICES", { userId });
|
|
1317
|
+
} else if (userId && sessionId) {
|
|
1318
|
+
await self.config.redis.del(`${self.config.keyPrefix}session:metadata:${sessionId}`);
|
|
1319
|
+
self.audit.log("LOGOUT", { userId, sessionId });
|
|
1320
|
+
}
|
|
1321
|
+
return res.status(200).json({ success: true, message: "Logged out" });
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
async loadUser(id) {
|
|
1325
|
+
try {
|
|
1326
|
+
return await this.config.userStore.findById(id);
|
|
1327
|
+
} catch {
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
assignUser(req, user, decoded) {
|
|
1332
|
+
req.user = {
|
|
1333
|
+
id: user.id,
|
|
1334
|
+
email: user.email,
|
|
1335
|
+
name: user.name,
|
|
1336
|
+
role: user.role,
|
|
1337
|
+
tokenJti: decoded.jti,
|
|
1338
|
+
sessionId: decoded.sessionId
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
var exchangeSchema = z.object({
|
|
1343
|
+
userId: z.string().uuid(),
|
|
1344
|
+
email: z.string().email(),
|
|
1345
|
+
role: z.string(),
|
|
1346
|
+
sessionId: z.string().uuid(),
|
|
1347
|
+
timestamp: z.string().or(z.number()),
|
|
1348
|
+
signature: z.string()
|
|
1349
|
+
});
|
|
1350
|
+
var refreshSchema = z.object({
|
|
1351
|
+
refreshToken: z.string().min(1)
|
|
1352
|
+
});
|
|
1353
|
+
var logoutSchema = z.object({
|
|
1354
|
+
logoutAll: z.boolean().optional()
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// src/index.ts
|
|
1358
|
+
init_config();
|
|
1359
|
+
|
|
1360
|
+
export { AUDIT_SEVERITY, AccountBannedError, AccountLockout, AuditLog, BadRequestError, DEFAULTS, EmailNotVerifiedError, ForbiddenError, InvalidTokenError, MiddlewareFactory, RefreshLock, SessionHijackError, SessionManager, SessionTimeoutManager, TokenExpiredError, TokenManager, TokenRevocation, TokenRevokedError, TotpManager, UnauthorizedError, ValidationError, createSessionMetadata, defaultErrors, exchangeSchema, generateDeviceFingerprint, getClientIp, isPrivateIp, logoutSchema, looksLikeServerToServerRequest, looksLikeServerToServerUA, normalizeUserAgent, refreshSchema, resolveConfig, validateSessionBinding };
|
|
1361
|
+
//# sourceMappingURL=index.js.map
|
|
1362
|
+
//# sourceMappingURL=index.js.map
|