@cairs-npm/express-auth-2fa 0.1.1 → 0.1.3

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.cjs CHANGED
@@ -174,6 +174,23 @@ var configSchema = import_zod.z.object({
174
174
  rpName: import_zod.z.string().min(1),
175
175
  origins: import_zod.z.array(import_zod.z.string().url()).min(1)
176
176
  }).optional(),
177
+ /**
178
+ * Allow POST /auth/login/start: sign in with the identifier and a verification method only
179
+ * (no password). Single-factor — enable only for apps that already sign in by code. Pass a
180
+ * function to allow it per request, e.g. only for `type: "badge"`.
181
+ */
182
+ passwordlessLogin: import_zod.z.union([
183
+ import_zod.z.boolean(),
184
+ import_zod.z.custom(
185
+ (v) => typeof v === "function"
186
+ )
187
+ ]).default(false),
188
+ /**
189
+ * Project-wide 2-Step Verification switch. False: password sign-in never asks for a code
190
+ * (whatever each user chose) and the two-step setting can't be turned on. Passwordless
191
+ * sign-in still sends a code — it's the only factor there.
192
+ */
193
+ twoStepEnabled: import_zod.z.boolean().default(true),
177
194
  /** 2-Step Verification state for users who have not chosen one yet. */
178
195
  twoStepDefault: import_zod.z.boolean().default(false),
179
196
  limits: limitSchema.default({}),
@@ -636,11 +653,16 @@ var MethodService = class {
636
653
  const cred = await this.cols.credentials.findOne({ userId });
637
654
  const docs = await this.cols.methods.find({ userId, status: "active" }).toArray();
638
655
  const methods = docs.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.createdAt.getTime() - b.createdAt.getTime()).map(toApiMethod);
639
- return { twoStepEnabled: cred?.twoStepEnabled ?? this.config.twoStepDefault, methods };
656
+ const available = this.config.twoStepEnabled;
657
+ const twoStepEnabled = available && (cred?.twoStepEnabled ?? this.config.twoStepDefault);
658
+ return { twoStepEnabled, twoStepAvailable: available, methods };
640
659
  }
641
660
  /** BR-15 — enable needs ≥1 active method; disable needs step-up + revokes trusted devices. */
642
661
  async setTwoStep(userId, sid, enabled, stepUpToken) {
643
662
  const now = this.config.clock();
663
+ if (enabled && !this.config.twoStepEnabled) {
664
+ throw apiError(409, "VALIDATION_ERROR", "2-Step Verification is turned off for this application.");
665
+ }
644
666
  if (enabled) {
645
667
  await ensureEmailMethod(this.config, this.cols, userId);
646
668
  const count = await this.cols.methods.countDocuments({ userId, status: "active" });
@@ -880,18 +902,48 @@ var AuthService = class {
880
902
  throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
881
903
  }
882
904
  await this.resetFailedLogins(cred, now);
883
- const trustedOk = cred.twoStepEnabled && await this.checkTrustedDevice(req, user.id);
884
- if (!cred.twoStepEnabled || trustedOk) {
905
+ const twoStep = this.config.twoStepEnabled && cred.twoStepEnabled;
906
+ const trustedOk = twoStep && await this.checkTrustedDevice(req, user.id);
907
+ if (!twoStep || trustedOk) {
885
908
  await this.audit.record(user.id, "login_success", ctx);
886
909
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
887
910
  }
888
- await ensureEmailMethod(this.config, this.cols, user.id, user.email);
889
- await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
890
- const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
891
- if (active.length === 0) {
911
+ const challenge = await this.issueLoginChallenge(user, ctx, now, false);
912
+ if (!challenge) {
892
913
  await this.audit.record(user.id, "login_success", ctx);
893
914
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
894
915
  }
916
+ return challenge;
917
+ }
918
+ /**
919
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
920
+ * verification step as a password sign-in. Always needs a method — there's no password to
921
+ * fall back on.
922
+ */
923
+ async loginStart(req, input) {
924
+ const allow = this.config.passwordlessLogin;
925
+ const allowed = typeof allow === "function" ? await allow({ username: input.username, type: input.type }) : allow;
926
+ if (!allowed) throw apiError(404, "VALIDATION_ERROR", "Not found.");
927
+ const now = this.config.clock();
928
+ const ctx = ctxOf(req);
929
+ const user = await this.config.userAdapter.findByLogin(input.username, { type: input.type, passwordless: true });
930
+ if (!user) throw apiError(404, "INVALID_CREDENTIALS", "We couldn't find an account with that email.");
931
+ const cred = await this.getCredentials(user.id);
932
+ if (cred?.lockedUntil && cred.lockedUntil.getTime() > now.getTime()) {
933
+ throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
934
+ }
935
+ const challenge = await this.issueLoginChallenge(user, ctx, now, true, input.type);
936
+ if (!challenge) {
937
+ throw apiError(409, "NO_METHODS", "We can't send you a code: this account has no email or verification method.");
938
+ }
939
+ return challenge;
940
+ }
941
+ /** Creates a login challenge over the user's active methods; null when there are none. */
942
+ async issueLoginChallenge(user, ctx, now, passwordless, clientType) {
943
+ await ensureEmailMethod(this.config, this.cols, user.id, user.email);
944
+ await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
945
+ const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
946
+ if (active.length === 0) return null;
895
947
  const challengeId = randomId("chg");
896
948
  await this.cols.challenges.insertOne({
897
949
  _id: challengeId,
@@ -902,6 +954,8 @@ var AuthService = class {
902
954
  smsSentAt: null,
903
955
  smsSendCount: 0,
904
956
  attempts: 0,
957
+ ...passwordless ? { passwordless: true } : {},
958
+ ...clientType ? { clientType } : {},
905
959
  expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
906
960
  createdAt: now
907
961
  });
@@ -1135,7 +1189,7 @@ var MfaService = class {
1135
1189
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1136
1190
  const user = await this.config.userAdapter.findById(ch.userId);
1137
1191
  const ua = req.headers["user-agent"];
1138
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "otp"], {
1192
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["otp"] : ["pwd", "otp"], {
1139
1193
  rememberDevice: input.rememberDevice,
1140
1194
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1141
1195
  });
@@ -1383,7 +1437,7 @@ var PasskeyService = class {
1383
1437
  );
1384
1438
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1385
1439
  const ua = req.headers["user-agent"];
1386
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "hwk"], {
1440
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["hwk"] : ["pwd", "hwk"], {
1387
1441
  rememberDevice: input.rememberDevice,
1388
1442
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1389
1443
  });
@@ -1573,6 +1627,10 @@ function buildAuthRouter(config, cols, services) {
1573
1627
  const input = parseBody(import_auth_2fa_contract.loginRequestSchema, req.body);
1574
1628
  return services.auth.login(req, res, input);
1575
1629
  }));
1630
+ router.post("/auth/login/start", rateLimit(cols, config, "login_ip", loginIpPerMin, 6e4), wrap(async (req) => {
1631
+ const input = parseBody(import_auth_2fa_contract.loginStartRequestSchema, req.body);
1632
+ return services.auth.loginStart(req, input);
1633
+ }));
1576
1634
  router.post("/auth/mfa/send", rateLimit(cols, config, "mfa_send_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
1577
1635
  const input = parseBody(import_auth_2fa_contract.mfaSendRequestSchema, req.body);
1578
1636
  return services.mfa.sendCode(input.challengeId, input.methodId);