@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 +56 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -2
- package/dist/index.d.ts +43 -2
- package/dist/index.js +57 -9
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
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,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;
|
|
@@ -551,12 +590,14 @@ declare class AuthService {
|
|
|
551
590
|
tokens: {
|
|
552
591
|
accessToken: string;
|
|
553
592
|
expiresInSec: number;
|
|
593
|
+
csrfToken?: string;
|
|
554
594
|
};
|
|
555
595
|
}>;
|
|
556
596
|
trustDevice(userId: string, res: Response, label: string): Promise<void>;
|
|
557
597
|
refresh(req: Request, res: Response): Promise<{
|
|
558
598
|
accessToken: string;
|
|
559
599
|
expiresInSec: number;
|
|
600
|
+
csrfToken?: string;
|
|
560
601
|
}>;
|
|
561
602
|
logout(req: Request, res: Response, sid: string): Promise<void>;
|
|
562
603
|
listActiveMethods(userId: string): Promise<Method[]>;
|
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,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;
|
|
@@ -551,12 +590,14 @@ declare class AuthService {
|
|
|
551
590
|
tokens: {
|
|
552
591
|
accessToken: string;
|
|
553
592
|
expiresInSec: number;
|
|
593
|
+
csrfToken?: string;
|
|
554
594
|
};
|
|
555
595
|
}>;
|
|
556
596
|
trustDevice(userId: string, res: Response, label: string): Promise<void>;
|
|
557
597
|
refresh(req: Request, res: Response): Promise<{
|
|
558
598
|
accessToken: string;
|
|
559
599
|
expiresInSec: number;
|
|
600
|
+
csrfToken?: string;
|
|
560
601
|
}>;
|
|
561
602
|
logout(req: Request, res: Response, sid: string): Promise<void>;
|
|
562
603
|
listActiveMethods(userId: string): Promise<Method[]>;
|
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
|
|
797
|
-
|
|
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
|
});
|
|
@@ -860,7 +902,8 @@ var AuthService = class {
|
|
|
860
902
|
const bundle = await this.tokens.issueTokens(userId, amr, ctx);
|
|
861
903
|
const refreshDays = opts.rememberMe ? this.config.ttl.refreshTokenDays : Math.min(this.config.ttl.refreshTokenDays, 1);
|
|
862
904
|
setCookie(res, this.config, REFRESH_COOKIE, bundle.refreshToken, refreshDays * 864e5);
|
|
863
|
-
|
|
905
|
+
const csrfToken = randomToken();
|
|
906
|
+
setCookie(res, this.config, CSRF_COOKIE, csrfToken, this.config.ttl.refreshTokenDays * 864e5, false);
|
|
864
907
|
if (opts.rememberDevice) {
|
|
865
908
|
await this.trustDevice(userId, res, opts.deviceLabel ?? "This device");
|
|
866
909
|
}
|
|
@@ -868,7 +911,7 @@ var AuthService = class {
|
|
|
868
911
|
return {
|
|
869
912
|
status: "AUTHENTICATED",
|
|
870
913
|
user: { id: userId, username: user?.username, email: user?.email },
|
|
871
|
-
tokens: { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec }
|
|
914
|
+
tokens: { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec, csrfToken }
|
|
872
915
|
};
|
|
873
916
|
}
|
|
874
917
|
async trustDevice(userId, res, label) {
|
|
@@ -894,7 +937,7 @@ var AuthService = class {
|
|
|
894
937
|
if (!raw) throw apiError(401, "INVALID_CREDENTIALS", "No refresh token.");
|
|
895
938
|
const bundle = await this.tokens.rotateRefreshToken(raw, ctxOf(req));
|
|
896
939
|
setCookie(res, this.config, REFRESH_COOKIE, bundle.refreshToken, this.config.ttl.refreshTokenDays * 864e5);
|
|
897
|
-
return { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec };
|
|
940
|
+
return { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec, csrfToken: csrfCookie };
|
|
898
941
|
}
|
|
899
942
|
async logout(req, res, sid) {
|
|
900
943
|
await this.tokens.revokeSession(sid);
|
|
@@ -1041,7 +1084,7 @@ var MfaService = class {
|
|
|
1041
1084
|
await this.audit.record(ch.userId, "login_success", ctxOf(req));
|
|
1042
1085
|
const user = await this.config.userAdapter.findById(ch.userId);
|
|
1043
1086
|
const ua = req.headers["user-agent"];
|
|
1044
|
-
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"], {
|
|
1045
1088
|
rememberDevice: input.rememberDevice,
|
|
1046
1089
|
deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
|
|
1047
1090
|
});
|
|
@@ -1293,7 +1336,7 @@ var PasskeyService = class {
|
|
|
1293
1336
|
);
|
|
1294
1337
|
await this.audit.record(ch.userId, "login_success", ctxOf(req));
|
|
1295
1338
|
const ua = req.headers["user-agent"];
|
|
1296
|
-
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"], {
|
|
1297
1340
|
rememberDevice: input.rememberDevice,
|
|
1298
1341
|
deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
|
|
1299
1342
|
});
|
|
@@ -1455,6 +1498,7 @@ var AuditService = class {
|
|
|
1455
1498
|
import { Router } from "express";
|
|
1456
1499
|
import {
|
|
1457
1500
|
loginRequestSchema,
|
|
1501
|
+
loginStartRequestSchema,
|
|
1458
1502
|
mfaSendRequestSchema,
|
|
1459
1503
|
mfaVerifyRequestSchema,
|
|
1460
1504
|
passkeyLoginOptionsRequestSchema,
|
|
@@ -1489,6 +1533,10 @@ function buildAuthRouter(config, cols, services) {
|
|
|
1489
1533
|
const input = parseBody(loginRequestSchema, req.body);
|
|
1490
1534
|
return services.auth.login(req, res, input);
|
|
1491
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
|
+
}));
|
|
1492
1540
|
router.post("/auth/mfa/send", rateLimit(cols, config, "mfa_send_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
|
|
1493
1541
|
const input = parseBody(mfaSendRequestSchema, req.body);
|
|
1494
1542
|
return services.mfa.sendCode(input.challengeId, input.methodId);
|