better-auth 1.6.21 → 1.6.22

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.
@@ -3,7 +3,8 @@ import { convertFromDB, convertToDB } from "./field-converter.mjs";
3
3
  import { getSchema } from "./get-schema.mjs";
4
4
  import { DatabaseHooksEntry, getWithHooks } from "./with-hooks.mjs";
5
5
  import { createInternalAdapter } from "./internal-adapter.mjs";
6
+ import { revokeUnprovenAccountAccess } from "./revoke-unproven-account-access.mjs";
6
7
  import { buildSyntheticUserOutput, getSessionDefaultFields, mergeSchema, parseAccountInput, parseAccountOutput, parseAdditionalUserInput, parseAdditionalUserInputFromProviderProfile, parseInputData, parseSessionInput, parseSessionOutput, parseUserInput, parseUserOutput } from "./schema.mjs";
7
8
  import { FieldAttributeToSchema, toZodSchema } from "./to-zod.mjs";
8
9
  export * from "@better-auth/core/db";
9
- export { DatabaseHooksEntry, FieldAttributeToObject, FieldAttributeToSchema, InferAdditionalFieldsFromPluginOptions, InferFieldsInputClient, InferFieldsOutput, RemoveFieldsWithReturnedFalse, buildSyntheticUserOutput, convertFromDB, convertToDB, createInternalAdapter, getSchema, getSessionDefaultFields, getWithHooks, mergeSchema, parseAccountInput, parseAccountOutput, parseAdditionalUserInput, parseAdditionalUserInputFromProviderProfile, parseInputData, parseSessionInput, parseSessionOutput, parseUserInput, parseUserOutput, toZodSchema };
10
+ export { DatabaseHooksEntry, FieldAttributeToObject, FieldAttributeToSchema, InferAdditionalFieldsFromPluginOptions, InferFieldsInputClient, InferFieldsOutput, RemoveFieldsWithReturnedFalse, buildSyntheticUserOutput, convertFromDB, convertToDB, createInternalAdapter, getSchema, getSessionDefaultFields, getWithHooks, mergeSchema, parseAccountInput, parseAccountOutput, parseAdditionalUserInput, parseAdditionalUserInputFromProviderProfile, parseInputData, parseSessionInput, parseSessionOutput, parseUserInput, parseUserOutput, revokeUnprovenAccountAccess, toZodSchema };
package/dist/db/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { buildSyntheticUserOutput, getSessionDefaultFields, mergeSchema, parseAc
4
4
  import { convertFromDB, convertToDB } from "./field-converter.mjs";
5
5
  import { getWithHooks } from "./with-hooks.mjs";
6
6
  import { createInternalAdapter } from "./internal-adapter.mjs";
7
+ import { revokeUnprovenAccountAccess } from "./revoke-unproven-account-access.mjs";
7
8
  import { toZodSchema } from "./to-zod.mjs";
8
9
  export * from "@better-auth/core/db";
9
10
  //#region src/db/index.ts
@@ -25,9 +26,10 @@ var db_exports = /* @__PURE__ */ __exportAll({
25
26
  parseSessionOutput: () => parseSessionOutput,
26
27
  parseUserInput: () => parseUserInput,
27
28
  parseUserOutput: () => parseUserOutput,
29
+ revokeUnprovenAccountAccess: () => revokeUnprovenAccountAccess,
28
30
  toZodSchema: () => toZodSchema
29
31
  });
30
32
  import * as import__better_auth_core_db from "@better-auth/core/db";
31
33
  __reExport(db_exports, import__better_auth_core_db);
32
34
  //#endregion
33
- export { buildSyntheticUserOutput, convertFromDB, convertToDB, createInternalAdapter, db_exports, getSchema, getSessionDefaultFields, getWithHooks, mergeSchema, parseAccountInput, parseAccountOutput, parseAdditionalUserInput, parseAdditionalUserInputFromProviderProfile, parseInputData, parseSessionInput, parseSessionOutput, parseUserInput, parseUserOutput, toZodSchema };
35
+ export { buildSyntheticUserOutput, convertFromDB, convertToDB, createInternalAdapter, db_exports, getSchema, getSessionDefaultFields, getWithHooks, mergeSchema, parseAccountInput, parseAccountOutput, parseAdditionalUserInput, parseAdditionalUserInputFromProviderProfile, parseInputData, parseSessionInput, parseSessionOutput, parseUserInput, parseUserOutput, revokeUnprovenAccountAccess, toZodSchema };
@@ -0,0 +1,19 @@
1
+ import { GenericEndpointContext } from "@better-auth/core";
2
+
3
+ //#region src/db/revoke-unproven-account-access.d.ts
4
+ /**
5
+ * Strip every credential and session a pre-existing account accrued before
6
+ * control of its email was proven.
7
+ *
8
+ * An `emailVerified: false` row carries no proof that the password on it belongs
9
+ * to the mailbox owner. When an email-primary proof (magic link, email OTP)
10
+ * resolves to such a row, deleting the `credential` account and revoking standing
11
+ * sessions makes the verified owner inherit no password or session that predates
12
+ * the proof. Call this before flipping `emailVerified` and minting the owner's
13
+ * session; it no-ops if a concurrent flow has already verified the account.
14
+ *
15
+ * @param userId - The pre-existing, not-yet-verified user being promoted.
16
+ */
17
+ declare function revokeUnprovenAccountAccess(ctx: GenericEndpointContext, userId: string): Promise<void>;
18
+ //#endregion
19
+ export { revokeUnprovenAccountAccess };
@@ -0,0 +1,23 @@
1
+ //#region src/db/revoke-unproven-account-access.ts
2
+ /**
3
+ * Strip every credential and session a pre-existing account accrued before
4
+ * control of its email was proven.
5
+ *
6
+ * An `emailVerified: false` row carries no proof that the password on it belongs
7
+ * to the mailbox owner. When an email-primary proof (magic link, email OTP)
8
+ * resolves to such a row, deleting the `credential` account and revoking standing
9
+ * sessions makes the verified owner inherit no password or session that predates
10
+ * the proof. Call this before flipping `emailVerified` and minting the owner's
11
+ * session; it no-ops if a concurrent flow has already verified the account.
12
+ *
13
+ * @param userId - The pre-existing, not-yet-verified user being promoted.
14
+ */
15
+ async function revokeUnprovenAccountAccess(ctx, userId) {
16
+ const user = await ctx.context.internalAdapter.findUserById(userId);
17
+ if (!user || user.emailVerified) return;
18
+ const accounts = await ctx.context.internalAdapter.findAccounts(userId);
19
+ for (const account of accounts) if (account.providerId === "credential") await ctx.context.internalAdapter.deleteAccount(account.id);
20
+ await ctx.context.internalAdapter.deleteUserSessions(userId);
21
+ }
22
+ //#endregion
23
+ export { revokeUnprovenAccountAccess };
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.6.21";
2
+ var version = "1.6.22";
3
3
  //#endregion
4
4
  export { version };
@@ -3,6 +3,7 @@ import { getDate } from "../../utils/date.mjs";
3
3
  import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { symmetricDecrypt } from "../../crypto/index.mjs";
5
5
  import { setCookieCache, setSessionCookie } from "../../cookies/index.mjs";
6
+ import { revokeUnprovenAccountAccess } from "../../db/revoke-unproven-account-access.mjs";
6
7
  import { getSessionFromCtx, sensitiveSessionMiddleware } from "../../api/routes/session.mjs";
7
8
  import { APIError as APIError$1 } from "../../api/index.mjs";
8
9
  import { EMAIL_OTP_ERROR_CODES } from "./error-codes.mjs";
@@ -420,7 +421,10 @@ const signInEmailOTP = (opts) => createAuthEndpoint("/sign-in/email-otp", {
420
421
  user: parseUserOutput(ctx.context.options, newUser)
421
422
  });
422
423
  }
423
- if (!user.user.emailVerified) await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
424
+ if (!user.user.emailVerified) {
425
+ await revokeUnprovenAccountAccess(ctx, user.user.id);
426
+ await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
427
+ }
424
428
  const session = await ctx.context.internalAdapter.createSession(user.user.id);
425
429
  await setSessionCookie(ctx, {
426
430
  session,
@@ -2,6 +2,7 @@ import { originCheck } from "../../api/middlewares/origin-check.mjs";
2
2
  import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
3
3
  import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { setSessionCookie } from "../../cookies/index.mjs";
5
+ import { revokeUnprovenAccountAccess } from "../../db/revoke-unproven-account-access.mjs";
5
6
  import { PACKAGE_VERSION } from "../../version.mjs";
6
7
  import { defaultKeyHasher } from "./utils.mjs";
7
8
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -134,7 +135,10 @@ const magicLink = (options) => {
134
135
  user = newUser;
135
136
  if (!user) redirectWithError("failed_to_create_user");
136
137
  } else redirectWithError("new_user_signup_disabled");
137
- if (!user.emailVerified) user = await ctx.context.internalAdapter.updateUser(user.id, { emailVerified: true });
138
+ if (!user.emailVerified) {
139
+ await revokeUnprovenAccountAccess(ctx, user.id);
140
+ user = await ctx.context.internalAdapter.updateUser(user.id, { emailVerified: true });
141
+ }
138
142
  const session = await ctx.context.internalAdapter.createSession(user.id);
139
143
  if (!session) redirectWithError("failed_to_create_session");
140
144
  await setSessionCookie(ctx, {
@@ -5,7 +5,7 @@ import { sessionMiddleware } from "../../../api/routes/session.mjs";
5
5
  import { shouldRequirePassword } from "../../../utils/password.mjs";
6
6
  import { PACKAGE_VERSION } from "../../../version.mjs";
7
7
  import { TWO_FACTOR_ERROR_CODES } from "../error-code.mjs";
8
- import { verifyTwoFactor } from "../verify-two-factor.mjs";
8
+ import { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor } from "../verify-two-factor.mjs";
9
9
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
10
10
  import { safeJSONParse } from "@better-auth/core/utils/json";
11
11
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -172,6 +172,7 @@ const backupCode2fa = (opts) => {
172
172
  }]
173
173
  });
174
174
  if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED);
175
+ if (isSignIn) await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
175
176
  const attempt = isSignIn ? await beginAttempt(5) : null;
176
177
  let validate;
177
178
  try {
@@ -185,6 +186,7 @@ const backupCode2fa = (opts) => {
185
186
  }
186
187
  if (!validate.status || !validate.updated) {
187
188
  await attempt?.recordFailure();
189
+ if (isSignIn) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
188
190
  throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
189
191
  }
190
192
  const updatedBackupCodes = await encodeBackupCodes(validate.updated, ctx.context.secretConfig, opts);
@@ -200,6 +202,7 @@ const backupCode2fa = (opts) => {
200
202
  increment: {},
201
203
  set: { backupCodes: updatedBackupCodes }
202
204
  })) throw APIError.fromStatus("CONFLICT", { message: "Failed to verify backup code. Please try again." });
205
+ if (isSignIn) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
203
206
  if (!ctx.body.disableSession) return valid(ctx);
204
207
  return ctx.json({
205
208
  token: session.session?.token,
@@ -65,6 +65,7 @@ declare const twoFactorClient: (options?: {
65
65
  INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
66
66
  INVALID_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_CODE">;
67
67
  TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: _better_auth_core_utils_error_codes0.RawError<"TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE">;
68
+ ACCOUNT_TEMPORARILY_LOCKED: _better_auth_core_utils_error_codes0.RawError<"ACCOUNT_TEMPORARILY_LOCKED">;
68
69
  INVALID_TWO_FACTOR_COOKIE: _better_auth_core_utils_error_codes0.RawError<"INVALID_TWO_FACTOR_COOKIE">;
69
70
  };
70
71
  };
@@ -10,6 +10,7 @@ declare const TWO_FACTOR_ERROR_CODES: {
10
10
  INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
11
11
  INVALID_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_CODE">;
12
12
  TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: _better_auth_core_utils_error_codes0.RawError<"TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE">;
13
+ ACCOUNT_TEMPORARILY_LOCKED: _better_auth_core_utils_error_codes0.RawError<"ACCOUNT_TEMPORARILY_LOCKED">;
13
14
  INVALID_TWO_FACTOR_COOKIE: _better_auth_core_utils_error_codes0.RawError<"INVALID_TWO_FACTOR_COOKIE">;
14
15
  };
15
16
  //#endregion
@@ -9,6 +9,7 @@ const TWO_FACTOR_ERROR_CODES = defineErrorCodes({
9
9
  INVALID_BACKUP_CODE: "Invalid backup code",
10
10
  INVALID_CODE: "Invalid code",
11
11
  TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: "Too many attempts. Please request a new code.",
12
+ ACCOUNT_TEMPORARILY_LOCKED: "Too many failed verification attempts. Your account is temporarily locked. Please try again later.",
12
13
  INVALID_TWO_FACTOR_COOKIE: "Invalid two factor cookie"
13
14
  });
14
15
  //#endregion
@@ -662,6 +662,19 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
662
662
  defaultValue: true;
663
663
  input: false;
664
664
  };
665
+ failedVerificationCount: {
666
+ type: "number";
667
+ required: false;
668
+ defaultValue: number;
669
+ input: false;
670
+ returned: false;
671
+ };
672
+ lockedUntil: {
673
+ type: "date";
674
+ required: false;
675
+ input: false;
676
+ returned: false;
677
+ };
665
678
  };
