@focura/auth-core 1.0.0 → 1.1.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/README.md +165 -25
- package/dist/index.d.ts +182 -93
- package/dist/index.js +410 -131
- package/dist/index.js.map +1 -1
- package/package.json +23 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import crypto4 from 'crypto';
|
|
1
2
|
import jwt from 'jsonwebtoken';
|
|
2
|
-
import crypto3 from 'crypto';
|
|
3
3
|
import { generateSecret, generateURI, verify } from 'otplib';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
|
|
@@ -157,6 +157,9 @@ var init_sessionTimeout = __esm({
|
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
// src/auth/authService.ts
|
|
161
|
+
init_config();
|
|
162
|
+
|
|
160
163
|
// src/tokens/backendToken.ts
|
|
161
164
|
init_config();
|
|
162
165
|
var TokenManager = class _TokenManager {
|
|
@@ -186,7 +189,7 @@ var TokenManager = class _TokenManager {
|
|
|
186
189
|
role: p.role,
|
|
187
190
|
type: "access",
|
|
188
191
|
version: this.currentVersion,
|
|
189
|
-
jti:
|
|
192
|
+
jti: crypto4.randomUUID(),
|
|
190
193
|
sessionId: p.sessionId
|
|
191
194
|
},
|
|
192
195
|
this.privateKey,
|
|
@@ -206,7 +209,7 @@ var TokenManager = class _TokenManager {
|
|
|
206
209
|
role: p.role,
|
|
207
210
|
type: "refresh",
|
|
208
211
|
version: this.currentVersion,
|
|
209
|
-
jti:
|
|
212
|
+
jti: crypto4.randomUUID(),
|
|
210
213
|
sessionId: p.sessionId
|
|
211
214
|
},
|
|
212
215
|
this.privateKey,
|
|
@@ -219,7 +222,7 @@ var TokenManager = class _TokenManager {
|
|
|
219
222
|
);
|
|
220
223
|
}
|
|
221
224
|
createTokenPair(p) {
|
|
222
|
-
const sessionId = p.sessionId ||
|
|
225
|
+
const sessionId = p.sessionId || crypto4.randomUUID();
|
|
223
226
|
const payload = { ...p, sessionId };
|
|
224
227
|
return {
|
|
225
228
|
accessToken: this.createAccessToken(payload),
|
|
@@ -234,7 +237,7 @@ var TokenManager = class _TokenManager {
|
|
|
234
237
|
sub: userId,
|
|
235
238
|
type: "sse",
|
|
236
239
|
version: this.currentVersion,
|
|
237
|
-
jti:
|
|
240
|
+
jti: crypto4.randomUUID()
|
|
238
241
|
},
|
|
239
242
|
this.privateKey,
|
|
240
243
|
{
|
|
@@ -466,48 +469,6 @@ var TokenRevocation = class {
|
|
|
466
469
|
}
|
|
467
470
|
};
|
|
468
471
|
|
|
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
472
|
// src/session/sessionManager.ts
|
|
512
473
|
init_config();
|
|
513
474
|
var SessionManager = class {
|
|
@@ -604,77 +565,6 @@ var SessionManager = class {
|
|
|
604
565
|
}
|
|
605
566
|
}
|
|
606
567
|
};
|
|
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
568
|
|
|
679
569
|
// src/lockout/accountLockout.ts
|
|
680
570
|
init_config();
|
|
@@ -784,12 +674,6 @@ var TotpManager = class {
|
|
|
784
674
|
}
|
|
785
675
|
};
|
|
786
676
|
|
|
787
|
-
// src/index.ts
|
|
788
|
-
init_sessionTimeout();
|
|
789
|
-
|
|
790
|
-
// src/middleware/middlewareFactory.ts
|
|
791
|
-
init_config();
|
|
792
|
-
|
|
793
677
|
// src/errors/index.ts
|
|
794
678
|
var UnauthorizedError = class extends Error {
|
|
795
679
|
code;
|
|
@@ -891,8 +775,403 @@ var defaultErrors = {
|
|
|
891
775
|
BadRequestError: (msg, code) => new BadRequestError(msg, code),
|
|
892
776
|
ValidationError: (msg, details, code) => new ValidationError(msg, details, code)
|
|
893
777
|
};
|
|
778
|
+
var SERVER_TO_SERVER_UA = /^(node|undici|axios|curl|go-http-client|python-requests|java\/|okhttp|superagent)/i;
|
|
779
|
+
function looksLikeServerToServerUA(userAgent) {
|
|
780
|
+
return !userAgent || SERVER_TO_SERVER_UA.test(userAgent);
|
|
781
|
+
}
|
|
782
|
+
function looksLikeServerToServerRequest(req) {
|
|
783
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
784
|
+
if (looksLikeServerToServerUA(ua)) return true;
|
|
785
|
+
return !req.headers["accept-language"] && !req.headers["accept-encoding"];
|
|
786
|
+
}
|
|
787
|
+
function normalizeUserAgent(userAgent) {
|
|
788
|
+
const ua = userAgent || "";
|
|
789
|
+
const mobile = /Mobile|Android|iPhone|iPad|iPod/i.test(ua);
|
|
790
|
+
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";
|
|
791
|
+
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";
|
|
792
|
+
return `${browser}|${os}|${mobile ? "mobile" : "desktop"}`;
|
|
793
|
+
}
|
|
794
|
+
function primaryLanguage(acceptLanguage) {
|
|
795
|
+
const first = (acceptLanguage || "").split(",")[0]?.trim() ?? "";
|
|
796
|
+
return first ? first.split("-")[0].toLowerCase() : "";
|
|
797
|
+
}
|
|
798
|
+
function generateDeviceFingerprint(req) {
|
|
799
|
+
const components = [
|
|
800
|
+
normalizeUserAgent(req.headers["user-agent"] || ""),
|
|
801
|
+
primaryLanguage(req.headers["accept-language"] || "")
|
|
802
|
+
].join("|");
|
|
803
|
+
return crypto4.createHash("sha256").update(components).digest("hex").substring(0, 32);
|
|
804
|
+
}
|
|
805
|
+
function getClientIp(req) {
|
|
806
|
+
if (typeof req.ip === "string" && req.ip.length > 0) {
|
|
807
|
+
return req.ip.startsWith("::ffff:") ? req.ip.slice(7) : req.ip;
|
|
808
|
+
}
|
|
809
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
810
|
+
if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
|
|
811
|
+
const ip = req.socket?.remoteAddress || "unknown";
|
|
812
|
+
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
813
|
+
}
|
|
814
|
+
function createSessionMetadata(req) {
|
|
815
|
+
return {
|
|
816
|
+
deviceId: null,
|
|
817
|
+
ipAddress: getClientIp(req),
|
|
818
|
+
userAgent: req.headers["user-agent"] || "unknown",
|
|
819
|
+
lastActivity: Date.now()
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function isPrivateIp(ip) {
|
|
823
|
+
if (!ip || ip === "unknown") return true;
|
|
824
|
+
const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
825
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(normalized)) {
|
|
826
|
+
const [a, b] = normalized.split(".").map(Number);
|
|
827
|
+
return a === 0 || a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
828
|
+
}
|
|
829
|
+
return normalized === "::1" || /^fe80:/i.test(normalized) || /^fc/i.test(normalized) || /^fd/i.test(normalized);
|
|
830
|
+
}
|
|
831
|
+
function validateSessionBinding(req, storedMetadata) {
|
|
832
|
+
const currentDeviceId = generateDeviceFingerprint(req);
|
|
833
|
+
const currentIp = getClientIp(req);
|
|
834
|
+
if (currentDeviceId !== storedMetadata.deviceId) {
|
|
835
|
+
return { valid: false, reason: "DEVICE_MISMATCH" };
|
|
836
|
+
}
|
|
837
|
+
if (currentIp !== storedMetadata.ipAddress) {
|
|
838
|
+
if (isPrivateIp(currentIp) || isPrivateIp(storedMetadata.ipAddress)) {
|
|
839
|
+
return { valid: true };
|
|
840
|
+
}
|
|
841
|
+
const timeSinceLastActivity = Date.now() - storedMetadata.lastActivity;
|
|
842
|
+
const maxIpChangeInterval = 5 * 60 * 1e3;
|
|
843
|
+
if (timeSinceLastActivity < maxIpChangeInterval) {
|
|
844
|
+
return { valid: false, reason: "SUSPICIOUS_IP_CHANGE" };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return { valid: true };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/auth/authService.ts
|
|
851
|
+
var AuthService = class {
|
|
852
|
+
tokenManager;
|
|
853
|
+
tokenRevocation;
|
|
854
|
+
sessionManager;
|
|
855
|
+
accountLockout;
|
|
856
|
+
totpManager;
|
|
857
|
+
audit;
|
|
858
|
+
errors;
|
|
859
|
+
config;
|
|
860
|
+
userStore;
|
|
861
|
+
constructor(rawConfig) {
|
|
862
|
+
this.config = resolveConfig(rawConfig);
|
|
863
|
+
this.userStore = rawConfig.userStore;
|
|
864
|
+
this.errors = rawConfig.errors ?? defaultErrors;
|
|
865
|
+
this.tokenManager = new TokenManager(rawConfig.jwt);
|
|
866
|
+
this.tokenRevocation = new TokenRevocation(rawConfig.redis, this.config.keyPrefix);
|
|
867
|
+
this.sessionManager = new SessionManager(
|
|
868
|
+
rawConfig.redis,
|
|
869
|
+
this.tokenRevocation,
|
|
870
|
+
rawConfig.auditLogger,
|
|
871
|
+
this.config.keyPrefix,
|
|
872
|
+
this.config.maxConcurrentSessions
|
|
873
|
+
);
|
|
874
|
+
this.accountLockout = new AccountLockout(rawConfig.redis, {
|
|
875
|
+
maxFailures: this.config.lockoutMaxFailures,
|
|
876
|
+
lockoutSeconds: this.config.lockoutSeconds,
|
|
877
|
+
windowSeconds: this.config.lockoutWindowSeconds,
|
|
878
|
+
prefix: `${this.config.keyPrefix}lockout`
|
|
879
|
+
});
|
|
880
|
+
this.totpManager = new TotpManager(rawConfig.jwt.issuer ?? "Auth");
|
|
881
|
+
this.audit = new AuditLog(rawConfig.auditLogger);
|
|
882
|
+
}
|
|
883
|
+
getConfig() {
|
|
884
|
+
return this.config;
|
|
885
|
+
}
|
|
886
|
+
getRedis() {
|
|
887
|
+
return this.config.redis;
|
|
888
|
+
}
|
|
889
|
+
async exchange(input) {
|
|
890
|
+
const age = Date.now() - input.timestamp;
|
|
891
|
+
if (age > 6e4 || age < 0) {
|
|
892
|
+
this.audit.log("EXCHANGE_FAILED", { userId: input.userId, reason: "Proof expired" });
|
|
893
|
+
throw this.errors.UnauthorizedError("Exchange proof expired", "PROOF_EXPIRED");
|
|
894
|
+
}
|
|
895
|
+
const proofPayload = `${input.userId}${input.email}${input.role}${input.sessionId}${input.timestamp}`;
|
|
896
|
+
const expected = crypto4.createHmac("sha256", this.config.hmacSecret).update(proofPayload).digest("hex");
|
|
897
|
+
let valid = false;
|
|
898
|
+
try {
|
|
899
|
+
if (input.signature) {
|
|
900
|
+
valid = crypto4.timingSafeEqual(Buffer.from(input.signature, "hex"), Buffer.from(expected, "hex"));
|
|
901
|
+
}
|
|
902
|
+
} catch {
|
|
903
|
+
valid = false;
|
|
904
|
+
}
|
|
905
|
+
if (!valid) {
|
|
906
|
+
this.audit.log("EXCHANGE_FAILED", { userId: input.userId, reason: "Invalid signature" });
|
|
907
|
+
throw this.errors.UnauthorizedError("Invalid exchange proof", "INVALID_SIGNATURE");
|
|
908
|
+
}
|
|
909
|
+
const idempotencyKey = `exchange:idempotent:${input.userId}:${input.sessionId}:${input.timestamp}`;
|
|
910
|
+
const existing = await this.config.redis.get(idempotencyKey);
|
|
911
|
+
if (existing) {
|
|
912
|
+
return JSON.parse(existing);
|
|
913
|
+
}
|
|
914
|
+
const user = await this.userStore.findById(input.userId);
|
|
915
|
+
if (!user) {
|
|
916
|
+
this.audit.log("EXCHANGE_FAILED", { userId: input.userId, reason: "User not found" });
|
|
917
|
+
throw this.errors.UnauthorizedError("User not found", "USER_NOT_FOUND");
|
|
918
|
+
}
|
|
919
|
+
if (user.email.toLowerCase() !== input.email.toLowerCase()) {
|
|
920
|
+
this.audit.log("EXCHANGE_FAILED", { userId: input.userId, reason: "Email mismatch" });
|
|
921
|
+
throw this.errors.UnauthorizedError("Email mismatch", "EMAIL_MISMATCH");
|
|
922
|
+
}
|
|
923
|
+
if (!user.emailVerified) {
|
|
924
|
+
this.audit.log("EXCHANGE_FAILED", { userId: input.userId, reason: "Email not verified" });
|
|
925
|
+
throw this.errors.UnauthorizedError("Email not verified", "EMAIL_NOT_VERIFIED");
|
|
926
|
+
}
|
|
927
|
+
const sessionId = input.sessionId || crypto4.randomUUID();
|
|
928
|
+
const tokens = this.tokenManager.createTokenPair({
|
|
929
|
+
id: user.id,
|
|
930
|
+
email: user.email,
|
|
931
|
+
role: user.role,
|
|
932
|
+
sessionId
|
|
933
|
+
});
|
|
934
|
+
const refreshTtlSeconds = TokenManager.parseExpiry(this.tokenManager.getRefreshTokenExpiry()) / 1e3;
|
|
935
|
+
await this.tokenRevocation.storeRefreshToken(user.id, TokenManager.extractJti(tokens.refreshToken), refreshTtlSeconds);
|
|
936
|
+
const sseToken = this.tokenManager.createSseToken(user.id);
|
|
937
|
+
const sseTtlSeconds = TokenManager.parseExpiry(this.tokenManager.getSseTokenExpiry()) / 1e3;
|
|
938
|
+
await this.tokenRevocation.storeSseToken(TokenManager.extractJti(sseToken), user.id, sseTtlSeconds);
|
|
939
|
+
await this.config.redis.setex(
|
|
940
|
+
`${this.config.keyPrefix}session:created:${sessionId}`,
|
|
941
|
+
this.config.absoluteTimeout,
|
|
942
|
+
Date.now().toString()
|
|
943
|
+
);
|
|
944
|
+
await this.config.redis.setex(
|
|
945
|
+
`${this.config.keyPrefix}session:activity:${sessionId}`,
|
|
946
|
+
this.config.inactivityTimeout,
|
|
947
|
+
Date.now().toString()
|
|
948
|
+
);
|
|
949
|
+
await this.sessionManager.trackUserSession(user.id, sessionId);
|
|
950
|
+
const result = { ...tokens, sseToken, sessionId };
|
|
951
|
+
await this.config.redis.setex(idempotencyKey, 90, JSON.stringify(result));
|
|
952
|
+
this.audit.log("EXCHANGE_SUCCESS", { userId: user.id, email: user.email });
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
async verifyToken(input) {
|
|
956
|
+
let decoded;
|
|
957
|
+
try {
|
|
958
|
+
decoded = this.tokenManager.verifyToken(input.token);
|
|
959
|
+
} catch (err) {
|
|
960
|
+
if (err.name === "TokenExpiredError") {
|
|
961
|
+
throw this.errors.TokenExpiredError();
|
|
962
|
+
}
|
|
963
|
+
throw this.errors.InvalidTokenError();
|
|
964
|
+
}
|
|
965
|
+
if (decoded.version !== this.config.currentVersion) {
|
|
966
|
+
throw this.errors.InvalidTokenError("TOKEN_VERSION_MISMATCH");
|
|
967
|
+
}
|
|
968
|
+
if (decoded.type !== "access") {
|
|
969
|
+
throw this.errors.InvalidTokenError("INVALID_TOKEN_TYPE");
|
|
970
|
+
}
|
|
971
|
+
const [isRevoked, sessionWasRevoked] = await Promise.all([
|
|
972
|
+
decoded.jti ? this.tokenRevocation.isAccessTokenRevoked(decoded.jti) : Promise.resolve(false),
|
|
973
|
+
decoded.sessionId ? this.tokenRevocation.isSessionRevoked(decoded.sessionId) : Promise.resolve(false)
|
|
974
|
+
]);
|
|
975
|
+
if (isRevoked) throw this.errors.TokenRevokedError();
|
|
976
|
+
if (sessionWasRevoked) throw this.errors.TokenRevokedError();
|
|
977
|
+
if (decoded.sessionId && input.ipAddress && input.userAgent) {
|
|
978
|
+
const metadataKey = `${this.config.keyPrefix}session:metadata:${decoded.sessionId}`;
|
|
979
|
+
const storedMetadata = await this.config.redis.get(metadataKey);
|
|
980
|
+
if (storedMetadata) {
|
|
981
|
+
let metadata;
|
|
982
|
+
try {
|
|
983
|
+
metadata = JSON.parse(storedMetadata);
|
|
984
|
+
} catch {
|
|
985
|
+
metadata = { deviceId: null, ipAddress: input.ipAddress, userAgent: input.userAgent, lastActivity: Date.now() };
|
|
986
|
+
}
|
|
987
|
+
if (metadata.deviceId == null) {
|
|
988
|
+
metadata.deviceId = crypto4.createHash("sha256").update(`${input.userAgent}|${input.ipAddress}`).digest("hex").substring(0, 32);
|
|
989
|
+
metadata.ipAddress = input.ipAddress;
|
|
990
|
+
metadata.userAgent = input.userAgent;
|
|
991
|
+
metadata.lastActivity = Date.now();
|
|
992
|
+
await this.config.redis.setex(metadataKey, this.config.sessionMetadataTtl, JSON.stringify(metadata));
|
|
993
|
+
} else {
|
|
994
|
+
const req = { headers: { "user-agent": input.userAgent }, ip: input.ipAddress };
|
|
995
|
+
const binding = validateSessionBinding(req, metadata);
|
|
996
|
+
if (!binding.valid) {
|
|
997
|
+
await this.tokenRevocation.revokeAllRefreshTokens(decoded.id);
|
|
998
|
+
this.audit.log("SESSION_HIJACK_DETECTED", {
|
|
999
|
+
userId: decoded.id,
|
|
1000
|
+
sessionId: decoded.sessionId,
|
|
1001
|
+
reason: binding.reason
|
|
1002
|
+
});
|
|
1003
|
+
throw this.errors.SessionHijackError(binding.reason);
|
|
1004
|
+
}
|
|
1005
|
+
metadata.lastActivity = Date.now();
|
|
1006
|
+
await this.config.redis.setex(metadataKey, this.config.sessionMetadataTtl, JSON.stringify(metadata));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const user = await this.userStore.findById(decoded.id);
|
|
1011
|
+
if (!user) throw this.errors.UnauthorizedError("User not found", "USER_NOT_FOUND");
|
|
1012
|
+
if (!user.emailVerified) throw this.errors.EmailNotVerifiedError();
|
|
1013
|
+
if (user.bannedAt) throw this.errors.AccountBannedError(user.banReason, user.bannedAt);
|
|
1014
|
+
return {
|
|
1015
|
+
user,
|
|
1016
|
+
payload: {
|
|
1017
|
+
id: decoded.id,
|
|
1018
|
+
email: decoded.email,
|
|
1019
|
+
role: decoded.role,
|
|
1020
|
+
jti: decoded.jti,
|
|
1021
|
+
sessionId: decoded.sessionId
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
async refresh(input) {
|
|
1026
|
+
let decoded;
|
|
1027
|
+
try {
|
|
1028
|
+
decoded = this.tokenManager.verifyToken(input.refreshToken, "refresh");
|
|
1029
|
+
} catch {
|
|
1030
|
+
throw this.errors.UnauthorizedError("Invalid refresh token", "INVALID_TOKEN");
|
|
1031
|
+
}
|
|
1032
|
+
const lockKey = `${this.config.keyPrefix}refresh:lock:${decoded.sessionId}`;
|
|
1033
|
+
const lockResult = await this.config.redis.set(lockKey, "1", "EX", DEFAULTS.refreshLockTtlSeconds, "NX");
|
|
1034
|
+
if (lockResult !== "OK") {
|
|
1035
|
+
throw this.errors.BadRequestError("Refresh already in progress", "REFRESH_IN_PROGRESS");
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
const dedupeKey = `${this.config.keyPrefix}refresh:dedupe:${decoded.id}:${decoded.jti}`;
|
|
1039
|
+
const cached = await this.config.redis.get(dedupeKey);
|
|
1040
|
+
if (cached) return JSON.parse(cached);
|
|
1041
|
+
const sessionRevokedKey = `${this.config.keyPrefix}session:revoked:${decoded.sessionId}`;
|
|
1042
|
+
if (await this.config.redis.get(sessionRevokedKey) === "1") {
|
|
1043
|
+
throw this.errors.UnauthorizedError("Session revoked", "SESSION_REVOKED");
|
|
1044
|
+
}
|
|
1045
|
+
const createdKey = `${this.config.keyPrefix}session:created:${decoded.sessionId}`;
|
|
1046
|
+
if (await this.config.redis.exists(createdKey) !== 1) {
|
|
1047
|
+
throw this.errors.UnauthorizedError("Session expired", "SESSION_TIMEOUT");
|
|
1048
|
+
}
|
|
1049
|
+
const newTokens = this.tokenManager.createTokenPair({
|
|
1050
|
+
id: decoded.id,
|
|
1051
|
+
email: decoded.email,
|
|
1052
|
+
role: decoded.role,
|
|
1053
|
+
sessionId: decoded.sessionId
|
|
1054
|
+
});
|
|
1055
|
+
const refreshTtlSeconds = TokenManager.parseExpiry(this.tokenManager.getRefreshTokenExpiry()) / 1e3;
|
|
1056
|
+
await this.tokenRevocation.rotateRefreshToken(
|
|
1057
|
+
decoded.id,
|
|
1058
|
+
decoded.jti,
|
|
1059
|
+
TokenManager.extractJti(newTokens.refreshToken),
|
|
1060
|
+
refreshTtlSeconds
|
|
1061
|
+
);
|
|
1062
|
+
const newSseToken = this.tokenManager.createSseToken(decoded.id);
|
|
1063
|
+
const sseTtlSeconds = TokenManager.parseExpiry(this.tokenManager.getSseTokenExpiry()) / 1e3;
|
|
1064
|
+
await this.tokenRevocation.storeSseToken(TokenManager.extractJti(newSseToken), decoded.id, sseTtlSeconds);
|
|
1065
|
+
const response = { ...newTokens, sseToken: newSseToken };
|
|
1066
|
+
await this.config.redis.setex(dedupeKey, DEFAULTS.refreshDedupeTtlSeconds, JSON.stringify(response));
|
|
1067
|
+
this.audit.log("TOKEN_REFRESHED", { userId: decoded.id, sessionId: decoded.sessionId });
|
|
1068
|
+
return response;
|
|
1069
|
+
} finally {
|
|
1070
|
+
await this.config.redis.del(lockKey);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
async logout(input) {
|
|
1074
|
+
if (input.accessTokenJti && input.accessToken) {
|
|
1075
|
+
const expiry = this.tokenManager.getAccessTokenExpiry();
|
|
1076
|
+
const ttlSeconds = TokenManager.parseExpiry(expiry) / 1e3;
|
|
1077
|
+
await this.tokenRevocation.revokeAccessToken(input.accessTokenJti, ttlSeconds);
|
|
1078
|
+
}
|
|
1079
|
+
if (input.logoutAll && input.userId) {
|
|
1080
|
+
await this.tokenRevocation.revokeAllRefreshTokens(input.userId);
|
|
1081
|
+
if (input.sessionId) {
|
|
1082
|
+
await this.config.redis.setex(
|
|
1083
|
+
`${this.config.keyPrefix}session:revoked:${input.sessionId}`,
|
|
1084
|
+
DEFAULTS.revokedSessionTtl,
|
|
1085
|
+
"1"
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
await this.sessionManager.revokeUserSession(input.userId, input.sessionId ?? "");
|
|
1089
|
+
this.audit.log("LOGOUT_ALL_DEVICES", { userId: input.userId });
|
|
1090
|
+
} else if (input.userId && input.sessionId) {
|
|
1091
|
+
await this.sessionManager.revokeUserSession(input.userId, input.sessionId);
|
|
1092
|
+
this.audit.log("LOGOUT", { userId: input.userId, sessionId: input.sessionId });
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async getActiveSessions(userId) {
|
|
1096
|
+
return this.sessionManager.getUserActiveSessions(userId);
|
|
1097
|
+
}
|
|
1098
|
+
async revokeSession(userId, sessionId) {
|
|
1099
|
+
await this.sessionManager.revokeUserSession(userId, sessionId);
|
|
1100
|
+
await this.tokenRevocation.markSessionRevoked(sessionId);
|
|
1101
|
+
this.audit.log("SESSION_REVOKED", { userId, sessionId });
|
|
1102
|
+
}
|
|
1103
|
+
generateTwoFactor() {
|
|
1104
|
+
const secret = this.totpManager.generateSecret();
|
|
1105
|
+
const uri = this.totpManager.createUri(secret, "");
|
|
1106
|
+
return { secret, uri };
|
|
1107
|
+
}
|
|
1108
|
+
createTwoFactorUri(secret, email) {
|
|
1109
|
+
return this.totpManager.createUri(secret, email);
|
|
1110
|
+
}
|
|
1111
|
+
async verifyTwoFactor(input) {
|
|
1112
|
+
return this.totpManager.verify(input.token, input.secret);
|
|
1113
|
+
}
|
|
1114
|
+
async recordLoginFailure(email) {
|
|
1115
|
+
return this.accountLockout.recordFailedAttempt(email);
|
|
1116
|
+
}
|
|
1117
|
+
async clearLoginFailures(email) {
|
|
1118
|
+
return this.accountLockout.clearFailedAttempts(email);
|
|
1119
|
+
}
|
|
1120
|
+
async isAccountLocked(email) {
|
|
1121
|
+
return this.accountLockout.isAccountLocked(email);
|
|
1122
|
+
}
|
|
1123
|
+
log(event, data) {
|
|
1124
|
+
this.audit.log(event, data);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
// src/refresh/refreshLock.ts
|
|
1129
|
+
init_config();
|
|
1130
|
+
var RefreshLock = class {
|
|
1131
|
+
constructor(redis, prefix = DEFAULTS.keyPrefix) {
|
|
1132
|
+
this.redis = redis;
|
|
1133
|
+
this.prefix = prefix;
|
|
1134
|
+
}
|
|
1135
|
+
redis;
|
|
1136
|
+
prefix;
|
|
1137
|
+
lockKey(sessionId) {
|
|
1138
|
+
return `${this.prefix}refresh:lock:${sessionId}`;
|
|
1139
|
+
}
|
|
1140
|
+
async acquire(sessionId) {
|
|
1141
|
+
try {
|
|
1142
|
+
const result = await this.redis.setnx(
|
|
1143
|
+
this.lockKey(sessionId),
|
|
1144
|
+
"1"
|
|
1145
|
+
);
|
|
1146
|
+
return result === 1;
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
console.error("[RefreshLock] Failed to acquire lock:", err);
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
async release(sessionId) {
|
|
1153
|
+
try {
|
|
1154
|
+
await this.redis.del(this.lockKey(sessionId));
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
console.error("[RefreshLock] Failed to release lock:", err);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async isLocked(sessionId) {
|
|
1160
|
+
try {
|
|
1161
|
+
const val = await this.redis.get(this.lockKey(sessionId));
|
|
1162
|
+
return !!val;
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
console.error("[RefreshLock] Failed to check lock status:", err);
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
// src/index.ts
|
|
1171
|
+
init_sessionTimeout();
|
|
894
1172
|
|
|
895
1173
|
// src/middleware/middlewareFactory.ts
|
|
1174
|
+
init_config();
|
|
896
1175
|
var MiddlewareFactory = class {
|
|
897
1176
|
config;
|
|
898
1177
|
tokenManager;
|
|
@@ -1082,7 +1361,7 @@ var MiddlewareFactory = class {
|
|
|
1082
1361
|
const CSRF_TOKEN_TTL = 3600;
|
|
1083
1362
|
return {
|
|
1084
1363
|
generateToken: async (userId, sessionId) => {
|
|
1085
|
-
const token =
|
|
1364
|
+
const token = crypto4.randomBytes(CSRF_TOKEN_LENGTH).toString("base64url");
|
|
1086
1365
|
const key = `${self.config.keyPrefix}csrf:${userId}:${sessionId}`;
|
|
1087
1366
|
await self.config.redis.setex(key, CSRF_TOKEN_TTL, token);
|
|
1088
1367
|
return token;
|
|
@@ -1093,7 +1372,7 @@ var MiddlewareFactory = class {
|
|
|
1093
1372
|
const storedToken = await self.config.redis.get(key);
|
|
1094
1373
|
if (!storedToken) return false;
|
|
1095
1374
|
try {
|
|
1096
|
-
return
|
|
1375
|
+
return crypto4.timingSafeEqual(Buffer.from(token, "base64url"), Buffer.from(storedToken, "base64url"));
|
|
1097
1376
|
} catch {
|
|
1098
1377
|
return false;
|
|
1099
1378
|
}
|
|
@@ -1111,7 +1390,7 @@ var MiddlewareFactory = class {
|
|
|
1111
1390
|
if (!stored || !csrfToken) return res.status(403).json({ success: false, message: "Invalid CSRF token", code: "CSRF_VALIDATION_FAILED" });
|
|
1112
1391
|
let isValid = false;
|
|
1113
1392
|
try {
|
|
1114
|
-
isValid =
|
|
1393
|
+
isValid = crypto4.timingSafeEqual(Buffer.from(csrfToken, "base64url"), Buffer.from(stored, "base64url"));
|
|
1115
1394
|
} catch {
|
|
1116
1395
|
isValid = false;
|
|
1117
1396
|
}
|
|
@@ -1180,11 +1459,11 @@ var MiddlewareFactory = class {
|
|
|
1180
1459
|
throw self.errors.UnauthorizedError("Exchange proof expired", "PROOF_EXPIRED");
|
|
1181
1460
|
}
|
|
1182
1461
|
const proofPayload = `${userId}${email}${role}${sessionId}${timestamp}`;
|
|
1183
|
-
const expected =
|
|
1462
|
+
const expected = crypto4.createHmac("sha256", self.config.hmacSecret).update(proofPayload).digest("hex");
|
|
1184
1463
|
let valid = false;
|
|
1185
1464
|
try {
|
|
1186
1465
|
if (signature) {
|
|
1187
|
-
valid =
|
|
1466
|
+
valid = crypto4.timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expected, "hex"));
|
|
1188
1467
|
}
|
|
1189
1468
|
} catch {
|
|
1190
1469
|
valid = false;
|
|
@@ -1211,7 +1490,7 @@ var MiddlewareFactory = class {
|
|
|
1211
1490
|
self.audit.log("EXCHANGE_FAILED", { userId, email, ip, reason: "Email not verified" });
|
|
1212
1491
|
throw self.errors.UnauthorizedError("Email not verified", "EMAIL_NOT_VERIFIED");
|
|
1213
1492
|
}
|
|
1214
|
-
const sid = sessionId ||
|
|
1493
|
+
const sid = sessionId || crypto4.randomUUID();
|
|
1215
1494
|
const tokens = self.tokenManager.createTokenPair({
|
|
1216
1495
|
id: user.id,
|
|
1217
1496
|
email: user.email,
|
|
@@ -1357,6 +1636,6 @@ var logoutSchema = z.object({
|
|
|
1357
1636
|
// src/index.ts
|
|
1358
1637
|
init_config();
|
|
1359
1638
|
|
|
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 };
|
|
1639
|
+
export { AUDIT_SEVERITY, AccountBannedError, AccountLockout, AuditLog, AuthService, 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
1640
|
//# sourceMappingURL=index.js.map
|
|
1362
1641
|
//# sourceMappingURL=index.js.map
|