better-auth 1.6.15 → 1.6.17

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 (105) 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 +6 -1
  4. package/dist/api/rate-limiter/index.mjs +259 -73
  5. package/dist/api/routes/account.mjs +31 -11
  6. package/dist/api/routes/callback.mjs +3 -3
  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 +16 -2
  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 +9 -4
  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 +6 -5
  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 +6 -5
  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 +6 -5
  31. package/dist/client/svelte/index.d.mts +6 -5
  32. package/dist/client/types.d.mts +2 -2
  33. package/dist/client/vanilla.d.mts +6 -5
  34. package/dist/client/vue/index.d.mts +6 -5
  35. package/dist/context/create-context.mjs +3 -2
  36. package/dist/context/store-capabilities.mjs +12 -0
  37. package/dist/cookies/index.mjs +30 -2
  38. package/dist/db/internal-adapter.mjs +56 -0
  39. package/dist/oauth2/link-account.d.mts +13 -0
  40. package/dist/oauth2/link-account.mjs +1 -1
  41. package/dist/package.mjs +1 -1
  42. package/dist/plugins/access/access.mjs +49 -19
  43. package/dist/plugins/admin/access/statement.d.mts +10 -10
  44. package/dist/plugins/admin/access/statement.mjs +2 -0
  45. package/dist/plugins/admin/admin.d.mts +6 -3
  46. package/dist/plugins/admin/client.d.mts +6 -4
  47. package/dist/plugins/admin/error-codes.d.mts +2 -0
  48. package/dist/plugins/admin/error-codes.mjs +3 -1
  49. package/dist/plugins/admin/routes.mjs +73 -5
  50. package/dist/plugins/admin/schema.d.mts +1 -0
  51. package/dist/plugins/admin/schema.mjs +2 -1
  52. package/dist/plugins/captcha/constants.mjs +8 -1
  53. package/dist/plugins/captcha/index.mjs +8 -2
  54. package/dist/plugins/captcha/types.d.mts +21 -0
  55. package/dist/plugins/captcha/verify-handlers/captchafox.mjs +2 -0
  56. package/dist/plugins/captcha/verify-handlers/cloudflare-turnstile.mjs +7 -2
  57. package/dist/plugins/captcha/verify-handlers/google-recaptcha.mjs +7 -2
  58. package/dist/plugins/captcha/verify-handlers/h-captcha.mjs +2 -0
  59. package/dist/plugins/device-authorization/routes.mjs +16 -9
  60. package/dist/plugins/email-otp/routes.mjs +23 -53
  61. package/dist/plugins/generic-oauth/index.mjs +7 -2
  62. package/dist/plugins/generic-oauth/routes.mjs +20 -9
  63. package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
  64. package/dist/plugins/haveibeenpwned/index.mjs +5 -1
  65. package/dist/plugins/index.d.mts +5 -1
  66. package/dist/plugins/index.mjs +4 -1
  67. package/dist/plugins/jwt/index.mjs +2 -2
  68. package/dist/plugins/mcp/client/index.mjs +1 -0
  69. package/dist/plugins/mcp/index.mjs +8 -0
  70. package/dist/plugins/multi-session/index.mjs +7 -5
  71. package/dist/plugins/oauth-popup/client.d.mts +82 -0
  72. package/dist/plugins/oauth-popup/client.mjs +203 -0
  73. package/dist/plugins/oauth-popup/constants.d.mts +11 -0
  74. package/dist/plugins/oauth-popup/constants.mjs +11 -0
  75. package/dist/plugins/oauth-popup/error-codes.d.mts +11 -0
  76. package/dist/plugins/oauth-popup/error-codes.mjs +10 -0
  77. package/dist/plugins/oauth-popup/index.d.mts +67 -0
  78. package/dist/plugins/oauth-popup/index.mjs +227 -0
  79. package/dist/plugins/oauth-popup/types.d.mts +30 -0
  80. package/dist/plugins/oauth-proxy/index.mjs +2 -2
  81. package/dist/plugins/oauth-proxy/utils.mjs +16 -2
  82. package/dist/plugins/oidc-provider/index.mjs +10 -0
  83. package/dist/plugins/one-tap/client.mjs +12 -6
  84. package/dist/plugins/one-tap/index.d.mts +1 -0
  85. package/dist/plugins/one-tap/index.mjs +9 -5
  86. package/dist/plugins/one-time-token/index.mjs +1 -3
  87. package/dist/plugins/open-api/generator.mjs +7 -4
  88. package/dist/plugins/organization/adapter.d.mts +29 -1
  89. package/dist/plugins/organization/adapter.mjs +66 -6
  90. package/dist/plugins/organization/organization.mjs +2 -0
  91. package/dist/plugins/organization/routes/crud-invites.mjs +55 -31
  92. package/dist/plugins/organization/routes/crud-members.mjs +42 -6
  93. package/dist/plugins/organization/routes/crud-team.mjs +51 -5
  94. package/dist/plugins/organization/schema.d.mts +2 -0
  95. package/dist/plugins/phone-number/routes.mjs +41 -36
  96. package/dist/plugins/siwe/index.mjs +30 -3
  97. package/dist/plugins/siwe/parse-message.mjs +60 -0
  98. package/dist/plugins/two-factor/backup-codes/index.mjs +1 -1
  99. package/dist/plugins/two-factor/index.mjs +9 -1
  100. package/dist/plugins/two-factor/otp/index.mjs +11 -13
  101. package/dist/plugins/two-factor/totp/index.mjs +1 -1
  102. package/dist/plugins/two-factor/verify-two-factor.mjs +6 -2
  103. package/dist/plugins/username/index.mjs +6 -6
  104. package/dist/test-utils/test-instance.d.mts +6 -5
  105. package/package.json +10 -10
