@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.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
|
|
889
|
-
|
|
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
|
-
|
|
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);
|