@cairs-npm/express-auth-2fa 0.1.0 → 0.1.2

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,17 @@ 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),
177
188
  /** 2-Step Verification state for users who have not chosen one yet. */
178
189
  twoStepDefault: import_zod.z.boolean().default(false),
179
190
  limits: limitSchema.default({}),
@@ -885,13 +896,42 @@ var AuthService = class {
885
896
  await this.audit.record(user.id, "login_success", ctx);
886
897
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
887
898
  }
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) {
899
+ const challenge = await this.issueLoginChallenge(user, ctx, now, false);
900
+ if (!challenge) {
892
901
  await this.audit.record(user.id, "login_success", ctx);
893
902
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
894
903
  }
904
+ return challenge;
905
+ }
906
+ /**
907
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
908
+ * verification step as a password sign-in. Always needs a method — there's no password to
909
+ * fall back on.
910
+ */
911
+ async loginStart(req, input) {
912
+ const allow = this.config.passwordlessLogin;
913
+ const allowed = typeof allow === "function" ? await allow({ username: input.username, type: input.type }) : allow;
914
+ if (!allowed) throw apiError(404, "VALIDATION_ERROR", "Not found.");
915
+ const now = this.config.clock();
916
+ const ctx = ctxOf(req);
917
+ const user = await this.config.userAdapter.findByLogin(input.username, { type: input.type, passwordless: true });
918
+ if (!user) throw apiError(404, "INVALID_CREDENTIALS", "We couldn't find an account with that email.");
919
+ const cred = await this.getCredentials(user.id);
920
+ if (cred?.lockedUntil && cred.lockedUntil.getTime() > now.getTime()) {
921
+ throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
922
+ }
923
+ const challenge = await this.issueLoginChallenge(user, ctx, now, true, input.type);
924
+ if (!challenge) {
925
+ throw apiError(409, "NO_METHODS", "We can't send you a code: this account has no email or verification method.");
926
+ }
927
+ return challenge;
928
+ }
929
+ /** Creates a login challenge over the user's active methods; null when there are none. */
930
+ async issueLoginChallenge(user, ctx, now, passwordless, clientType) {
931
+ await ensureEmailMethod(this.config, this.cols, user.id, user.email);
932
+ await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
933
+ const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
934
+ if (active.length === 0) return null;
895
935
  const challengeId = randomId("chg");
896
936
  await this.cols.challenges.insertOne({
897
937
  _id: challengeId,
@@ -902,6 +942,8 @@ var AuthService = class {
902
942
  smsSentAt: null,
903
943
  smsSendCount: 0,
904
944
  attempts: 0,
945
+ ...passwordless ? { passwordless: true } : {},
946
+ ...clientType ? { clientType } : {},
905
947
  expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
906
948
  createdAt: now
907
949
  });
@@ -952,7 +994,8 @@ var AuthService = class {
952
994
  const bundle = await this.tokens.issueTokens(userId, amr, ctx);
953
995
  const refreshDays = opts.rememberMe ? this.config.ttl.refreshTokenDays : Math.min(this.config.ttl.refreshTokenDays, 1);
954
996
  setCookie(res, this.config, REFRESH_COOKIE, bundle.refreshToken, refreshDays * 864e5);
955
- setCookie(res, this.config, CSRF_COOKIE, randomToken(), this.config.ttl.refreshTokenDays * 864e5, false);
997
+ const csrfToken = randomToken();
998
+ setCookie(res, this.config, CSRF_COOKIE, csrfToken, this.config.ttl.refreshTokenDays * 864e5, false);
956
999
  if (opts.rememberDevice) {
957
1000
  await this.trustDevice(userId, res, opts.deviceLabel ?? "This device");
958
1001
  }
@@ -960,7 +1003,7 @@ var AuthService = class {
960
1003
  return {
961
1004
  status: "AUTHENTICATED",
962
1005
  user: { id: userId, username: user?.username, email: user?.email },
963
- tokens: { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec }
1006
+ tokens: { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec, csrfToken }
964
1007
  };
965
1008
  }
966
1009
  async trustDevice(userId, res, label) {
@@ -986,7 +1029,7 @@ var AuthService = class {
986
1029
  if (!raw) throw apiError(401, "INVALID_CREDENTIALS", "No refresh token.");
987
1030
  const bundle = await this.tokens.rotateRefreshToken(raw, ctxOf(req));
988
1031
  setCookie(res, this.config, REFRESH_COOKIE, bundle.refreshToken, this.config.ttl.refreshTokenDays * 864e5);
989
- return { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec };
1032
+ return { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec, csrfToken: csrfCookie };
990
1033
  }
991
1034
  async logout(req, res, sid) {
992
1035
  await this.tokens.revokeSession(sid);
@@ -1134,7 +1177,7 @@ var MfaService = class {
1134
1177
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1135
1178
  const user = await this.config.userAdapter.findById(ch.userId);
1136
1179
  const ua = req.headers["user-agent"];
1137
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "otp"], {
1180
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["otp"] : ["pwd", "otp"], {
1138
1181
  rememberDevice: input.rememberDevice,
1139
1182
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1140
1183
  });
@@ -1382,7 +1425,7 @@ var PasskeyService = class {
1382
1425
  );
1383
1426
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1384
1427
  const ua = req.headers["user-agent"];
1385
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "hwk"], {
1428
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["hwk"] : ["pwd", "hwk"], {
1386
1429
  rememberDevice: input.rememberDevice,
1387
1430
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1388
1431
  });
@@ -1572,6 +1615,10 @@ function buildAuthRouter(config, cols, services) {
1572
1615
  const input = parseBody(import_auth_2fa_contract.loginRequestSchema, req.body);
1573
1616
  return services.auth.login(req, res, input);
1574
1617
  }));
1618
+ router.post("/auth/login/start", rateLimit(cols, config, "login_ip", loginIpPerMin, 6e4), wrap(async (req) => {
1619
+ const input = parseBody(import_auth_2fa_contract.loginStartRequestSchema, req.body);
1620
+ return services.auth.loginStart(req, input);
1621
+ }));
1575
1622
  router.post("/auth/mfa/send", rateLimit(cols, config, "mfa_send_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
1576
1623
  const input = parseBody(import_auth_2fa_contract.mfaSendRequestSchema, req.body);
1577
1624
  return services.mfa.sendCode(input.challengeId, input.methodId);