@cairs-npm/express-auth-2fa 0.1.4 → 0.1.6
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 +9 -0
- package/dist/index.cjs +124 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -252
- package/dist/index.d.ts +31 -252
- package/dist/index.js +126 -60
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
package/dist/index.js
CHANGED
|
@@ -111,16 +111,16 @@ var configSchema = z.object({
|
|
|
111
111
|
]).default(true),
|
|
112
112
|
/** 2-Step Verification state for users who have not chosen one yet. */
|
|
113
113
|
twoStepDefault: z.boolean().default(false),
|
|
114
|
-
limits: limitSchema.
|
|
114
|
+
limits: limitSchema.prefault({}),
|
|
115
115
|
/**
|
|
116
116
|
* Set false to turn off every RATE_LIMITED check: the per-IP limits on login / code
|
|
117
117
|
* verify / send / step-up and the code-send cooldown and caps. Account lockouts after
|
|
118
118
|
* failed passwords or codes (LOCKED) still apply.
|
|
119
119
|
*/
|
|
120
120
|
rateLimits: z.boolean().default(true),
|
|
121
|
-
argon2: argon2Schema.
|
|
122
|
-
cookies: cookieSchema.
|
|
123
|
-
ttl: ttlSchema.
|
|
121
|
+
argon2: argon2Schema.prefault({}),
|
|
122
|
+
cookies: cookieSchema.prefault({}),
|
|
123
|
+
ttl: ttlSchema.prefault({}),
|
|
124
124
|
/** Serve GET /auth/openapi.json (A.7 docs). */
|
|
125
125
|
exposeDocs: z.boolean().default(false)
|
|
126
126
|
});
|
|
@@ -385,8 +385,28 @@ function clearCookie(res, config, name) {
|
|
|
385
385
|
});
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
// src/
|
|
388
|
+
// src/data/objectId.ts
|
|
389
389
|
import { ObjectId } from "mongodb";
|
|
390
|
+
var classes = /* @__PURE__ */ new WeakMap();
|
|
391
|
+
function objectIdClass(db) {
|
|
392
|
+
let cls = classes.get(db);
|
|
393
|
+
if (!cls) {
|
|
394
|
+
cls = ObjectId;
|
|
395
|
+
try {
|
|
396
|
+
const factory = db.s?.pkFactory;
|
|
397
|
+
const ctor = factory?.createPk?.()?.constructor;
|
|
398
|
+
if (typeof ctor === "function" && typeof ctor.isValid === "function") {
|
|
399
|
+
cls = ctor;
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
}
|
|
403
|
+
classes.set(db, cls);
|
|
404
|
+
}
|
|
405
|
+
return cls;
|
|
406
|
+
}
|
|
407
|
+
var toObjectId = (db, id) => new (objectIdClass(db))(id);
|
|
408
|
+
|
|
409
|
+
// src/services/method.service.ts
|
|
390
410
|
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
|
391
411
|
|
|
392
412
|
// src/crypto/encrypt.ts
|
|
@@ -530,6 +550,51 @@ async function deliverCode(config, encKey, method, code) {
|
|
|
530
550
|
throw apiError(400, "VALIDATION_ERROR", "This method does not send codes.");
|
|
531
551
|
}
|
|
532
552
|
|
|
553
|
+
// src/middleware/rateLimit.ts
|
|
554
|
+
function rateLimit(cols, config, bucket, max, windowMs) {
|
|
555
|
+
return async (req, _res, next) => {
|
|
556
|
+
if (!config.rateLimits) {
|
|
557
|
+
next();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const now = config.clock().getTime();
|
|
561
|
+
const slot = Math.floor(now / windowMs);
|
|
562
|
+
const key = `${bucket}:${req.ip ?? "unknown"}:${slot}`;
|
|
563
|
+
const expiresAt = new Date((slot + 1) * windowMs + 6e4);
|
|
564
|
+
try {
|
|
565
|
+
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
566
|
+
{ _id: key },
|
|
567
|
+
{ $inc: { count: 1 }, $setOnInsert: { expiresAt } },
|
|
568
|
+
{ upsert: true, returnDocument: "after" }
|
|
569
|
+
);
|
|
570
|
+
const count = doc?.count ?? 1;
|
|
571
|
+
if (count > max) {
|
|
572
|
+
next(
|
|
573
|
+
apiError(429, "RATE_LIMITED", "Too many requests. Try again later.", {
|
|
574
|
+
retryAt: new Date((slot + 1) * windowMs)
|
|
575
|
+
})
|
|
576
|
+
);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
next();
|
|
580
|
+
} catch {
|
|
581
|
+
next();
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
async function consumeUserQuota(cols, config, bucket, userId, max, windowMs) {
|
|
586
|
+
const now = config.clock().getTime();
|
|
587
|
+
const slot = Math.floor(now / windowMs);
|
|
588
|
+
const key = `${bucket}:u:${userId}:${slot}`;
|
|
589
|
+
const retryAt = new Date((slot + 1) * windowMs);
|
|
590
|
+
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
591
|
+
{ _id: key },
|
|
592
|
+
{ $inc: { count: 1 }, $setOnInsert: { expiresAt: new Date((slot + 1) * windowMs + 6e4) } },
|
|
593
|
+
{ upsert: true, returnDocument: "after" }
|
|
594
|
+
);
|
|
595
|
+
return { allowed: (doc?.count ?? 1) <= max, retryAt };
|
|
596
|
+
}
|
|
597
|
+
|
|
533
598
|
// src/services/method.service.ts
|
|
534
599
|
function toApiMethod(doc) {
|
|
535
600
|
return {
|
|
@@ -559,7 +624,7 @@ var MethodService = class {
|
|
|
559
624
|
encKey;
|
|
560
625
|
objectId(id) {
|
|
561
626
|
try {
|
|
562
|
-
return
|
|
627
|
+
return toObjectId(this.config.db, id);
|
|
563
628
|
} catch {
|
|
564
629
|
throw apiError(400, "VALIDATION_ERROR", "Invalid id.");
|
|
565
630
|
}
|
|
@@ -646,6 +711,8 @@ var MethodService = class {
|
|
|
646
711
|
phoneE164Enc: encryptString(e164, this.encKey, this.config.keyId),
|
|
647
712
|
phoneLast4: last4,
|
|
648
713
|
pendingCodeHash: sha256Hex(code),
|
|
714
|
+
pendingCodeSentAt: now,
|
|
715
|
+
pendingSendCount: 1,
|
|
649
716
|
pendingExpiresAt,
|
|
650
717
|
createdAt: now,
|
|
651
718
|
lastUsedAt: null
|
|
@@ -655,6 +722,44 @@ var MethodService = class {
|
|
|
655
722
|
await this.audit.record(userId, "method_add_started", { meta: { type: "SMS" } });
|
|
656
723
|
return { pendingMethodId: insertedId.toString() };
|
|
657
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* BR-6 for a phone being added: send a new code for a pending SMS method, with the same cooldown
|
|
727
|
+
* and caps as a sign-in code. The new code replaces the old one and the pending time restarts.
|
|
728
|
+
*/
|
|
729
|
+
async resendCode(userId, methodId) {
|
|
730
|
+
const method = await this.cols.methods.findOne({ _id: this.objectId(methodId), userId, type: "SMS", status: "pending" });
|
|
731
|
+
if (!method) throw apiError(404, "VALIDATION_ERROR", "Unknown method.");
|
|
732
|
+
const now = this.config.clock();
|
|
733
|
+
const limits = this.config.limits;
|
|
734
|
+
const limited = this.config.rateLimits;
|
|
735
|
+
const sentAt = method.pendingCodeSentAt ?? method.createdAt;
|
|
736
|
+
if (limited && sentAt && now.getTime() - sentAt.getTime() < limits.smsResendSec * 1e3) {
|
|
737
|
+
throw apiError(429, "RATE_LIMITED", "Please wait before requesting another code.", {
|
|
738
|
+
retryAt: new Date(sentAt.getTime() + limits.smsResendSec * 1e3)
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
if (limited && (method.pendingSendCount ?? 1) >= limits.smsMaxPerChallenge) {
|
|
742
|
+
throw apiError(429, "RATE_LIMITED", "Too many codes sent for this number.", { retryAt: method.pendingExpiresAt });
|
|
743
|
+
}
|
|
744
|
+
const quota = limited ? await consumeUserQuota(this.cols, this.config, "sms_send", userId, limits.smsMaxPerUserHour, 36e5) : { allowed: true, retryAt: void 0 };
|
|
745
|
+
if (!quota.allowed) {
|
|
746
|
+
throw apiError(429, "RATE_LIMITED", "Too many codes sent. Try again later.", { retryAt: quota.retryAt });
|
|
747
|
+
}
|
|
748
|
+
const code = randomSmsCode();
|
|
749
|
+
await this.cols.methods.updateOne(
|
|
750
|
+
{ _id: method._id },
|
|
751
|
+
{
|
|
752
|
+
$set: {
|
|
753
|
+
pendingCodeHash: sha256Hex(code),
|
|
754
|
+
pendingCodeSentAt: now,
|
|
755
|
+
pendingExpiresAt: new Date(now.getTime() + this.config.ttl.pendingMethodMin * 6e4)
|
|
756
|
+
},
|
|
757
|
+
$inc: { pendingSendCount: 1 }
|
|
758
|
+
}
|
|
759
|
+
);
|
|
760
|
+
await deliverCode(this.config, this.encKey, method, code);
|
|
761
|
+
return { resendAfterSec: limited ? limits.smsResendSec : 0 };
|
|
762
|
+
}
|
|
658
763
|
/** BR-17 — confirm a pending method; first active method becomes default. */
|
|
659
764
|
async confirmMethod(userId, methodId, code) {
|
|
660
765
|
const method = await this.cols.methods.findOne({ _id: this.objectId(methodId), userId, status: "pending" });
|
|
@@ -677,7 +782,7 @@ var MethodService = class {
|
|
|
677
782
|
{ _id: method._id },
|
|
678
783
|
{
|
|
679
784
|
$set: { status: "active", isDefault: activeCount === 0, lastUsedAt: now },
|
|
680
|
-
$unset: { pendingCodeHash: "", pendingExpiresAt: "" }
|
|
785
|
+
$unset: { pendingCodeHash: "", pendingExpiresAt: "", pendingCodeSentAt: "", pendingSendCount: "" }
|
|
681
786
|
}
|
|
682
787
|
);
|
|
683
788
|
await this.audit.record(userId, "method_added", { meta: { type: method.type } });
|
|
@@ -975,54 +1080,6 @@ var AuthService = class {
|
|
|
975
1080
|
}
|
|
976
1081
|
};
|
|
977
1082
|
|
|
978
|
-
// src/services/mfa.service.ts
|
|
979
|
-
import { ObjectId as ObjectId2 } from "mongodb";
|
|
980
|
-
|
|
981
|
-
// src/middleware/rateLimit.ts
|
|
982
|
-
function rateLimit(cols, config, bucket, max, windowMs) {
|
|
983
|
-
return async (req, _res, next) => {
|
|
984
|
-
if (!config.rateLimits) {
|
|
985
|
-
next();
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
const now = config.clock().getTime();
|
|
989
|
-
const slot = Math.floor(now / windowMs);
|
|
990
|
-
const key = `${bucket}:${req.ip ?? "unknown"}:${slot}`;
|
|
991
|
-
const expiresAt = new Date((slot + 1) * windowMs + 6e4);
|
|
992
|
-
try {
|
|
993
|
-
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
994
|
-
{ _id: key },
|
|
995
|
-
{ $inc: { count: 1 }, $setOnInsert: { expiresAt } },
|
|
996
|
-
{ upsert: true, returnDocument: "after" }
|
|
997
|
-
);
|
|
998
|
-
const count = doc?.count ?? 1;
|
|
999
|
-
if (count > max) {
|
|
1000
|
-
next(
|
|
1001
|
-
apiError(429, "RATE_LIMITED", "Too many requests. Try again later.", {
|
|
1002
|
-
retryAt: new Date((slot + 1) * windowMs)
|
|
1003
|
-
})
|
|
1004
|
-
);
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
next();
|
|
1008
|
-
} catch {
|
|
1009
|
-
next();
|
|
1010
|
-
}
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
async function consumeUserQuota(cols, config, bucket, userId, max, windowMs) {
|
|
1014
|
-
const now = config.clock().getTime();
|
|
1015
|
-
const slot = Math.floor(now / windowMs);
|
|
1016
|
-
const key = `${bucket}:u:${userId}:${slot}`;
|
|
1017
|
-
const retryAt = new Date((slot + 1) * windowMs);
|
|
1018
|
-
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
1019
|
-
{ _id: key },
|
|
1020
|
-
{ $inc: { count: 1 }, $setOnInsert: { expiresAt: new Date((slot + 1) * windowMs + 6e4) } },
|
|
1021
|
-
{ upsert: true, returnDocument: "after" }
|
|
1022
|
-
);
|
|
1023
|
-
return { allowed: (doc?.count ?? 1) <= max, retryAt };
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
1083
|
// src/services/mfa.service.ts
|
|
1027
1084
|
var MfaService = class {
|
|
1028
1085
|
constructor(config, cols, tokens, audit, auth) {
|
|
@@ -1181,7 +1238,7 @@ var MfaService = class {
|
|
|
1181
1238
|
}
|
|
1182
1239
|
objectId(id) {
|
|
1183
1240
|
try {
|
|
1184
|
-
return
|
|
1241
|
+
return toObjectId(this.config.db, id);
|
|
1185
1242
|
} catch {
|
|
1186
1243
|
throw apiError(400, "VALIDATION_ERROR", "Invalid id.");
|
|
1187
1244
|
}
|
|
@@ -1224,7 +1281,9 @@ var PasskeyService = class {
|
|
|
1224
1281
|
return ch;
|
|
1225
1282
|
}
|
|
1226
1283
|
/** Step 1 of adding a passkey: WebAuthn creation options for the signed-in user. */
|
|
1227
|
-
|
|
1284
|
+
// authenticator: suggest this device, a phone (QR code) or a security key. Passkeys already on
|
|
1285
|
+
// the account are excluded, so a second passkey needs another authenticator.
|
|
1286
|
+
async registrationOptions(userId, authenticator2) {
|
|
1228
1287
|
const rp = this.rp();
|
|
1229
1288
|
const now = this.config.clock();
|
|
1230
1289
|
if (await this.cols.methods.countDocuments({ userId }) >= this.config.limits.maxMethods) {
|
|
@@ -1240,7 +1299,8 @@ var PasskeyService = class {
|
|
|
1240
1299
|
userID: new TextEncoder().encode(userId),
|
|
1241
1300
|
attestationType: "none",
|
|
1242
1301
|
excludeCredentials: (await this.activePasskeys(userId)).map((m) => this.descriptor(m)),
|
|
1243
|
-
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }
|
|
1302
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
|
1303
|
+
...authenticator2 ? { preferredAuthenticatorType: authenticator2 } : {}
|
|
1244
1304
|
});
|
|
1245
1305
|
await this.cols.challenges.deleteMany({ userId, purpose: "enroll" });
|
|
1246
1306
|
await this.cols.challenges.insertOne({
|
|
@@ -1615,6 +1675,7 @@ function buildOpenApiDoc() {
|
|
|
1615
1675
|
"/account/security/two-step": { put: { summary: "Enable/disable 2-Step Verification" } },
|
|
1616
1676
|
"/account/security/methods": { post: { summary: "Add a pending TOTP or SMS method" } },
|
|
1617
1677
|
"/account/security/methods/{id}/confirm": { post: { summary: "Confirm a pending method with a code" } },
|
|
1678
|
+
"/account/security/methods/{id}/resend": { post: { summary: "Send a new code for a phone being added" } },
|
|
1618
1679
|
"/account/security/methods/{id}": {
|
|
1619
1680
|
patch: { summary: "Rename or set-default a method" },
|
|
1620
1681
|
delete: { summary: "Remove a method" }
|
|
@@ -1631,7 +1692,8 @@ import {
|
|
|
1631
1692
|
patchMethodRequestSchema,
|
|
1632
1693
|
deleteMethodRequestSchema,
|
|
1633
1694
|
twoStepRequestSchema,
|
|
1634
|
-
passkeyRegisterRequestSchema
|
|
1695
|
+
passkeyRegisterRequestSchema,
|
|
1696
|
+
passkeyRegisterOptionsRequestSchema
|
|
1635
1697
|
} from "@cairs-npm/auth-2fa-contract";
|
|
1636
1698
|
var wrap2 = (fn) => (req, res, next) => fn(req, res).then((r) => r === void 0 ? void 0 : res.json(r)).catch(next);
|
|
1637
1699
|
function buildAccountRouter(_config, _cols, services) {
|
|
@@ -1649,7 +1711,8 @@ function buildAccountRouter(_config, _cols, services) {
|
|
|
1649
1711
|
return services.methods.addMethod(req.auth.sub, input);
|
|
1650
1712
|
}));
|
|
1651
1713
|
router.post("/account/security/passkeys/options", auth, wrap2(async (req) => {
|
|
1652
|
-
|
|
1714
|
+
const input = parseBody(passkeyRegisterOptionsRequestSchema, req.body ?? {});
|
|
1715
|
+
return services.passkeys.registrationOptions(req.auth.sub, input.authenticator);
|
|
1653
1716
|
}));
|
|
1654
1717
|
router.post("/account/security/passkeys", auth, wrap2(async (req) => {
|
|
1655
1718
|
const input = parseBody(passkeyRegisterRequestSchema, req.body);
|
|
@@ -1659,6 +1722,9 @@ function buildAccountRouter(_config, _cols, services) {
|
|
|
1659
1722
|
const input = parseBody(confirmMethodRequestSchema, req.body);
|
|
1660
1723
|
return services.methods.confirmMethod(req.auth.sub, req.params.id, input.code);
|
|
1661
1724
|
}));
|
|
1725
|
+
router.post("/account/security/methods/:id/resend", auth, wrap2(async (req) => {
|
|
1726
|
+
return services.methods.resendCode(req.auth.sub, req.params.id);
|
|
1727
|
+
}));
|
|
1662
1728
|
router.patch("/account/security/methods/:id", auth, wrap2(async (req) => {
|
|
1663
1729
|
const input = parseBody(patchMethodRequestSchema, req.body);
|
|
1664
1730
|
return services.methods.patchMethod(req.auth.sub, req.auth.sid, req.params.id, input);
|