better-auth 1.6.16 → 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 (84) 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/path-to-object.d.mts +5 -2
  21. package/dist/client/plugins/index.d.mts +4 -1
  22. package/dist/client/plugins/index.mjs +4 -1
  23. package/dist/client/query.d.mts +4 -3
  24. package/dist/client/query.mjs +27 -17
  25. package/dist/client/session-atom.mjs +129 -4
  26. package/dist/client/session-refresh.d.mts +3 -18
  27. package/dist/client/session-refresh.mjs +38 -49
  28. package/dist/client/types.d.mts +2 -2
  29. package/dist/context/create-context.mjs +2 -1
  30. package/dist/context/store-capabilities.mjs +12 -0
  31. package/dist/cookies/index.mjs +25 -2
  32. package/dist/db/internal-adapter.mjs +51 -0
  33. package/dist/package.mjs +1 -1
  34. package/dist/plugins/access/access.mjs +49 -19
  35. package/dist/plugins/admin/routes.mjs +10 -3
  36. package/dist/plugins/captcha/constants.mjs +8 -1
  37. package/dist/plugins/captcha/index.mjs +8 -2
  38. package/dist/plugins/captcha/types.d.mts +21 -0
  39. package/dist/plugins/captcha/verify-handlers/captchafox.mjs +2 -0
  40. package/dist/plugins/captcha/verify-handlers/cloudflare-turnstile.mjs +7 -2
  41. package/dist/plugins/captcha/verify-handlers/google-recaptcha.mjs +7 -2
  42. package/dist/plugins/captcha/verify-handlers/h-captcha.mjs +2 -0
  43. package/dist/plugins/device-authorization/routes.mjs +16 -9
  44. package/dist/plugins/email-otp/routes.mjs +22 -52
  45. package/dist/plugins/generic-oauth/index.mjs +7 -2
  46. package/dist/plugins/generic-oauth/routes.mjs +16 -12
  47. package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
  48. package/dist/plugins/haveibeenpwned/index.mjs +5 -1
  49. package/dist/plugins/index.d.mts +5 -1
  50. package/dist/plugins/index.mjs +4 -1
  51. package/dist/plugins/jwt/index.mjs +2 -2
  52. package/dist/plugins/mcp/client/index.mjs +1 -0
  53. package/dist/plugins/mcp/index.mjs +8 -0
  54. package/dist/plugins/multi-session/index.mjs +7 -5
  55. package/dist/plugins/oauth-popup/client.d.mts +82 -0
  56. package/dist/plugins/oauth-popup/client.mjs +203 -0
  57. package/dist/plugins/oauth-popup/constants.d.mts +11 -0
  58. package/dist/plugins/oauth-popup/constants.mjs +11 -0
  59. package/dist/plugins/oauth-popup/error-codes.d.mts +11 -0
  60. package/dist/plugins/oauth-popup/error-codes.mjs +10 -0
  61. package/dist/plugins/oauth-popup/index.d.mts +67 -0
  62. package/dist/plugins/oauth-popup/index.mjs +227 -0
  63. package/dist/plugins/oauth-popup/types.d.mts +30 -0
  64. package/dist/plugins/oauth-proxy/index.mjs +2 -2
  65. package/dist/plugins/oauth-proxy/utils.mjs +16 -2
  66. package/dist/plugins/oidc-provider/index.mjs +10 -0
  67. package/dist/plugins/one-tap/client.mjs +12 -6
  68. package/dist/plugins/one-tap/index.d.mts +1 -0
  69. package/dist/plugins/one-tap/index.mjs +9 -5
  70. package/dist/plugins/one-time-token/index.mjs +1 -3
  71. package/dist/plugins/open-api/generator.mjs +7 -4
  72. package/dist/plugins/organization/adapter.d.mts +29 -1
  73. package/dist/plugins/organization/adapter.mjs +66 -6
  74. package/dist/plugins/organization/routes/crud-invites.mjs +49 -34
  75. package/dist/plugins/organization/routes/crud-members.mjs +42 -6
  76. package/dist/plugins/organization/routes/crud-team.mjs +36 -3
  77. package/dist/plugins/phone-number/routes.mjs +41 -36
  78. package/dist/plugins/siwe/index.mjs +2 -3
  79. package/dist/plugins/two-factor/backup-codes/index.mjs +1 -1
  80. package/dist/plugins/two-factor/otp/index.mjs +11 -13
  81. package/dist/plugins/two-factor/totp/index.mjs +1 -1
  82. package/dist/plugins/two-factor/verify-two-factor.mjs +6 -2
  83. package/dist/plugins/username/index.mjs +6 -6
  84. package/package.json +9 -9
@@ -1,28 +1,13 @@
1
- import { Session, User } from "../types/models.mjs";
2
- import { AuthQueryAtom } from "./query.mjs";
3
1
  import { BetterAuthClientOptions } from "@better-auth/core";
