@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.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as mongodb from 'mongodb';
2
2
  import { ObjectId, Db, MongoClient } from 'mongodb';
3
- import { MethodType, ErrorCode, LoginResponse, Method } from '@cairs-npm/auth-2fa-contract';
3
+ import { MethodType, ErrorCode, LoginResponse, LoginMfaRequired, Method } from '@cairs-npm/auth-2fa-contract';
4
4
  export { ApiError, Method, Session, Tokens, User } from '@cairs-npm/auth-2fa-contract';
5
5
  import { Request, Response, NextFunction, Router } from 'express';
6
6
  import { z } from 'zod';
@@ -58,6 +58,10 @@ interface ChallengeDoc {
58
58
  smsSentAt: Date | null;
59
59
  smsSendCount: number;
60
60
  attempts: number;
61
+ /** Started by /auth/login/start (no password): sign-in records only the second factor. */
62
+ passwordless?: boolean;
63
+ /** `type` from /auth/login/start (e.g. "badge"), so the host app knows who's signing in. */
64
+ clientType?: string;
61
65
  /** WebAuthn challenge issued for this login / passkey enrollment (base64url). */
62
66
  webauthnChallenge?: string | null;
63
67
  expiresAt: Date;
@@ -121,7 +125,11 @@ interface AdapterUser {
121
125
  [key: string]: unknown;
122
126
  }
123
127
  interface UserAdapter {
124
- findByLogin: (login: string) => Promise<AdapterUser | null>;
128
+ /** `ctx` is only set by passwordless sign-in (/auth/login/start), e.g. { type: "badge", passwordless: true }. */
129
+ findByLogin: (login: string, ctx?: {
130
+ type?: string;
131
+ passwordless?: boolean;
132
+ }) => Promise<AdapterUser | null>;
125
133
  findById: (id: string) => Promise<AdapterUser | null>;
126
134
  }
127
135
  interface SmsProvider {
@@ -188,6 +196,24 @@ declare const configSchema: z.ZodObject<{
188
196
  rpName: string;
189
197
  origins: string[];
190
198
  }>>;
199
+ /**
200
+ * Allow POST /auth/login/start: sign in with the identifier and a verification method only
201
+ * (no password). Single-factor — enable only for apps that already sign in by code. Pass a
202
+ * function to allow it per request, e.g. only for `type: "badge"`.
203
+ */
204
+ passwordlessLogin: z.ZodDefault<z.ZodUnion<[z.ZodBoolean, z.ZodType<(input: {
205
+ username: string;
206
+ type?: string;
207
+ }) => boolean | Promise<boolean>, z.ZodTypeDef, (input: {
208
+ username: string;
209
+ type?: string;
210
+ }) => boolean | Promise<boolean>>]>>;
211
+ /**
212
+ * Project-wide 2-Step Verification switch. False: password sign-in never asks for a code
213
+ * (whatever each user chose) and the two-step setting can't be turned on. Passwordless
214
+ * sign-in still sends a code — it's the only factor there.
215
+ */
216
+ twoStepEnabled: z.ZodDefault<z.ZodBoolean>;
191
217
  /** 2-Step Verification state for users who have not chosen one yet. */
192
218
  twoStepDefault: z.ZodDefault<z.ZodBoolean>;
193
219
  limits: z.ZodDefault<z.ZodObject<{
@@ -309,6 +335,11 @@ declare const configSchema: z.ZodObject<{
309
335
  userAdapter: UserAdapter;
310
336
  smsProvider: SmsProvider;
311
337
  collectionPrefix: string;
338
+ passwordlessLogin: boolean | ((input: {
339
+ username: string;
340
+ type?: string;
341
+ }) => boolean | Promise<boolean>);
342
+ twoStepEnabled: boolean;
312
343
  twoStepDefault: boolean;
313
344
  limits: {
314
345
  loginMaxAttempts: number;
@@ -374,6 +405,11 @@ declare const configSchema: z.ZodObject<{
374
405
  rpName: string;
375
406
  origins: string[];
376
407
  } | undefined;
408
+ passwordlessLogin?: boolean | ((input: {
409
+ username: string;
410
+ type?: string;
411
+ }) => boolean | Promise<boolean>) | undefined;
412
+ twoStepEnabled?: boolean | undefined;
377
413
  twoStepDefault?: boolean | undefined;
378
414
  limits?: {
379
415
  loginMaxAttempts?: number | undefined;
@@ -533,6 +569,17 @@ declare class AuthService {
533
569
  password: string;
534
570
  rememberMe: boolean;
535
571
  }): Promise<LoginResponse>;
572
+ /**
573
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
574
+ * verification step as a password sign-in. Always needs a method — there's no password to
575
+ * fall back on.
576
+ */
577
+ loginStart(req: Request, input: {
578
+ username: string;
579
+ type?: string;
580
+ }): Promise<LoginMfaRequired>;
581
+ /** Creates a login challenge over the user's active methods; null when there are none. */
582
+ private issueLoginChallenge;
536
583
  private registerFailedLogin;
537
584
  private resetFailedLogins;
538
585
  private checkTrustedDevice;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as mongodb from 'mongodb';
2
2
  import { ObjectId, Db, MongoClient } from 'mongodb';
3
- import { MethodType, ErrorCode, LoginResponse, Method } from '@cairs-npm/auth-2fa-contract';
3
+ import { MethodType, ErrorCode, LoginResponse, LoginMfaRequired, Method } from '@cairs-npm/auth-2fa-contract';
4
4
  export { ApiError, Method, Session, Tokens, User } from '@cairs-npm/auth-2fa-contract';
5
5
  import { Request, Response, NextFunction, Router } from 'express';
6
6
  import { z } from 'zod';
@@ -58,6 +58,10 @@ interface ChallengeDoc {
58
58
  smsSentAt: Date | null;
59
59
  smsSendCount: number;
60
60
  attempts: number;
61
+ /** Started by /auth/login/start (no password): sign-in records only the second factor. */
62
+ passwordless?: boolean;
63
+ /** `type` from /auth/login/start (e.g. "badge"), so the host app knows who's signing in. */
64
+ clientType?: string;
61
65
  /** WebAuthn challenge issued for this login / passkey enrollment (base64url). */
62
66
  webauthnChallenge?: string | null;
63
67
  expiresAt: Date;
@@ -121,7 +125,11 @@ interface AdapterUser {
121
125
  [key: string]: unknown;
122
126
  }
123
127
  interface UserAdapter {
124
- findByLogin: (login: string) => Promise<AdapterUser | null>;
128
+ /** `ctx` is only set by passwordless sign-in (/auth/login/start), e.g. { type: "badge", passwordless: true }. */
129
+ findByLogin: (login: string, ctx?: {
130
+ type?: string;
131
+ passwordless?: boolean;
132
+ }) => Promise<AdapterUser | null>;
125
133
  findById: (id: string) => Promise<AdapterUser | null>;
126
134
  }
127
135
  interface SmsProvider {
@@ -188,6 +196,24 @@ declare const configSchema: z.ZodObject<{
188
196
  rpName: string;
189
197
  origins: string[];
190
198
  }>>;
199
+ /**
200
+ * Allow POST /auth/login/start: sign in with the identifier and a verification method only
201
+ * (no password). Single-factor — enable only for apps that already sign in by code. Pass a
202
+ * function to allow it per request, e.g. only for `type: "badge"`.
203
+ */
204
+ passwordlessLogin: z.ZodDefault<z.ZodUnion<[z.ZodBoolean, z.ZodType<(input: {
205
+ username: string;
206
+ type?: string;
207
+ }) => boolean | Promise<boolean>, z.ZodTypeDef, (input: {
208
+ username: string;
209
+ type?: string;
210
+ }) => boolean | Promise<boolean>>]>>;
211
+ /**
212
+ * Project-wide 2-Step Verification switch. False: password sign-in never asks for a code
213
+ * (whatever each user chose) and the two-step setting can't be turned on. Passwordless
214
+ * sign-in still sends a code — it's the only factor there.
215
+ */
216
+ twoStepEnabled: z.ZodDefault<z.ZodBoolean>;
191
217
  /** 2-Step Verification state for users who have not chosen one yet. */
192
218
  twoStepDefault: z.ZodDefault<z.ZodBoolean>;
193
219
  limits: z.ZodDefault<z.ZodObject<{
@@ -309,6 +335,11 @@ declare const configSchema: z.ZodObject<{
309
335
  userAdapter: UserAdapter;
310
336
  smsProvider: SmsProvider;
311
337
  collectionPrefix: string;
338
+ passwordlessLogin: boolean | ((input: {
339
+ username: string;
340
+ type?: string;
341
+ }) => boolean | Promise<boolean>);
342
+ twoStepEnabled: boolean;
312
343
  twoStepDefault: boolean;
313
344
  limits: {
314
345
  loginMaxAttempts: number;
@@ -374,6 +405,11 @@ declare const configSchema: z.ZodObject<{
374
405
  rpName: string;
375
406
  origins: string[];
376
407
  } | undefined;
408
+ passwordlessLogin?: boolean | ((input: {
409
+ username: string;
410
+ type?: string;
411
+ }) => boolean | Promise<boolean>) | undefined;
412
+ twoStepEnabled?: boolean | undefined;
377
413
  twoStepDefault?: boolean | undefined;
378
414
  limits?: {
379
415
  loginMaxAttempts?: number | undefined;
@@ -533,6 +569,17 @@ declare class AuthService {
533
569
  password: string;
534
570
  rememberMe: boolean;
535
571
  }): Promise<LoginResponse>;
572
+ /**
573
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
574
+ * verification step as a password sign-in. Always needs a method — there's no password to
575
+ * fall back on.
576
+ */
577
+ loginStart(req: Request, input: {
578
+ username: string;
579
+ type?: string;
580
+ }): Promise<LoginMfaRequired>;
581
+ /** Creates a login challenge over the user's active methods; null when there are none. */
582
+ private issueLoginChallenge;
536
583
  private registerFailedLogin;
537
584
  private resetFailedLogins;
538
585
  private checkTrustedDevice;
package/dist/index.js CHANGED
@@ -88,6 +88,23 @@ var configSchema = z.object({
88
88
  rpName: z.string().min(1),
89
89
  origins: z.array(z.string().url()).min(1)
90
90
  }).optional(),
91
+ /**
92
+ * Allow POST /auth/login/start: sign in with the identifier and a verification method only
93
+ * (no password). Single-factor — enable only for apps that already sign in by code. Pass a
94
+ * function to allow it per request, e.g. only for `type: "badge"`.
95
+ */
96
+ passwordlessLogin: z.union([
97
+ z.boolean(),
98
+ z.custom(
99
+ (v) => typeof v === "function"
100
+ )
101
+ ]).default(false),
102
+ /**
103
+ * Project-wide 2-Step Verification switch. False: password sign-in never asks for a code
104
+ * (whatever each user chose) and the two-step setting can't be turned on. Passwordless
105
+ * sign-in still sends a code — it's the only factor there.
106
+ */
107
+ twoStepEnabled: z.boolean().default(true),
91
108
  /** 2-Step Verification state for users who have not chosen one yet. */
92
109
  twoStepDefault: z.boolean().default(false),
93
110
  limits: limitSchema.default({}),
@@ -544,11 +561,16 @@ var MethodService = class {
544
561
  const cred = await this.cols.credentials.findOne({ userId });
545
562
  const docs = await this.cols.methods.find({ userId, status: "active" }).toArray();
546
563
  const methods = docs.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.createdAt.getTime() - b.createdAt.getTime()).map(toApiMethod);
547
- return { twoStepEnabled: cred?.twoStepEnabled ?? this.config.twoStepDefault, methods };
564
+ const available = this.config.twoStepEnabled;
565
+ const twoStepEnabled = available && (cred?.twoStepEnabled ?? this.config.twoStepDefault);
566
+ return { twoStepEnabled, twoStepAvailable: available, methods };
548
567
  }
549
568
  /** BR-15 — enable needs ≥1 active method; disable needs step-up + revokes trusted devices. */
550
569
  async setTwoStep(userId, sid, enabled, stepUpToken) {
551
570
  const now = this.config.clock();
571
+ if (enabled && !this.config.twoStepEnabled) {
572
+ throw apiError(409, "VALIDATION_ERROR", "2-Step Verification is turned off for this application.");
573
+ }
552
574
  if (enabled) {
553
575
  await ensureEmailMethod(this.config, this.cols, userId);
554
576
  const count = await this.cols.methods.countDocuments({ userId, status: "active" });
@@ -788,18 +810,48 @@ var AuthService = class {
788
810
  throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
789
811
  }
790
812
  await this.resetFailedLogins(cred, now);
791
- const trustedOk = cred.twoStepEnabled && await this.checkTrustedDevice(req, user.id);
792
- if (!cred.twoStepEnabled || trustedOk) {
813
+ const twoStep = this.config.twoStepEnabled && cred.twoStepEnabled;
814
+ const trustedOk = twoStep && await this.checkTrustedDevice(req, user.id);
815
+ if (!twoStep || trustedOk) {
793
816
  await this.audit.record(user.id, "login_success", ctx);
794
817
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
795
818
  }
796
- await ensureEmailMethod(this.config, this.cols, user.id, user.email);
797
- await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
798
- const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
799
- if (active.length === 0) {
819
+ const challenge = await this.issueLoginChallenge(user, ctx, now, false);
820
+ if (!challenge) {
800
821
  await this.audit.record(user.id, "login_success", ctx);
801
822
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
802
823
  }
824
+ return challenge;
825
+ }
826
+ /**
827
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
828
+ * verification step as a password sign-in. Always needs a method — there's no password to
829
+ * fall back on.
830
+ */
831
+ async loginStart(req, input) {
832
+ const allow = this.config.passwordlessLogin;
833
+ const allowed = typeof allow === "function" ? await allow({ username: input.username, type: input.type }) : allow;
834
+ if (!allowed) throw apiError(404, "VALIDATION_ERROR", "Not found.");
835
+ const now = this.config.clock();
836
+ const ctx = ctxOf(req);
837
+ const user = await this.config.userAdapter.findByLogin(input.username, { type: input.type, passwordless: true });
838
+ if (!user) throw apiError(404, "INVALID_CREDENTIALS", "We couldn't find an account with that email.");
839
+ const cred = await this.getCredentials(user.id);
840
+ if (cred?.lockedUntil && cred.lockedUntil.getTime() > now.getTime()) {
841
+ throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
842
+ }
843
+ const challenge = await this.issueLoginChallenge(user, ctx, now, true, input.type);
844
+ if (!challenge) {
845
+ throw apiError(409, "NO_METHODS", "We can't send you a code: this account has no email or verification method.");
846
+ }
847
+ return challenge;
848
+ }
849
+ /** Creates a login challenge over the user's active methods; null when there are none. */
850
+ async issueLoginChallenge(user, ctx, now, passwordless, clientType) {
851
+ await ensureEmailMethod(this.config, this.cols, user.id, user.email);
852
+ await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
853
+ const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
854
+ if (active.length === 0) return null;
803
855
  const challengeId = randomId("chg");
804
856
  await this.cols.challenges.insertOne({
805
857
  _id: challengeId,
@@ -810,6 +862,8 @@ var AuthService = class {
810
862
  smsSentAt: null,
811
863
  smsSendCount: 0,
812
864
  attempts: 0,
865
+ ...passwordless ? { passwordless: true } : {},
866
+ ...clientType ? { clientType } : {},
813
867
  expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
814
868
  createdAt: now
815
869
  });
@@ -1042,7 +1096,7 @@ var MfaService = class {
1042
1096
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1043
1097
  const user = await this.config.userAdapter.findById(ch.userId);
1044
1098
  const ua = req.headers["user-agent"];
1045
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "otp"], {
1099
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["otp"] : ["pwd", "otp"], {
1046
1100
  rememberDevice: input.rememberDevice,
1047
1101
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1048
1102
  });
@@ -1294,7 +1348,7 @@ var PasskeyService = class {
1294
1348
  );
1295
1349
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1296
1350
  const ua = req.headers["user-agent"];
1297
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "hwk"], {
1351
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["hwk"] : ["pwd", "hwk"], {
1298
1352
  rememberDevice: input.rememberDevice,
1299
1353
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1300
1354
  });
@@ -1456,6 +1510,7 @@ var AuditService = class {
1456
1510
  import { Router } from "express";
1457
1511
  import {
1458
1512
  loginRequestSchema,
1513
+ loginStartRequestSchema,
1459
1514
  mfaSendRequestSchema,
1460
1515
  mfaVerifyRequestSchema,
1461
1516
  passkeyLoginOptionsRequestSchema,
@@ -1490,6 +1545,10 @@ function buildAuthRouter(config, cols, services) {
1490
1545
  const input = parseBody(loginRequestSchema, req.body);
1491
1546
  return services.auth.login(req, res, input);
1492
1547
  }));
1548
+ router.post("/auth/login/start", rateLimit(cols, config, "login_ip", loginIpPerMin, 6e4), wrap(async (req) => {
1549
+ const input = parseBody(loginStartRequestSchema, req.body);
1550
+ return services.auth.loginStart(req, input);
1551
+ }));
1493
1552
  router.post("/auth/mfa/send", rateLimit(cols, config, "mfa_send_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
1494
1553
  const input = parseBody(mfaSendRequestSchema, req.body);
1495
1554
  return services.mfa.sendCode(input.challengeId, input.methodId);