better-auth 1.6.16 → 1.6.18

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 (93) hide show
  1. package/dist/api/index.d.mts +2 -2
  2. package/dist/api/index.mjs +3 -4
  3. package/dist/api/middlewares/origin-check.mjs +5 -1
  4. package/dist/api/rate-limiter/index.mjs +259 -73
  5. package/dist/api/routes/account.mjs +22 -7
  6. package/dist/api/routes/callback.mjs +2 -2
  7. package/dist/api/routes/index.d.mts +1 -1
  8. package/dist/api/routes/password.mjs +3 -4
  9. package/dist/api/routes/session.d.mts +12 -1
  10. package/dist/api/routes/session.mjs +13 -1
  11. package/dist/api/routes/sign-in.mjs +5 -5
  12. package/dist/api/routes/sign-up.mjs +2 -2
  13. package/dist/api/routes/update-session.mjs +2 -3
  14. package/dist/api/routes/update-user.mjs +10 -12
  15. package/dist/auth/base.mjs +11 -7
  16. package/dist/client/equality.d.mts +19 -0
  17. package/dist/client/equality.mjs +42 -0
  18. package/dist/client/index.d.mts +5 -4
  19. package/dist/client/index.mjs +2 -1
  20. package/dist/client/lynx/index.d.mts +4 -2
  21. package/dist/client/path-to-object.d.mts +5 -2
  22. package/dist/client/plugins/index.d.mts +4 -1
  23. package/dist/client/plugins/index.mjs +4 -1
  24. package/dist/client/query.d.mts +4 -3
  25. package/dist/client/query.mjs +27 -17
  26. package/dist/client/react/index.d.mts +4 -2
  27. package/dist/client/session-atom.mjs +129 -4
  28. package/dist/client/session-refresh.d.mts +3 -18
  29. package/dist/client/session-refresh.mjs +38 -49
  30. package/dist/client/solid/index.d.mts +4 -2
  31. package/dist/client/svelte/index.d.mts +4 -2
  32. package/dist/client/types.d.mts +27 -16
  33. package/dist/client/vanilla.d.mts +4 -2
  34. package/dist/client/vue/index.d.mts +4 -2
  35. package/dist/context/create-context.mjs +2 -1
  36. package/dist/context/store-capabilities.mjs +12 -0
  37. package/dist/cookies/index.mjs +25 -2
  38. package/dist/db/internal-adapter.mjs +51 -0
  39. package/dist/package.mjs +1 -1
  40. package/dist/plugins/access/access.mjs +49 -19
  41. package/dist/plugins/admin/routes.mjs +10 -3
  42. package/dist/plugins/captcha/constants.mjs +8 -1
  43. package/dist/plugins/captcha/index.mjs +8 -2
  44. package/dist/plugins/captcha/types.d.mts +21 -0
  45. package/dist/plugins/captcha/verify-handlers/captchafox.mjs +2 -0
  46. package/dist/plugins/captcha/verify-handlers/cloudflare-turnstile.mjs +7 -2
  47. package/dist/plugins/captcha/verify-handlers/google-recaptcha.mjs +7 -2
  48. package/dist/plugins/captcha/verify-handlers/h-captcha.mjs +2 -0
  49. package/dist/plugins/device-authorization/routes.mjs +16 -9
  50. package/dist/plugins/email-otp/routes.mjs +22 -52
  51. package/dist/plugins/generic-oauth/index.mjs +7 -2
  52. package/dist/plugins/generic-oauth/routes.mjs +16 -12
  53. package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
  54. package/dist/plugins/haveibeenpwned/index.mjs +5 -1
  55. package/dist/plugins/index.d.mts +6 -2
  56. package/dist/plugins/index.mjs +4 -1
  57. package/dist/plugins/jwt/index.mjs +2 -2
  58. package/dist/plugins/mcp/client/index.mjs +1 -0
  59. package/dist/plugins/mcp/index.mjs +8 -0
  60. package/dist/plugins/multi-session/index.mjs +7 -5
  61. package/dist/plugins/oauth-popup/client.d.mts +82 -0
  62. package/dist/plugins/oauth-popup/client.mjs +203 -0
  63. package/dist/plugins/oauth-popup/constants.d.mts +11 -0
  64. package/dist/plugins/oauth-popup/constants.mjs +11 -0
  65. package/dist/plugins/oauth-popup/error-codes.d.mts +11 -0
  66. package/dist/plugins/oauth-popup/error-codes.mjs +10 -0
  67. package/dist/plugins/oauth-popup/index.d.mts +67 -0
  68. package/dist/plugins/oauth-popup/index.mjs +227 -0
  69. package/dist/plugins/oauth-popup/types.d.mts +30 -0
  70. package/dist/plugins/oauth-proxy/index.mjs +2 -2
  71. package/dist/plugins/oauth-proxy/utils.mjs +16 -2
  72. package/dist/plugins/oidc-provider/index.mjs +10 -0
  73. package/dist/plugins/one-tap/client.mjs +12 -6
  74. package/dist/plugins/one-tap/index.d.mts +1 -0
  75. package/dist/plugins/one-tap/index.mjs +9 -5
  76. package/dist/plugins/one-time-token/index.mjs +1 -3
  77. package/dist/plugins/open-api/generator.d.mts +66 -57
  78. package/dist/plugins/open-api/generator.mjs +185 -67
  79. package/dist/plugins/open-api/index.d.mts +2 -2
  80. package/dist/plugins/organization/adapter.d.mts +29 -1
  81. package/dist/plugins/organization/adapter.mjs +66 -6
  82. package/dist/plugins/organization/routes/crud-invites.mjs +49 -34
  83. package/dist/plugins/organization/routes/crud-members.mjs +42 -6
  84. package/dist/plugins/organization/routes/crud-team.mjs +36 -3
  85. package/dist/plugins/phone-number/routes.mjs +41 -36
  86. package/dist/plugins/siwe/index.mjs +2 -3
  87. package/dist/plugins/two-factor/backup-codes/index.mjs +1 -1
  88. package/dist/plugins/two-factor/otp/index.mjs +11 -13
  89. package/dist/plugins/two-factor/totp/index.mjs +1 -1
  90. package/dist/plugins/two-factor/verify-two-factor.mjs +6 -2
  91. package/dist/plugins/username/index.mjs +6 -6
  92. package/dist/test-utils/test-instance.d.mts +26 -23
  93. package/package.json +9 -9