4
2
  import { WritableAtom } from "nanostores";
5
- import { BetterFetch } from "@better-fetch/fetch";
6
3
 
7
4
  //#region src/client/session-refresh.d.ts
8
5
  interface SessionRefreshOptions {
9
- sessionAtom: AuthQueryAtom<{
10
- user: User;
11
- session: Session;
12
- } & Record<string, any>>;
6
+ fetchSession: () => Promise<void>;
7
+ shouldPollSession?: () => boolean;
13
8
  sessionSignal: WritableAtom<boolean>;
14
- $fetch: BetterFetch;
15
9
  options?: BetterAuthClientOptions | undefined;
16
10
  }
17
- type SessionResponse = ({
18
- session: null;
19
- user: null;
20
- needsRefresh?: boolean;
21
- } | {
22
- session: Session;
23
- user: User;
24
- needsRefresh?: boolean;
25
- }) & Record<string, any>;
26
11
  declare function createSessionRefreshManager(opts: SessionRefreshOptions): {
27
12
  init: () => void;
28
13
  cleanup: () => void;
@@ -32,4 +17,4 @@ declare function createSessionRefreshManager(opts: SessionRefreshOptions): {
32
17
  broadcastSessionUpdate: (trigger: "signout" | "getSession" | "updateUser") => void;
33
18
  };
34
19
  //#endregion
35
- export { SessionRefreshOptions, SessionResponse, createSessionRefreshManager };
20
+ export { SessionRefreshOptions, createSessionRefreshManager };
@@ -4,28 +4,17 @@ import { getGlobalOnlineManager } from "./online-manager.mjs";
4
4
  //#region src/client/session-refresh.ts
5
5
  const now = () => Math.floor(Date.now() / 1e3);
6
6
  /**
7
- * Normalize $fetch response: `throw: true` returns data directly, otherwise `{ data, error }`.
8
- */
9
- function normalizeSessionResponse(res) {
10
- if (typeof res === "object" && res !== null && "data" in res && "error" in res) return res;
11
- return {
12
- data: res,
13
- error: null
14
- };
15
- }
16
- /**
17
7
  * Rate limit: don't refetch on focus if a session request was made within this many seconds
18
8
  */
19
9
  const FOCUS_REFETCH_RATE_LIMIT_SECONDS = 5;
20
10
  function createSessionRefreshManager(opts) {
21
- const { sessionAtom, sessionSignal, $fetch, options = {} } = opts;
11
+ const { fetchSession, shouldPollSession = () => true, sessionSignal, options = {} } = opts;
22
12
  const refetchInterval = options.sessionOptions?.refetchInterval ?? 0;
23
13
  const refetchOnWindowFocus = options.sessionOptions?.refetchOnWindowFocus ?? true;
24
14
  const refetchWhenOffline = options.sessionOptions?.refetchWhenOffline ?? false;
25
15
  const state = {
26
- lastSync: 0,
27
- lastSessionRequest: 0,
28
- cachedSession: void 0
16
+ isInitialized: false,
17
+ lastSessionRequest: 0
29
18
  };
30
19
  const shouldRefetch = () => {
31
20
  return refetchWhenOffline || getGlobalOnlineManager().isOnline;
@@ -33,45 +22,21 @@ function createSessionRefreshManager(opts) {
33
22
  const triggerRefetch = (event) => {
34
23
  if (!shouldRefetch()) return;
35
24
  if (event?.event === "storage") {
36
- state.lastSync = now();
37
- sessionSignal.set(!sessionSignal.get());
25
+ fetchSession();
38
26
  return;
39
27
  }
40
- const currentSession = sessionAtom.get();
41
- const fetchSessionWithRefresh = () => {
42
- state.lastSessionRequest = now();
43
- $fetch("/get-session").then(async (res) => {
44
- let { data, error } = normalizeSessionResponse(res);
45
- if (data?.needsRefresh) try {
46
- const refreshRes = await $fetch("/get-session", { method: "POST" });
47
- ({data, error} = normalizeSessionResponse(refreshRes));
48
- } catch {}
49
- const sessionData = data?.session && data?.user ? data : null;
50
- sessionAtom.set({
51
- ...currentSession,
52
- data: sessionData,
53
- error
54
- });
55
- state.lastSync = now();
56
- sessionSignal.set(!sessionSignal.get());
57
- }).catch(() => {});
58
- };
59
28
  if (event?.event === "poll") {
60
- fetchSessionWithRefresh();
29
+ state.lastSessionRequest = now();
30
+ fetchSession();
61
31
  return;
62
32
  }
63
33
  if (event?.event === "visibilitychange") {
64
34
  if (now() - state.lastSessionRequest < FOCUS_REFETCH_RATE_LIMIT_SECONDS) return;
65
35
  state.lastSessionRequest = now();
66
- }
67
- if (event?.event === "visibilitychange") {
68
- fetchSessionWithRefresh();
36
+ fetchSession();
69
37
  return;
70
38
  }
71
- if (currentSession?.data === null || currentSession?.data === void 0) {
72
- state.lastSync = now();
73
- sessionSignal.set(!sessionSignal.get());
74
- }
39
+ fetchSession();
75
40
  };
76
41
  const broadcastSessionUpdate = (trigger) => {
77
42
  getGlobalBroadcastChannel().post({
@@ -82,7 +47,7 @@ function createSessionRefreshManager(opts) {
82
47
  };
83
48
  const setupPolling = () => {
84
49
  if (refetchInterval && refetchInterval > 0) state.pollInterval = setInterval(() => {
85
- if (sessionAtom.get()?.data) triggerRefetch({ event: "poll" });
50
+ if (shouldPollSession()) triggerRefetch({ event: "poll" });
86
51
  }, refetchInterval * 1e3);
87
52
  };
88
53
  const setupBroadcast = () => {
@@ -101,16 +66,25 @@ function createSessionRefreshManager(opts) {
101
66
  if (online) triggerRefetch({ event: "visibilitychange" });
102
67
  });
103
68
  };
69
+ const setupSignalSubscription = () => {
70
+ state.unsubscribeSignal = sessionSignal.listen(() => {
71
+ fetchSession();
72
+ });
73
+ };
104
74
  const init = () => {
75
+ if (state.isInitialized) return;
76
+ state.isInitialized = true;
105
77
  setupPolling();
106
78
  setupBroadcast();
107
79
  setupFocusRefetch();
108
80
  setupOnlineRefetch();
109
- getGlobalBroadcastChannel().setup();
110
- getGlobalFocusManager().setup();
111
- getGlobalOnlineManager().setup();
81
+ setupSignalSubscription();
82
+ state.cleanupBroadcastSetup = getGlobalBroadcastChannel().setup();
83
+ state.cleanupFocusSetup = getGlobalFocusManager().setup();
84
+ state.cleanupOnlineSetup = getGlobalOnlineManager().setup();
112
85
  };
113
86
  const cleanup = () => {
87
+ if (!state.isInitialized) return;
114
88
  if (state.pollInterval) {
115
89
  clearInterval(state.pollInterval);
116
90
  state.pollInterval = void 0;
@@ -127,9 +101,24 @@ function createSessionRefreshManager(opts) {
127
101
  state.unsubscribeOnline();
128
102
  state.unsubscribeOnline = void 0;
129
103
  }
130
- state.lastSync = 0;
104
+ if (state.unsubscribeSignal) {
105
+ state.unsubscribeSignal();
106
+ state.unsubscribeSignal = void 0;
107
+ }
108
+ if (state.cleanupBroadcastSetup) {
109
+ state.cleanupBroadcastSetup();
110
+ state.cleanupBroadcastSetup = void 0;
111
+ }
112
+ if (state.cleanupFocusSetup) {
113
+ state.cleanupFocusSetup();
114
+ state.cleanupFocusSetup = void 0;
115
+ }
116
+ if (state.cleanupOnlineSetup) {
117
+ state.cleanupOnlineSetup();
118
+ state.cleanupOnlineSetup = void 0;
119
+ }
120
+ state.isInitialized = false;
131
121
  state.lastSessionRequest = 0;
132
- state.cachedSession = void 0;
133
122
  };
134
123
  return {
135
124
  init,
@@ -3,7 +3,7 @@ import { StripEmptyObjects, UnionToIntersection } from "../types/helper.mjs";
3
3
  import { InferRoutes } from "./path-to-object.mjs";
4
4
  import { Session as Session$1, User as User$1 } from "../types/models.mjs";
5
5
  import { Auth } from "../types/auth.mjs";
6
- import { BetterAuthClientOptions as BetterAuthClientOptions$1, BetterAuthClientPlugin as BetterAuthClientPlugin$1, ClientAtomListener, ClientStore } from "@better-auth/core";
6
+ import { BetterAuthClientOptions as BetterAuthClientOptions$1, BetterAuthClientPlugin as BetterAuthClientPlugin$1, ClientAtomListener, ClientStore as ClientStore$1 } from "@better-auth/core";
7
7
  import { BetterAuthPluginDBSchema, InferDBFieldsOutput } from "@better-auth/core/db";
8
8
  import { RawError } from "@better-auth/core/utils/error-codes";
9
9
 
@@ -37,4 +37,4 @@ type SessionQueryParams = {
37
37
  disableRefresh?: boolean | undefined;
38
38
  };
39
39
  //#endregion
40
- export { type BetterAuthClientOptions$1 as BetterAuthClientOptions, type BetterAuthClientPlugin$1 as BetterAuthClientPlugin, type ClientAtomListener, type ClientStore, InferActions, InferAdditionalFromClient, InferClientAPI, InferErrorCodes, InferSessionFromClient, InferUserFromClient, IsSignal, SessionQueryParams };
40
+ export { type BetterAuthClientOptions$1 as BetterAuthClientOptions, type BetterAuthClientPlugin$1 as BetterAuthClientPlugin, type ClientAtomListener, type ClientStore$1 as ClientStore, InferActions, InferAdditionalFromClient, InferClientAPI, InferErrorCodes, InferSessionFromClient, InferUserFromClient, IsSignal, SessionQueryParams };
@@ -1,5 +1,6 @@
1
1
  import { getBaseURL, isDynamicBaseURLConfig } from "../utils/url.mjs";
2
2
  import { matchesOriginPattern } from "../auth/trusted-origins.mjs";
3
+ import { hasServerSessionStore } from "./store-capabilities.mjs";
3
4
  import { isPromise } from "../utils/is-promise.mjs";
4
5
  import { hashPassword, verifyPassword } from "../crypto/password.mjs";
5
6
  import { createCookieGetter, getCookies } from "../cookies/index.mjs";
@@ -42,7 +43,7 @@ function validateSecret(secret, logger) {
42
43
  if (estimateEntropy(secret) < 120) logger.warn("[better-auth] Warning: your BETTER_AUTH_SECRET appears low-entropy. Use a randomly generated secret for production.");
43
44
  }
44
45
  async function createAuthContext(adapter, options, getDatabaseType) {
45
- const isStateful = !!options.database || !!options.secondaryStorage;
46
+ const isStateful = hasServerSessionStore(options);
46
47
  if (!isStateful) options = defu$1(options, { session: { cookieCache: {
47
48
  enabled: true,
48
49
  strategy: "jwe",
@@ -0,0 +1,12 @@
1
+ //#region src/context/store-capabilities.ts
2
+ function hasServerSessionStore(options) {
3
+ return !!options.database || !!options.secondaryStorage;
4
+ }
5
+ function hasServerAccountStore(options) {
6
+ return !!options.database;
7
+ }
8
+ function shouldBindAccountCookieToSessionUser(options) {
9
+ return hasServerAccountStore(options);
10
+ }
11
+ //#endregion
12
+ export { hasServerSessionStore, shouldBindAccountCookieToSessionUser };
@@ -1,4 +1,5 @@
1
1
  import { isDynamicBaseURLConfig } from "../utils/url.mjs";
2
+ import { shouldBindAccountCookieToSessionUser } from "../context/store-capabilities.mjs";
2
3
  import { signJWT, symmetricDecodeJWT, symmetricEncodeJWT, verifyJWT } from "../crypto/jwt.mjs";
3
4
  import { parseUserOutput } from "../db/schema.mjs";
4
5
  import { getDate } from "../utils/date.mjs";
@@ -114,9 +115,9 @@ async function setCookieCache(ctx, session, dontRememberMe) {
114
115
  }
115
116
  ctx.setCookie(ctx.context.authCookies.sessionData.name, data, options);
116
117
  }
117
- if (ctx.context.options.account?.storeAccountCookie) {
118
+ if (ctx.context.options.account?.storeAccountCookie && !hasPendingSetCookie(ctx, ctx.context.authCookies.accountData.name)) {
118
119
  const accountData = await getAccountCookie(ctx);
119
- if (accountData) if (accountData.userId === session.user.id) await setAccountCookie(ctx, accountData);
120
+ if (accountData) if (!shouldBindAccountCookieToSessionUser(ctx.context.options) || accountData.userId === session.user.id) await setAccountCookie(ctx, accountData);
120
121
  else {
121
122
  expireCookie(ctx, ctx.context.authCookies.accountData);
122
123
  const accountStore = createAccountStore(ctx.context.authCookies.accountData.name, ctx.context.authCookies.accountData.attributes, ctx);
@@ -175,6 +176,20 @@ function removeSetCookieEntries(ctx, cookieName) {
175
176
  }
176
177
  }
177
178
  /**
179
+ * Whether the response already has a pending `Set-Cookie` for `cookieName`
180
+ * or a chunked variant.
181
+ */
182
+ function hasPendingSetCookie(ctx, cookieName) {
183
+ const scoped = ctx;
184
+ const targets = /* @__PURE__ */ new Set();
185
+ if (scoped.responseHeaders) targets.add(scoped.responseHeaders);
186
+ if (scoped.context?.responseHeaders) targets.add(scoped.context.responseHeaders);
187
+ const exact = `${cookieName}=`;
188
+ const chunk = `${cookieName}.`;
189
+ for (const headers of targets) if ((typeof headers.getSetCookie === "function" ? headers.getSetCookie() : splitSetCookieHeader(headers.get("set-cookie") || "")).some((entry) => entry.startsWith(exact) || entry.startsWith(chunk))) return true;
190
+ return false;
191
+ }
192
+ /**
178
193
  * Expires a cookie by setting `maxAge: 0` while preserving its attributes
179
194
  */
180
195
  function expireCookie(ctx, cookie) {
@@ -236,6 +251,10 @@ const getCookieCache = async (request, config) => {
236
251
  const secret = config?.secret || env.BETTER_AUTH_SECRET;
237
252
  if (!secret) throw new BetterAuthError("getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable");
238
253
  const strategy = config?.strategy || "compact";
254
+ const isEmbeddedSessionExpired = (payload) => {
255
+ const expiresAt = payload.session?.expiresAt;
256
+ return !!expiresAt && new Date(expiresAt).getTime() < Date.now();
257
+ };
239
258
  if (strategy === "jwe") {
240
259
  const payload = await symmetricDecodeJWT(sessionData, secret, "better-auth-session");
241
260
  if (payload && payload.session && payload.user) {
@@ -249,6 +268,7 @@ const getCookieCache = async (request, config) => {
249
268
  }
250
269
  if (cookieVersion !== expectedVersion) return null;
251
270
  }
271
+ if (isEmbeddedSessionExpired(payload)) return null;
252
272
  return payload;
253
273
  }
254
274
  return null;
@@ -265,6 +285,7 @@ const getCookieCache = async (request, config) => {
265
285
  }
266
286
  if (cookieVersion !== expectedVersion) return null;
267
287
  }
288
+ if (isEmbeddedSessionExpired(payload)) return null;
268
289
  return payload;
269
290
  }
270
291
  return null;
@@ -285,6 +306,8 @@ const getCookieCache = async (request, config) => {
285
306
  }
286
307
  if (cookieVersion !== expectedVersion) return null;
287
308
  }
309
+ if (typeof sessionDataPayload.expiresAt === "number" && sessionDataPayload.expiresAt < Date.now()) return null;
310
+ if (isEmbeddedSessionExpired(sessionDataPayload.session)) return null;
288
311
  return sessionDataPayload.session;
289
312
  }
290
313
  }
@@ -6,6 +6,8 @@ import { getWithHooks } from "./with-hooks.mjs";
6
6
  import { getCurrentAdapter, getCurrentAuthContext, runWithTransaction } from "@better-auth/core/context";
7
7
  import { generateId } from "@better-auth/core/utils/id";
8
8
  import { safeJSONParse } from "@better-auth/core/utils/json";
9
+ import { base64Url } from "@better-auth/utils/base64";
10
+ import { createHash } from "@better-auth/utils/hash";
9
11
  //#region src/db/internal-adapter.ts
10
12
  function getTTLSeconds(expiresAt, now = Date.now()) {
11
13
  const expiresMs = typeof expiresAt === "number" ? expiresAt : expiresAt.getTime();
@@ -717,6 +719,55 @@ const createInternalAdapter = (adapter, ctx) => {
717
719
  if (!consumed || consumed.expiresAt < /* @__PURE__ */ new Date()) return null;
718
720
  return consumed;
719
721
  },
722
+ reserveVerificationValue: async (data) => {
723
+ const reservationId = base64Url.encode(new Uint8Array(await createHash("SHA-256").digest(new TextEncoder().encode("reserve:" + data.identifier))), { padding: false });
724
+ const storageOption = getStorageOption(data.identifier, options.verification?.storeIdentifier);
725
+ const storedIdentifier = await processIdentifier(data.identifier, storageOption);
726
+ if (secondaryStorage && !options.verification?.storeInDatabase) {
727
+ const cacheKey = `verification:${storedIdentifier}`;
728
+ if (await secondaryStorage.get(cacheKey)) return false;
729
+ await secondaryStorage.set(cacheKey, JSON.stringify({
730
+ id: reservationId,
731
+ identifier: storedIdentifier,
732
+ value: data.value,
733
+ expiresAt: data.expiresAt
734
+ }), getTTLSeconds(data.expiresAt));
735
+ return true;
736
+ }
737
+ try {
738
+ await adapter.create({
739
+ model: "verification",
740
+ data: {
741
+ id: reservationId,
742
+ identifier: storedIdentifier,
743
+ value: data.value,
744
+ expiresAt: data.expiresAt,
745
+ createdAt: /* @__PURE__ */ new Date(),
746
+ updatedAt: /* @__PURE__ */ new Date()
747
+ },
748
+ forceAllowId: true
749
+ });
750
+ } catch (error) {
751
+ if (await adapter.findOne({
752
+ model: "verification",
753
+ where: [{
754
+ field: "id",
755
+ value: reservationId
756
+ }]
757
+ })) return false;
758
+ throw error;
759
+ }
760
+ if (secondaryStorage) {
761
+ const ttl = getTTLSeconds(data.expiresAt);
762
+ if (ttl > 0) await secondaryStorage.set(`verification:${storedIdentifier}`, JSON.stringify({
763
+ id: reservationId,
764
+ identifier: storedIdentifier,
765
+ value: data.value,
766
+ expiresAt: data.expiresAt
767
+ }), ttl);
768
+ }
769
+ return true;
770
+ },
720
771
  updateVerificationByIdentifier: async (identifier, data) => {
721
772
  const storedIdentifier = await processIdentifier(identifier, getStorageOption(identifier, options.verification?.storeIdentifier));
722
773
  if (secondaryStorage) {
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.6.16";
2
+ var version = "1.6.17";
3
3
  //#endregion
4
4
  export { version };
@@ -1,33 +1,63 @@
1
1
  import { BetterAuthError } from "@better-auth/core/error";
2
2
  //#region src/plugins/access/access.ts
3
+ function unknownResourceResponse(requestedResource) {
4
+ return {
5
+ success: false,
6
+ error: `You are not allowed to access resource: ${requestedResource}`
7
+ };
8
+ }
9
+ function unauthorizedResourceResponse(requestedResource) {
10
+ return {
11
+ success: false,
12
+ error: `unauthorized to access resource "${requestedResource}"`
13
+ };
14
+ }
15
+ function normalizeConnector(connector) {
16
+ return connector === "OR" ? "OR" : "AND";
17
+ }
18
+ function isActionList(actions) {
19
+ return Array.isArray(actions);
20
+ }
21
+ function normalizeActionRequest(requestedActions) {
22
+ if (isActionList(requestedActions)) return {
23
+ actions: requestedActions,
24
+ connector: "AND"
25
+ };
26
+ if (!requestedActions || typeof requestedActions !== "object") throw new BetterAuthError("Invalid access control request");
27
+ const { actions, connector } = requestedActions;
28
+ if (!isActionList(actions)) return {
29
+ actions: [],
30
+ connector: normalizeConnector(connector)
31
+ };
32
+ return {
33
+ actions,
34
+ connector: normalizeConnector(connector)
35
+ };
36
+ }
37
+ function hasAllowedAction(allowedActions, requestedAction) {
38
+ return typeof requestedAction === "string" && allowedActions.includes(requestedAction);
39
+ }
40
+ function isResourceAuthorized(allowedActions, { actions, connector }) {
41
+ if (actions.length === 0) return false;
42
+ if (connector === "OR") return actions.some((requestedAction) => hasAllowedAction(allowedActions, requestedAction));
43
+ return actions.every((requestedAction) => hasAllowedAction(allowedActions, requestedAction));
44
+ }
3
45
  function role(statements) {
4
46
  return {
5
47
  authorize(request, connector = "AND") {
6
- let success = false;
48
+ let hasAuthorizedResource = false;
7
49
  for (const [requestedResource, requestedActions] of Object.entries(request)) {
8
50
  const allowedActions = statements[requestedResource];
9
51
  if (!allowedActions) {
10
- if (connector === "AND") return {
11
- success: false,
12
- error: `You are not allowed to access resource: ${requestedResource}`
13
- };
14
- success = false;
52
+ if (connector === "AND") return unknownResourceResponse(requestedResource);
15
53
  continue;
16
54
  }
17
- if (Array.isArray(requestedActions)) success = requestedActions.length > 0 && requestedActions.every((requestedAction) => allowedActions.includes(requestedAction));
18
- else if (typeof requestedActions === "object") {
19
- const actions = requestedActions;
20
- if (!Array.isArray(actions.actions) || actions.actions.length === 0) success = false;
21
- else if (actions.connector === "OR") success = actions.actions.some((requestedAction) => allowedActions.includes(requestedAction));
22
- else success = actions.actions.every((requestedAction) => allowedActions.includes(requestedAction));
23
- } else throw new BetterAuthError("Invalid access control request");
24
- if (success && connector === "OR") return { success };
25
- if (!success && connector === "AND") return {
26
- success: false,
27
- error: `unauthorized to access resource "${requestedResource}"`
28
- };
55
+ const isAuthorized = isResourceAuthorized(allowedActions, normalizeActionRequest(requestedActions));
56
+ if (isAuthorized) hasAuthorizedResource = true;
57
+ if (isAuthorized && connector === "OR") return { success: true };
58
+ if (!isAuthorized && connector === "AND") return unauthorizedResourceResponse(requestedResource);
29
59
  }
30
- if (success) return { success };
60
+ if (hasAuthorizedResource) return { success: true };
31
61
  return {
32
62
  success: false,
33
63
  error: "Not authorized"
@@ -816,16 +816,23 @@ const setUserPassword = (opts) => createAuthEndpoint("/admin/set-user-password",
816
816
  const { newPassword, userId } = ctx.body;
817
817
  const minPasswordLength = ctx.context.password.config.minPasswordLength;
818
818
  if (newPassword.length < minPasswordLength) {
819
- ctx.context.logger.error("Password is too short");
819
+ ctx.context.logger.warn("Password is too short");
820
820
  throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
821
821
  }
822
822
  const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
823
823
  if (newPassword.length > maxPasswordLength) {
824
- ctx.context.logger.error("Password is too long");
824
+ ctx.context.logger.warn("Password is too long");
825
825
  throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
826
826
  }
827
+ if (!await ctx.context.internalAdapter.findUserById(userId)) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.USER_NOT_FOUND);
827
828
  const hashedPassword = await ctx.context.password.hash(newPassword);
828
- await ctx.context.internalAdapter.updatePassword(userId, hashedPassword);
829
+ if ((await ctx.context.internalAdapter.findAccounts(userId)).find((account) => account.providerId === "credential")) await ctx.context.internalAdapter.updatePassword(userId, hashedPassword);
830
+ else await ctx.context.internalAdapter.createAccount({
831
+ userId,
832
+ providerId: "credential",
833
+ accountId: userId,
834
+ password: hashedPassword
835
+ });
829
836
  return ctx.json({ status: true });
830
837
  });
831
838
  const userHasPermissionBodySchema = z.object({
@@ -1,4 +1,11 @@
1
1
  //#region src/plugins/captcha/constants.ts
2
+ /**
3
+ * Upper bound (in milliseconds) for a single provider verification request.
4
+ * Without it, a hanging provider would tie up the request indefinitely before
5
+ * any rate limiting applies, so every verify handler aborts at this deadline
6
+ * and fails closed.
7
+ */
8
+ const CAPTCHA_VERIFY_TIMEOUT_MS = 1e4;
2
9
  const defaultEndpoints = [
3
10
  "/sign-up/email",
4
11
  "/sign-in/email",
@@ -17,4 +24,4 @@ const siteVerifyMap = {
17
24
  [Providers.CAPTCHAFOX]: "https://api.captchafox.com/siteverify"
18
25
  };
19
26
  //#endregion
20
- export { Providers, defaultEndpoints, siteVerifyMap };
27
+ export { CAPTCHA_VERIFY_TIMEOUT_MS, Providers, defaultEndpoints, siteVerifyMap };
@@ -42,10 +42,16 @@ const captcha = (options) => ({
42
42
  secretKey: options.secretKey,
43
43
  remoteIP: remoteUserIP
44
44
  };
45
- if (options.provider === Providers.CLOUDFLARE_TURNSTILE) return await cloudflareTurnstile(handlerParams);
45
+ if (options.provider === Providers.CLOUDFLARE_TURNSTILE) return await cloudflareTurnstile({
46
+ ...handlerParams,
47
+ expectedAction: options.expectedAction,
48
+ allowedHostnames: options.allowedHostnames
49
+ });
46
50
  if (options.provider === Providers.GOOGLE_RECAPTCHA) return await googleRecaptcha({
47
51
  ...handlerParams,
48
- minScore: options.minScore
52
+ minScore: options.minScore,
53
+ expectedAction: options.expectedAction,
54
+ allowedHostnames: options.allowedHostnames
49
55
  });
50
56
  if (options.provider === Providers.HCAPTCHA) return await hCaptcha({
51
57
  ...handlerParams,
@@ -10,9 +10,30 @@ interface BaseCaptchaOptions {
10
10
  interface GoogleRecaptchaOptions extends BaseCaptchaOptions {
11
11
  provider: typeof Providers.GOOGLE_RECAPTCHA;
12
12
  minScore?: number | undefined;
13
+ /**
14
+ * Expected reCAPTCHA v3 `action`. When set, a verification whose action does
15
+ * not match is rejected, preventing a token minted for another action on the
16
+ * same site key from being replayed against this endpoint.
17
+ */
18
+ expectedAction?: string | undefined;
19
+ /**
20
+ * Allow-list of hostnames the token must have been issued for. When set, a
21
+ * verification reporting a different hostname is rejected.
22
+ */
23
+ allowedHostnames?: string[] | undefined;
13
24
  }
14
25
  interface CloudflareTurnstileOptions extends BaseCaptchaOptions {
15
26
  provider: typeof Providers.CLOUDFLARE_TURNSTILE;
27
+ /**
28
+ * Expected Turnstile `action`. When set, a verification whose action does
29
+ * not match is rejected, preventing cross-context token reuse.
30
+ */
31
+ expectedAction?: string | undefined;
32
+ /**
33
+ * Allow-list of hostnames the token must have been issued for. When set, a
34
+ * verification reporting a different or missing hostname is rejected.
35
+ */
36
+ allowedHostnames?: string[] | undefined;
16
37
  }
17
38
  interface HCaptchaOptions extends BaseCaptchaOptions {
18
39
  provider: typeof Providers.HCAPTCHA;
@@ -1,4 +1,5 @@
1
1
  import { middlewareResponse } from "../../../utils/middleware-response.mjs";
2
+ import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants.mjs";
2
3
  import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes.mjs";
3
4
  import { encodeToURLParams } from "../utils.mjs";
4
5
  import { betterFetch } from "@better-fetch/fetch";
@@ -6,6 +7,7 @@ import { betterFetch } from "@better-fetch/fetch";
6
7
  const captchaFox = async ({ siteVerifyURL, captchaResponse, secretKey, siteKey, remoteIP }) => {
7
8
  const response = await betterFetch(siteVerifyURL, {
8
9
  method: "POST",
10
+ timeout: CAPTCHA_VERIFY_TIMEOUT_MS,
9
11
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
10
12
  body: encodeToURLParams({
11
13
  secret: secretKey,
@@ -1,10 +1,12 @@
1
1
  import { middlewareResponse } from "../../../utils/middleware-response.mjs";
2
+ import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants.mjs";
2
3
  import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes.mjs";
3
4
  import { betterFetch } from "@better-fetch/fetch";
4
5
  //#region src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts
5
- const cloudflareTurnstile = async ({ siteVerifyURL, captchaResponse, secretKey, remoteIP }) => {
6
+ const cloudflareTurnstile = async ({ siteVerifyURL, captchaResponse, secretKey, remoteIP, expectedAction, allowedHostnames }) => {
6
7
  const response = await betterFetch(siteVerifyURL, {
7
8
  method: "POST",
9
+ timeout: CAPTCHA_VERIFY_TIMEOUT_MS,
8
10
  headers: { "Content-Type": "application/json" },
9
11
  body: JSON.stringify({
10
12
  secret: secretKey,
@@ -13,11 +15,14 @@ const cloudflareTurnstile = async ({ siteVerifyURL, captchaResponse, secretKey,
13
15
  })
14
16
  });
15
17
  if (!response.data || response.error) throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message);
16
- if (!response.data.success) return middlewareResponse({
18
+ const verificationFailed = () => middlewareResponse({
17
19
  message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message,
18
20
  code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code,
19
21
  status: 403
20
22
  });
23
+ if (!response.data.success) return verificationFailed();
24
+ if (expectedAction && response.data.action !== expectedAction) return verificationFailed();
25
+ if (allowedHostnames && allowedHostnames.length > 0 && !(response.data.hostname && allowedHostnames.includes(response.data.hostname))) return verificationFailed();
21
26
  };
22
27
  //#endregion
23
28
  export { cloudflareTurnstile };
@@ -1,4 +1,5 @@
1
1
  import { middlewareResponse } from "../../../utils/middleware-response.mjs";
2
+ import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants.mjs";
2
3
  import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes.mjs";
3
4
  import { encodeToURLParams } from "../utils.mjs";
4
5
  import { betterFetch } from "@better-fetch/fetch";
@@ -6,9 +7,10 @@ import { betterFetch } from "@better-fetch/fetch";
6
7
  const isV3 = (response) => {
7
8
  return "score" in response && typeof response.score === "number";
8
9
  };
9
- const googleRecaptcha = async ({ siteVerifyURL, captchaResponse, secretKey, minScore = .5, remoteIP }) => {
10
+ const googleRecaptcha = async ({ siteVerifyURL, captchaResponse, secretKey, minScore = .5, remoteIP, expectedAction, allowedHostnames }) => {
10
11
  const response = await betterFetch(siteVerifyURL, {
11
12
  method: "POST",
13
+ timeout: CAPTCHA_VERIFY_TIMEOUT_MS,
12
14
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
13
15
  body: encodeToURLParams({
14
16
  secret: secretKey,
@@ -17,11 +19,14 @@ const googleRecaptcha = async ({ siteVerifyURL, captchaResponse, secretKey, minS
17
19
  })
18
20
  });
19
21
  if (!response.data || response.error) throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message);
20
- if (!response.data.success || isV3(response.data) && response.data.score < minScore) return middlewareResponse({
22
+ const verificationFailed = () => middlewareResponse({
21
23
  message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message,
22
24
  code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code,
23
25
  status: 403
24
26
  });
27
+ if (!response.data.success || isV3(response.data) && response.data.score < minScore) return verificationFailed();
28
+ if (expectedAction && response.data.action !== expectedAction) return verificationFailed();
29
+ if (allowedHostnames && allowedHostnames.length > 0 && !allowedHostnames.includes(response.data.hostname)) return verificationFailed();
25
30
  };
26
31
  //#endregion
27
32
  export { googleRecaptcha };
@@ -1,4 +1,5 @@
1
1
  import { middlewareResponse } from "../../../utils/middleware-response.mjs";
2
+ import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants.mjs";
2
3
  import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes.mjs";
3
4
  import { encodeToURLParams } from "../utils.mjs";
4
5
  import { betterFetch } from "@better-fetch/fetch";
@@ -6,6 +7,7 @@ import { betterFetch } from "@better-fetch/fetch";
6
7
  const hCaptcha = async ({ siteVerifyURL, captchaResponse, secretKey, siteKey, remoteIP }) => {
7
8
  const response = await betterFetch(siteVerifyURL, {
8
9
  method: "POST",
10
+ timeout: CAPTCHA_VERIFY_TIMEOUT_MS,
9
11
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
10
12
  body: encodeToURLParams({
11
13
  secret: secretKey,