@@ -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}`);
@@ -141,6 +145,7 @@ async function validateFormCsrf(ctx) {
141
145
  }
142
146
  return await validateOrigin(ctx, true);
143
147
  }
148
+ if (headers.get("origin") || headers.get("referer")) return await validateOrigin(ctx, true);
144
149
  }
145
150
  //#endregion
146
151
  export { formCsrfMiddleware, originCheck, originCheckMiddleware };
@@ -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({
@@ -225,9 +226,15 @@ const unlinkAccount = createAuthEndpoint("/unlink-account", {
225
226
  * `userId` directly. Throws `UNAUTHORIZED` when an HTTP caller is
226
227
  * unauthenticated, and `USER_ID_OR_SESSION_REQUIRED` when neither a session
227
228
  * nor a `userId` is available.
229
+ *
230
+ * When a durable store is authoritative, bypasses the cookie cache: these
231
+ * routes mint or refresh provider access tokens, so a server-side session
232
+ * revocation must take effect immediately rather than waiting for the cached
233
+ * cookie to expire. DB-less deployments keep the session in the cookie itself,
234
+ * so the cache is left in place for them.
228
235
  */
229
236
  async function resolveUserId(ctx, userId) {
230
- const session = await getSessionFromCtx(ctx);
237
+ const session = await getSessionFromCtx(ctx, { disableCookieCache: isStateful(ctx) });
231
238
  if (!session && (ctx.request || ctx.headers)) throw ctx.error("UNAUTHORIZED");
232
239
  const resolvedUserId = session?.user?.id || userId;
233
240
  if (!resolvedUserId) throw APIError.from("BAD_REQUEST", {
@@ -236,6 +243,9 @@ async function resolveUserId(ctx, userId) {
236
243
  });
237
244
  return resolvedUserId;
238
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
+ }
239
249
  /**
240
250
  * Fetches a currently-valid access token for a user's provider account,
241
251
  * refreshing and persisting it when it is within five seconds of expiry.
@@ -251,7 +261,11 @@ async function getValidAccessToken(ctx, { resolvedUserId, providerId, accountId,
251
261
  let account = resolvedAccount;
252
262
  if (!account) {
253
263
  const accountData = await getAccountCookie(ctx);
254
- 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;
255
269
  else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId);
256
270
  }
257
271
  if (!account) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
@@ -382,12 +396,15 @@ const refreshToken = createAuthEndpoint("/refresh-token", {
382
396
  });
383
397
  let account = void 0;
384
398
  const accountData = await getAccountCookie(ctx);
385
- if (accountData && accountData.userId === resolvedUserId && (!providerId || providerId === accountData?.providerId)) account = accountData;
399
+ const usedAccountCookie = !!accountData && matchesAccountSelection(ctx, accountData, {
400
+ resolvedUserId,
401
+ providerId,
402
+ accountId
403
+ });
404
+ if (usedAccountCookie) account = accountData;
386
405
  else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId);
387
406
  if (!account) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
388
- let refreshToken = void 0;
389
- if (accountData && providerId === accountData.providerId) refreshToken = accountData.refreshToken ?? void 0;
390
- else refreshToken = account.refreshToken ?? void 0;
407
+ const refreshToken = account.refreshToken ?? void 0;
391
408
  if (!refreshToken) throw APIError.from("BAD_REQUEST", {
392
409
  message: "Refresh token not found",
393
410
  code: "REFRESH_TOKEN_NOT_FOUND"
@@ -409,7 +426,7 @@ const refreshToken = createAuthEndpoint("/refresh-token", {
409
426
  };
410
427
  await ctx.context.internalAdapter.updateAccount(account.id, updateData);
411
428
  }
412
- if (accountData && providerId === accountData.providerId && ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, {
429
+ if (usedAccountCookie && ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, {
413
430
  ...accountData,
414
431
  accessToken: await setTokenUtil(tokens.accessToken, ctx.context),
415
432
  refreshToken: resolvedRefreshToken,
@@ -479,7 +496,10 @@ const accountInfo = createAuthEndpoint("/account-info", {
479
496
  if (!providedAccountId) {
480
497
  if (ctx.context.options.account?.storeAccountCookie) {
481
498
  const accountData = await getAccountCookie(ctx);
482
- if (accountData) account = accountData;
499
+ if (accountData && matchesAccountSelection(ctx, accountData, {
500
+ resolvedUserId,
501
+ providerId: providedProviderId
502
+ })) account = accountData;
483
503
  }
484
504
  } else {
485
505
  const matchingAccounts = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).filter((acc) => acc.accountId === providedAccountId && (!providedProviderId || acc.providerId === providedProviderId));
@@ -489,7 +509,7 @@ const accountInfo = createAuthEndpoint("/account-info", {
489
509
  });
490
510
  account = matchingAccounts[0];
491
511
  }
492
- 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);
493
513
  const provider = await getAwaitableValue(ctx.context.socialProviders, { value: account.providerId });
494
514
  if (!provider) throw APIError.from("BAD_REQUEST", {
495
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;
@@ -81,7 +81,7 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
81
81
  ...tokens,
82
82
  user: parsedUserData ?? void 0
83
83
  }).then((res) => res?.user);
84
- if (!userInfo || userInfo.id === void 0 || userInfo.id === null) {
84
+ if (!userInfo || userInfo.id === void 0 || userInfo.id === null || userInfo.id === "") {
85
85
  c.context.logger.error("Unable to get user info");
86
86
  redirectOnError(c, resolvedErrorURL, "unable_to_get_user_info");
87
87
  }
@@ -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);