666
679
  };
667
680
  };
@@ -679,6 +692,7 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
679
692
  INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
680
693
  INVALID_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_CODE">;
681
694
  TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: _better_auth_core_utils_error_codes0.RawError<"TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE">;
695
+ ACCOUNT_TEMPORARILY_LOCKED: _better_auth_core_utils_error_codes0.RawError<"ACCOUNT_TEMPORARILY_LOCKED">;
682
696
  INVALID_TWO_FACTOR_COOKIE: _better_auth_core_utils_error_codes0.RawError<"INVALID_TWO_FACTOR_COOKIE">;
683
697
  };
684
698
  };
@@ -5,7 +5,7 @@ import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto/index.mjs";
5
5
  import { setSessionCookie } from "../../../cookies/index.mjs";
6
6
  import { PACKAGE_VERSION } from "../../../version.mjs";
7
7
  import { TWO_FACTOR_ERROR_CODES } from "../error-code.mjs";
8
- import { verifyTwoFactor } from "../verify-two-factor.mjs";
8
+ import { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor } from "../verify-two-factor.mjs";
9
9
  import { defaultKeyHasher } from "../utils.mjs";
10
10
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
11
11
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -158,6 +158,20 @@ const otp2fa = (options) => {
158
158
  } }
159
159
  }, async (ctx) => {
160
160
  const { session, key, valid, invalid } = await verifyTwoFactor(ctx);
161
+ const isSignIn = !session.session;
162
+ const twoFactorTable = "twoFactor";
163
+ let twoFactor = null;
164
+ if (isSignIn) {
165
+ twoFactor = await ctx.context.adapter.findOne({
166
+ model: twoFactorTable,
167
+ where: [{
168
+ field: "userId",
169
+ value: session.user.id
170
+ }]
171
+ });
172
+ if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED);
173
+ await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
174
+ }
161
175
  const consumed = await ctx.context.internalAdapter.consumeVerificationValue(`2fa-otp-${key}`);
162
176
  if (!consumed) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.OTP_HAS_EXPIRED);
163
177
  const [otp, counter] = consumed.value?.split(":") ?? [];
@@ -166,6 +180,7 @@ const otp2fa = (options) => {
166
180
  if (attempts >= allowedAttempts) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE);
167
181
  const [storedValue, inputValue] = await decryptOrHashForComparison(ctx, otp, ctx.body.code);
168
182
  if (constantTimeEqual(new TextEncoder().encode(storedValue), new TextEncoder().encode(inputValue))) {
183
+ if (twoFactor) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
169
184
  if (!session.user.twoFactorEnabled) {
170
185
  if (!session.session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
171
186
  const updatedUser = await ctx.context.internalAdapter.updateUser(session.user.id, { twoFactorEnabled: true });
@@ -187,6 +202,7 @@ const otp2fa = (options) => {
187
202
  identifier: `2fa-otp-${key}`,
188
203
  expiresAt: consumed.expiresAt
189
204
  });
205
+ if (twoFactor) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
190
206
  return invalid("INVALID_CODE");
191
207
  })
192
208
  }
@@ -39,6 +39,19 @@ declare const schema: {
39
39
  defaultValue: true;
40
40
  input: false;
41
41
  };
42
+ failedVerificationCount: {
43
+ type: "number";
44
+ required: false;
45
+ defaultValue: number;
46
+ input: false;
47
+ returned: false;
48
+ };
49
+ lockedUntil: {
50
+ type: "date";
51
+ required: false;
52
+ input: false;
53
+ returned: false;
54
+ };
42
55
  };
43
56
  };
44
57
  };
@@ -33,6 +33,19 @@ const schema = {
33
33
  required: false,
34
34
  defaultValue: true,
35
35
  input: false
36
+ },
37
+ failedVerificationCount: {
38
+ type: "number",
39
+ required: false,
40
+ defaultValue: 0,
41
+ input: false,
42
+ returned: false
43
+ },
44
+ lockedUntil: {
45
+ type: "date",
46
+ required: false,
47
+ input: false,
48
+ returned: false
36
49
  }
37
50
  } }
38
51
  };
@@ -4,7 +4,7 @@ import { sessionMiddleware } from "../../../api/routes/session.mjs";
4
4
  import { shouldRequirePassword } from "../../../utils/password.mjs";
5
5
  import { PACKAGE_VERSION } from "../../../version.mjs";
6
6
  import { TWO_FACTOR_ERROR_CODES } from "../error-code.mjs";
7
- import { verifyTwoFactor } from "../verify-two-factor.mjs";
7
+ import { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor } from "../verify-two-factor.mjs";
8
8
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
9
9
  import { createAuthEndpoint } from "@better-auth/core/api";
10
10
  import * as z from "zod";
@@ -134,6 +134,7 @@ const totp2fa = (options) => {
134
134
  });
135
135
  if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
136
136
  if (isSignIn && twoFactor.verified === false) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
137
+ if (isSignIn) await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
137
138
  const attempt = isSignIn ? await beginAttempt(5) : null;
138
139
  let status;
