@better-auth/core 1.7.0-beta.0 → 1.7.0-beta.10

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 (218) hide show
  1. package/dist/api/index.d.mts +44 -1
  2. package/dist/api/index.mjs +69 -4
  3. package/dist/context/global.mjs +1 -1
  4. package/dist/context/transaction.d.mts +7 -4
  5. package/dist/context/transaction.mjs +6 -3
  6. package/dist/db/adapter/factory.mjs +91 -3
  7. package/dist/db/adapter/get-id-field.mjs +1 -1
  8. package/dist/db/adapter/index.d.mts +87 -3
  9. package/dist/db/adapter/types.d.mts +1 -1
  10. package/dist/db/get-tables.mjs +2 -1
  11. package/dist/db/type.d.mts +17 -0
  12. package/dist/env/env-impl.mjs +1 -1
  13. package/dist/error/codes.d.mts +1 -0
  14. package/dist/error/codes.mjs +1 -0
  15. package/dist/error/index.d.mts +7 -0
  16. package/dist/index.d.mts +2 -2
  17. package/dist/instrumentation/api.mjs +12 -0
  18. package/dist/instrumentation/noop.mjs +42 -0
  19. package/dist/instrumentation/pure.index.d.mts +7 -0
  20. package/dist/instrumentation/pure.index.mjs +7 -0
  21. package/dist/instrumentation/tracer.mjs +6 -3
  22. package/dist/oauth2/authorization-params.d.mts +12 -0
  23. package/dist/oauth2/authorization-params.mjs +12 -0
  24. package/dist/oauth2/basic-credentials.d.mts +30 -0
  25. package/dist/oauth2/basic-credentials.mjs +64 -0
  26. package/dist/oauth2/client-assertion.d.mts +38 -22
  27. package/dist/oauth2/client-assertion.mjs +63 -28
  28. package/dist/oauth2/client-credentials-token.d.mts +19 -40
  29. package/dist/oauth2/client-credentials-token.mjs +20 -31
  30. package/dist/oauth2/create-authorization-url.d.mts +11 -1
  31. package/dist/oauth2/create-authorization-url.mjs +27 -7
  32. package/dist/oauth2/dpop.d.mts +142 -0
  33. package/dist/oauth2/dpop.mjs +246 -0
  34. package/dist/oauth2/index.d.mts +14 -9
  35. package/dist/oauth2/index.mjs +12 -8
  36. package/dist/oauth2/oauth-provider.d.mts +150 -10
  37. package/dist/oauth2/refresh-access-token.d.mts +20 -40
  38. package/dist/oauth2/refresh-access-token.mjs +38 -36
  39. package/dist/oauth2/reject-redirects.mjs +65 -0
  40. package/dist/oauth2/token-endpoint-auth.d.mts +17 -0
  41. package/dist/oauth2/token-endpoint-auth.mjs +89 -0
  42. package/dist/oauth2/utils.d.mts +23 -1
  43. package/dist/oauth2/utils.mjs +48 -2
  44. package/dist/oauth2/validate-authorization-code.d.mts +17 -52
  45. package/dist/oauth2/validate-authorization-code.mjs +28 -34
  46. package/dist/oauth2/verify-id-token.d.mts +27 -0
  47. package/dist/oauth2/verify-id-token.mjs +62 -0
  48. package/dist/oauth2/verify.d.mts +88 -15
  49. package/dist/oauth2/verify.mjs +203 -25
  50. package/dist/social-providers/apple.d.mts +16 -4
  51. package/dist/social-providers/apple.mjs +17 -24
  52. package/dist/social-providers/atlassian.d.mts +5 -1
  53. package/dist/social-providers/atlassian.mjs +6 -3
  54. package/dist/social-providers/cognito.d.mts +27 -3
  55. package/dist/social-providers/cognito.mjs +15 -25
  56. package/dist/social-providers/discord.d.mts +7 -3
  57. package/dist/social-providers/discord.mjs +16 -3
  58. package/dist/social-providers/dropbox.d.mts +5 -1
  59. package/dist/social-providers/dropbox.mjs +5 -4
  60. package/dist/social-providers/facebook.d.mts +24 -5
  61. package/dist/social-providers/facebook.mjs +54 -20
  62. package/dist/social-providers/figma.d.mts +5 -1
  63. package/dist/social-providers/figma.mjs +4 -3
  64. package/dist/social-providers/github.d.mts +6 -2
  65. package/dist/social-providers/github.mjs +5 -4
  66. package/dist/social-providers/gitlab.d.mts +5 -1
  67. package/dist/social-providers/gitlab.mjs +3 -2
  68. package/dist/social-providers/google.d.mts +53 -5
  69. package/dist/social-providers/google.mjs +69 -25
  70. package/dist/social-providers/huggingface.d.mts +5 -1
  71. package/dist/social-providers/huggingface.mjs +3 -2
  72. package/dist/social-providers/index.d.mts +221 -45
  73. package/dist/social-providers/index.mjs +2 -2
  74. package/dist/social-providers/kakao.d.mts +5 -1
  75. package/dist/social-providers/kakao.mjs +3 -2
  76. package/dist/social-providers/kick.d.mts +5 -1
  77. package/dist/social-providers/kick.mjs +3 -2
  78. package/dist/social-providers/line.d.mts +8 -2
  79. package/dist/social-providers/line.mjs +5 -6
  80. package/dist/social-providers/linear.d.mts +5 -1
  81. package/dist/social-providers/linear.mjs +3 -2
  82. package/dist/social-providers/linkedin.d.mts +7 -3
  83. package/dist/social-providers/linkedin.mjs +4 -3
  84. package/dist/social-providers/microsoft-entra-id.d.mts +41 -7
  85. package/dist/social-providers/microsoft-entra-id.mjs +37 -28
  86. package/dist/social-providers/naver.d.mts +5 -1
  87. package/dist/social-providers/naver.mjs +3 -2
  88. package/dist/social-providers/notion.d.mts +5 -1
  89. package/dist/social-providers/notion.mjs +5 -2
  90. package/dist/social-providers/paybin.d.mts +5 -1
  91. package/dist/social-providers/paybin.mjs +4 -3
  92. package/dist/social-providers/paypal.d.mts +6 -2
  93. package/dist/social-providers/paypal.mjs +19 -14
  94. package/dist/social-providers/polar.d.mts +5 -1
  95. package/dist/social-providers/polar.mjs +3 -2
  96. package/dist/social-providers/railway.d.mts +5 -1
  97. package/dist/social-providers/railway.mjs +3 -2
  98. package/dist/social-providers/reddit.d.mts +5 -1
  99. package/dist/social-providers/reddit.mjs +7 -5
  100. package/dist/social-providers/roblox.d.mts +6 -2
  101. package/dist/social-providers/roblox.mjs +12 -2
  102. package/dist/social-providers/salesforce.d.mts +5 -1
  103. package/dist/social-providers/salesforce.mjs +4 -3
  104. package/dist/social-providers/slack.d.mts +6 -2
  105. package/dist/social-providers/slack.mjs +11 -8
  106. package/dist/social-providers/spotify.d.mts +5 -1
  107. package/dist/social-providers/spotify.mjs +3 -2
  108. package/dist/social-providers/tiktok.d.mts +5 -1
  109. package/dist/social-providers/tiktok.mjs +14 -2
  110. package/dist/social-providers/twitch.d.mts +5 -1
  111. package/dist/social-providers/twitch.mjs +3 -2
  112. package/dist/social-providers/twitter.d.mts +4 -1
  113. package/dist/social-providers/twitter.mjs +2 -1
  114. package/dist/social-providers/vercel.d.mts +5 -1
  115. package/dist/social-providers/vercel.mjs +3 -2
  116. package/dist/social-providers/vk.d.mts +5 -1
  117. package/dist/social-providers/vk.mjs +3 -2
  118. package/dist/social-providers/wechat.d.mts +5 -1
  119. package/dist/social-providers/wechat.mjs +8 -2
  120. package/dist/social-providers/zoom.d.mts +5 -1
  121. package/dist/social-providers/zoom.mjs +10 -17
  122. package/dist/types/context.d.mts +55 -6
  123. package/dist/types/index.d.mts +1 -1
  124. package/dist/types/init-options.d.mts +182 -12
  125. package/dist/types/plugin-client.d.mts +12 -2
  126. package/dist/utils/async.d.mts +22 -0
  127. package/dist/utils/async.mjs +32 -0
  128. package/dist/utils/host.d.mts +147 -0
  129. package/dist/utils/host.mjs +298 -0
  130. package/dist/utils/ip.d.mts +28 -5
  131. package/dist/utils/ip.mjs +118 -4
  132. package/dist/utils/is-api-error.d.mts +6 -0
  133. package/dist/utils/is-api-error.mjs +8 -0
  134. package/dist/utils/redirect-uri.d.mts +20 -0
  135. package/dist/utils/redirect-uri.mjs +48 -0
  136. package/dist/utils/string.d.mts +5 -1
  137. package/dist/utils/string.mjs +20 -1
  138. package/dist/utils/url.d.mts +18 -1
  139. package/dist/utils/url.mjs +34 -4
  140. package/package.json +26 -16
  141. package/src/api/index.ts +121 -5
  142. package/src/context/transaction.ts +45 -12
  143. package/src/db/adapter/factory.ts +193 -7
  144. package/src/db/adapter/get-id-field.ts +2 -2
  145. package/src/db/adapter/index.ts +85 -2
  146. package/src/db/adapter/types.ts +2 -0
  147. package/src/db/get-tables.ts +9 -1
  148. package/src/db/schema/account.ts +4 -1
  149. package/src/db/schema/user.ts +3 -0
  150. package/src/db/type.ts +17 -0
  151. package/src/env/env-impl.ts +1 -2
  152. package/src/error/codes.ts +1 -0
  153. package/src/error/index.ts +9 -0
  154. package/src/instrumentation/api.ts +17 -0
  155. package/src/instrumentation/noop.ts +74 -0
  156. package/src/instrumentation/pure.index.ts +31 -0
  157. package/src/instrumentation/tracer.ts +8 -3
  158. package/src/oauth2/authorization-params.ts +28 -0
  159. package/src/oauth2/basic-credentials.ts +87 -0
  160. package/src/oauth2/client-assertion.ts +131 -58
  161. package/src/oauth2/client-credentials-token.ts +50 -74
  162. package/src/oauth2/create-authorization-url.ts +33 -7
  163. package/src/oauth2/dpop.ts +568 -0
  164. package/src/oauth2/index.ts +81 -11
  165. package/src/oauth2/oauth-provider.ts +159 -11
  166. package/src/oauth2/refresh-access-token.ts +82 -83
  167. package/src/oauth2/reject-redirects.ts +75 -0
  168. package/src/oauth2/token-endpoint-auth.ts +221 -0
  169. package/src/oauth2/utils.ts +72 -5
  170. package/src/oauth2/validate-authorization-code.ts +69 -89
  171. package/src/oauth2/verify-id-token.ts +115 -0
  172. package/src/oauth2/verify.ts +409 -78
  173. package/src/social-providers/apple.ts +30 -40
  174. package/src/social-providers/atlassian.ts +8 -1
  175. package/src/social-providers/cognito.ts +36 -39
  176. package/src/social-providers/discord.ts +22 -18
  177. package/src/social-providers/dropbox.ts +7 -5
  178. package/src/social-providers/facebook.ts +108 -52
  179. package/src/social-providers/figma.ts +8 -1
  180. package/src/social-providers/github.ts +5 -3
  181. package/src/social-providers/gitlab.ts +2 -0
  182. package/src/social-providers/google.ts +141 -39
  183. package/src/social-providers/huggingface.ts +8 -1
  184. package/src/social-providers/kakao.ts +2 -1
  185. package/src/social-providers/kick.ts +8 -1
  186. package/src/social-providers/line.ts +25 -27
  187. package/src/social-providers/linear.ts +8 -1
  188. package/src/social-providers/linkedin.ts +5 -3
  189. package/src/social-providers/microsoft-entra-id.ts +100 -55
  190. package/src/social-providers/naver.ts +2 -1
  191. package/src/social-providers/notion.ts +8 -1
  192. package/src/social-providers/paybin.ts +2 -0
  193. package/src/social-providers/paypal.ts +28 -17
  194. package/src/social-providers/polar.ts +8 -1
  195. package/src/social-providers/railway.ts +8 -1
  196. package/src/social-providers/reddit.ts +9 -4
  197. package/src/social-providers/roblox.ts +16 -11
  198. package/src/social-providers/salesforce.ts +8 -1
  199. package/src/social-providers/slack.ts +15 -9
  200. package/src/social-providers/spotify.ts +8 -1
  201. package/src/social-providers/tiktok.ts +22 -9
  202. package/src/social-providers/twitch.ts +2 -1
  203. package/src/social-providers/twitter.ts +1 -0
  204. package/src/social-providers/vercel.ts +8 -1
  205. package/src/social-providers/vk.ts +8 -1
  206. package/src/social-providers/wechat.ts +17 -2
  207. package/src/social-providers/zoom.ts +15 -19
  208. package/src/types/context.ts +57 -5
  209. package/src/types/index.ts +7 -0
  210. package/src/types/init-options.ts +204 -14
  211. package/src/types/plugin-client.ts +16 -2
  212. package/src/utils/async.ts +53 -0
  213. package/src/utils/host.ts +425 -0
  214. package/src/utils/ip.ts +197 -13
  215. package/src/utils/is-api-error.ts +10 -0
  216. package/src/utils/redirect-uri.ts +54 -0
  217. package/src/utils/string.ts +37 -0
  218. package/src/utils/url.ts +38 -4
