@better-auth/core 1.7.0-beta.2 → 1.7.0-beta.4

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 (172) hide show
  1. package/dist/context/global.mjs +1 -1
  2. package/dist/db/adapter/factory.mjs +64 -3
  3. package/dist/db/adapter/index.d.mts +35 -1
  4. package/dist/db/adapter/types.d.mts +1 -1
  5. package/dist/db/type.d.mts +12 -0
  6. package/dist/error/codes.d.mts +1 -0
  7. package/dist/error/codes.mjs +1 -0
  8. package/dist/instrumentation/tracer.mjs +1 -1
  9. package/dist/oauth2/authorization-params.d.mts +12 -0
  10. package/dist/oauth2/authorization-params.mjs +12 -0
  11. package/dist/oauth2/basic-credentials.d.mts +30 -0
  12. package/dist/oauth2/basic-credentials.mjs +64 -0
  13. package/dist/oauth2/client-assertion.d.mts +38 -22
  14. package/dist/oauth2/client-assertion.mjs +63 -28
  15. package/dist/oauth2/client-credentials-token.d.mts +19 -40
  16. package/dist/oauth2/client-credentials-token.mjs +18 -29
  17. package/dist/oauth2/create-authorization-url.d.mts +9 -1
  18. package/dist/oauth2/create-authorization-url.mjs +23 -5
  19. package/dist/oauth2/index.d.mts +10 -7
  20. package/dist/oauth2/index.mjs +9 -7
  21. package/dist/oauth2/oauth-provider.d.mts +21 -2
  22. package/dist/oauth2/refresh-access-token.d.mts +20 -40
  23. package/dist/oauth2/refresh-access-token.mjs +19 -32
  24. package/dist/oauth2/token-endpoint-auth.d.mts +17 -0
  25. package/dist/oauth2/token-endpoint-auth.mjs +89 -0
  26. package/dist/oauth2/utils.d.mts +9 -1
  27. package/dist/oauth2/utils.mjs +12 -1
  28. package/dist/oauth2/validate-authorization-code.d.mts +17 -52
  29. package/dist/oauth2/validate-authorization-code.mjs +17 -30
  30. package/dist/oauth2/verify.mjs +15 -5
  31. package/dist/social-providers/apple.d.mts +5 -12
  32. package/dist/social-providers/apple.mjs +14 -3
  33. package/dist/social-providers/atlassian.d.mts +3 -1
  34. package/dist/social-providers/atlassian.mjs +5 -2
  35. package/dist/social-providers/cognito.d.mts +16 -1
  36. package/dist/social-providers/cognito.mjs +6 -2
  37. package/dist/social-providers/discord.d.mts +5 -3
  38. package/dist/social-providers/discord.mjs +16 -3
  39. package/dist/social-providers/dropbox.d.mts +3 -1
  40. package/dist/social-providers/dropbox.mjs +5 -4
  41. package/dist/social-providers/facebook.d.mts +5 -3
  42. package/dist/social-providers/facebook.mjs +6 -3
  43. package/dist/social-providers/figma.d.mts +3 -1
  44. package/dist/social-providers/figma.mjs +3 -2
  45. package/dist/social-providers/github.d.mts +4 -2
  46. package/dist/social-providers/github.mjs +5 -4
  47. package/dist/social-providers/gitlab.d.mts +3 -1
  48. package/dist/social-providers/gitlab.mjs +3 -2
  49. package/dist/social-providers/google.d.mts +3 -1
  50. package/dist/social-providers/google.mjs +5 -2
  51. package/dist/social-providers/huggingface.d.mts +3 -1
  52. package/dist/social-providers/huggingface.mjs +3 -2
  53. package/dist/social-providers/index.d.mts +104 -36
  54. package/dist/social-providers/kakao.d.mts +3 -1
  55. package/dist/social-providers/kakao.mjs +3 -2
  56. package/dist/social-providers/kick.d.mts +3 -1
  57. package/dist/social-providers/kick.mjs +3 -2
  58. package/dist/social-providers/line.d.mts +3 -1
  59. package/dist/social-providers/line.mjs +3 -2
  60. package/dist/social-providers/linear.d.mts +3 -1
  61. package/dist/social-providers/linear.mjs +3 -2
  62. package/dist/social-providers/linkedin.d.mts +5 -3
  63. package/dist/social-providers/linkedin.mjs +4 -3
  64. package/dist/social-providers/microsoft-entra-id.d.mts +3 -2
  65. package/dist/social-providers/microsoft-entra-id.mjs +3 -2
  66. package/dist/social-providers/naver.d.mts +3 -1
  67. package/dist/social-providers/naver.mjs +3 -2
  68. package/dist/social-providers/notion.d.mts +3 -1
  69. package/dist/social-providers/notion.mjs +5 -2
  70. package/dist/social-providers/paybin.d.mts +3 -1
  71. package/dist/social-providers/paybin.mjs +3 -2
  72. package/dist/social-providers/paypal.d.mts +3 -1
  73. package/dist/social-providers/paypal.mjs +4 -3
  74. package/dist/social-providers/polar.d.mts +3 -1
  75. package/dist/social-providers/polar.mjs +3 -2
  76. package/dist/social-providers/railway.d.mts +3 -1
  77. package/dist/social-providers/railway.mjs +3 -2
  78. package/dist/social-providers/reddit.d.mts +3 -1
  79. package/dist/social-providers/reddit.mjs +3 -2
  80. package/dist/social-providers/roblox.d.mts +4 -2
  81. package/dist/social-providers/roblox.mjs +12 -2
  82. package/dist/social-providers/salesforce.d.mts +3 -1
  83. package/dist/social-providers/salesforce.mjs +3 -2
  84. package/dist/social-providers/slack.d.mts +4 -2
  85. package/dist/social-providers/slack.mjs +11 -8
  86. package/dist/social-providers/spotify.d.mts +3 -1
  87. package/dist/social-providers/spotify.mjs +3 -2
  88. package/dist/social-providers/tiktok.d.mts +3 -1
  89. package/dist/social-providers/tiktok.mjs +14 -2
  90. package/dist/social-providers/twitch.d.mts +3 -1
  91. package/dist/social-providers/twitch.mjs +3 -2
  92. package/dist/social-providers/twitter.d.mts +5 -2
  93. package/dist/social-providers/twitter.mjs +2 -1
  94. package/dist/social-providers/vercel.d.mts +3 -1
  95. package/dist/social-providers/vercel.mjs +3 -2
  96. package/dist/social-providers/vk.d.mts +3 -1
  97. package/dist/social-providers/vk.mjs +3 -2
  98. package/dist/social-providers/wechat.d.mts +3 -1
  99. package/dist/social-providers/wechat.mjs +7 -1
  100. package/dist/social-providers/zoom.d.mts +4 -2
  101. package/dist/social-providers/zoom.mjs +10 -17
  102. package/dist/types/context.d.mts +30 -4
  103. package/dist/types/init-options.d.mts +29 -5
  104. package/dist/utils/ip.d.mts +5 -4
  105. package/dist/utils/ip.mjs +3 -3
  106. package/dist/utils/redirect-uri.d.mts +20 -0
  107. package/dist/utils/redirect-uri.mjs +48 -0
  108. package/dist/utils/string.d.mts +5 -1
  109. package/dist/utils/string.mjs +20 -1
  110. package/dist/utils/url.d.mts +18 -1
  111. package/dist/utils/url.mjs +30 -1
  112. package/package.json +9 -8
  113. package/src/db/adapter/factory.ts +121 -3
  114. package/src/db/adapter/index.ts +32 -0
  115. package/src/db/adapter/types.ts +1 -0
  116. package/src/db/get-tables.ts +2 -0
  117. package/src/db/schema/user.ts +3 -0
  118. package/src/db/type.ts +12 -0
  119. package/src/error/codes.ts +1 -0
  120. package/src/oauth2/authorization-params.ts +28 -0
  121. package/src/oauth2/basic-credentials.ts +87 -0
  122. package/src/oauth2/client-assertion.ts +131 -58
  123. package/src/oauth2/client-credentials-token.ts +48 -72
  124. package/src/oauth2/create-authorization-url.ts +28 -6
  125. package/src/oauth2/index.ts +25 -9
  126. package/src/oauth2/oauth-provider.ts +21 -2
  127. package/src/oauth2/refresh-access-token.ts +50 -76
  128. package/src/oauth2/token-endpoint-auth.ts +221 -0
  129. package/src/oauth2/utils.ts +19 -0
  130. package/src/oauth2/validate-authorization-code.ts +55 -85
  131. package/src/oauth2/verify.ts +20 -4
  132. package/src/social-providers/apple.ts +27 -3
  133. package/src/social-providers/atlassian.ts +8 -1
  134. package/src/social-providers/cognito.ts +26 -1
  135. package/src/social-providers/discord.ts +22 -18
  136. package/src/social-providers/dropbox.ts +7 -5
  137. package/src/social-providers/facebook.ts +14 -9
  138. package/src/social-providers/figma.ts +8 -1
  139. package/src/social-providers/github.ts +5 -3
  140. package/src/social-providers/gitlab.ts +2 -0
  141. package/src/social-providers/google.ts +2 -0
  142. package/src/social-providers/huggingface.ts +8 -1
  143. package/src/social-providers/kakao.ts +2 -1
  144. package/src/social-providers/kick.ts +8 -1
  145. package/src/social-providers/line.ts +2 -0
  146. package/src/social-providers/linear.ts +8 -1
  147. package/src/social-providers/linkedin.ts +5 -3
  148. package/src/social-providers/microsoft-entra-id.ts +2 -1
  149. package/src/social-providers/naver.ts +2 -1
  150. package/src/social-providers/notion.ts +8 -1
  151. package/src/social-providers/paybin.ts +2 -0
  152. package/src/social-providers/paypal.ts +7 -1
  153. package/src/social-providers/polar.ts +8 -1
  154. package/src/social-providers/railway.ts +8 -1
  155. package/src/social-providers/reddit.ts +2 -1
  156. package/src/social-providers/roblox.ts +16 -11
  157. package/src/social-providers/salesforce.ts +8 -1
  158. package/src/social-providers/slack.ts +15 -9
  159. package/src/social-providers/spotify.ts +8 -1
  160. package/src/social-providers/tiktok.ts +22 -9
  161. package/src/social-providers/twitch.ts +2 -1
  162. package/src/social-providers/twitter.ts +1 -0
  163. package/src/social-providers/vercel.ts +8 -1
  164. package/src/social-providers/vk.ts +8 -1
  165. package/src/social-providers/wechat.ts +9 -1
  166. package/src/social-providers/zoom.ts +15 -19
  167. package/src/types/context.ts +33 -5
  168. package/src/types/init-options.ts +29 -5
  169. package/src/utils/ip.ts +12 -13
  170. package/src/utils/redirect-uri.ts +54 -0
  171. package/src/utils/string.ts +37 -0
  172. package/src/utils/url.ts +28 -0
