better-auth 1.6.20 → 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.
Files changed (45) hide show
  1. package/dist/api/index.d.mts +3 -3
  2. package/dist/api/index.mjs +5 -5
  3. package/dist/api/rate-limiter/index.mjs +2 -3
  4. package/dist/api/routes/index.d.mts +1 -1
  5. package/dist/api/routes/session.d.mts +30 -3
  6. package/dist/api/routes/session.mjs +15 -4
  7. package/dist/context/create-context.mjs +6 -0
  8. package/dist/db/get-migration.mjs +1 -0
  9. package/dist/db/get-schema.d.mts +1 -0
  10. package/dist/db/get-schema.mjs +3 -1
  11. package/dist/db/index.d.mts +3 -2
  12. package/dist/db/index.mjs +5 -2
  13. package/dist/db/internal-adapter.mjs +1 -1
  14. package/dist/db/revoke-unproven-account-access.d.mts +19 -0
  15. package/dist/db/revoke-unproven-account-access.mjs +23 -0
  16. package/dist/db/schema.d.mts +2 -1
  17. package/dist/db/schema.mjs +13 -1
  18. package/dist/oauth2/link-account.mjs +20 -7
  19. package/dist/package.mjs +1 -1
  20. package/dist/plugins/admin/routes.mjs +4 -4
  21. package/dist/plugins/captcha/index.mjs +1 -1
  22. package/dist/plugins/device-authorization/index.d.mts +9 -9
  23. package/dist/plugins/device-authorization/index.mjs +1 -1
  24. package/dist/plugins/email-otp/routes.mjs +5 -1
  25. package/dist/plugins/magic-link/index.d.mts +3 -3
  26. package/dist/plugins/magic-link/index.mjs +6 -2
  27. package/dist/plugins/oauth-proxy/index.mjs +11 -6
  28. package/dist/plugins/one-tap/index.mjs +10 -9
  29. package/dist/plugins/siwe/index.mjs +5 -1
  30. package/dist/plugins/two-factor/backup-codes/index.mjs +21 -7
  31. package/dist/plugins/two-factor/client.d.mts +1 -0
  32. package/dist/plugins/two-factor/error-code.d.mts +1 -0
  33. package/dist/plugins/two-factor/error-code.mjs +1 -0
  34. package/dist/plugins/two-factor/index.d.mts +14 -0
  35. package/dist/plugins/two-factor/index.mjs +7 -1
  36. package/dist/plugins/two-factor/otp/index.mjs +17 -1
  37. package/dist/plugins/two-factor/schema.d.mts +13 -0
  38. package/dist/plugins/two-factor/schema.mjs +13 -0
  39. package/dist/plugins/two-factor/totp/index.mjs +23 -9
  40. package/dist/plugins/two-factor/types.d.mts +25 -0
  41. package/dist/plugins/two-factor/verify-two-factor.mjs +110 -3
  42. package/dist/plugins/username/index.mjs +56 -42
  43. package/package.json +9 -9
  44. package/dist/utils/get-request-ip.d.mts +0 -6
  45. package/dist/utils/get-request-ip.mjs +0 -20
@@ -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";
@@ -27,7 +28,7 @@ const magicLink = (options) => {
27
28
  allowedAttempts: 1,
28
29
  ...options
29
30
  };
