@cairs-npm/express-auth-2fa 0.1.1 → 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.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,18 @@ 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>>]>>;
191
211
  /** 2-Step Verification state for users who have not chosen one yet. */
192
212
  twoStepDefault: z.ZodDefault<z.ZodBoolean>;
193
213
  limits: z.ZodDefault<z.ZodObject<{
@@ -309,6 +329,10 @@ declare const configSchema: z.ZodObject<{
309
329
  userAdapter: UserAdapter;
310
330
  smsProvider: SmsProvider;
311
331
  collectionPrefix: string;
332
+ passwordlessLogin: boolean | ((input: {
333
+ username: string;
334
+ type?: string;
335
+ }) => boolean | Promise<boolean>);
312
336
  twoStepDefault: boolean;
313
337
  limits: {
314
338
  loginMaxAttempts: number;
@@ -374,6 +398,10 @@ declare const configSchema: z.ZodObject<{
374
398
  rpName: string;
375
399
  origins: string[];
376
400
  } | undefined;
401
+ passwordlessLogin?: boolean | ((input: {
402
+ username: string;
403
+ type?: string;
404
+ }) => boolean | Promise<boolean>) | undefined;
377
405
  twoStepDefault?: boolean | undefined;
378
406
  limits?: {
379
407
  loginMaxAttempts?: number | undefined;
@@ -533,6 +561,17 @@ declare class AuthService {
533
561
  password: string;
534
562
  rememberMe: boolean;
535
563
  }): Promise<LoginResponse>;
564
+ /**
565
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
566
+ * verification step as a password sign-in. Always needs a method — there's no password to
567
+ * fall back on.
568
+ */
569
+ loginStart(req: Request, input: {
570
+ username: string;
571
+ type?: string;
572
+ }): Promise<LoginMfaRequired>;
573
+ /** Creates a login challenge over the user's active methods; null when there are none. */
574
+ private issueLoginChallenge;
536
575
  private registerFailedLogin;
537
576
  private resetFailedLogins;
538
577
  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,18 @@ 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>>]>>;
191
211
  /** 2-Step Verification state for users who have not chosen one yet. */
192
212
  twoStepDefault: z.ZodDefault<z.ZodBoolean>;
193
213
  limits: z.ZodDefault<z.ZodObject<{
@@ -309,6 +329,10 @@ declare const configSchema: z.ZodObject<{
309
329
  userAdapter: UserAdapter;
310
330
  smsProvider: SmsProvider;
311
331
  collectionPrefix: string;
332
+ passwordlessLogin: boolean | ((input: {
333
+ username: string;
334
+ type?: string;
335
+ }) => boolean | Promise<boolean>);
312
336
  twoStepDefault: boolean;
313
337
  limits: {
314
338
  loginMaxAttempts: number;
@@ -374,6 +398,10 @@ declare const configSchema: z.ZodObject<{
374
398
  rpName: string;
375
399
  origins: string[];
376
400
  } | undefined;
401
+ passwordlessLogin?: boolean | ((input: {
402
+ username: string;
403
+ type?: string;
404
+ }) => boolean | Promise<boolean>) | undefined;
377
405
  twoStepDefault?: boolean | undefined;
378
406
  limits?: {
379
407
  loginMaxAttempts?: number | undefined;
@@ -533,6 +561,17 @@ declare class AuthService {
533
561
  password: string;
534
562
  rememberMe: boolean;
535
563
  }): Promise<LoginResponse>;
564
+ /**
565
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
566
+ * verification step as a password sign-in. Always needs a method — there's no password to
567
+ * fall back on.
568
+ */
569
+ loginStart(req: Request, input: {
570
+ username: string;
571
+ type?: string;
572
+ }): Promise<LoginMfaRequired>;
573
+ /** Creates a login challenge over the user's active methods; null when there are none. */
574
+ private issueLoginChallenge;
536
575
  private registerFailedLogin;
537
576
  private resetFailedLogins;
538
577
  private checkTrustedDevice;
package/dist/index.js CHANGED
@@ -88,6 +88,17 @@ 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),
91
102
  /** 2-Step Verification state for users who have not chosen one yet. */
92
103
  twoStepDefault: z.boolean().default(false),
93
104
  limits: limitSchema.default({}),
@@ -793,13 +804,42 @@ var AuthService = class {
793
804
  await this.audit.record(user.id, "login_success", ctx);
794
805
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
795
806
  }
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) {
807
+ const challenge = await this.issueLoginChallenge(user, ctx, now, false);
808
+ if (!challenge) {
800
809
  await this.audit.record(user.id, "login_success", ctx);
801
810
  return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
802
811
  }
812
+ return challenge;
813
+ }
814
+ /**
815
+ * Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
816
+ * verification step as a password sign-in. Always needs a method — there's no password to
817
+ * fall back on.
818
+ */
819
+ async loginStart(req, input) {
820
+ const allow = this.config.passwordlessLogin;
821
+ const allowed = typeof allow === "function" ? await allow({ username: input.username, type: input.type }) : allow;
822
+ if (!allowed) throw apiError(404, "VALIDATION_ERROR", "Not found.");
823
+ const now = this.config.clock();
824
+ const ctx = ctxOf(req);
825
+ const user = await this.config.userAdapter.findByLogin(input.username, { type: input.type, passwordless: true });
826
+ if (!user) throw apiError(404, "INVALID_CREDENTIALS", "We couldn't find an account with that email.");
827
+ const cred = await this.getCredentials(user.id);
828
+ if (cred?.lockedUntil && cred.lockedUntil.getTime() > now.getTime()) {
829
+ throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
830
+ }
831
+ const challenge = await this.issueLoginChallenge(user, ctx, now, true, input.type);
832
+ if (!challenge) {
833
+ throw apiError(409, "NO_METHODS", "We can't send you a code: this account has no email or verification method.");
834
+ }
835
+ return challenge;
836
+ }
837
+ /** Creates a login challenge over the user's active methods; null when there are none. */
838
+ async issueLoginChallenge(user, ctx, now, passwordless, clientType) {
839
+ await ensureEmailMethod(this.config, this.cols, user.id, user.email);
840
+ await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
841
+ const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
842
+ if (active.length === 0) return null;
803
843
  const challengeId = randomId("chg");
804
844
  await this.cols.challenges.insertOne({
805
845
  _id: challengeId,
@@ -810,6 +850,8 @@ var AuthService = class {
810
850
  smsSentAt: null,
811
851
  smsSendCount: 0,
812
852
  attempts: 0,
853
+ ...passwordless ? { passwordless: true } : {},
854
+ ...clientType ? { clientType } : {},
813
855
  expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
814
856
  createdAt: now
815
857
  });
@@ -1042,7 +1084,7 @@ var MfaService = class {
1042
1084
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1043
1085
  const user = await this.config.userAdapter.findById(ch.userId);
1044
1086
  const ua = req.headers["user-agent"];
1045
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "otp"], {
1087
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["otp"] : ["pwd", "otp"], {
1046
1088
  rememberDevice: input.rememberDevice,
1047
1089
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1048
1090
  });
@@ -1294,7 +1336,7 @@ var PasskeyService = class {
1294
1336
  );
1295
1337
  await this.audit.record(ch.userId, "login_success", ctxOf(req));
1296
1338
  const ua = req.headers["user-agent"];
1297
- const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "hwk"], {
1339
+ const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["hwk"] : ["pwd", "hwk"], {
1298
1340
  rememberDevice: input.rememberDevice,
1299
1341
  deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
1300
1342
  });
@@ -1456,6 +1498,7 @@ var AuditService = class {
1456
1498
  import { Router } from "express";
1457
1499
  import {
1458
1500
  loginRequestSchema,
1501
+ loginStartRequestSchema,
1459
1502
  mfaSendRequestSchema,
1460
1503
  mfaVerifyRequestSchema,
1461
1504
  passkeyLoginOptionsRequestSchema,
@@ -1490,6 +1533,10 @@ function buildAuthRouter(config, cols, services) {
1490
1533
  const input = parseBody(loginRequestSchema, req.body);
1491
1534
  return services.auth.login(req, res, input);
1492
1535
  }));
1536
+ router.post("/auth/login/start", rateLimit(cols, config, "login_ip", loginIpPerMin, 6e4), wrap(async (req) => {
1537
+ const input = parseBody(loginStartRequestSchema, req.body);
1538
+ return services.auth.loginStart(req, input);
1539
+ }));
1493
1540
  router.post("/auth/mfa/send", rateLimit(cols, config, "mfa_send_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
1494
1541
  const input = parseBody(mfaSendRequestSchema, req.body);
1495
1542
  return services.mfa.sendCode(input.challengeId, input.methodId);