139
140
  try {
@@ -150,8 +151,10 @@ const totp2fa = (options) => {
150
151
  }
151
152
  if (!status) {
152
153
  await attempt?.recordFailure();
154
+ if (isSignIn) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
153
155
  return invalid("INVALID_CODE");
154
156
  }
157
+ if (isSignIn) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
155
158
  if (twoFactor.verified !== true) {
156
159
  if (!user.twoFactorEnabled) {
157
160
  const activeSession = session.session;
@@ -63,6 +63,29 @@ interface TwoFactorOptions {
63
63
  * @default 2592000 (30 days)
64
64
  */
65
65
  trustDeviceMaxAge?: number | undefined;
66
+ /**
67
+ * Account-level lockout for failed second-factor verifications during
68
+ * sign-in. Caps consecutive failed attempts per account, across challenges
69
+ * and factors, and resets on a successful verification.
70
+ */
71
+ accountLockout?: {
72
+ /**
73
+ * Whether account-level lockout is enforced.
74
+ * @default true
75
+ */
76
+ enabled?: boolean | undefined;
77
+ /**
78
+ * Consecutive failed verifications, across challenges and factors,
79
+ * before the account is locked.
80
+ * @default 10
81
+ */
82
+ maxFailedAttempts?: number | undefined;
83
+ /**
84
+ * How long the account stays locked, in seconds.
85
+ * @default 900 (15 minutes)
86
+ */
87
+ durationSeconds?: number | undefined;
88
+ } | undefined;
66
89
  }
67
90
  interface UserWithTwoFactor extends User {
68
91
  /**
@@ -81,6 +104,8 @@ interface TwoFactorTable {
81
104
  secret: string;
82
105
  backupCodes: string;
83
106
  verified: boolean;
107
+ failedVerificationCount?: number;
108
+ lockedUntil?: Date | null;
84
109
  }
85
110
  //#endregion
86
111
  export { TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor };
@@ -106,5 +106,87 @@ async function verifyTwoFactor(ctx) {
106
106
  })
107
107
  };
108
108
  }
109
+ function resolveAccountLockoutConfig(ctx) {
110
+ const lockout = (ctx.context.getPlugin("two-factor")?.options)?.accountLockout;
111
+ return {
112
+ enabled: lockout?.enabled ?? true,
113
+ maxFailedAttempts: lockout?.maxFailedAttempts ?? 10,
114
+ durationMs: (lockout?.durationSeconds ?? 900) * 1e3
115
+ };
116
+ }
117
+ /**
118
+ * Reject the verification when the account is locked, and lazily clear an
119
+ * expired lock. The lock caps consecutive failed verifications per account,
120
+ * across challenges and factors (NIST SP 800-63B §5.2.2).
121
+ */
122
+ async function assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor) {
123
+ const { enabled } = resolveAccountLockoutConfig(ctx);
124
+ if (!enabled || !twoFactor.lockedUntil) return;
125
+ if (new Date(twoFactor.lockedUntil).getTime() > Date.now()) throw APIError.from("TOO_MANY_REQUESTS", TWO_FACTOR_ERROR_CODES.ACCOUNT_TEMPORARILY_LOCKED);
126
+ await ctx.context.adapter.incrementOne({
127
+ model: twoFactorTable,
128
+ where: [{
129
+ field: "id",
130
+ value: twoFactor.id
131
+ }, {
132
+ field: "lockedUntil",
133
+ operator: "lte",
134
+ value: /* @__PURE__ */ new Date()
135
+ }],
136
+ increment: {},
137
+ set: {
138
+ failedVerificationCount: 0,
139
+ lockedUntil: null
140
+ }
141
+ });
142
+ }
143
+ /**
144
+ * Count one failed verification toward the account-level budget, and lock the
145
+ * account once the budget is spent. The increment is atomic, so concurrent
146
+ * failures cannot lose updates. It is unguarded so it still applies to a row
147
+ * whose counter is null or absent (a row created before the migration, or a
148
+ * document-store record predating the column), where a guarded comparison
149
+ * would never match.
150
+ */
151
+ async function recordTwoFactorFailure(ctx, twoFactorTable, twoFactor) {
152
+ const { enabled, maxFailedAttempts, durationMs } = resolveAccountLockoutConfig(ctx);
153
+ if (!enabled) return;
154
+ if (((await ctx.context.adapter.incrementOne({
155
+ model: twoFactorTable,
156
+ where: [{
157
+ field: "id",
158
+ value: twoFactor.id
159
+ }],
160
+ increment: { failedVerificationCount: 1 }
161
+ }))?.failedVerificationCount ?? 0) >= maxFailedAttempts) await ctx.context.adapter.update({
162
+ model: twoFactorTable,
163
+ where: [{
164
+ field: "id",
165
+ value: twoFactor.id
166
+ }],
167
+ update: { lockedUntil: new Date(Date.now() + durationMs) }
168
+ });
169
+ }
170
+ /**
171
+ * Clear the account-level failure budget after a successful verification, so the
172
+ * count tracks only consecutive failures. The write is unconditional: a snapshot
173
+ * read at the start of the request can miss a concurrent failure, so skipping it
174
+ * could leave the counter non-zero after a success.
175
+ */
176
+ async function resetTwoFactorFailures(ctx, twoFactorTable, twoFactor) {
177
+ const { enabled } = resolveAccountLockoutConfig(ctx);
178
+ if (!enabled) return;
179
+ await ctx.context.adapter.update({
180
+ model: twoFactorTable,
181
+ where: [{
182
+ field: "id",
183
+ value: twoFactor.id
184
+ }],
185
+ update: {
186
+ failedVerificationCount: 0,
187
+ lockedUntil: null
188
+ }
189
+ });
190
+ }
109
191
  //#endregion
110
- export { verifyTwoFactor };
192
+ export { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.6.21",
3
+ "version": "1.6.22",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -489,13 +489,13 @@
489
489
  "kysely": "^0.28.17 || ^0.29.0",
490
490
  "nanostores": "^1.1.1",
491
491
  "zod": "^4.3.6",
492
- "@better-auth/core": "1.6.21",
493
- "@better-auth/drizzle-adapter": "1.6.21",
494
- "@better-auth/kysely-adapter": "1.6.21",
495
- "@better-auth/memory-adapter": "1.6.21",
496
- "@better-auth/mongo-adapter": "1.6.21",
497
- "@better-auth/prisma-adapter": "1.6.21",
498
- "@better-auth/telemetry": "1.6.21"
492
+ "@better-auth/core": "1.6.22",
493
+ "@better-auth/drizzle-adapter": "1.6.22",
494
+ "@better-auth/kysely-adapter": "1.6.22",
495
+ "@better-auth/memory-adapter": "1.6.22",
496
+ "@better-auth/mongo-adapter": "1.6.22",
497
+ "@better-auth/prisma-adapter": "1.6.22",
498
+ "@better-auth/telemetry": "1.6.22"
499
499
  },
500
500
  "devDependencies": {
501
501
  "@lynx-js/react": "^0.116.3",