30
- if (options.allowedAttempts !== void 0 && options.allowedAttempts !== 1) console.warn("[better-auth/magic-link] `allowedAttempts` is ignored: tokens are consumed atomically on the first verification call (GHSA-hc7v-rggr-4hvx). Any value other than `1` has no effect; remove the option to silence this warning.");
31
+ if (options.allowedAttempts !== void 0 && options.allowedAttempts !== 1) console.warn("[better-auth/magic-link] `allowedAttempts` is ignored: tokens are consumed atomically on the first verification call. Any value other than `1` has no effect; remove the option to silence this warning.");
31
32
  async function storeToken(ctx, token) {
32
33
  if (opts.storeToken === "hashed") return await defaultKeyHasher(token);
33
34
  if (typeof opts.storeToken === "object" && "type" in opts.storeToken && opts.storeToken.type === "custom-hasher") return await opts.storeToken.hash(token);
@@ -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, {
@@ -14,6 +14,15 @@ import { defu } from "defu";
14
14
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
15
15
  import * as z from "zod";
16
16
  //#region src/plugins/oauth-proxy/index.ts
17
+ const consumeOAuthProxyState = async (ctx, state) => {
18
+ try {
19
+ await parseGenericState(ctx, state, { skipStateCookieCheck: true });
20
+ return true;
21
+ } catch (e) {
22
+ ctx.context.logger.warn("OAuth proxy state missing or invalid", e);
23
+ return false;
24
+ }
25
+ };
17
26
  const oauthProxyQuerySchema = z.object({
18
27
  callbackURL: z.string().meta({ description: "The URL to redirect to after the proxy" }),
19
28
  profile: z.string().optional().meta({ description: "Encrypted OAuth profile data" })
@@ -81,7 +90,7 @@ const oAuthProxy = (opts) => {
81
90
  ctx.context.logger.error("Failed to parse OAuth proxy payload", e);
82
91
  throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
83
92
  }
84
- if (typeof payload.timestamp !== "number" || !payload.userInfo || !payload.account || !payload.callbackURL) {
93
+ if (typeof payload.timestamp !== "number" || !payload.userInfo || !payload.account || !payload.state || !payload.callbackURL) {
85
94
  ctx.context.logger.error("Failed to parse OAuth proxy payload");
86
95
  throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
87
96
  }
@@ -91,11 +100,7 @@ const oAuthProxy = (opts) => {
91
100
  ctx.context.logger.error(`OAuth proxy payload expired or invalid (age: ${age}s, maxAge: ${maxAge}s)`);
92
101
  throw redirectOnError(ctx, errorURL, "payload_expired");
93
102
  }
94
- try {
95
- await parseGenericState(ctx, payload.state, { skipStateCookieCheck: true });
96
- } catch (e) {
97
- ctx.context.logger.warn("Failed to clean up OAuth state", e);
98
- }
103
+ if (!await consumeOAuthProxyState(ctx, payload.state)) throw redirectOnError(ctx, errorURL, "state_mismatch");
99
104
  let result;
100
105
  try {
101
106
  result = await handleOAuthUserInfo(ctx, {
@@ -4,9 +4,9 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
4
4
  import { APIError } from "../../api/index.mjs";
5
5
  import { PACKAGE_VERSION } from "../../version.mjs";
6
6
  import { toBoolean } from "../../utils/boolean.mjs";
7
+ import { isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "@better-auth/core/social-providers";
7
8
  import { createAuthEndpoint } from "@better-auth/core/api";
8
9
  import * as z from "zod";
9
- import { createRemoteJWKSet, jwtVerify } from "jose";
10
10
  //#region src/plugins/one-tap/index.ts
11
11
  const oneTapCallbackBodySchema = z.object({
12
12
  idToken: z.string().meta({ description: "Google ID token, which the client obtains from the One Tap API" }),
@@ -40,14 +40,15 @@ const oneTap = (options) => ({
40
40
  const googleProvider = typeof ctx.context.options.socialProviders?.google === "function" ? await ctx.context.options.socialProviders?.google() : ctx.context.options.socialProviders?.google;
41
41
  const audience = options?.clientId || googleProvider?.clientId;
42
42
  if (!audience || Array.isArray(audience) && audience.length === 0) throw new APIError("BAD_REQUEST", { message: "Google client ID is required for One Tap. Set it on the oneTap plugin (clientId) or on socialProviders.google." });
43
- let payload;
44
- try {
45
- const { payload: verifiedPayload } = await jwtVerify(idToken, createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs")), {
46
- issuer: ["https://accounts.google.com", "accounts.google.com"],
47
- audience
48
- });
49
- payload = verifiedPayload;
50
- } catch {
43
+ const payload = await verifyGoogleIdToken({
44
+ token: idToken,
45
+ audience
46
+ });
47
+ if (!payload) throw new APIError("BAD_REQUEST", { message: "invalid id token" });
48
+ if (!payload.sub) throw new APIError("BAD_REQUEST", { message: "invalid id token" });
49
+ const configuredHostedDomain = googleProvider?.hd;
50
+ if (!isGoogleHostedDomainAllowed(configuredHostedDomain, payload.hd)) {
51
+ ctx.context.logger.error(`Google One Tap sign-in rejected: id token hosted domain (hd) "${payload.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${configuredHostedDomain}".`);
51
52
  throw new APIError("BAD_REQUEST", { message: "invalid id token" });
52
53
  }
53
54
  const { email: rawEmail, email_verified, name, picture, sub } = payload;
@@ -166,7 +166,11 @@ const siwe = (options) => {
166
166
  }
167
167
  if (!user) {
168
168
  const domain = options.emailDomainName ?? getOrigin(ctx.context.baseURL);
169
- const userEmail = !isAnon && email ? email : `${walletAddress}@${domain}`;
169
+ const normalizedEmail = email?.toLowerCase();
170
+ let userEmail = `${walletAddress}@${domain}`;
171
+ if (!isAnon && normalizedEmail) {
172
+ if (!await ctx.context.internalAdapter.findUserByEmail(normalizedEmail)) userEmail = normalizedEmail;
173
+ }
170
174
  const { name, avatar } = await options.ensLookup?.({ walletAddress }) ?? {};
171
175
  user = await ctx.context.internalAdapter.createUser({
172
176
  name: name ?? walletAddress,
@@ -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";
@@ -161,8 +161,9 @@ const backupCode2fa = (opts) => {
161
161
  } }
162
162
  } }
163
163
  }, async (ctx) => {
164
- const { session, valid } = await verifyTwoFactor(ctx);
164
+ const { session, valid, beginAttempt } = await verifyTwoFactor(ctx);
165
165
  const user = session.user;
166
+ const isSignIn = !session.session;
166
167
  const twoFactor = await ctx.context.adapter.findOne({
167
168
  model: twoFactorTable,
168
169
  where: [{
@@ -171,11 +172,23 @@ const backupCode2fa = (opts) => {
171
172
  }]
172
173
  });
173
174
  if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED);
174
- const validate = await verifyBackupCode({
175
- backupCodes: twoFactor.backupCodes,
176
- code: ctx.body.code
177
- }, ctx.context.secretConfig, opts);
178
- if (!validate.status || !validate.updated) throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
175
+ if (isSignIn) await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
176
+ const attempt = isSignIn ? await beginAttempt(5) : null;
177
+ let validate;
178
+ try {
179
+ validate = await verifyBackupCode({
180
+ backupCodes: twoFactor.backupCodes,
181
+ code: ctx.body.code
182
+ }, ctx.context.secretConfig, opts);
183
+ } catch (error) {
184
+ await attempt?.restore();
185
+ throw error;
186
+ }
187
+ if (!validate.status || !validate.updated) {
188
+ await attempt?.recordFailure();
189
+ if (isSignIn) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
190
+ throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
191
+ }
179
192
  const updatedBackupCodes = await encodeBackupCodes(validate.updated, ctx.context.secretConfig, opts);
180
193
  if (!await ctx.context.adapter.incrementOne({
181
194
  model: twoFactorTable,
@@ -189,6 +202,7 @@ const backupCode2fa = (opts) => {
189
202
  increment: {},
190
203
  set: { backupCodes: updatedBackupCodes }
191
204
  })) throw APIError.fromStatus("CONFLICT", { message: "Failed to verify backup code. Please try again." });
205
+ if (isSignIn) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
192
206
  if (!ctx.body.disableSession) return valid(ctx);
193
207
  return ctx.json({
194
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
  };
@@ -236,10 +236,16 @@ const twoFactor = (options) => {
236
236
  const maxAge = options?.twoFactorCookieMaxAge ?? 600;
237
237
  const twoFactorCookie = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME, { maxAge });
238
238
  const identifier = `2fa-${generateRandomString(20)}`;
239
+ const expiresAt = new Date(Date.now() + maxAge * 1e3);
239
240
  await ctx.context.internalAdapter.createVerificationValue({
240
241
  value: data.user.id,
241
242
  identifier,
242
- expiresAt: new Date(Date.now() + maxAge * 1e3)
243
+ expiresAt
244
+ });
245
+ await ctx.context.internalAdapter.createVerificationValue({
246
+ value: "0",
247
+ identifier: `2fa-attempts-${identifier}`,
248
+ expiresAt
243
249
  });
244
250
  await ctx.setSignedCookie(twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes);
245
251
  const twoFactorMethods = [];
@@ -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";
@@ -122,7 +122,7 @@ const totp2fa = (options) => {
122
122
  code: "TOTP_NOT_CONFIGURED"
123
123
  });
124
124
  }
125
- const { session, valid, invalid } = await verifyTwoFactor(ctx);
125
+ const { session, valid, invalid, beginAttempt } = await verifyTwoFactor(ctx);
126
126
  const user = session.user;
127
127
  const isSignIn = !session.session;
128
128
  const twoFactor = await ctx.context.adapter.findOne({
@@ -134,13 +134,27 @@ 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 (!await createOTP(await symmetricDecrypt({
138
- key: ctx.context.secretConfig,
139
- data: twoFactor.secret
140
- }), {
141
- period: opts.period,
142
- digits: opts.digits
143
- }).verify(ctx.body.code)) return invalid("INVALID_CODE");
137
+ if (isSignIn) await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
138
+ const attempt = isSignIn ? await beginAttempt(5) : null;
139
+ let status;
140
+ try {
141
+ status = await createOTP(await symmetricDecrypt({
142
+ key: ctx.context.secretConfig,
143
+ data: twoFactor.secret
144
+ }), {
145
+ period: opts.period,
146
+ digits: opts.digits
147
+ }).verify(ctx.body.code);
148
+ } catch (error) {
149
+ await attempt?.restore();
150
+ throw error;
151
+ }
152
+ if (!status) {
153
+ await attempt?.recordFailure();
154
+ if (isSignIn) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
155
+ return invalid("INVALID_CODE");
156
+ }
157
+ if (isSignIn) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
144
158
  if (twoFactor.verified !== true) {
145
159
  if (!user.twoFactorEnabled) {
146
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 };
@@ -66,7 +66,28 @@ async function verifyTwoFactor(ctx) {
66
66
  session: null,
67
67
  user
68
68
  },
69
- key: signedTwoFactorCookie
69
+ key: signedTwoFactorCookie,
70
+ beginAttempt: async (allowedAttempts) => {
71
+ const identifier = `2fa-attempts-${signedTwoFactorCookie}`;
72
+ const consumed = await ctx.context.internalAdapter.consumeVerificationValue(identifier).catch(() => null);
73
+ if (!consumed) throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE);
74
+ const parsed = Number(consumed.value);
75
+ const attempts = Number.isInteger(parsed) && parsed >= 0 ? parsed : allowedAttempts;
76
+ if (attempts >= allowedAttempts) {
77
+ await ctx.context.internalAdapter.consumeVerificationValue(signedTwoFactorCookie).catch(() => {});
78
+ expireCookie(ctx, twoFactorCookie);
79
+ throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE);
80
+ }
81
+ const rearm = (count) => ctx.context.internalAdapter.createVerificationValue({
82
+ value: `${count}`,
83
+ identifier,
84
+ expiresAt: verificationToken.expiresAt
85
+ }).catch(() => {});
86
+ return {
87
+ recordFailure: () => rearm(attempts + 1),
88
+ restore: () => rearm(attempts)
89
+ };
90
+ }
70
91
  };
71
92
  }
72
93
  return {
@@ -78,8 +99,94 @@ async function verifyTwoFactor(ctx) {
78
99
  },
79
100
  invalid,
80
101
  session,
81
- key: `${session.user.id}!${session.session.id}`
102
+ key: `${session.user.id}!${session.session.id}`,
103
+ beginAttempt: async (_allowedAttempts) => ({
104
+ recordFailure: async () => {},
105
+ restore: async () => {}
106
+ })
107
+ };
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
82
115
  };
83
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
+ }
84
191
  //#endregion
85
- export { verifyTwoFactor };
192
+ export { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor };