@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 +67 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -2
- package/dist/index.d.ts +49 -2
- package/dist/index.js +68 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
792
|
-
|
|
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
|
|
797
|
-
|
|
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);
|