@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/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.default({}),
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.default({}),
122
- cookies: cookieSchema.default({}),
123
- ttl: ttlSchema.default({}),
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/services/method.service.ts
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 new ObjectId(id);
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 new ObjectId2(id);
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
- async registrationOptions(userId) {
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
- return services.passkeys.registrationOptions(req.auth.sub);
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);