@@ -42,7 +42,7 @@ export const twitch = (options: TwitchOptions) => {
42
42
  return {
43
43
  id: "twitch",
44
44
  name: "Twitch",
45
- createAuthorizationURL({ state, scopes, redirectURI }) {
45
+ createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
46
46
  const _scopes = options.disableDefaultScope
47
47
  ? []
48
48
  : ["user:read:email", "openid"];
@@ -61,6 +61,7 @@ export const twitch = (options: TwitchOptions) => {
61
61
  "preferred_username",
62
62
  "picture",
63
63
  ],
64
+ additionalParams,
64
65
  });
65
66
  },
66
67
  validateAuthorizationCode: async ({ code, redirectURI }) => {
@@ -122,6 +122,7 @@ export const twitter = (options: TwitterOption) => {
122
122
  state: data.state,
123
123
  codeVerifier: data.codeVerifier,
124
124
  redirectURI: data.redirectURI,
125
+ additionalParams: data.additionalParams,
125
126
  });
126
127
  },
127
128
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
@@ -20,7 +20,13 @@ export const vercel = (options: VercelOptions) => {
20
20
  return {
21
21
  id: "vercel",
22
22
  name: "Vercel",
23
- createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
23
+ createAuthorizationURL({
24
+ state,
25
+ scopes,
26
+ codeVerifier,
27
+ redirectURI,
28
+ additionalParams,
29
+ }) {
24
30
  if (!codeVerifier) {
25
31
  throw new BetterAuthError("codeVerifier is required for Vercel");
26
32
  }
@@ -40,6 +46,7 @@ export const vercel = (options: VercelOptions) => {
40
46
  state,
41
47
  codeVerifier,
42
48
  redirectURI,
49
+ additionalParams,
43
50
  });
44
51
  },
45
52
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
@@ -30,7 +30,13 @@ export const vk = (options: VkOption) => {
30
30
  return {
31
31
  id: "vk",
32
32
  name: "VK",
33
- async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
33
+ async createAuthorizationURL({
34
+ state,
35
+ scopes,
36
+ codeVerifier,
37
+ redirectURI,
38
+ additionalParams,
39
+ }) {
34
40
  const _scopes = options.disableDefaultScope ? [] : ["email", "phone"];
35
41
  if (options.scope) _scopes.push(...options.scope);
36
42
  if (scopes) _scopes.push(...scopes);
@@ -44,6 +50,7 @@ export const vk = (options: VkOption) => {
44
50
  state,
45
51
  redirectURI,
46
52
  codeVerifier,
53
+ additionalParams,
47
54
  });
48
55
  },
49
56
  validateAuthorizationCode: async ({
@@ -1,5 +1,6 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
2
  import type { OAuth2Tokens, OAuthProvider, ProviderOptions } from "../oauth2";
3
+ import { RESERVED_AUTHORIZATION_PARAMS_SET } from "../oauth2";
3
4
 
4
5
  /**
5
6
  * WeChat user profile information
@@ -58,7 +59,7 @@ export const wechat = (options: WeChatOptions) => {
58
59
  return {
59
60
  id: "wechat",
60
61
  name: "WeChat",
61
- createAuthorizationURL({ state, scopes, redirectURI }) {
62
+ createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
62
63
  const _scopes = options.disableDefaultScope ? [] : ["snsapi_login"];
63
64
  options.scope && _scopes.push(...options.scope);
64
65
  scopes && _scopes.push(...scopes);
@@ -72,6 +73,13 @@ export const wechat = (options: WeChatOptions) => {
72
73
  url.searchParams.set("redirect_uri", options.redirectURI || redirectURI);
73
74
  url.searchParams.set("state", state);
74
75
  url.searchParams.set("lang", options.lang || "cn");
76
+ if (additionalParams) {
77
+ for (const [key, value] of Object.entries(additionalParams)) {
78
+ if (RESERVED_AUTHORIZATION_PARAMS_SET.has(key)) continue;
79
+ if (key === "appid") continue;
80
+ url.searchParams.set(key, value);
81
+ }
82
+ }
75
83
  url.hash = "wechat_redirect";
76
84
 
77
85
  return url;
@@ -1,7 +1,7 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
2
  import type { OAuthProvider, ProviderOptions } from "../oauth2";
3
3
  import {
4
- generateCodeChallenge,
4
+ createAuthorizationURL,
5
5
  refreshAccessToken,
6
6
  validateAuthorizationCode,
7
7
  } from "../oauth2";
@@ -152,25 +152,21 @@ export const zoom = (userOptions: ZoomOptions) => {
152
152
  return {
153
153
  id: "zoom",
154
154
  name: "Zoom",
155
- createAuthorizationURL: async ({ state, redirectURI, codeVerifier }) => {
156
- const params = new URLSearchParams({
157
- response_type: "code",
158
- redirect_uri: options.redirectURI ? options.redirectURI : redirectURI,
159
- client_id: options.clientId,
155
+ createAuthorizationURL: async ({
156
+ state,
157
+ redirectURI,
158
+ codeVerifier,
159
+ additionalParams,
160
+ }) =>
161
+ createAuthorizationURL({
162
+ id: "zoom",
163
+ options,
164
+ authorizationEndpoint: "https://zoom.us/oauth/authorize",
160
165
  state,
161
- });
162
-
163
- if (options.pkce) {
164
- const codeChallenge = await generateCodeChallenge(codeVerifier);
165
- params.set("code_challenge_method", "S256");
166
- params.set("code_challenge", codeChallenge);
167
- }
168
-
169
- const url = new URL("https://zoom.us/oauth/authorize");
170
- url.search = params.toString();
171
-
172
- return url;
173
- },
166
+ redirectURI,
167
+ codeVerifier: options.pkce ? codeVerifier : undefined,
168
+ additionalParams,
169
+ }),
174
170
  validateAuthorizationCode: async ({ code, redirectURI, codeVerifier }) => {
175
171
  return validateAuthorizationCode({
176
172
  code,
@@ -151,9 +151,22 @@ export interface InternalAdapter<
151
151
 
152
152
  deleteAccounts(userId: string): Promise<void>;
153
153
 
154
- deleteAccount(accountId: string): Promise<void>;
154
+ /**
155
+ * Delete an account by its primary key.
156
+ *
157
+ * @param id - The account row's primary key (the `id` column, not the `accountId` column).
158
+ */
159
+ deleteAccount(id: string): Promise<void>;
155
160
 
156
- deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>;
161
+ /**
162
+ * Delete every session belonging to a user.
163
+ */
164
+ deleteUserSessions(userId: string): Promise<void>;
165
+
166
+ /**
167
+ * Delete sessions by their session tokens.
168
+ */
169
+ deleteSessions(sessionTokens: string[]): Promise<void>;
157
170
 
158
171
  findOAuthUser(
159
172
  email: string,
@@ -191,8 +204,6 @@ export interface InternalAdapter<
191
204
 
192
205
  findAccounts(userId: string): Promise<Account[]>;
193
206
 
194
- findAccount(accountId: string): Promise<Account | null>;
195
-
196
207
  findAccountByProviderId(
197
208
  accountId: string,
198
209
  providerId: string,
@@ -211,10 +222,27 @@ export interface InternalAdapter<
211
222
 
212
223
  deleteVerificationByIdentifier(identifier: string): Promise<void>;
213
224
 
225
+ /**
226
+ * Atomically consume a single-use verification row by `identifier` and
227
+ * return it. Only the first concurrent caller receives the latest row;
228
+ * subsequent callers receive `null`. Consuming one row invalidates the
229
+ * whole identifier so stale rows cannot be replayed. Rows past their
230
+ * `expiresAt` are treated as already invalid: the row is deleted but
231
+ * `null` is returned, so callers do not need to gate on `expiresAt`
232
+ * themselves. Callers MUST gate any state change (issue session, mint
233
+ * token, change password) on a non-null result.
234
+ *
235
+ * Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
236
+ * pair at single-use credential consumption sites.
237
+ */
238
+ consumeVerificationValue(identifier: string): Promise<Verification | null>;
239
+
214
240
  updateVerificationByIdentifier(
215
241
  identifier: string,
216
242
  data: Partial<Verification>,
217
243
  ): Promise<Verification>;
244
+
245
+ refreshUserSessions(user: User): Promise<void>;
218
246
  }
219
247
 
220
248
  type CreateCookieGetterFn = (
@@ -299,7 +327,7 @@ export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> =
299
327
  * - "cookie": Store state in an encrypted cookie (stateless)
300
328
  * - "database": Store state in the database
301
329
  *
302
- * @default "cookie"
330
+ * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
303
331
  */
304
332
  storeStateStrategy: "database" | "cookie";
305
333
  };
@@ -207,12 +207,13 @@ export type BetterAuthAdvancedOptions = {
207
207
  */
208
208
  disableIpTracking?: boolean;
209
209
  /**
210
- * IPv6 subnet prefix length for rate limiting.
211
- * IPv6 addresses will be normalized to this subnet.
210
+ * IPv6 prefix length used to collapse addresses before rate-limit keying.
211
+ * Any integer from 0 to 128 is accepted; common values are 32, 48, 56, 64, 128.
212
+ * Out-of-range values fall back to safe behavior (negative -> mask all, > 128 -> no mask).
212
213
  *
213
214
  * @default 64
214
215
  */
215
- ipv6Subnet?: 128 | 64 | 48 | 32;
216
+ ipv6Subnet?: number;
216
217
  }
217
218
  | undefined;
218
219
  /**
@@ -1020,6 +1021,25 @@ export type BetterAuthOptions = {
1020
1021
  * @default false
1021
1022
  */
1022
1023
  disableImplicitLinking?: boolean;
1024
+ /**
1025
+ * Require the existing local user row to have
1026
+ * `emailVerified: true` before implicit account linking
1027
+ * uses the IdP's `email_verified` claim as ownership
1028
+ * proof. Defaults to `true` so an attacker who
1029
+ * pre-registers an unverified account at a victim's
1030
+ * email cannot have the victim's OAuth identity linked
1031
+ * into the attacker-owned row on first sign-in. Set to
1032
+ * `false` for backward compatibility on apps whose
1033
+ * users sign up via OAuth without verifying their email
1034
+ * locally; understand the takeover risk before doing
1035
+ * so.
1036
+ *
1037
+ * @default true
1038
+ *
1039
+ * @deprecated The option will be removed on the next
1040
+ * minor; the gate will become unconditional.
1041
+ */
1042
+ requireLocalEmailVerified?: boolean;
1023
1043
  /**
1024
1044
  * List of trusted providers. Can be a static array or a function
1025
1045
  * that returns providers dynamically. The function is called
@@ -1073,7 +1093,11 @@ export type BetterAuthOptions = {
1073
1093
  */
1074
1094
  allowUnlinkingAll?: boolean;
1075
1095
  /**
1076
- * If enabled (true), this will update the user information based on the newly linked account
1096
+ * When enabled, linking an account copies the provider's profile onto
1097
+ * the local user, matching the fields persisted on sign-up (`name`,
1098
+ * `image`, and any `mapProfileToUser` fields). The local `email` and
1099
+ * `emailVerified` are never changed, so a link cannot rebind the
1100
+ * account's identity.
1077
1101
  *
1078
1102
  * @default false
1079
1103
  */
@@ -1106,7 +1130,7 @@ export type BetterAuthOptions = {
1106
1130
  * - "cookie": Store state in an encrypted cookie (stateless)
1107
1131
  * - "database": Store state in the database
1108
1132
  *
1109
- * @default "cookie"
1133
+ * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
1110
1134
  */
1111
1135
  storeStateStrategy?: "database" | "cookie";
1112
1136
  /**
package/src/utils/ip.ts CHANGED
@@ -12,12 +12,13 @@ import * as z from "zod";
12
12
 
13
13
  interface NormalizeIPOptions {
14
14
  /**
15
- * For IPv6 addresses, extract the subnet prefix instead of full address.
16
- * Common values: 32, 48, 64, 128 (default: 128 = full address)
15
+ * Prefix length used to collapse IPv6 addresses before keying.
16
+ * Any integer from 0 to 128 is accepted. Common values: 32, 48, 56, 64, 128.
17
+ * Values outside 0-128 are clamped.
17
18
  *
18
- * @default 128
19
+ * @default 64
19
20
  */
20
- ipv6Subnet?: 128 | 64 | 48 | 32;
21
+ ipv6Subnet?: number;
21
22
  }
22
23
 
23
24
  /**
@@ -117,15 +118,13 @@ function expandIPv6(ipv6: string): string[] {
117
118
  * Normalizes an IPv6 address to canonical form
118
119
  * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
119
120
  */
120
- function normalizeIPv6(
121
- ipv6: string,
122
- subnetPrefix?: 128 | 32 | 48 | 64,
123
- ): string {
121
+ function normalizeIPv6(ipv6: string, subnetPrefix?: number): string {
124
122
  const groups = expandIPv6(ipv6);
125
123
 
126
- if (subnetPrefix && subnetPrefix < 128) {
127
- // Apply subnet mask
128
- const prefix = subnetPrefix;
124
+ if (subnetPrefix !== undefined && subnetPrefix < 128) {
125
+ // Clamp to a valid bit range so out-of-spec inputs degrade safely:
126
+ // negative or fractional values would otherwise produce malformed masks.
127
+ const prefix = Math.max(0, Math.floor(subnetPrefix));
129
128
  let bitsRemaining: number = prefix;
130
129
 
131
130
  const maskedGroups = groups.map((group) => {
@@ -191,8 +190,8 @@ export function normalizeIP(
191
190
  return ipv4.toLowerCase();
192
191
  }
193
192
 
194
- // Normalize IPv6
195
- const subnetPrefix = options.ipv6Subnet || 64;
193
+ // Normalize IPv6. Use ?? so an explicit 0 (mask-all) is honoured.
194
+ const subnetPrefix = options.ipv6Subnet ?? 64;
196
195
  return normalizeIPv6(ip, subnetPrefix);
197
196
  }
198
197
 
@@ -0,0 +1,54 @@
1
+ import * as z from "zod";
2
+ import { isLoopbackHost } from "./host";
3
+ import { DANGEROUS_URL_SCHEMES } from "./url";
4
+
5
+ /**
6
+ * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
7
+ * server stores and later hands back to a browser.
8
+ *
9
+ * - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
10
+ * - Rejects URIs with a fragment component (`#...`) per RFC 6749 §3.1.2.
11
+ * - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
12
+ * `*.localhost` per RFC 6761), where HTTP is allowed for local development.
13
+ * - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
14
+ *
15
+ * This is the single source of truth for redirect-URI validation across the
16
+ * OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
17
+ * rather than re-implementing the scheme policy per plugin.
18
+ */
19
+ export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
20
+ let u: URL;
21
+ try {
22
+ u = new URL(val);
23
+ } catch {
24
+ ctx.addIssue({
25
+ code: "custom",
26
+ message: "URL must be parseable",
27
+ fatal: true,
28
+ });
29
+ return z.NEVER;
30
+ }
31
+
32
+ if (DANGEROUS_URL_SCHEMES.includes(u.protocol)) {
33
+ ctx.addIssue({
34
+ code: "custom",
35
+ message: "URL cannot use javascript:, data:, or vbscript: scheme",
36
+ });
37
+ return;
38
+ }
39
+
40
+ if (val.includes("#")) {
41
+ ctx.addIssue({
42
+ code: "custom",
43
+ message: "Redirect URI must not contain a fragment component",
44
+ });
45
+ }
46
+
47
+ if (u.protocol === "http:" && !isLoopbackHost(u.host)) {
48
+ ctx.addIssue({
49
+ code: "custom",
50
+ message:
51
+ "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)",
52
+ });
53
+ }
54
+ });
@@ -1,3 +1,40 @@
1
1
  export function capitalizeFirstLetter(str: string) {
2
2
  return str.charAt(0).toUpperCase() + str.slice(1);
3
3
  }
4
+
5
+ const WORD_PATTERN =
6
+ /[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu;
7
+ const APOSTROPHE_PATTERN = /['\u2019]/g;
8
+
9
+ function splitWords(input: string): string[] {
10
+ return input.replace(APOSTROPHE_PATTERN, "").match(WORD_PATTERN) ?? [];
11
+ }
12
+
13
+ export function toSnakeCase(input: string): string {
14
+ return splitWords(input)
15
+ .map((word) => word.toLowerCase())
16
+ .join("_");
17
+ }
18
+
19
+ export function toKebabCase(input: string): string {
20
+ return splitWords(input)
21
+ .map((word) => word.toLowerCase())
22
+ .join("-");
23
+ }
24
+
25
+ export function toCamelCase(input: string): string {
26
+ return splitWords(input).reduce((acc, word, i) => {
27
+ return (
28
+ acc +
29
+ (i === 0
30
+ ? word.toLowerCase()
31
+ : `${word[0]!.toUpperCase()}${word.slice(1)}`)
32
+ );
33
+ }, "");
34
+ }
35
+
36
+ export function toPascalCase(input: string): string {
37
+ return splitWords(input)
38
+ .map((word) => `${word[0]!.toUpperCase()}${word.slice(1).toLowerCase()}`)
39
+ .join("");
40
+ }
package/src/utils/url.ts CHANGED
@@ -41,3 +41,31 @@ export function normalizePathname(
41
41
 
42
42
  return pathname;
43
43
  }
44
+
45
+ /**
46
+ * Schemes that execute or embed code when navigated to or accepted as a
47
+ * redirect target. These are never safe as an OAuth `redirect_uri` or as a
48
+ * client-side navigation target (`window.location.href`, `location.assign`, ...).
49
+ */
50
+ export const DANGEROUS_URL_SCHEMES = ["javascript:", "data:", "vbscript:"];
51
+
52
+ /**
53
+ * Returns `false` only when `value` is an absolute URL using a dangerous scheme
54
+ * (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
55
+ * safe absolute schemes (`http`, `https`, custom app schemes such as
56
+ * `myapp://`) return `true`.
57
+ *
58
+ * Use this to guard browser navigation sinks and any redirect target that may
59
+ * originate from untrusted input. It is intentionally narrow: it blocks code
60
+ * execution schemes without rejecting relative paths or mobile deep links.
61
+ */
62
+ export function isSafeUrlScheme(value: string): boolean {
63
+ let parsed: URL;
64
+ try {
65
+ parsed = new URL(value);
66
+ } catch {
67
+ // Relative URLs carry no scheme to abuse.
68
+ return true;
69
+ }
70
+ return !DANGEROUS_URL_SCHEMES.includes(parsed.protocol);
71
+ }