@@ -0,0 +1,425 @@
1
+ import { isValidIP, normalizeIP } from "./ip";
2
+
3
+ /**
4
+ * Host classification per RFC 6890 (Special-Purpose IP Address Registries),
5
+ * RFC 6761 (Special-Use Domain Names), and RFC 8252 §7.3 (loopback redirect URIs).
6
+ *
7
+ * This module is the single source of truth for "is this host public? private?
8
+ * loopback? link-local?" in the codebase. Consumers MUST prefer these predicates
9
+ * over bespoke regexes or substring matches; divergent checks are how bypass
10
+ * vulnerabilities get introduced (e.g. Oligo's "0.0.0.0 Day" 2024).
11
+ *
12
+ * Four user-facing primitives:
13
+ *
14
+ * - `classifyHost(host)` — the workhorse. Returns a {@link HostClassification}
15
+ * with `kind`, `literal`, and `canonical` fields.
16
+ * - `isLoopbackIP(host)` — strict: IPv4 `127.0.0.0/8` or IPv6 `::1` only.
17
+ * Use this for RFC 8252 §7.3 loopback redirect URI matching where IP
18
+ * literals are REQUIRED.
19
+ * - `isLoopbackHost(host)` — permissive: also accepts `localhost` and RFC 6761
20
+ * `.localhost` subdomains. Use this for developer ergonomics (CORS, cookie
21
+ * secure bypass, dev-mode HTTP allow-list).
22
+ * - `isPublicRoutableHost(host)` — SSRF gate. Returns false for every
23
+ * non-`public` kind. Use this before server-side fetches to user-controlled
24
+ * URLs.
25
+ */
26
+
27
+ /**
28
+ * The semantic kind of a host, derived from RFC 6890 special-purpose registries
29
+ * plus a few domain-name categories (localhost, cloud metadata FQDNs).
30
+ */
31
+ export type HostKind =
32
+ /** IPv4 `127.0.0.0/8` or IPv6 `::1`. */
33
+ | "loopback"
34
+ /** DNS name `localhost` or RFC 6761 `.localhost` TLD. */
35
+ | "localhost"
36
+ /** IPv4 `0.0.0.0` or IPv6 `::` — "this host on this network", not loopback. */
37
+ | "unspecified"
38
+ /** RFC 1918 `10/8`, `172.16/12`, `192.168/16`, or IPv6 ULA `fc00::/7`. */
39
+ | "private"
40
+ /** IPv4 `169.254/16` or IPv6 `fe80::/10`. Includes AWS IMDS `169.254.169.254`. */
41
+ | "linkLocal"
42
+ /** RFC 6598 carrier-grade NAT `100.64.0.0/10`. */
43
+ | "sharedAddressSpace"
44
+ /** RFC 5737 `192.0.2/24`, `198.51.100/24`, `203.0.113/24`, or RFC 3849 `2001:db8::/32`. */
45
+ | "documentation"
46
+ /** RFC 2544 `198.18.0.0/15`. */
47
+ | "benchmarking"
48
+ /** IPv4 `224.0.0.0/4` or IPv6 `ff00::/8`. */
49
+ | "multicast"
50
+ /** IPv4 limited broadcast `255.255.255.255`. */
51
+ | "broadcast"
52
+ /** Other RFC 6890 special-purpose ranges (0.0.0.0/8, 192.0.0.0/24, 192.88.99.0/24, 240.0.0.0/4, 2001::/32, etc.). */
53
+ | "reserved"
54
+ /** Cloud metadata service FQDN (e.g. `metadata.google.internal`). */
55
+ | "cloudMetadata"
56
+ /** Any host not matching a special-purpose range above. */
57
+ | "public";
58
+
59
+ /**
60
+ * The syntactic form of the input host: an IPv4 literal, an IPv6 literal, or
61
+ * a domain name. IPv4-mapped IPv6 (`::ffff:192.0.2.1`) is reported as `ipv4`
62
+ * because it's unmapped during canonicalization.
63
+ */
64
+ export type HostLiteral = "ipv4" | "ipv6" | "fqdn";
65
+
66
+ /**
67
+ * Result of {@link classifyHost}. All fields are readonly.
68
+ *
69
+ * @property kind - Semantic classification per RFC 6890 + RFC 6761.
70
+ * @property literal - Syntactic form of the input (IPv4, IPv6, or FQDN).
71
+ * @property canonical - Lowercase, port-stripped, bracket-stripped, zone-id-stripped
72
+ * form suitable for equality comparison. IPv6 is expanded to full form.
73
+ * IPv4-mapped IPv6 is collapsed to the underlying IPv4.
74
+ */
75
+ export interface HostClassification {
76
+ readonly kind: HostKind;
77
+ readonly literal: HostLiteral;
78
+ readonly canonical: string;
79
+ }
80
+
81
+ /**
82
+ * Cloud provider instance metadata service FQDNs. These resolve to link-local
83
+ * IPs (usually `169.254.169.254`) inside their respective clouds and are
84
+ * prime SSRF targets.
85
+ *
86
+ * The IPs themselves are already caught by the `linkLocal` kind; this set
87
+ * only exists for the FQDN form that a naive server-side fetch might resolve
88
+ * via its own resolver.
89
+ */
90
+ const CLOUD_METADATA_HOSTS: ReadonlySet<string> = new Set([
91
+ "metadata.google.internal",
92
+ "metadata.goog",
93
+ "metadata",
94
+ "instance-data",
95
+ "instance-data.ec2.internal",
96
+ ]);
97
+
98
+ /** Strip `[...]` if the entire input is bracketed (IPv6 literal form). */
99
+ function stripBrackets(host: string): string {
100
+ if (host.length >= 2 && host.startsWith("[") && host.endsWith("]")) {
101
+ return host.slice(1, -1);
102
+ }
103
+ return host;
104
+ }
105
+
106
+ /**
107
+ * Strip trailing `:port` from host-with-port strings.
108
+ *
109
+ * - Bracketed IPv6 with port: `[::1]:8080` → `[::1]`
110
+ * - IPv4/FQDN with port: `127.0.0.1:3000` / `example.com:443` → base form
111
+ * - Bare IPv6: `::1` / `fe80::1` → unchanged (multiple colons means no port)
112
+ */
113
+ function stripPort(host: string): string {
114
+ if (host.startsWith("[")) {
115
+ const end = host.indexOf("]");
116
+ if (end === -1) return host;
117
+ return host.slice(0, end + 1);
118
+ }
119
+ const firstColon = host.indexOf(":");
120
+ if (firstColon === -1) return host;
121
+ if (host.indexOf(":", firstColon + 1) !== -1) return host;
122
+ return host.slice(0, firstColon);
123
+ }
124
+
125
+ /** Strip IPv6 zone identifier: `fe80::1%eth0` → `fe80::1`. */
126
+ function stripZoneId(host: string): string {
127
+ const zone = host.indexOf("%");
128
+ if (zone === -1) return host;
129
+ return host.slice(0, zone);
130
+ }
131
+
132
+ /**
133
+ * Strip trailing dots (RFC 1034 absolute DNS form): `localhost.` → `localhost`.
134
+ * Without this, `metadata.google.internal.` would fall through to `public` and
135
+ * bypass the cloud-metadata / `.localhost` checks, since WHATWG URL parsing
136
+ * preserves the trailing dot in `url.hostname`.
137
+ */
138
+ function stripTrailingDot(host: string): string {
139
+ return host.replace(/\.+$/, "");
140
+ }
141
+
142
+ /** Fast dotted-decimal shape check. Does NOT validate octet bounds. */
143
+ function looksLikeIPv4(host: string): boolean {
144
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
145
+ }
146
+
147
+ /** Pack a validated dotted-decimal IPv4 into a 32-bit unsigned integer. */
148
+ function ipv4ToUint32(ip: string): number {
149
+ const parts = ip.split(".");
150
+ return (
151
+ ((Number(parts[0]) << 24) |
152
+ (Number(parts[1]) << 16) |
153
+ (Number(parts[2]) << 8) |
154
+ Number(parts[3])) >>>
155
+ 0
156
+ );
157
+ }
158
+
159
+ /** Check whether a 32-bit value matches `prefix/length` (both unsigned). */
160
+ function inIPv4Range(value: number, prefix: number, length: number): boolean {
161
+ if (length === 0) return true;
162
+ const mask = length === 32 ? 0xffffffff : (~0 << (32 - length)) >>> 0;
163
+ return (value & mask) === (prefix & mask);
164
+ }
165
+
166
+ function classifyIPv4(ip: string): HostKind {
167
+ if (ip === "0.0.0.0") return "unspecified";
168
+ if (ip === "255.255.255.255") return "broadcast";
169
+
170
+ const n = ipv4ToUint32(ip);
171
+
172
+ if (inIPv4Range(n, ipv4ToUint32("127.0.0.0"), 8)) return "loopback";
173
+ if (inIPv4Range(n, ipv4ToUint32("10.0.0.0"), 8)) return "private";
174
+ if (inIPv4Range(n, ipv4ToUint32("172.16.0.0"), 12)) return "private";
175
+ if (inIPv4Range(n, ipv4ToUint32("192.168.0.0"), 16)) return "private";
176
+ if (inIPv4Range(n, ipv4ToUint32("169.254.0.0"), 16)) return "linkLocal";
177
+ if (inIPv4Range(n, ipv4ToUint32("100.64.0.0"), 10))
178
+ return "sharedAddressSpace";
179
+ if (inIPv4Range(n, ipv4ToUint32("192.0.2.0"), 24)) return "documentation";
180
+ if (inIPv4Range(n, ipv4ToUint32("198.51.100.0"), 24)) return "documentation";
181
+ if (inIPv4Range(n, ipv4ToUint32("203.0.113.0"), 24)) return "documentation";
182
+ if (inIPv4Range(n, ipv4ToUint32("198.18.0.0"), 15)) return "benchmarking";
183
+ if (inIPv4Range(n, ipv4ToUint32("224.0.0.0"), 4)) return "multicast";
184
+ if (inIPv4Range(n, ipv4ToUint32("0.0.0.0"), 8)) return "reserved";
185
+ if (inIPv4Range(n, ipv4ToUint32("192.0.0.0"), 24)) return "reserved";
186
+ // 6to4 relay anycast (RFC 7526, deprecated), not globally reachable.
187
+ if (inIPv4Range(n, ipv4ToUint32("192.88.99.0"), 24)) return "reserved";
188
+ if (inIPv4Range(n, ipv4ToUint32("240.0.0.0"), 4)) return "reserved";
189
+
190
+ return "public";
191
+ }
192
+
193
+ /**
194
+ * Extract an IPv4 address embedded in an expanded IPv6 literal.
195
+ *
196
+ * Used to recurse into tunnel/translation forms (6to4, NAT64, Teredo) so a
197
+ * private destination cannot be smuggled behind a syntactically-public IPv6
198
+ * literal. `startGroup` is the index of the first of two 16-bit groups in the
199
+ * expanded form (`0000:0000:...`). With `xor: true`, the 32-bit value is XORed
200
+ * with `0xffffffff` before decoding (Teredo obfuscates the client IPv4 this
201
+ * way).
202
+ */
203
+ function extractEmbeddedIPv4(
204
+ expanded: string,
205
+ startGroup: number,
206
+ options: { xor?: boolean } = {},
207
+ ): string | null {
208
+ const offset = startGroup * 5;
209
+ const g1 = Number.parseInt(expanded.slice(offset, offset + 4), 16);
210
+ const g2 = Number.parseInt(expanded.slice(offset + 5, offset + 9), 16);
211
+ if (!Number.isFinite(g1) || !Number.isFinite(g2)) return null;
212
+ let combined = ((g1 << 16) | g2) >>> 0;
213
+ if (options.xor) combined = (combined ^ 0xffffffff) >>> 0;
214
+ return `${(combined >>> 24) & 0xff}.${(combined >>> 16) & 0xff}.${(combined >>> 8) & 0xff}.${combined & 0xff}`;
215
+ }
216
+
217
+ /**
218
+ * Classify an expanded, full-form, lowercase IPv6 address (no IPv4-mapped
219
+ * input — those are unmapped to IPv4 before reaching here).
220
+ *
221
+ * 6to4 (`2002::/16`), NAT64 (`64:ff9b::/96`) and Teredo (`2001:0000::/32`)
222
+ * embed an IPv4 that can route to private/loopback space. If the embedded
223
+ * IPv4 classifies as non-`public`, return `reserved` — blocks SSRF without
224
+ * advertising the address as a loopback literal for RFC 8252 §7.3 matching.
225
+ */
226
+ function classifyIPv6(expanded: string): HostKind {
227
+ if (expanded === "0000:0000:0000:0000:0000:0000:0000:0000")
228
+ return "unspecified";
229
+ if (expanded === "0000:0000:0000:0000:0000:0000:0000:0001") return "loopback";
230
+
231
+ const firstByte = Number.parseInt(expanded.slice(0, 2), 16);
232
+ const secondByte = Number.parseInt(expanded.slice(2, 4), 16);
233
+
234
+ if (firstByte === 0xff) return "multicast";
235
+ if (firstByte === 0xfe && (secondByte & 0xc0) === 0x80) return "linkLocal";
236
+ // fec0::/10 — deprecated site-local (RFC 3879), not globally reachable.
237
+ if (firstByte === 0xfe && (secondByte & 0xc0) === 0xc0) return "reserved";
238
+ if ((firstByte & 0xfe) === 0xfc) return "private";
239
+
240
+ if (expanded.startsWith("2001:0db8:")) return "documentation";
241
+
242
+ // 2001:2::/48 — Benchmarking (RFC 5180). A specific non-globally-reachable
243
+ // block inside the otherwise-mixed 2001::/23 protocol-assignments space.
244
+ if (expanded.startsWith("2001:0002:0000:")) return "benchmarking";
245
+
246
+ if (expanded.startsWith("2002:")) {
247
+ const embedded = extractEmbeddedIPv4(expanded, 1);
248
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
249
+ return "public";
250
+ }
251
+
252
+ if (expanded.startsWith("0064:ff9b:0000:0000:0000:0000:")) {
253
+ const embedded = extractEmbeddedIPv4(expanded, 6);
254
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
255
+ return "reserved";
256
+ }
257
+
258
+ // 64:ff9b:1::/48 — Local-Use IPv4/IPv6 Translation (RFC 8215). Distinct from
259
+ // the well-known NAT64 /96 prefix above and not globally reachable.
260
+ if (expanded.startsWith("0064:ff9b:0001:")) return "reserved";
261
+
262
+ if (expanded.startsWith("2001:0000:")) {
263
+ const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true });
264
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
265
+ return "reserved";
266
+ }
267
+
268
+ if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved";
269
+
270
+ // 3fff::/20 — Documentation (RFC 9637). The /20 fixes the first 16 bits to
271
+ // `3fff` and the next nibble to 0, so only `3fff:0xxx` is in range.
272
+ if (expanded.startsWith("3fff:0")) return "documentation";
273
+
274
+ // 5f00::/16 — SRv6 SIDs (RFC 9602), not globally reachable.
275
+ if (expanded.startsWith("5f00:")) return "reserved";
276
+
277
+ // ::/96 — deprecated IPv4-compatible IPv6 (RFC 4291 §2.5.5.1). `::` and
278
+ // `::1` are matched above; the rest of the block embeds an IPv4 (e.g.
279
+ // `::127.0.0.1`) and is never a valid public target.
280
+ if (expanded.startsWith("0000:0000:0000:0000:0000:0000:")) return "reserved";
281
+
282
+ return "public";
283
+ }
284
+
285
+ /**
286
+ * Classify a host string according to RFC 6890 / RFC 6761.
287
+ *
288
+ * Accepts inputs in any of these shapes and normalizes before classifying:
289
+ *
290
+ * - Bare IPv4: `127.0.0.1`
291
+ * - Bare IPv6: `::1`, `fe80::1%eth0`
292
+ * - Bracketed IPv6: `[::1]`
293
+ * - Host with port: `localhost:3000`, `127.0.0.1:443`, `[::1]:8080`
294
+ * - FQDN: `example.com`, `tenant.localhost`
295
+ * - IPv4-mapped IPv6: `::ffff:192.0.2.1` (reported as `literal: "ipv4"`)
296
+ *
297
+ * Invalid or non-resolvable FQDNs are returned as `{ kind: "public", literal: "fqdn" }`
298
+ * — this function never throws. Callers that need structural validation must
299
+ * combine this with a URL/hostname validator upstream.
300
+ *
301
+ * @example
302
+ * classifyHost("127.0.0.1")
303
+ * // { kind: "loopback", literal: "ipv4", canonical: "127.0.0.1" }
304
+ *
305
+ * @example
306
+ * classifyHost("[::1]:8080")
307
+ * // { kind: "loopback", literal: "ipv6", canonical: "0000:0000:...:0001" }
308
+ *
309
+ * @example
310
+ * classifyHost("::ffff:192.0.2.1")
311
+ * // { kind: "documentation", literal: "ipv4", canonical: "192.0.2.1" }
312
+ *
313
+ * @example
314
+ * classifyHost("tenant-a.localhost")
315
+ * // { kind: "localhost", literal: "fqdn", canonical: "tenant-a.localhost" }
316
+ */
317
+ export function classifyHost(host: string): HostClassification {
318
+ const stripped = stripTrailingDot(
319
+ stripZoneId(stripBrackets(stripPort(host.trim()))),
320
+ );
321
+ const lowered = stripped.toLowerCase();
322
+
323
+ if (lowered === "") {
324
+ return { kind: "reserved", literal: "fqdn", canonical: "" };
325
+ }
326
+
327
+ if (!isValidIP(lowered)) {
328
+ if (lowered === "localhost" || lowered.endsWith(".localhost")) {
329
+ return { kind: "localhost", literal: "fqdn", canonical: lowered };
330
+ }
331
+ if (CLOUD_METADATA_HOSTS.has(lowered)) {
332
+ return { kind: "cloudMetadata", literal: "fqdn", canonical: lowered };
333
+ }
334
+ return { kind: "public", literal: "fqdn", canonical: lowered };
335
+ }
336
+
337
+ if (looksLikeIPv4(lowered)) {
338
+ return { kind: classifyIPv4(lowered), literal: "ipv4", canonical: lowered };
339
+ }
340
+
341
+ const canonical = normalizeIP(lowered, { ipv6Subnet: 128 });
342
+
343
+ if (looksLikeIPv4(canonical)) {
344
+ return {
345
+ kind: classifyIPv4(canonical),
346
+ literal: "ipv4",
347
+ canonical,
348
+ };
349
+ }
350
+
351
+ return { kind: classifyIPv6(canonical), literal: "ipv6", canonical };
352
+ }
353
+
354
+ /**
355
+ * Strict loopback-IP-literal check per RFC 8252 §7.3.
356
+ *
357
+ * Returns true ONLY for IPv4 `127.0.0.0/8` or IPv6 `::1`. The DNS name
358
+ * `localhost` returns false — RFC 8252 §8.3 explicitly recommends against
359
+ * relying on name resolution for loopback redirect URIs.
360
+ *
361
+ * Use this for OAuth redirect URI matching.
362
+ *
363
+ * @example
364
+ * isLoopbackIP("127.0.0.1") // true
365
+ * isLoopbackIP("::1") // true
366
+ * isLoopbackIP("[::1]:8080") // true
367
+ * isLoopbackIP("localhost") // false (use isLoopbackHost for DNS names)
368
+ * isLoopbackIP("0.0.0.0") // false (unspecified, not loopback)
369
+ */
370
+ export function isLoopbackIP(host: string): boolean {
371
+ return classifyHost(host).kind === "loopback";
372
+ }
373
+
374
+ /**
375
+ * Permissive loopback check for developer-ergonomics code paths.
376
+ *
377
+ * Returns true for IPv4 `127.0.0.0/8`, IPv6 `::1`, the literal name `localhost`,
378
+ * and any RFC 6761 `.localhost` subdomain (`tenant.localhost`, `app.localhost`).
379
+ *
380
+ * Use this for things like: allowing HTTP for dev servers, skipping Secure
381
+ * cookie requirements, browser-trust heuristics. Do NOT use this for OAuth
382
+ * redirect URI matching — use {@link isLoopbackIP} there.
383
+ *
384
+ * @example
385
+ * isLoopbackHost("localhost") // true
386
+ * isLoopbackHost("tenant.localhost") // true (RFC 6761)
387
+ * isLoopbackHost("127.0.0.1") // true
388
+ * isLoopbackHost("0.0.0.0") // false (unspecified, NOT loopback)
389
+ */
390
+ export function isLoopbackHost(host: string): boolean {
391
+ const kind = classifyHost(host).kind;
392
+ return kind === "loopback" || kind === "localhost";
393
+ }
394
+
395
+ /**
396
+ * First-line SSRF gate: returns true ONLY for hosts that classify as `public`.
397
+ *
398
+ * Every RFC 6890 special-purpose range (loopback, private, link-local,
399
+ * unspecified, documentation, multicast, broadcast, reserved, shared address
400
+ * space, benchmarking) and cloud-metadata FQDN returns false.
401
+ *
402
+ * Use this BEFORE issuing a server-side fetch to a user-supplied URL, e.g.
403
+ * OAuth introspection endpoints, webhook targets, or metadata-document
404
+ * fetches (CIMD).
405
+ *
406
+ * Limitations (this is a syntactic check, not a complete SSRF mitigation):
407
+ * - No DNS resolution: a public-looking FQDN that resolves to a private IP
408
+ * passes this check. Re-verify the resolved address before connecting, or
409
+ * pin the socket to the resolved IP.
410
+ * - No DNS-rebinding defense: attackers can return a public IP on the first
411
+ * lookup and a private IP on the second. Resolve once and reuse the IP.
412
+ * - No redirect following: HTTP 3xx responses can redirect to private hosts.
413
+ * Re-run this check on every redirect target, or disable auto-follow.
414
+ *
415
+ * @example
416
+ * isPublicRoutableHost("example.com") // true
417
+ * isPublicRoutableHost("127.0.0.1") // false (loopback)
418
+ * isPublicRoutableHost("169.254.169.254") // false (linkLocal / AWS IMDS)
419
+ * isPublicRoutableHost("metadata.google.internal") // false (cloudMetadata)
420
+ * isPublicRoutableHost("10.0.0.1") // false (private)
421
+ * isPublicRoutableHost("::ffff:127.0.0.1") // false (mapped loopback)
422
+ */
423
+ export function isPublicRoutableHost(host: string): boolean {
424
+ return classifyHost(host).kind === "public";
425
+ }
package/src/utils/ip.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as z from "zod";
2
+ import { isDevelopment, isTest } from "../env";
3
+ import type { BetterAuthOptions } from "../types";
2
4
 