@@ -11,7 +11,7 @@ import { createEmailVerificationToken, sendVerificationEmail, sendVerificationEm
11
11
  import { error } from "./routes/error.mjs";
12
12
  import { ok } from "./routes/ok.mjs";
13
13
  import { requestPasswordReset, requestPasswordResetCallback, resetPassword, verifyPassword } from "./routes/password.mjs";
14
- import { freshSessionMiddleware, getSession, getSessionFromCtx, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./routes/session.mjs";
14
+ import { freshSessionMiddleware, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./routes/session.mjs";
15
15
  import { signInEmail, signInSocial } from "./routes/sign-in.mjs";
16
16
  import { signOut } from "./routes/sign-out.mjs";
17
17
  import { signUpEmail } from "./routes/sign-up.mjs";
@@ -3961,4 +3961,4 @@ declare const router: <Option extends BetterAuthOptions>(ctx: AuthContext, optio
3961
3961
  } extends infer T_2 ? { [K in keyof T_2 as K extends keyof T_1 ? never : K]: T_2[K] } : never) & T_1> : never : never : never;
3962
3962
  };
3963
3963
  //#endregion
3964
- export { APIError, type AuthEndpoint, type AuthMiddleware, type DispatchContext, accountInfo, callbackOAuth, changeEmail, changePassword, checkEndpointConflicts, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, getAccessToken, getEndpoints, getIp, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, router, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
3964
+ export { APIError, type AuthEndpoint, type AuthMiddleware, type DispatchContext, accountInfo, callbackOAuth, changeEmail, changePassword, checkEndpointConflicts, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, getAccessToken, getEndpoints, getIp, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, isStateful, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, router, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
@@ -2,10 +2,10 @@ import { isAPIError } from "../utils/is-api-error.mjs";
2
2
  import { requireOrgRole, requireResourceOwnership } from "./middlewares/authorization.mjs";
3
3
  import { formCsrfMiddleware, originCheck, originCheckMiddleware } from "./middlewares/origin-check.mjs";
4
4
  import { getIp } from "../utils/get-request-ip.mjs";
5
- import { onRequestRateLimit, onResponseRateLimit } from "./rate-limiter/index.mjs";
5
+ import { onRequestRateLimit } from "./rate-limiter/index.mjs";
6
6
  import { getOAuthState } from "./state/oauth.mjs";
7
7
  import { getShouldSkipSessionRefresh, setShouldSkipSessionRefresh } from "./state/should-session-refresh.mjs";
8
- import { freshSessionMiddleware, getSession, getSessionFromCtx, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./routes/session.mjs";
8
+ import { freshSessionMiddleware, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./routes/session.mjs";
9
9
  import { accountInfo, getAccessToken, linkSocialAccount, listUserAccounts, refreshToken, unlinkAccount } from "./routes/account.mjs";
10
10
  import { callbackOAuth } from "./routes/callback.mjs";
11
11
  import { createEmailVerificationToken, sendVerificationEmail, sendVerificationEmailFn, verifyEmail } from "./routes/email-verification.mjs";
@@ -178,7 +178,6 @@ const router = (ctx, options) => {
178
178
  return currentRequest;
179
179
  },
180
180
  async onResponse(res, req) {
181
- await onResponseRateLimit(req, ctx);
182
181
  for (const plugin of ctx.options.plugins || []) if (plugin.onResponse) {
183
182
  const response = await withSpan(`onResponse ${plugin.id}`, {
184
183
  [ATTR_HOOK_TYPE]: "onResponse",
@@ -214,4 +213,4 @@ const router = (ctx, options) => {
214
213
  });
215
214
  };
216
215
  //#endregion
217
- export { APIError, accountInfo, callbackOAuth, changeEmail, changePassword, checkEndpointConflicts, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, getAccessToken, getEndpoints, getIp, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, router, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
216
+ export { APIError, accountInfo, callbackOAuth, changeEmail, changePassword, checkEndpointConflicts, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, getAccessToken, getEndpoints, getIp, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, isStateful, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, router, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
@@ -23,7 +23,10 @@ function shouldSkipOriginCheck(ctx) {
23
23
  if (Array.isArray(skipOriginCheck) && ctx.request) try {
24
24
  const basePath = new URL(ctx.context.baseURL).pathname;
25
25
  const currentPath = normalizePathname(ctx.request.url, basePath);
26
- return skipOriginCheck.some((skipPath) => currentPath.startsWith(skipPath));
26
+ return skipOriginCheck.some((skipPath) => {
27
+ const normalizedSkipPath = skipPath.replace(/\/+$/, "");
28
+ return currentPath === normalizedSkipPath || currentPath.startsWith(`${normalizedSkipPath}/`);
29
+ });
27
30
  } catch {}
28
31
  return false;
29
32
  }
@@ -47,6 +50,7 @@ const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
47
50
  const newUserCallbackURL = body?.newUserCallbackURL;
48
51
  const validateURL = (url, label) => {
49
52
  if (!url) return;
53
+ if (typeof url !== "string") throw APIError.fromStatus("BAD_REQUEST", { message: `Invalid ${label}: expected a string` });
50
54
  if (!ctx.context.isTrustedOrigin(url, { allowRelativePaths: label !== "origin" })) {
51
55
  ctx.context.logger.error(`Invalid ${label}: ${url}`);
52
56
  ctx.context.logger.info(`If it's a valid URL, please add ${url} to trustedOrigins in your auth config\n`, `Current list of trustedOrigins: ${ctx.context.trustedOrigins}`);
@@ -5,10 +5,62 @@ import { normalizePathname } from "@better-auth/core/utils/url";
5
5
  import { createRateLimitKey } from "@better-auth/core/utils/ip";
6
6
  //#region src/api/rate-limiter/index.ts
7
7
  const memory = /* @__PURE__ */ new Map();
8
- function shouldRateLimit(max, window, rateLimitData) {
8
+ const MEMORY_STORE_MAX_ENTRIES = 1e5;
9
+ function pruneMemoryStore() {
9
10
  const now = Date.now();
10
- const windowInMs = window * 1e3;
11
- return now - rateLimitData.lastRequest < windowInMs && rateLimitData.count >= max;
11
+ for (const [key, entry] of memory) if (now >= entry.expiresAt) memory.delete(key);
12
+ if (memory.size <= MEMORY_STORE_MAX_ENTRIES) return;
13
+ const overflow = memory.size - MEMORY_STORE_MAX_ENTRIES;
14
+ let removed = 0;
15
+ for (const key of memory.keys()) {
16
+ memory.delete(key);
17
+ if (++removed >= overflow) break;
18
+ }
19
+ }
20
+ /**
21
+ * Decide an atomic rate-limit step against an in-memory `RateLimit` snapshot
22
+ * for the rolling `window` (seconds) and `max`. Shared by the memory backend
23
+ * (read-decide-write is atomic under single-threaded JS) and as the fallback
24
+ * for storages lacking an atomic primitive.
25
+ */
26
+ function decideConsume(data, rule, now) {
27
+ const windowInMs = rule.window * 1e3;
28
+ if (!data) return {
29
+ next: {
30
+ key: "",
31
+ count: 1,
32
+ lastRequest: now
33
+ },
34
+ update: false,
35
+ allowed: true,
36
+ retryAfter: null
37
+ };
38
+ if (now - data.lastRequest > windowInMs) return {
39
+ next: {
40
+ ...data,
41
+ count: 1,
42
+ lastRequest: now
43
+ },
44
+ update: true,
45
+ allowed: true,
46
+ retryAfter: null
47
+ };
48
+ if (data.count >= rule.max) return {
49
+ next: data,
50
+ update: true,
51
+ allowed: false,
52
+ retryAfter: getRetryAfter(data.lastRequest, rule.window)
53
+ };
54
+ return {
55
+ next: {
56
+ ...data,
57
+ count: data.count + 1,
58
+ lastRequest: now
59
+ },
60
+ update: true,
61
+ allowed: true,
62
+ retryAfter: null
63
+ };
12
64
  }
13
65
  function rateLimitResponse(retryAfter) {
14
66
  return new Response(JSON.stringify({ message: "Too many requests. Please try again later." }), {
@@ -25,18 +77,109 @@ function getRetryAfter(lastRequest, window) {
25
77
  function createDatabaseStorageWrapper(ctx) {
26
78
  const model = "rateLimit";
27
79
  const db = ctx.adapter;
28
- return {
29
- get: async (key) => {
30
- const data = (await db.findMany({
80
+ const readRow = async (key) => {
81
+ const data = (await db.findMany({
82
+ model,
83
+ where: [{
84
+ field: "key",
85
+ value: key
86
+ }]
87
+ }))[0];
88
+ if (typeof data?.lastRequest === "bigint") data.lastRequest = Number(data.lastRequest);
89
+ return data;
90
+ };
91
+ const consume = async (key, rule) => {
92
+ const windowInMs = rule.window * 1e3;
93
+ const data = await readRow(key);
94
+ const now = Date.now();
95
+ if (!data) try {
96
+ await db.create({
97
+ model,
98
+ data: {
99
+ key,
100
+ count: 1,
101
+ lastRequest: now
102
+ }
103
+ });
104
+ return {
105
+ allowed: true,
106
+ retryAfter: null
107
+ };
108
+ } catch (error) {
109
+ if (!await readRow(key)) throw error;
110
+ return consume(key, rule);
111
+ }
112
+ if (now - data.lastRequest > windowInMs) {
113
+ if (await db.incrementOne({
31
114
  model,
32
115
  where: [{
33
116
  field: "key",
34
117
  value: key
35
- }]
36
- }))[0];
37
- if (typeof data?.lastRequest === "bigint") data.lastRequest = Number(data.lastRequest);
38
- return data;
39
- },
118
+ }, {
119
+ field: "lastRequest",
120
+ operator: "lte",
121
+ value: data.lastRequest
122
+ }],
123
+ increment: {},
124
+ set: {
125
+ count: 1,
126
+ lastRequest: now
127
+ }
128
+ })) {
129
+ deleteExpiredRows(now);
130
+ return {
131
+ allowed: true,
132
+ retryAfter: null
133
+ };
134
+ }
135
+ return consume(key, rule);
136
+ }
137
+ const windowStart = now - windowInMs;
138
+ if (await db.incrementOne({
139
+ model,
140
+ where: [
141
+ {
142
+ field: "key",
143
+ value: key
144
+ },
145
+ {
146
+ field: "lastRequest",
147
+ operator: "gt",
148
+ value: windowStart
149
+ },
150
+ {
151
+ field: "count",
152
+ operator: "lt",
153
+ value: rule.max
154
+ }
155
+ ],
156
+ increment: { count: 1 },
157
+ set: { lastRequest: now }
158
+ })) return {
159
+ allowed: true,
160
+ retryAfter: null
161
+ };
162
+ const fresh = await readRow(key);
163
+ if (!fresh) return consume(key, rule);
164
+ if (now - fresh.lastRequest > windowInMs) return consume(key, rule);
165
+ return {
166
+ allowed: false,
167
+ retryAfter: getRetryAfter(fresh.lastRequest, rule.window)
168
+ };
169
+ };
170
+ const deleteExpiredRows = (now) => {
171
+ const cutoff = now - Math.max(ctx.rateLimit.window, ...getDefaultSpecialRules().map((r) => r.window)) * 1e3;
172
+ ctx.runInBackground(db.deleteMany({
173
+ model,
174
+ where: [{
175
+ field: "lastRequest",
176
+ operator: "lt",
177
+ value: cutoff
178
+ }]
179
+ }).then(() => void 0).catch((e) => ctx.logger.error("Error pruning rate limit rows", e)));
180
+ };
181
+ return {
182
+ get: readRow,
40
183
  set: async (key, value, _update) => {
41
184
  try {
42
185
  if (_update) await db.updateMany({
@@ -61,58 +204,88 @@ function createDatabaseStorageWrapper(ctx) {
61
204
  } catch (e) {
62
205
  ctx.logger.error("Error setting rate limit", e);
63
206
  }
64
- }
207
+ },
208
+ consume
65
209
  };
66
210
  }
67
211
  function getRateLimitStorage(ctx, rateLimitSettings) {
68
212
  if (ctx.options.rateLimit?.customStorage) return ctx.options.rateLimit.customStorage;
69
213
  const storage = ctx.rateLimit.storage;
70
- if (storage === "secondary-storage") return {
71
- get: async (key) => {
72
- const data = await ctx.options.secondaryStorage?.get(key);
73
- return data ? safeJSONParse(data) : null;
74
- },
75
- set: async (key, value, _update) => {
76
- const ttl = rateLimitSettings?.window ?? ctx.options.rateLimit?.window ?? 10;
77
- await ctx.options.secondaryStorage?.set?.(key, JSON.stringify(value), ttl);
78
- }
79
- };
80
- else if (storage === "memory") return {
81
- async get(key) {
82
- const entry = memory.get(key);
83
- if (!entry) return null;
84
- if (Date.now() >= entry.expiresAt) {
85
- memory.delete(key);
86
- return null;
214
+ if (storage === "secondary-storage") {
215
+ const ttlFor = (window) => window ?? ctx.options.rateLimit?.window ?? 10;
216
+ return {
217
+ get: async (key) => {
218
+ const data = await ctx.options.secondaryStorage?.get(key);
219
+ return data ? safeJSONParse(data) : null;
220
+ },
221
+ set: async (key, value, _update) => {
222
+ await ctx.options.secondaryStorage?.set?.(key, JSON.stringify(value), ttlFor(rateLimitSettings.window));
223
+ },
224
+ consume: ctx.options.secondaryStorage?.increment ? async (key, rule) => {
225
+ if (await ctx.options.secondaryStorage.increment(key, ttlFor(rule.window)) <= rule.max) return {
226
+ allowed: true,
227
+ retryAfter: null
228
+ };
229
+ return {
230
+ allowed: false,
231
+ retryAfter: rule.window
232
+ };
233
+ } : void 0
234
+ };
235
+ } else if (storage === "memory") {
236
+ const ttlFor = (window) => window ?? ctx.options.rateLimit?.window ?? 10;
237
+ return {
238
+ async get(key) {
239
+ const entry = memory.get(key);
240
+ if (!entry) return null;
241
+ if (Date.now() >= entry.expiresAt) {
242
+ memory.delete(key);
243
+ return null;
244
+ }
245
+ return entry.data;
246
+ },
247
+ async set(key, value, _update) {
248
+ const expiresAt = Date.now() + ttlFor(rateLimitSettings.window) * 1e3;
249
+ memory.set(key, {
250
+ data: value,
251
+ expiresAt
252
+ });
253
+ },
254
+ async consume(key, rule) {
255
+ pruneMemoryStore();
256
+ const now = Date.now();
257
+ const entry = memory.get(key);
258
+ const decision = decideConsume(entry && now < entry.expiresAt ? entry.data : void 0, rule, now);
259
+ if (decision.allowed) memory.set(key, {
260
+ data: {
261
+ ...decision.next,
262
+ key
263
+ },
264
+ expiresAt: now + ttlFor(rule.window) * 1e3
265
+ });
266
+ return {
267
+ allowed: decision.allowed,
268
+ retryAfter: decision.retryAfter
269
+ };
87
270
  }
88
- return entry.data;
89
- },
90
- async set(key, value, _update) {
91
- const ttl = rateLimitSettings?.window ?? ctx.options.rateLimit?.window ?? 10;
92
- const expiresAt = Date.now() + ttl * 1e3;
93
- memory.set(key, {
94
- data: value,
95
- expiresAt
96
- });
97
- }
98
- };
271
+ };
272
+ }
99
273
  return createDatabaseStorageWrapper(ctx);
100
274
  }
101
275
  let ipWarningLogged = false;
276
+ const NO_TRUSTED_IP_KEY = "no-trusted-ip";
102
277
  async function resolveRateLimitConfig(req, ctx) {
103
278
  const basePath = new URL(ctx.baseURL).pathname;
104
279
  const path = normalizePathname(req.url, basePath);
105
280
  let currentWindow = ctx.rateLimit.window;
106
281
  let currentMax = ctx.rateLimit.max;
107
282
  const ip = getIp(req, ctx.options);
108
- if (!ip) {
109
- if (!ipWarningLogged) {
110
- ctx.logger.warn("Rate limiting skipped: could not determine client IP address. Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed.");
111
- ipWarningLogged = true;
112
- }
113
- return null;
283
+ if (!ip && ctx.options.advanced?.ipAddress?.disableIpTracking) return null;
284
+ if (!ip && !ipWarningLogged) {
285
+ ctx.logger.warn("Rate limiting could not determine a client IP and is falling back to a single shared per-path bucket. Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed.");
286
+ ipWarningLogged = true;
114
287
  }
115
- const key = createRateLimitKey(ip, path);
288
+ const key = createRateLimitKey(ip ?? NO_TRUSTED_IP_KEY, path);
116
289
  const specialRule = getDefaultSpecialRules().find((rule) => rule.pathMatcher(path));
117
290
  if (specialRule) {
118
291
  currentWindow = specialRule.window;
@@ -150,37 +323,50 @@ async function resolveRateLimitConfig(req, ctx) {
150
323
  currentMax
151
324
  };
152
325
  }
326
+ let legacyFallbackWarningLogged = false;
327
+ /**
328
+ * Decides the rate limit for the request in a single atomic step. The whole
329
+ * check-and-increment happens here in the request phase; there is no separate
330
+ * response-phase write-back, so concurrent requests cannot all pass a stale
331
+ * read before any increment lands.
332
+ */
153
333
  async function onRequestRateLimit(req, ctx) {
154
334
  if (!ctx.rateLimit.enabled) return;
155
335
  const config = await resolveRateLimitConfig(req, ctx);
156
336
  if (!config) return;
157
337
  const { key, currentWindow, currentMax } = config;
158
- const data = await getRateLimitStorage(ctx, { window: currentWindow }).get(key);
159
- if (data && shouldRateLimit(currentMax, currentWindow, data)) return rateLimitResponse(getRetryAfter(data.lastRequest, currentWindow));
160
- }
161
- async function onResponseRateLimit(req, ctx) {
162
- if (!ctx.rateLimit.enabled) return;
163
- const config = await resolveRateLimitConfig(req, ctx);
164
- if (!config) return;
165
- const { key, currentWindow } = config;
166
338
  const storage = getRateLimitStorage(ctx, { window: currentWindow });
167
- const data = await storage.get(key);
168
- const now = Date.now();
169
- if (!data) await storage.set(key, {
170
- key,
171
- count: 1,
172
- lastRequest: now
173
- });
174
- else if (now - data.lastRequest > currentWindow * 1e3) await storage.set(key, {
175
- ...data,
176
- count: 1,
177
- lastRequest: now
178
- }, true);
179
- else await storage.set(key, {
180
- ...data,
181
- count: data.count + 1,
182
- lastRequest: now
183
- }, true);
339
+ const rule = {
340
+ window: currentWindow,
341
+ max: currentMax
342
+ };
343
+ if (storage.consume) {
344
+ const { allowed, retryAfter } = await storage.consume(key, rule);
345
+ if (!allowed) return rateLimitResponse(retryAfter ?? currentWindow);
346
+ return;
347
+ }
348
+ return legacyConsume(ctx, storage, key, rule);
349
+ }
350
+ /**
351
+ * Non-atomic check-then-increment for storages that do not implement `consume`
352
+ * (custom storages, or secondary storages without `increment`). Under
353
+ * concurrency this is best-effort: simultaneous requests can each pass the
354
+ * check before either write lands.
355
+ *
356
+ * FIXME(rate-limit-consume-required): remove on `next` once `consume` is the
357
+ * sole required member of the storage contract.
358
+ */
359
+ async function legacyConsume(ctx, storage, key, rule) {
360
+ if (!legacyFallbackWarningLogged) {
361
+ ctx.logger.warn("Rate limiting is best-effort: the configured storage has no atomic `consume`, so concurrent requests may bypass the limit. Provide a storage that implements `consume` for strict enforcement.");
362
+ legacyFallbackWarningLogged = true;
363
+ }
364
+ const decision = decideConsume(await storage.get(key), rule, Date.now());
365
+ if (!decision.allowed) return rateLimitResponse(decision.retryAfter ?? rule.window);
366
+ await storage.set(key, {
367
+ ...decision.next,
368
+ key
369
+ }, decision.update);
184
370
  }
185
371
  function getDefaultSpecialRules() {
186
372
  return [{
@@ -198,4 +384,4 @@ function getDefaultSpecialRules() {
198
384
  }];
199
385
  }
200
386
  //#endregion
201
- export { onRequestRateLimit, onResponseRateLimit };
387
+ export { onRequestRateLimit };
@@ -1,3 +1,4 @@
1
+ import { shouldBindAccountCookieToSessionUser } from "../../context/store-capabilities.mjs";
1
2
  import { parseAccountOutput } from "../../db/schema.mjs";
2
3
  import { getAccountCookie, setAccountCookie } from "../../cookies/session-store.mjs";
3
4
  import { getAwaitableValue } from "../../context/helpers.mjs";
@@ -5,7 +6,7 @@ import { missingEmailLogMessage } from "../../oauth2/errors.mjs";
5
6
  import { decryptOAuthToken, setTokenUtil } from "../../oauth2/utils.mjs";
6
7
  import { applyUpdateUserInfoOnLink } from "../../oauth2/link-account.mjs";
7
8
  import { generateState } from "../../oauth2/state.mjs";
8
- import { freshSessionMiddleware, getSessionFromCtx, sessionMiddleware } from "./session.mjs";
9
+ import { freshSessionMiddleware, getSessionFromCtx, isStateful, sessionMiddleware } from "./session.mjs";
9
10
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
10
11
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
11
12
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -121,7 +122,7 @@ const linkSocialAccount = createAuthEndpoint("/link-social", {
121
122
  }
122
123
  const { token, nonce } = c.body.idToken;
123
124
  if (!await provider.verifyIdToken(token, nonce)) {
124
- c.context.logger.error("Invalid id token", { provider: c.body.provider });
125
+ c.context.logger.warn("Invalid id token", { provider: c.body.provider });
125
126
  throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_TOKEN);
126
127
  }
127
128
  const linkingUserInfo = await provider.getUserInfo({
@@ -233,7 +234,7 @@ const unlinkAccount = createAuthEndpoint("/unlink-account", {
233
234
  * so the cache is left in place for them.
234
235
  */
235
236
  async function resolveUserId(ctx, userId) {
236
- const session = await getSessionFromCtx(ctx, { disableCookieCache: !!ctx.context.options.database || !!ctx.context.options.secondaryStorage });
237
+ const session = await getSessionFromCtx(ctx, { disableCookieCache: isStateful(ctx) });
237
238
  if (!session && (ctx.request || ctx.headers)) throw ctx.error("UNAUTHORIZED");
238
239
  const resolvedUserId = session?.user?.id || userId;
239
240
  if (!resolvedUserId) throw APIError.from("BAD_REQUEST", {
@@ -242,6 +243,9 @@ async function resolveUserId(ctx, userId) {
242
243
  });
243
244
  return resolvedUserId;
244
245
  }
246
+ function matchesAccountSelection(ctx, account, { resolvedUserId, providerId, accountId }) {
247
+ return (!shouldBindAccountCookieToSessionUser(ctx.context.options) || account.userId === resolvedUserId) && (!providerId || providerId === account.providerId) && (!accountId || account.accountId === accountId);
248
+ }
245
249
  /**
246
250
  * Fetches a currently-valid access token for a user's provider account,
247
251
  * refreshing and persisting it when it is within five seconds of expiry.
@@ -257,7 +261,11 @@ async function getValidAccessToken(ctx, { resolvedUserId, providerId, accountId,
257
261
  let account = resolvedAccount;
258
262
  if (!account) {
259
263
  const accountData = await getAccountCookie(ctx);
260
- if (accountData && accountData.userId === resolvedUserId && providerId === accountData.providerId && (!accountId || accountData.accountId === accountId)) account = accountData;
264
+ if (accountData && matchesAccountSelection(ctx, accountData, {
265
+ resolvedUserId,
266
+ providerId,
267
+ accountId
268
+ })) account = accountData;
261
269
  else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId);
262
270
  }
263
271
  if (!account) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
@@ -388,7 +396,11 @@ const refreshToken = createAuthEndpoint("/refresh-token", {
388
396
  });
389
397
  let account = void 0;
390
398
  const accountData = await getAccountCookie(ctx);
391
- const usedAccountCookie = !!accountData && accountData.userId === resolvedUserId && providerId === accountData.providerId && (!accountId || accountData.accountId === accountId);
399
+ const usedAccountCookie = !!accountData && matchesAccountSelection(ctx, accountData, {
400
+ resolvedUserId,
401
+ providerId,
402
+ accountId
403
+ });
392
404
  if (usedAccountCookie) account = accountData;
393
405
  else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId);
394
406
  if (!account) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
@@ -484,7 +496,10 @@ const accountInfo = createAuthEndpoint("/account-info", {
484
496
  if (!providedAccountId) {
485
497
  if (ctx.context.options.account?.storeAccountCookie) {
486
498
  const accountData = await getAccountCookie(ctx);
487
- if (accountData) account = accountData;
499
+ if (accountData && matchesAccountSelection(ctx, accountData, {
500
+ resolvedUserId,
501
+ providerId: providedProviderId
502
+ })) account = accountData;
488
503
  }
489
504
  } else {
490
505
  const matchingAccounts = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).filter((acc) => acc.accountId === providedAccountId && (!providedProviderId || acc.providerId === providedProviderId));
@@ -494,7 +509,7 @@ const accountInfo = createAuthEndpoint("/account-info", {
494
509
  });
495
510
  account = matchingAccounts[0];
496
511
  }
497
- if (!account || account.userId !== resolvedUserId) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
512
+ if (!account || !matchesAccountSelection(ctx, account, { resolvedUserId })) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
498
513
  const provider = await getAwaitableValue(ctx.context.socialProviders, { value: account.providerId });
499
514
  if (!provider) throw APIError.from("BAD_REQUEST", {
500
515
  message: "Account is not associated with a configured social provider.",
@@ -55,12 +55,12 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
55
55
  const resolvedErrorURL = errorURL ?? defaultErrorURL;
56
56
  if (error) redirectOnError(c, resolvedErrorURL, error, error_description);
57
57
  if (!code) {
58
- c.context.logger.error("Code not found");
58
+ c.context.logger.warn("Code not found");
59
59
  redirectOnError(c, resolvedErrorURL, "no_code");
60
60
  }
61
61
  const provider = await getAwaitableValue(c.context.socialProviders, { value: c.params.id });
62
62
  if (!provider) {
63
- c.context.logger.error("Oauth provider with id", c.params.id, "not found");
63
+ c.context.logger.warn("OAuth provider not found", { providerId: c.params.id });
64
64
  redirectOnError(c, resolvedErrorURL, "oauth_provider_not_found");
65
65
  }
66
66
  let tokens;
@@ -4,7 +4,7 @@ import { createEmailVerificationToken, sendVerificationEmail, sendVerificationEm
4
4
  import { error } from "./error.mjs";
5
5
  import { ok } from "./ok.mjs";
6
6
  import { requestPasswordReset, requestPasswordResetCallback, resetPassword, verifyPassword } from "./password.mjs";
7
- import { freshSessionMiddleware, getSession, getSessionFromCtx, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./session.mjs";
7
+ import { freshSessionMiddleware, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./session.mjs";
8
8
  import { signInEmail, signInSocial } from "./sign-in.mjs";
9
9
  import { signOut } from "./sign-out.mjs";
10
10
  import { signUpEmail } from "./sign-up.mjs";
@@ -55,7 +55,7 @@ const requestPasswordReset = createAuthEndpoint("/request-password-reset", {
55
55
  */
56
56
  generateId(24);
57
57
  await ctx.context.internalAdapter.findVerificationValue("dummy-verification-token");
58
- ctx.context.logger.error("Reset Password: User not found", { email });
58
+ ctx.context.logger.warn("Reset Password: User not found");
59
59
  return ctx.json({
60
60
  status: true,
61
61
  message: "If this email exists in our system, check your email for the reset link"
@@ -145,8 +145,8 @@ const resetPassword = createAuthEndpoint("/reset-password", {
145
145
  if (newPassword.length < minLength) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
146
146
  if (newPassword.length > maxLength) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
147
147
  const id = `reset-password:${token}`;
148
- const verification = await ctx.context.internalAdapter.findVerificationValue(id);
149
- if (!verification || verification.expiresAt < /* @__PURE__ */ new Date()) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_TOKEN);
148
+ const verification = await ctx.context.internalAdapter.consumeVerificationValue(id);
149
+ if (!verification) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_TOKEN);
150
150
  const userId = verification.value;
151
151
  const hashedPassword = await ctx.context.password.hash(newPassword);
152
152
  if (!(await ctx.context.internalAdapter.findAccounts(userId)).find((ac) => ac.providerId === "credential")) await ctx.context.internalAdapter.createAccount({
@@ -156,7 +156,6 @@ const resetPassword = createAuthEndpoint("/reset-password", {
156
156
  accountId: userId
157
157
  });
158
158
  else await ctx.context.internalAdapter.updatePassword(userId, hashedPassword);
159
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(id);
160
159
  if (ctx.context.options.emailAndPassword?.onPasswordReset) {
161
160
  const user = await ctx.context.internalAdapter.findUserById(userId);
162
161
  if (user) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
@@ -45,6 +45,17 @@ declare const getSession: <Option extends BetterAuthOptions>() => better_call0.S
45
45
  session: Session$1<Option["session"], Option["plugins"]>;
46
46
  user: User$1<Option["user"], Option["plugins"]>;
47
47
  } | null>;
48
+ /**
49
+ * Whether the deployment keeps sessions in a durable server-side store
50
+ * (a database or secondary storage) rather than only in the signed cookie.
51
+ *
52
+ * Sensitive operations use this to decide whether the cookie cache is merely an
53
+ * optimization that must be bypassed for an authoritative read (`true`), or the
54
+ * only place the session lives and therefore the authority itself (`false`, for
55
+ * stateless / DB-less deployments). Pass the result as `disableCookieCache` so a
56
+ * revoked-but-cached session cannot authorize a sensitive action.
57
+ */
58
+ declare const isStateful: (ctx: GenericEndpointContext) => boolean;
48
59
  declare const getSessionFromCtx: <U extends Record<string, any> = Record<string, any>, S extends Record<string, any> = Record<string, any>>(ctx: GenericEndpointContext, config?: {
49
60
  disableCookieCache?: boolean;
50
61
  disableRefresh?: boolean;
@@ -409,4 +420,4 @@ declare const revokeOtherSessions: better_call0.StrictEndpoint<"/revoke-other-se
409
420
  status: boolean;
410
421
  }>;
411
422
  //#endregion
412
- export { freshSessionMiddleware, getSession, getSessionFromCtx, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware };
423
+ export { freshSessionMiddleware, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware };
@@ -1,4 +1,5 @@
1
1
  import { isAPIError } from "../../utils/is-api-error.mjs";
2
+ import { hasServerSessionStore } from "../../context/store-capabilities.mjs";
2
3
  import { symmetricDecodeJWT, verifyJWT } from "../../crypto/jwt.mjs";
3
4
  import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
4
5
  import { getDate } from "../../utils/date.mjs";
@@ -265,6 +266,17 @@ const getSession = () => createAuthEndpoint("/get-session", {
265
266
  throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_GET_SESSION);
266
267
  }
267
268
  });
269
+ /**
270
+ * Whether the deployment keeps sessions in a durable server-side store
271
+ * (a database or secondary storage) rather than only in the signed cookie.
272
+ *
273
+ * Sensitive operations use this to decide whether the cookie cache is merely an
274
+ * optimization that must be bypassed for an authoritative read (`true`), or the
275
+ * only place the session lives and therefore the authority itself (`false`, for
276
+ * stateless / DB-less deployments). Pass the result as `disableCookieCache` so a
277
+ * revoked-but-cached session cannot authorize a sensitive action.
278
+ */
279
+ const isStateful = (ctx) => hasServerSessionStore(ctx.context.options);
268
280
  const getSessionFromCtx = async (ctx, config) => {
269
281
  if (ctx.context.session) return ctx.context.session;
270
282
  const session = await getSession()({
@@ -488,4 +500,4 @@ const revokeOtherSessions = createAuthEndpoint("/revoke-other-sessions", {
488
500
  return ctx.json({ status: true });
489
501
  });
490
502
  //#endregion
491
- export { freshSessionMiddleware, getSession, getSessionFromCtx, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware };
503
+ export { freshSessionMiddleware, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware };