@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/README.md
CHANGED
|
@@ -73,11 +73,20 @@ tokens and trusted-device tokens are stored as SHA-256 hashes only.
|
|
|
73
73
|
- Per-IP and per-user rate limits backed by MongoDB (stateless across instances).
|
|
74
74
|
- One error shape: `{ error, message, attemptsLeft?, retryAt? }`.
|
|
75
75
|
|
|
76
|
+
## Compatibility
|
|
77
|
+
|
|
78
|
+
- Node 20 and 24 (`>=20`; the mongodb 7 driver itself needs Node `>=20.19`).
|
|
79
|
+
- `mongodb` driver 6 or 7 (peer `^6.0.0 || ^7.0.0`), or a mongoose 9 connection: pass
|
|
80
|
+
`connection.db` (and `connection.getClient()` as `client` for transactions). Ids are built with
|
|
81
|
+
the ObjectId class of the driver that owns `db`, so the app may run a different mongodb major
|
|
82
|
+
than the one this package resolves.
|
|
83
|
+
|
|
76
84
|
## Development
|
|
77
85
|
|
|
78
86
|
```sh
|
|
79
87
|
npm install
|
|
80
88
|
npm test # vitest + mongodb-memory-server (replica set)
|
|
89
|
+
npm run test:mongoose # the same suite on a mongoose 9 connection (driver 7)
|
|
81
90
|
npm run build # tsup → ESM + CJS + d.ts
|
|
82
91
|
npm run typecheck && npm run lint
|
|
83
92
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -197,16 +197,16 @@ var configSchema = import_zod.z.object({
|
|
|
197
197
|
]).default(true),
|
|
198
198
|
/** 2-Step Verification state for users who have not chosen one yet. */
|
|
199
199
|
twoStepDefault: import_zod.z.boolean().default(false),
|
|
200
|
-
limits: limitSchema.
|
|
200
|
+
limits: limitSchema.prefault({}),
|
|
201
201
|
/**
|
|
202
202
|
* Set false to turn off every RATE_LIMITED check: the per-IP limits on login / code
|
|
203
203
|
* verify / send / step-up and the code-send cooldown and caps. Account lockouts after
|
|
204
204
|
* failed passwords or codes (LOCKED) still apply.
|
|
205
205
|
*/
|
|
206
206
|
rateLimits: import_zod.z.boolean().default(true),
|
|
207
|
-
argon2: argon2Schema.
|
|
208
|
-
cookies: cookieSchema.
|
|
209
|
-
ttl: ttlSchema.
|
|
207
|
+
argon2: argon2Schema.prefault({}),
|
|
208
|
+
cookies: cookieSchema.prefault({}),
|
|
209
|
+
ttl: ttlSchema.prefault({}),
|
|
210
210
|
/** Serve GET /auth/openapi.json (A.7 docs). */
|
|
211
211
|
exposeDocs: import_zod.z.boolean().default(false)
|
|
212
212
|
});
|
|
@@ -474,8 +474,28 @@ function clearCookie(res, config, name) {
|
|
|
474
474
|
});
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
-
// src/
|
|
477
|
+
// src/data/objectId.ts
|
|
478
478
|
var import_mongodb = require("mongodb");
|
|
479
|
+
var classes = /* @__PURE__ */ new WeakMap();
|
|
480
|
+
function objectIdClass(db) {
|
|
481
|
+
let cls = classes.get(db);
|
|
482
|
+
if (!cls) {
|
|
483
|
+
cls = import_mongodb.ObjectId;
|
|
484
|
+
try {
|
|
485
|
+
const factory = db.s?.pkFactory;
|
|
486
|
+
const ctor = factory?.createPk?.()?.constructor;
|
|
487
|
+
if (typeof ctor === "function" && typeof ctor.isValid === "function") {
|
|
488
|
+
cls = ctor;
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
492
|
+
classes.set(db, cls);
|
|
493
|
+
}
|
|
494
|
+
return cls;
|
|
495
|
+
}
|
|
496
|
+
var toObjectId = (db, id) => new (objectIdClass(db))(id);
|
|
497
|
+
|
|
498
|
+
// src/services/method.service.ts
|
|
479
499
|
var import_libphonenumber_js = require("libphonenumber-js");
|
|
480
500
|
|
|
481
501
|
// src/crypto/encrypt.ts
|
|
@@ -622,6 +642,51 @@ async function deliverCode(config, encKey, method, code) {
|
|
|
622
642
|
throw apiError(400, "VALIDATION_ERROR", "This method does not send codes.");
|
|
623
643
|
}
|
|
624
644
|
|
|
645
|
+
// src/middleware/rateLimit.ts
|
|
646
|
+
function rateLimit(cols, config, bucket, max, windowMs) {
|
|
647
|
+
return async (req, _res, next) => {
|
|
648
|
+
if (!config.rateLimits) {
|
|
649
|
+
next();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const now = config.clock().getTime();
|
|
653
|
+
const slot = Math.floor(now / windowMs);
|
|
654
|
+
const key = `${bucket}:${req.ip ?? "unknown"}:${slot}`;
|
|
655
|
+
const expiresAt = new Date((slot + 1) * windowMs + 6e4);
|
|
656
|
+
try {
|
|
657
|
+
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
658
|
+
{ _id: key },
|
|
659
|
+
{ $inc: { count: 1 }, $setOnInsert: { expiresAt } },
|
|
660
|
+
{ upsert: true, returnDocument: "after" }
|
|
661
|
+
);
|
|
662
|
+
const count = doc?.count ?? 1;
|
|
663
|
+
if (count > max) {
|
|
664
|
+
next(
|
|
665
|
+
apiError(429, "RATE_LIMITED", "Too many requests. Try again later.", {
|
|
666
|
+
retryAt: new Date((slot + 1) * windowMs)
|
|
667
|
+
})
|
|
668
|
+
);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
next();
|
|
672
|
+
} catch {
|
|
673
|
+
next();
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
async function consumeUserQuota(cols, config, bucket, userId, max, windowMs) {
|
|
678
|
+
const now = config.clock().getTime();
|
|
679
|
+
const slot = Math.floor(now / windowMs);
|
|
680
|
+
const key = `${bucket}:u:${userId}:${slot}`;
|
|
681
|
+
const retryAt = new Date((slot + 1) * windowMs);
|
|
682
|
+
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
683
|
+
{ _id: key },
|
|
684
|
+
{ $inc: { count: 1 }, $setOnInsert: { expiresAt: new Date((slot + 1) * windowMs + 6e4) } },
|
|
685
|
+
{ upsert: true, returnDocument: "after" }
|
|
686
|
+
);
|
|
687
|
+
return { allowed: (doc?.count ?? 1) <= max, retryAt };
|
|
688
|
+
}
|
|
689
|
+
|
|
625
690
|
// src/services/method.service.ts
|
|
626
691
|
function toApiMethod(doc) {
|
|
627
692
|
return {
|
|
@@ -651,7 +716,7 @@ var MethodService = class {
|
|
|
651
716
|
encKey;
|
|
652
717
|
objectId(id) {
|
|
653
718
|
try {
|
|
654
|
-
return
|
|
719
|
+
return toObjectId(this.config.db, id);
|
|
655
720
|
} catch {
|
|
656
721
|
throw apiError(400, "VALIDATION_ERROR", "Invalid id.");
|
|
657
722
|
}
|
|
@@ -738,6 +803,8 @@ var MethodService = class {
|
|
|
738
803
|
phoneE164Enc: encryptString(e164, this.encKey, this.config.keyId),
|
|
739
804
|
phoneLast4: last4,
|
|
740
805
|
pendingCodeHash: sha256Hex(code),
|
|
806
|
+
pendingCodeSentAt: now,
|
|
807
|
+
pendingSendCount: 1,
|
|
741
808
|
pendingExpiresAt,
|
|
742
809
|
createdAt: now,
|
|
743
810
|
lastUsedAt: null
|
|
@@ -747,6 +814,44 @@ var MethodService = class {
|
|
|
747
814
|
await this.audit.record(userId, "method_add_started", { meta: { type: "SMS" } });
|
|
748
815
|
return { pendingMethodId: insertedId.toString() };
|
|
749
816
|
}
|
|
817
|
+
/**
|
|
818
|
+
* BR-6 for a phone being added: send a new code for a pending SMS method, with the same cooldown
|
|
819
|
+
* and caps as a sign-in code. The new code replaces the old one and the pending time restarts.
|
|
820
|
+
*/
|
|
821
|
+
async resendCode(userId, methodId) {
|
|
822
|
+
const method = await this.cols.methods.findOne({ _id: this.objectId(methodId), userId, type: "SMS", status: "pending" });
|
|
823
|
+
if (!method) throw apiError(404, "VALIDATION_ERROR", "Unknown method.");
|
|
824
|
+
const now = this.config.clock();
|
|
825
|
+
const limits = this.config.limits;
|
|
826
|
+
const limited = this.config.rateLimits;
|
|
827
|
+
const sentAt = method.pendingCodeSentAt ?? method.createdAt;
|
|
828
|
+
if (limited && sentAt && now.getTime() - sentAt.getTime() < limits.smsResendSec * 1e3) {
|
|
829
|
+
throw apiError(429, "RATE_LIMITED", "Please wait before requesting another code.", {
|
|
830
|
+
retryAt: new Date(sentAt.getTime() + limits.smsResendSec * 1e3)
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
if (limited && (method.pendingSendCount ?? 1) >= limits.smsMaxPerChallenge) {
|
|
834
|
+
throw apiError(429, "RATE_LIMITED", "Too many codes sent for this number.", { retryAt: method.pendingExpiresAt });
|
|
835
|
+
}
|
|
836
|
+
const quota = limited ? await consumeUserQuota(this.cols, this.config, "sms_send", userId, limits.smsMaxPerUserHour, 36e5) : { allowed: true, retryAt: void 0 };
|
|
837
|
+
if (!quota.allowed) {
|
|
838
|
+
throw apiError(429, "RATE_LIMITED", "Too many codes sent. Try again later.", { retryAt: quota.retryAt });
|
|
839
|
+
}
|
|
840
|
+
const code = randomSmsCode();
|
|
841
|
+
await this.cols.methods.updateOne(
|
|
842
|
+
{ _id: method._id },
|
|
843
|
+
{
|
|
844
|
+
$set: {
|
|
845
|
+
pendingCodeHash: sha256Hex(code),
|
|
846
|
+
pendingCodeSentAt: now,
|
|
847
|
+
pendingExpiresAt: new Date(now.getTime() + this.config.ttl.pendingMethodMin * 6e4)
|
|
848
|
+
},
|
|
849
|
+
$inc: { pendingSendCount: 1 }
|
|
850
|
+
}
|
|
851
|
+
);
|
|
852
|
+
await deliverCode(this.config, this.encKey, method, code);
|
|
853
|
+
return { resendAfterSec: limited ? limits.smsResendSec : 0 };
|
|
854
|
+
}
|
|
750
855
|
/** BR-17 — confirm a pending method; first active method becomes default. */
|
|
751
856
|
async confirmMethod(userId, methodId, code) {
|
|
752
857
|
const method = await this.cols.methods.findOne({ _id: this.objectId(methodId), userId, status: "pending" });
|
|
@@ -769,7 +874,7 @@ var MethodService = class {
|
|
|
769
874
|
{ _id: method._id },
|
|
770
875
|
{
|
|
771
876
|
$set: { status: "active", isDefault: activeCount === 0, lastUsedAt: now },
|
|
772
|
-
$unset: { pendingCodeHash: "", pendingExpiresAt: "" }
|
|
877
|
+
$unset: { pendingCodeHash: "", pendingExpiresAt: "", pendingCodeSentAt: "", pendingSendCount: "" }
|
|
773
878
|
}
|
|
774
879
|
);
|
|
775
880
|
await this.audit.record(userId, "method_added", { meta: { type: method.type } });
|
|
@@ -1068,55 +1173,7 @@ var AuthService = class {
|
|
|
1068
1173
|
};
|
|
1069
1174
|
|
|
1070
1175
|
// src/services/mfa.service.ts
|
|
1071
|
-
var import_mongodb2 = require("mongodb");
|
|
1072
1176
|
init_hash();
|
|
1073
|
-
|
|
1074
|
-
// src/middleware/rateLimit.ts
|
|
1075
|
-
function rateLimit(cols, config, bucket, max, windowMs) {
|
|
1076
|
-
return async (req, _res, next) => {
|
|
1077
|
-
if (!config.rateLimits) {
|
|
1078
|
-
next();
|
|
1079
|
-
return;
|
|
1080
|
-
}
|
|
1081
|
-
const now = config.clock().getTime();
|
|
1082
|
-
const slot = Math.floor(now / windowMs);
|
|
1083
|
-
const key = `${bucket}:${req.ip ?? "unknown"}:${slot}`;
|
|
1084
|
-
const expiresAt = new Date((slot + 1) * windowMs + 6e4);
|
|
1085
|
-
try {
|
|
1086
|
-
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
1087
|
-
{ _id: key },
|
|
1088
|
-
{ $inc: { count: 1 }, $setOnInsert: { expiresAt } },
|
|
1089
|
-
{ upsert: true, returnDocument: "after" }
|
|
1090
|
-
);
|
|
1091
|
-
const count = doc?.count ?? 1;
|
|
1092
|
-
if (count > max) {
|
|
1093
|
-
next(
|
|
1094
|
-
apiError(429, "RATE_LIMITED", "Too many requests. Try again later.", {
|
|
1095
|
-
retryAt: new Date((slot + 1) * windowMs)
|
|
1096
|
-
})
|
|
1097
|
-
);
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
next();
|
|
1101
|
-
} catch {
|
|
1102
|
-
next();
|
|
1103
|
-
}
|
|
1104
|
-
};
|
|
1105
|
-
}
|
|
1106
|
-
async function consumeUserQuota(cols, config, bucket, userId, max, windowMs) {
|
|
1107
|
-
const now = config.clock().getTime();
|
|
1108
|
-
const slot = Math.floor(now / windowMs);
|
|
1109
|
-
const key = `${bucket}:u:${userId}:${slot}`;
|
|
1110
|
-
const retryAt = new Date((slot + 1) * windowMs);
|
|
1111
|
-
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
1112
|
-
{ _id: key },
|
|
1113
|
-
{ $inc: { count: 1 }, $setOnInsert: { expiresAt: new Date((slot + 1) * windowMs + 6e4) } },
|
|
1114
|
-
{ upsert: true, returnDocument: "after" }
|
|
1115
|
-
);
|
|
1116
|
-
return { allowed: (doc?.count ?? 1) <= max, retryAt };
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
// src/services/mfa.service.ts
|
|
1120
1177
|
var MfaService = class {
|
|
1121
1178
|
constructor(config, cols, tokens, audit, auth) {
|
|
1122
1179
|
this.config = config;
|
|
@@ -1274,7 +1331,7 @@ var MfaService = class {
|
|
|
1274
1331
|
}
|
|
1275
1332
|
objectId(id) {
|
|
1276
1333
|
try {
|
|
1277
|
-
return
|
|
1334
|
+
return toObjectId(this.config.db, id);
|
|
1278
1335
|
} catch {
|
|
1279
1336
|
throw apiError(400, "VALIDATION_ERROR", "Invalid id.");
|
|
1280
1337
|
}
|
|
@@ -1313,7 +1370,9 @@ var PasskeyService = class {
|
|
|
1313
1370
|
return ch;
|
|
1314
1371
|
}
|
|
1315
1372
|
/** Step 1 of adding a passkey: WebAuthn creation options for the signed-in user. */
|
|
1316
|
-
|
|
1373
|
+
// authenticator: suggest this device, a phone (QR code) or a security key. Passkeys already on
|
|
1374
|
+
// the account are excluded, so a second passkey needs another authenticator.
|
|
1375
|
+
async registrationOptions(userId, authenticator2) {
|
|
1317
1376
|
const rp = this.rp();
|
|
1318
1377
|
const now = this.config.clock();
|
|
1319
1378
|
if (await this.cols.methods.countDocuments({ userId }) >= this.config.limits.maxMethods) {
|
|
@@ -1329,7 +1388,8 @@ var PasskeyService = class {
|
|
|
1329
1388
|
userID: new TextEncoder().encode(userId),
|
|
1330
1389
|
attestationType: "none",
|
|
1331
1390
|
excludeCredentials: (await this.activePasskeys(userId)).map((m) => this.descriptor(m)),
|
|
1332
|
-
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }
|
|
1391
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
|
1392
|
+
...authenticator2 ? { preferredAuthenticatorType: authenticator2 } : {}
|
|
1333
1393
|
});
|
|
1334
1394
|
await this.cols.challenges.deleteMany({ userId, purpose: "enroll" });
|
|
1335
1395
|
await this.cols.challenges.insertOne({
|
|
@@ -1697,6 +1757,7 @@ function buildOpenApiDoc() {
|
|
|
1697
1757
|
"/account/security/two-step": { put: { summary: "Enable/disable 2-Step Verification" } },
|
|
1698
1758
|
"/account/security/methods": { post: { summary: "Add a pending TOTP or SMS method" } },
|
|
1699
1759
|
"/account/security/methods/{id}/confirm": { post: { summary: "Confirm a pending method with a code" } },
|
|
1760
|
+
"/account/security/methods/{id}/resend": { post: { summary: "Send a new code for a phone being added" } },
|
|
1700
1761
|
"/account/security/methods/{id}": {
|
|
1701
1762
|
patch: { summary: "Rename or set-default a method" },
|
|
1702
1763
|
delete: { summary: "Remove a method" }
|
|
@@ -1724,7 +1785,8 @@ function buildAccountRouter(_config, _cols, services) {
|
|
|
1724
1785
|
return services.methods.addMethod(req.auth.sub, input);
|
|
1725
1786
|
}));
|
|
1726
1787
|
router.post("/account/security/passkeys/options", auth, wrap2(async (req) => {
|
|
1727
|
-
|
|
1788
|
+
const input = parseBody(import_auth_2fa_contract2.passkeyRegisterOptionsRequestSchema, req.body ?? {});
|
|
1789
|
+
return services.passkeys.registrationOptions(req.auth.sub, input.authenticator);
|
|
1728
1790
|
}));
|
|
1729
1791
|
router.post("/account/security/passkeys", auth, wrap2(async (req) => {
|
|
1730
1792
|
const input = parseBody(import_auth_2fa_contract2.passkeyRegisterRequestSchema, req.body);
|
|
@@ -1734,6 +1796,9 @@ function buildAccountRouter(_config, _cols, services) {
|
|
|
1734
1796
|
const input = parseBody(import_auth_2fa_contract2.confirmMethodRequestSchema, req.body);
|
|
1735
1797
|
return services.methods.confirmMethod(req.auth.sub, req.params.id, input.code);
|
|
1736
1798
|
}));
|
|
1799
|
+
router.post("/account/security/methods/:id/resend", auth, wrap2(async (req) => {
|
|
1800
|
+
return services.methods.resendCode(req.auth.sub, req.params.id);
|
|
1801
|
+
}));
|
|
1737
1802
|
router.patch("/account/security/methods/:id", auth, wrap2(async (req) => {
|
|
1738
1803
|
const input = parseBody(import_auth_2fa_contract2.patchMethodRequestSchema, req.body);
|
|
1739
1804
|
return services.methods.patchMethod(req.auth.sub, req.auth.sid, req.params.id, input);
|