3
5
  /**
4
6
  * Normalizes an IP address for consistent rate limiting.
@@ -12,12 +14,13 @@ import * as z from "zod";
12
14
 
13
15
  interface NormalizeIPOptions {
14
16
  /**
15
- * For IPv6 addresses, extract the subnet prefix instead of full address.
16
- * Common values: 32, 48, 64, 128 (default: 128 = full address)
17
+ * Prefix length used to collapse IPv6 addresses before keying.
18
+ * Any integer from 0 to 128 is accepted. Common values: 32, 48, 56, 64, 128.
19
+ * Values outside 0-128 are clamped.
17
20
  *
18
- * @default 128
21
+ * @default 64
19
22
  */
20
- ipv6Subnet?: 128 | 64 | 48 | 32;
23
+ ipv6Subnet?: number;
21
24
  }
22
25
 
23
26
  /**
@@ -117,15 +120,13 @@ function expandIPv6(ipv6: string): string[] {
117
120
  * Normalizes an IPv6 address to canonical form
118
121
  * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
119
122
  */
120
- function normalizeIPv6(
121
- ipv6: string,
122
- subnetPrefix?: 128 | 32 | 48 | 64,
123
- ): string {
123
+ function normalizeIPv6(ipv6: string, subnetPrefix?: number): string {
124
124
  const groups = expandIPv6(ipv6);
125
125
 
126
- if (subnetPrefix && subnetPrefix < 128) {
127
- // Apply subnet mask
128
- const prefix = subnetPrefix;
126
+ if (subnetPrefix !== undefined && subnetPrefix < 128) {
127
+ // Clamp to a valid bit range so out-of-spec inputs degrade safely:
128
+ // negative or fractional values would otherwise produce malformed masks.
129
+ const prefix = Math.max(0, Math.floor(subnetPrefix));
129
130
  let bitsRemaining: number = prefix;
130
131
 
131
132
  const maskedGroups = groups.map((group) => {
@@ -191,11 +192,194 @@ export function normalizeIP(
191
192
  return ipv4.toLowerCase();
192
193
  }
193
194
 
194
- // Normalize IPv6
195
- const subnetPrefix = options.ipv6Subnet || 64;
195
+ // Normalize IPv6. Use ?? so an explicit 0 (mask-all) is honoured.
196
+ const subnetPrefix = options.ipv6Subnet ?? 64;
196
197
  return normalizeIPv6(ip, subnetPrefix);
197
198
  }
198
199
 
200
+ /**
201
+ * Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP.
202
+ */
203
+ function ipToBytes(ip: string): Uint8Array | null {
204
+ if (z.ipv4().safeParse(ip).success) {
205
+ return Uint8Array.from(ip.split(".").map((octet) => Number(octet)));
206
+ }
207
+ if (!isIPv6(ip)) {
208
+ return null;
209
+ }
210
+ const mapped = extractIPv4FromMapped(ip);
211
+ if (mapped) {
212
+ return Uint8Array.from(mapped.split(".").map((octet) => Number(octet)));
213
+ }
214
+ const groups = expandIPv6(ip);
215
+ const bytes = new Uint8Array(16);
216
+ for (let i = 0; i < 8; i++) {
217
+ const group = Number.parseInt(groups[i] ?? "0", 16);
218
+ bytes[i * 2] = (group >> 8) & 0xff;
219
+ bytes[i * 2 + 1] = group & 0xff;
220
+ }
221
+ return bytes;
222
+ }
223
+
224
+ // A CIDR prefix length must be decimal digits only, so values like "8x" or
225
+ // "1e3" that `Number()` would otherwise coerce are rejected.
226
+ const CIDR_PREFIX_PATTERN = /^\d+$/;
227
+
228
+ /**
229
+ * Parses an IP or `IP/prefix` string into network bytes and a prefix length.
230
+ * The prefix must be digits only and within the address family. `null` if the
231
+ * value is not a valid IP or CIDR range, which keeps a malformed entry from
232
+ * silently behaving like a non-match.
233
+ */
234
+ function parseCIDR(
235
+ value: string,
236
+ ): { bytes: Uint8Array; prefix: number } | null {
237
+ const slash = value.lastIndexOf("/");
238
+ const bytes = ipToBytes(slash === -1 ? value : value.slice(0, slash));
239
+ if (!bytes) {
240
+ return null;
241
+ }
242
+ const maxBits = bytes.length * 8;
243
+ if (slash === -1) {
244
+ return { bytes, prefix: maxBits };
245
+ }
246
+ const prefixPart = value.slice(slash + 1);
247
+ if (!CIDR_PREFIX_PATTERN.test(prefixPart)) {
248
+ return null;
249
+ }
250
+ const prefix = Number(prefixPart);
251
+ return prefix <= maxBits ? { bytes, prefix } : null;
252
+ }
253
+
254
+ /**
255
+ * Whether `ipBytes` falls inside an already-parsed CIDR network.
256
+ */
257
+ function matchesCIDR(
258
+ ipBytes: Uint8Array,
259
+ net: { bytes: Uint8Array; prefix: number },
260
+ ): boolean {
261
+ if (ipBytes.length !== net.bytes.length) {
262
+ return false;
263
+ }
264
+ let bitsRemaining = net.prefix;
265
+ for (let i = 0; i < ipBytes.length && bitsRemaining > 0; i++) {
266
+ const take = bitsRemaining >= 8 ? 8 : bitsRemaining;
267
+ const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
268
+ if (((ipBytes[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) {
269
+ return false;
270
+ }
271
+ bitsRemaining -= 8;
272
+ }
273
+ return true;
274
+ }
275
+
276
+ /**
277
+ * Trusted-proxy entries that are not a valid IP address or CIDR range.
278
+ */
279
+ export function findInvalidTrustedProxies(entries: string[]): string[] {
280
+ return entries.filter((entry) => parseCIDR(entry) === null);
281
+ }
282
+
283
+ /**
284
+ * Resolves the client IP from a forwarded header. The leftmost token is spoofable,
285
+ * so with `trustedProxies` the chain is stripped from the right to the first
286
+ * untrusted hop. Otherwise only a single-value header is trusted. Returns `null`
287
+ * when no trustworthy client IP can be resolved.
288
+ */
289
+ export function getIPFromHeader(
290
+ value: string,
291
+ options: {
292
+ ipv6Subnet?: number;
293
+ trustedProxies?: string[];
294
+ } = {},
295
+ ): string | null {
296
+ const forwardedIps = value
297
+ .split(",")
298
+ .map((ip) => ip.trim())
299
+ .filter(Boolean);
300
+ if (forwardedIps.length === 0) {
301
+ return null;
302
+ }
303
+
304
+ // Parse trusted proxies once, dropping malformed entries so a config typo
305
+ // cannot leave the chain enabled-but-empty and return a real proxy hop as
306
+ // the client. With no valid proxy the chain mode does not engage.
307
+ const trustedProxies = (options.trustedProxies ?? [])
308
+ .map(parseCIDR)
309
+ .filter((proxy): proxy is { bytes: Uint8Array; prefix: number } => {
310
+ return proxy !== null;
311
+ });
312
+
313
+ if (trustedProxies.length > 0) {
314
+ for (let i = forwardedIps.length - 1; i >= 0; i--) {
315
+ const ip = forwardedIps[i];
316
+ const ipBytes = ip ? ipToBytes(ip) : null;
317
+ // A malformed hop breaks the chain: fail closed.
318
+ if (!ip || !ipBytes) {
319
+ return null;
320
+ }
321
+ if (trustedProxies.some((proxy) => matchesCIDR(ipBytes, proxy))) {
322
+ continue;
323
+ }
324
+ return normalizeIP(ip, { ipv6Subnet: options.ipv6Subnet });
325
+ }
326
+ return null;
327
+ }
328
+
329
+ // Without valid trusted proxies a multi-hop chain is unresolvable.
330
+ if (forwardedIps.length !== 1) {
331
+ return null;
332
+ }
333
+ const selectedIp = forwardedIps[0];
334
+ if (!selectedIp || !isValidIP(selectedIp)) {
335
+ return null;
336
+ }
337
+
338
+ return normalizeIP(selectedIp, { ipv6Subnet: options.ipv6Subnet });
339
+ }
340
+
341
+ const LOCALHOST_IP = "127.0.0.1";
342
+ const DEFAULT_IP_HEADERS = ["x-forwarded-for"];
343
+
344
+ /**
345
+ * Resolves the client IP for a request from the configured IP headers.
346
+ * Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default
347
+ * `x-forwarded-for`), and falls back to localhost in development and test.
348
+ * Returns `null` when tracking is disabled or no trustworthy IP can be resolved.
349
+ */
350
+ export function getIP(
351
+ req: Request | Headers,
352
+ options: BetterAuthOptions,
353
+ ): string | null {
354
+ if (options.advanced?.ipAddress?.disableIpTracking) {
355
+ return null;
356
+ }
357
+
358
+ const headers = "headers" in req ? req.headers : req;
359
+
360
+ const ipHeaders =
361
+ options.advanced?.ipAddress?.ipAddressHeaders || DEFAULT_IP_HEADERS;
362
+
363
+ for (const key of ipHeaders) {
364
+ const value = "get" in headers ? headers.get(key) : headers[key];
365
+ if (typeof value === "string") {
366
+ const ip = getIPFromHeader(value, {
367
+ ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet,
368
+ trustedProxies: options.advanced?.ipAddress?.trustedProxies,
369
+ });
370
+ if (ip) {
371
+ return ip;
372
+ }
373
+ }
374
+ }
375
+
376
+ if (isTest() || isDevelopment()) {
377
+ return LOCALHOST_IP;
378
+ }
379
+
380
+ return null;
381
+ }
382
+
199
383
  /**
200
384
  * Creates a rate limit key from IP and path
201
385
  * Uses a separator to prevent collision attacks
@@ -0,0 +1,10 @@
1
+ import { APIError as BaseAPIError } from "better-call";
2
+ import { APIError } from "../error";
3
+
4
+ export function isAPIError(error: unknown): error is APIError {
5
+ return (
6
+ error instanceof BaseAPIError ||
7
+ error instanceof APIError ||
8
+ (error as { name?: string })?.name === "APIError"
9
+ );
10
+ }