@better-auth/core 1.4.15 → 1.4.16

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @better-auth/core@1.4.15 build /home/runner/work/better-auth/better-auth/packages/core
2
+ > @better-auth/core@1.4.16 build /home/runner/work/better-auth/better-auth/packages/core
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
@@ -14,15 +14,16 @@
14
14
  ℹ dist/oauth2/index.mjs  0.87 kB │ gzip: 0.27 kB
15
15
  ℹ dist/context/index.mjs  0.79 kB │ gzip: 0.23 kB
16
16
  ℹ dist/db/adapter/index.mjs  0.71 kB │ gzip: 0.22 kB
17
+ ℹ dist/utils/index.mjs  0.51 kB │ gzip: 0.22 kB
17
18
  ℹ dist/db/index.mjs  0.50 kB │ gzip: 0.18 kB
18
19
  ℹ dist/env/index.mjs  0.43 kB │ gzip: 0.21 kB
19
- ℹ dist/utils/index.mjs  0.33 kB │ gzip: 0.16 kB
20
20
  ℹ dist/error/index.mjs  0.33 kB │ gzip: 0.21 kB
21
21
  ℹ dist/index.mjs  0.01 kB │ gzip: 0.03 kB
22
22
  ℹ dist/db/adapter/factory.mjs 30.11 kB │ gzip: 5.99 kB
23
23
  ℹ dist/db/get-tables.mjs  6.89 kB │ gzip: 1.30 kB
24
24
  ℹ dist/social-providers/cognito.mjs  5.93 kB │ gzip: 1.93 kB
25
25
  ℹ dist/social-providers/paypal.mjs  5.03 kB │ gzip: 1.48 kB
26
+ ℹ dist/utils/ip.mjs  3.81 kB │ gzip: 1.29 kB
26
27
  ℹ dist/social-providers/google.mjs  3.79 kB │ gzip: 1.39 kB
27
28
  ℹ dist/oauth2/verify.mjs  3.74 kB │ gzip: 1.32 kB
28
29
  ℹ dist/social-providers/apple.mjs  3.71 kB │ gzip: 1.34 kB
@@ -71,6 +72,7 @@
71
72
  ℹ dist/context/endpoint-context.mjs  1.31 kB │ gzip: 0.53 kB
72
73
  ℹ dist/db/adapter/get-field-attributes.mjs  1.26 kB │ gzip: 0.46 kB
73
74
  ℹ dist/db/adapter/utils.mjs  1.15 kB │ gzip: 0.46 kB
75
+ ℹ dist/utils/url.mjs  1.14 kB │ gzip: 0.51 kB
74
76
  ℹ dist/db/adapter/get-field-name.mjs  1.07 kB │ gzip: 0.47 kB
75
77
  ℹ dist/oauth2/utils.mjs  0.98 kB │ gzip: 0.50 kB
76
78
  ℹ dist/context/global.mjs  0.96 kB │ gzip: 0.47 kB
@@ -94,12 +96,12 @@
94
96
  ℹ dist/db/index.d.mts  1.04 kB │ gzip: 0.34 kB
95
97
  ℹ dist/context/index.d.mts  0.92 kB │ gzip: 0.27 kB
96
98
  ℹ dist/env/index.d.mts  0.58 kB │ gzip: 0.27 kB
97
- ℹ dist/utils/index.d.mts  0.33 kB │ gzip: 0.16 kB
99
+ ℹ dist/utils/index.d.mts  0.51 kB │ gzip: 0.22 kB
98
100
  ℹ dist/error/index.d.mts  0.27 kB │ gzip: 0.21 kB
99
101
  ℹ dist/async_hooks/index.d.mts  0.24 kB │ gzip: 0.16 kB
100
102
  ℹ dist/async_hooks/pure.index.d.mts  0.22 kB │ gzip: 0.16 kB
101
- ℹ dist/types/init-options.d.mts 38.32 kB │ gzip: 8.50 kB
102
- ℹ dist/types/context.d.mts  9.05 kB │ gzip: 2.65 kB
103
+ ℹ dist/types/init-options.d.mts 39.01 kB │ gzip: 8.77 kB
104
+ ℹ dist/types/context.d.mts  9.09 kB │ gzip: 2.66 kB
103
105
  ℹ dist/social-providers/zoom.d.mts  6.71 kB │ gzip: 2.29 kB
104
106
  ℹ dist/oauth2/oauth-provider.d.mts  5.92 kB │ gzip: 1.67 kB
105
107
  ℹ dist/social-providers/microsoft-entra-id.d.mts  5.59 kB │ gzip: 1.96 kB
@@ -136,6 +138,7 @@
136
138
  ℹ dist/social-providers/kick.d.mts  1.62 kB │ gzip: 0.59 kB
137
139
  ℹ dist/social-providers/linkedin.d.mts  1.61 kB │ gzip: 0.60 kB
138
140
  ℹ dist/social-providers/linear.d.mts  1.60 kB │ gzip: 0.59 kB
141
+ ℹ dist/utils/ip.d.mts  1.58 kB │ gzip: 0.69 kB
139
142
  ℹ dist/oauth2/validate-authorization-code.d.mts  1.57 kB │ gzip: 0.49 kB
140
143
  ℹ dist/social-providers/notion.d.mts  1.56 kB │ gzip: 0.59 kB
141
144
  ℹ dist/social-providers/reddit.d.mts  1.54 kB │ gzip: 0.57 kB
@@ -153,6 +156,7 @@
153
156
  ℹ dist/oauth2/client-credentials-token.d.mts  0.91 kB │ gzip: 0.36 kB
154
157
  ℹ dist/context/transaction.d.mts  0.86 kB │ gzip: 0.38 kB
155
158
  ℹ dist/utils/error-codes.d.mts  0.84 kB │ gzip: 0.43 kB
159
+ ℹ dist/utils/url.d.mts  0.84 kB │ gzip: 0.40 kB
156
160
  ℹ dist/db/schema/session.d.mts  0.75 kB │ gzip: 0.40 kB
157
161
  ℹ dist/types/index.d.mts  0.74 kB │ gzip: 0.32 kB
158
162
  ℹ dist/db/adapter/get-field-attributes.d.mts  0.72 kB │ gzip: 0.34 kB
@@ -176,5 +180,5 @@
176
180
  ℹ dist/utils/json.d.mts  0.13 kB │ gzip: 0.13 kB
177
181
  ℹ dist/utils/id.d.mts  0.12 kB │ gzip: 0.12 kB
178
182
  ℹ dist/env/color-depth.d.mts  0.12 kB │ gzip: 0.11 kB
179
- ℹ 169 files, total: 440.50 kB
180
- ✔ Build complete in 6540ms
183
+ ℹ 173 files, total: 448.95 kB
184
+ ✔ Build complete in 6430ms
@@ -2,7 +2,7 @@
2
2
  const symbol = Symbol.for("better-auth:global");
3
3
  let bind = null;
4
4
  const __context = {};
5
- const __betterAuthVersion = "1.4.15";
5
+ const __betterAuthVersion = "1.4.16";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -47,6 +47,7 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
47
47
  deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>;
48
48
  findOAuthUser(email: string, accountId: string, providerId: string): Promise<{
49
49
  user: User;
50
+ linkedAccount: Account | null;
50
51
  accounts: Account[];
51
52
  } | null>;
52
53
  findUserByEmail(email: string, options?: {
@@ -122,6 +122,25 @@ type BetterAuthAdvancedOptions = {
122
122
  * ⚠︎ This is a security risk and it may expose your application to abuse
123
123
  */
124
124
  disableIpTracking?: boolean;
125
+ /**
126
+ * IPv6 subnet prefix length for rate limiting.
127
+ *
128
+ * IPv6 addresses can be grouped by subnet to prevent attackers from
129
+ * bypassing rate limits by rotating through multiple addresses in
130
+ * their allocation.
131
+ *
132
+ * Common values:
133
+ * - 128 (default): Individual IPv6 address
134
+ * - 64: /64 subnet (typical home/business allocation)
135
+ * - 48: /48 subnet (larger network allocation)
136
+ * - 32: /32 subnet (ISP allocation)
137
+ *
138
+ * Note: This only affects IPv6 addresses. IPv4 addresses are always
139
+ * rate limited individually.
140
+ *
141
+ * @default 128 (individual address)
142
+ */
143
+ ipv6Subnet?: 128 | 64 | 48 | 32 | undefined;
125
144
  } | undefined;
126
145
  /**
127
146
  * Use secure cookies
@@ -1,6 +1,8 @@
1
1
  import { deprecate } from "./deprecate.mjs";
2
2
  import { defineErrorCodes } from "./error-codes.mjs";
3
3
  import { generateId } from "./id.mjs";
4
+ import { createRateLimitKey, isValidIP, normalizeIP } from "./ip.mjs";
4
5
  import { safeJSONParse } from "./json.mjs";
5
6
  import { capitalizeFirstLetter } from "./string.mjs";
6
- export { capitalizeFirstLetter, defineErrorCodes, deprecate, generateId, safeJSONParse };
7
+ import { normalizePathname } from "./url.mjs";
8
+ export { capitalizeFirstLetter, createRateLimitKey, defineErrorCodes, deprecate, generateId, isValidIP, normalizeIP, normalizePathname, safeJSONParse };
@@ -1,7 +1,9 @@
1
1
  import { deprecate } from "./deprecate.mjs";
2
2
  import { defineErrorCodes } from "./error-codes.mjs";
3
3
  import { generateId } from "./id.mjs";
4
+ import { createRateLimitKey, isValidIP, normalizeIP } from "./ip.mjs";
4
5
  import { safeJSONParse } from "./json.mjs";
5
6
  import { capitalizeFirstLetter } from "./string.mjs";
7
+ import { normalizePathname } from "./url.mjs";
6
8
 
7
- export { capitalizeFirstLetter, defineErrorCodes, deprecate, generateId, safeJSONParse };
9
+ export { capitalizeFirstLetter, createRateLimitKey, defineErrorCodes, deprecate, generateId, isValidIP, normalizeIP, normalizePathname, safeJSONParse };
@@ -0,0 +1,54 @@
1
+ //#region src/utils/ip.d.ts
2
+ /**
3
+ * Normalizes an IP address for consistent rate limiting.
4
+ *
5
+ * Features:
6
+ * - Normalizes IPv6 to canonical lowercase form
7
+ * - Converts IPv4-mapped IPv6 to IPv4
8
+ * - Supports IPv6 subnet extraction
9
+ * - Handles all edge cases (::1, ::, etc.)
10
+ */
11
+ interface NormalizeIPOptions {
12
+ /**
13
+ * For IPv6 addresses, extract the subnet prefix instead of full address.
14
+ * Common values: 32, 48, 64, 128 (default: 128 = full address)
15
+ *
16
+ * @default 128
17
+ */
18
+ ipv6Subnet?: 128 | 64 | 48 | 32;
19
+ }
20
+ /**
21
+ * Checks if an IP is valid IPv4 or IPv6
22
+ */
23
+ declare function isValidIP(ip: string): boolean;
24
+ /**
25
+ * Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
26
+ *
27
+ * @param ip - The IP address to normalize
28
+ * @param options - Normalization options
29
+ * @returns Normalized IP address
30
+ *
31
+ * @example
32
+ * normalizeIP("2001:DB8::1")
33
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0001"
34
+ *
35
+ * @example
36
+ * normalizeIP("::ffff:192.0.2.1")
37
+ * // -> "192.0.2.1" (converted to IPv4)
38
+ *
39
+ * @example
40
+ * normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
41
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
42
+ */
43
+ declare function normalizeIP(ip: string, options?: NormalizeIPOptions): string;
44
+ /**
45
+ * Creates a rate limit key from IP and path
46
+ * Uses a separator to prevent collision attacks
47
+ *
48
+ * @param ip - The IP address (should be normalized)
49
+ * @param path - The request path
50
+ * @returns Rate limit key
51
+ */
52
+ declare function createRateLimitKey(ip: string, path: string): string;
53
+ //#endregion
54
+ export { createRateLimitKey, isValidIP, normalizeIP };
@@ -0,0 +1,118 @@
1
+ import * as z from "zod";
2
+
3
+ //#region src/utils/ip.ts
4
+ /**
5
+ * Checks if an IP is valid IPv4 or IPv6
6
+ */
7
+ function isValidIP(ip) {
8
+ return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success;
9
+ }
10
+ /**
11
+ * Checks if an IP is IPv6
12
+ */
13
+ function isIPv6(ip) {
14
+ return z.ipv6().safeParse(ip).success;
15
+ }
16
+ /**
17
+ * Converts IPv4-mapped IPv6 address to IPv4
18
+ * e.g., "::ffff:192.0.2.1" -> "192.0.2.1"
19
+ */
20
+ function extractIPv4FromMapped(ipv6) {
21
+ const lower = ipv6.toLowerCase();
22
+ if (lower.startsWith("::ffff:")) {
23
+ const ipv4Part = lower.substring(7);
24
+ if (z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
25
+ }
26
+ const parts = ipv6.split(":");
27
+ if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") {
28
+ const ipv4Part = parts[6];
29
+ if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
30
+ }
31
+ if (lower.includes("::ffff:") || lower.includes(":ffff:")) {
32
+ const groups = expandIPv6(ipv6);
33
+ if (groups.length === 8 && groups[0] === "0000" && groups[1] === "0000" && groups[2] === "0000" && groups[3] === "0000" && groups[4] === "0000" && groups[5] === "ffff" && groups[6] && groups[7]) return `${Number.parseInt(groups[6].substring(0, 2), 16)}.${Number.parseInt(groups[6].substring(2, 4), 16)}.${Number.parseInt(groups[7].substring(0, 2), 16)}.${Number.parseInt(groups[7].substring(2, 4), 16)}`;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Expands a compressed IPv6 address to full form
39
+ * e.g., "2001:db8::1" -> ["2001", "0db8", "0000", "0000", "0000", "0000", "0000", "0001"]
40
+ */
41
+ function expandIPv6(ipv6) {
42
+ if (ipv6.includes("::")) {
43
+ const sides = ipv6.split("::");
44
+ const left = sides[0] ? sides[0].split(":") : [];
45
+ const right = sides[1] ? sides[1].split(":") : [];
46
+ const missingGroups = 8 - left.length - right.length;
47
+ const zeros = Array(missingGroups).fill("0000");
48
+ const paddedLeft = left.map((g) => g.padStart(4, "0"));
49
+ const paddedRight = right.map((g) => g.padStart(4, "0"));
50
+ return [
51
+ ...paddedLeft,
52
+ ...zeros,
53
+ ...paddedRight
54
+ ];
55
+ }
56
+ return ipv6.split(":").map((g) => g.padStart(4, "0"));
57
+ }
58
+ /**
59
+ * Normalizes an IPv6 address to canonical form
60
+ * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
61
+ */
62
+ function normalizeIPv6(ipv6, subnetPrefix) {
63
+ const groups = expandIPv6(ipv6);
64
+ if (subnetPrefix && subnetPrefix < 128) {
65
+ let bitsRemaining = subnetPrefix;
66
+ return groups.map((group) => {
67
+ if (bitsRemaining <= 0) return "0000";
68
+ if (bitsRemaining >= 16) {
69
+ bitsRemaining -= 16;
70
+ return group;
71
+ }
72
+ const masked = Number.parseInt(group, 16) & (65535 << 16 - bitsRemaining & 65535);
73
+ bitsRemaining = 0;
74
+ return masked.toString(16).padStart(4, "0");
75
+ }).join(":").toLowerCase();
76
+ }
77
+ return groups.join(":").toLowerCase();
78
+ }
79
+ /**
80
+ * Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
81
+ *
82
+ * @param ip - The IP address to normalize
83
+ * @param options - Normalization options
84
+ * @returns Normalized IP address
85
+ *
86
+ * @example
87
+ * normalizeIP("2001:DB8::1")
88
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0001"
89
+ *
90
+ * @example
91
+ * normalizeIP("::ffff:192.0.2.1")
92
+ * // -> "192.0.2.1" (converted to IPv4)
93
+ *
94
+ * @example
95
+ * normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
96
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
97
+ */
98
+ function normalizeIP(ip, options = {}) {
99
+ if (z.ipv4().safeParse(ip).success) return ip.toLowerCase();
100
+ if (!isIPv6(ip)) return ip.toLowerCase();
101
+ const ipv4 = extractIPv4FromMapped(ip);
102
+ if (ipv4) return ipv4.toLowerCase();
103
+ return normalizeIPv6(ip, options.ipv6Subnet || 128);
104
+ }
105
+ /**
106
+ * Creates a rate limit key from IP and path
107
+ * Uses a separator to prevent collision attacks
108
+ *
109
+ * @param ip - The IP address (should be normalized)
110
+ * @param path - The request path
111
+ * @returns Rate limit key
112
+ */
113
+ function createRateLimitKey(ip, path) {
114
+ return `${ip}|${path}`;
115
+ }
116
+
117
+ //#endregion
118
+ export { createRateLimitKey, isValidIP, normalizeIP };
@@ -0,0 +1,20 @@
1
+ //#region src/utils/url.d.ts
2
+ /**
3
+ * Normalizes a request pathname by removing the basePath prefix and trailing slashes.
4
+ * This is useful for matching paths against configured path lists.
5
+ *
6
+ * @param requestUrl - The full request URL
7
+ * @param basePath - The base path of the auth API (e.g., "/api/auth")
8
+ * @returns The normalized path without basePath prefix or trailing slashes,
9
+ * or "/" if URL parsing fails
10
+ *
11
+ * @example
12
+ * normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth")
13
+ * // Returns: "/sso/saml2/callback/provider1"
14
+ *
15
+ * normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/")
16
+ * // Returns: "/sso/saml2/callback/provider1"
17
+ */
18
+ declare function normalizePathname(requestUrl: string, basePath: string): string;
19
+ //#endregion
20
+ export { normalizePathname };
@@ -0,0 +1,32 @@
1
+ //#region src/utils/url.ts
2
+ /**
3
+ * Normalizes a request pathname by removing the basePath prefix and trailing slashes.
4
+ * This is useful for matching paths against configured path lists.
5
+ *
6
+ * @param requestUrl - The full request URL
7
+ * @param basePath - The base path of the auth API (e.g., "/api/auth")
8
+ * @returns The normalized path without basePath prefix or trailing slashes,
9
+ * or "/" if URL parsing fails
10
+ *
11
+ * @example
12
+ * normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth")
13
+ * // Returns: "/sso/saml2/callback/provider1"
14
+ *
15
+ * normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/")
16
+ * // Returns: "/sso/saml2/callback/provider1"
17
+ */
18
+ function normalizePathname(requestUrl, basePath) {
19
+ let pathname;
20
+ try {
21
+ pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
22
+ } catch {
23
+ return "/";
24
+ }
25
+ if (basePath === "/" || basePath === "") return pathname;
26
+ if (pathname === basePath) return "/";
27
+ if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
28
+ return pathname;
29
+ }
30
+
31
+ //#endregion
32
+ export { normalizePathname };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.4.15",
3
+ "version": "1.4.16",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -91,7 +91,11 @@ export interface InternalAdapter<
91
91
  email: string,
92
92
  accountId: string,
93
93
  providerId: string,
94
- ): Promise<{ user: User; accounts: Account[] } | null>;
94
+ ): Promise<{
95
+ user: User;
96
+ linkedAccount: Account | null;
97
+ accounts: Account[];
98
+ } | null>;
95
99
 
96
100
  findUserByEmail(
97
101
  email: string,
@@ -142,6 +142,25 @@ export type BetterAuthAdvancedOptions = {
142
142
  * ⚠︎ This is a security risk and it may expose your application to abuse
143
143
  */
144
144
  disableIpTracking?: boolean;
145
+ /**
146
+ * IPv6 subnet prefix length for rate limiting.
147
+ *
148
+ * IPv6 addresses can be grouped by subnet to prevent attackers from
149
+ * bypassing rate limits by rotating through multiple addresses in
150
+ * their allocation.
151
+ *
152
+ * Common values:
153
+ * - 128 (default): Individual IPv6 address
154
+ * - 64: /64 subnet (typical home/business allocation)
155
+ * - 48: /48 subnet (larger network allocation)
156
+ * - 32: /32 subnet (ISP allocation)
157
+ *
158
+ * Note: This only affects IPv6 addresses. IPv4 addresses are always
159
+ * rate limited individually.
160
+ *
161
+ * @default 128 (individual address)
162
+ */
163
+ ipv6Subnet?: 128 | 64 | 48 | 32 | undefined;
145
164
  }
146
165
  | undefined;
147
166
  /**
@@ -1,5 +1,7 @@
1
1
  export { deprecate } from "./deprecate";
2
2
  export { defineErrorCodes } from "./error-codes";
3
3
  export { generateId } from "./id";
4
+ export { createRateLimitKey, isValidIP, normalizeIP } from "./ip";
4
5
  export { safeJSONParse } from "./json";
5
6
  export { capitalizeFirstLetter } from "./string";
7
+ export { normalizePathname } from "./url";
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createRateLimitKey, isValidIP, normalizeIP } from "./ip";
3
+
4
+ describe("IP Normalization", () => {
5
+ describe("isValidIP", () => {
6
+ it("should validate IPv4 addresses", () => {
7
+ expect(isValidIP("192.168.1.1")).toBe(true);
8
+ expect(isValidIP("127.0.0.1")).toBe(true);
9
+ expect(isValidIP("0.0.0.0")).toBe(true);
10
+ expect(isValidIP("255.255.255.255")).toBe(true);
11
+ });
12
+
13
+ it("should validate IPv6 addresses", () => {
14
+ expect(isValidIP("2001:db8::1")).toBe(true);
15
+ expect(isValidIP("::1")).toBe(true);
16
+ expect(isValidIP("::")).toBe(true);
17
+ expect(isValidIP("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe(true);
18
+ });
19
+
20
+ it("should reject invalid IPs", () => {
21
+ expect(isValidIP("not-an-ip")).toBe(false);
22
+ expect(isValidIP("999.999.999.999")).toBe(false);
23
+ expect(isValidIP("gggg::1")).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("IPv4 Normalization", () => {
28
+ it("should return IPv4 addresses unchanged", () => {
29
+ expect(normalizeIP("192.168.1.1")).toBe("192.168.1.1");
30
+ expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1");
31
+ expect(normalizeIP("10.0.0.1")).toBe("10.0.0.1");
32
+ });
33
+ });
34
+
35
+ describe("IPv6 Normalization", () => {
36
+ it("should normalize compressed IPv6 to full form", () => {
37
+ expect(normalizeIP("2001:db8::1")).toBe(
38
+ "2001:0db8:0000:0000:0000:0000:0000:0001",
39
+ );
40
+ expect(normalizeIP("::1")).toBe(
41
+ "0000:0000:0000:0000:0000:0000:0000:0001",
42
+ );
43
+ expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000");
44
+ });
45
+
46
+ it("should normalize uppercase to lowercase", () => {
47
+ expect(normalizeIP("2001:DB8::1")).toBe(
48
+ "2001:0db8:0000:0000:0000:0000:0000:0001",
49
+ );
50
+ expect(normalizeIP("2001:0DB8:ABCD:EF00::1")).toBe(
51
+ "2001:0db8:abcd:ef00:0000:0000:0000:0001",
52
+ );
53
+ });
54
+
55
+ it("should handle various IPv6 formats consistently", () => {
56
+ // All these represent the same address
57
+ const normalized = "2001:0db8:0000:0000:0000:0000:0000:0001";
58
+ expect(normalizeIP("2001:db8::1")).toBe(normalized);
59
+ expect(normalizeIP("2001:0db8:0:0:0:0:0:1")).toBe(normalized);
60
+ expect(normalizeIP("2001:db8:0::1")).toBe(normalized);
61
+ expect(normalizeIP("2001:0db8::0:0:0:1")).toBe(normalized);
62
+ });
63
+
64
+ it("should handle IPv6 with :: at different positions", () => {
65
+ expect(normalizeIP("2001:db8:85a3::8a2e:370:7334")).toBe(
66
+ "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
67
+ );
68
+ expect(normalizeIP("::ffff:192.0.2.1")).not.toContain("::");
69
+ });
70
+ });
71
+
72
+ describe("IPv4-mapped IPv6 Conversion", () => {
73
+ it("should convert IPv4-mapped IPv6 to IPv4", () => {
74
+ expect(normalizeIP("::ffff:192.0.2.1")).toBe("192.0.2.1");
75
+ expect(normalizeIP("::ffff:127.0.0.1")).toBe("127.0.0.1");
76
+ expect(normalizeIP("::FFFF:10.0.0.1")).toBe("10.0.0.1");
77
+ });
78
+
79
+ it("should handle hex-encoded IPv4 in mapped addresses", () => {
80
+ // ::ffff:c000:0201 = ::ffff:192.0.2.1 = 192.0.2.1
81
+ expect(normalizeIP("::ffff:c000:0201")).toBe("192.0.2.1");
82
+ // ::ffff:7f00:0001 = ::ffff:127.0.0.1 = 127.0.0.1
83
+ expect(normalizeIP("::ffff:7f00:0001")).toBe("127.0.0.1");
84
+ });
85
+
86
+ it("should handle full form IPv4-mapped IPv6", () => {
87
+ expect(normalizeIP("0:0:0:0:0:ffff:192.0.2.1")).toBe("192.0.2.1");
88
+ });
89
+ });
90
+
91
+ describe("IPv6 Subnet Support", () => {
92
+ it("should extract /64 subnet", () => {
93
+ /* cspell:disable-next-line */
94
+ const ip1 = normalizeIP("2001:db8:0:0:1234:5678:90ab:cdef", {
95
+ ipv6Subnet: 64,
96
+ });
97
+ const ip2 = normalizeIP("2001:db8:0:0:ffff:ffff:ffff:ffff", {
98
+ ipv6Subnet: 64,
99
+ });
100
+ // Both should have same /64 prefix
101
+ expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
102
+ expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
103
+ expect(ip1).toBe(ip2);
104
+ });
105
+
106
+ it("should extract /48 subnet", () => {
107
+ /* cspell:disable-next-line */
108
+ const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", {
109
+ ipv6Subnet: 48,
110
+ });
111
+ const ip2 = normalizeIP("2001:db8:1234:ffff:ffff:ffff:ffff:ffff", {
112
+ ipv6Subnet: 48,
113
+ });
114
+ // Both should have same /48 prefix
115
+ expect(ip1).toBe("2001:0db8:1234:0000:0000:0000:0000:0000");
116
+ expect(ip2).toBe("2001:0db8:1234:0000:0000:0000:0000:0000");
117
+ expect(ip1).toBe(ip2);
118
+ });
119
+
120
+ it("should extract /32 subnet", () => {
121
+ /* cspell:disable-next-line */
122
+ const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", {
123
+ ipv6Subnet: 32,
124
+ });
125
+ const ip2 = normalizeIP("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", {
126
+ ipv6Subnet: 32,
127
+ });
128
+ // Both should have same /32 prefix
129
+ expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
130
+ expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
131
+ expect(ip1).toBe(ip2);
132
+ });
133
+
134
+ it("should handle /128 (full address) by default", () => {
135
+ const ip1 = normalizeIP("2001:db8::1");
136
+ const ip2 = normalizeIP("2001:db8::1", { ipv6Subnet: 128 });
137
+ expect(ip1).toBe(ip2);
138
+ expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0001");
139
+ });
140
+
141
+ it("should not affect IPv4 addresses when ipv6Subnet is set", () => {
142
+ expect(normalizeIP("192.168.1.1", { ipv6Subnet: 64 })).toBe(
143
+ "192.168.1.1",
144
+ );
145
+ });
146
+ });
147
+
148
+ describe("Rate Limit Key Creation", () => {
149
+ it("should create keys with separator", () => {
150
+ expect(createRateLimitKey("192.168.1.1", "/sign-in")).toBe(
151
+ "192.168.1.1|/sign-in",
152
+ );
153
+ expect(createRateLimitKey("2001:db8::1", "/api/auth")).toBe(
154
+ "2001:db8::1|/api/auth",
155
+ );
156
+ });
157
+
158
+ it("should prevent collision attacks", () => {
159
+ // Without separator: "192.0.2.1" + "/sign-in" = "192.0.2.1/sign-in"
160
+ // "192.0.2" + ".1/sign-in" = "192.0.2.1/sign-in"
161
+ // With separator: they're different
162
+ const key1 = createRateLimitKey("192.0.2.1", "/sign-in");
163
+ const key2 = createRateLimitKey("192.0.2", ".1/sign-in");
164
+ expect(key1).not.toBe(key2);
165
+ expect(key1).toBe("192.0.2.1|/sign-in");
166
+ expect(key2).toBe("192.0.2|.1/sign-in");
167
+ });
168
+ });
169
+
170
+ describe("Security: Bypass Prevention", () => {
171
+ it("should prevent IPv6 representation bypass", () => {
172
+ // Attacker tries different representations of same address
173
+ const representations = [
174
+ "2001:db8::1",
175
+ "2001:DB8::1",
176
+ "2001:0db8::1",
177
+ "2001:db8:0::1",
178
+ "2001:0db8:0:0:0:0:0:1",
179
+ "2001:db8::0:1",
180
+ ];
181
+
182
+ const normalized = representations.map((ip) => normalizeIP(ip));
183
+ // All should normalize to the same value
184
+ const uniqueValues = new Set(normalized);
185
+ expect(uniqueValues.size).toBe(1);
186
+ expect(normalized[0]).toBe("2001:0db8:0000:0000:0000:0000:0000:0001");
187
+ });
188
+
189
+ it("should prevent IPv4-mapped bypass", () => {
190
+ // Attacker switches between IPv4 and IPv4-mapped IPv6
191
+ const ip1 = normalizeIP("192.0.2.1");
192
+ const ip2 = normalizeIP("::ffff:192.0.2.1");
193
+ const ip3 = normalizeIP("::FFFF:192.0.2.1");
194
+ const ip4 = normalizeIP("::ffff:c000:0201");
195
+
196
+ // All should normalize to the same IPv4
197
+ expect(ip1).toBe("192.0.2.1");
198
+ expect(ip2).toBe("192.0.2.1");
199
+ expect(ip3).toBe("192.0.2.1");
200
+ expect(ip4).toBe("192.0.2.1");
201
+ });
202
+
203
+ it("should group IPv6 subnet attacks", () => {
204
+ // Attacker rotates through addresses in their /64 allocation
205
+ const attackIPs = [
206
+ "2001:db8:abcd:1234:0000:0000:0000:0001",
207
+ "2001:db8:abcd:1234:1111:2222:3333:4444",
208
+ "2001:db8:abcd:1234:ffff:ffff:ffff:ffff",
209
+ "2001:db8:abcd:1234:aaaa:bbbb:cccc:dddd",
210
+ ];
211
+
212
+ const normalized = attackIPs.map((ip) =>
213
+ normalizeIP(ip, { ipv6Subnet: 64 }),
214
+ );
215
+
216
+ // All should map to same /64 subnet
217
+ const uniqueValues = new Set(normalized);
218
+ expect(uniqueValues.size).toBe(1);
219
+ expect(normalized[0]).toBe("2001:0db8:abcd:1234:0000:0000:0000:0000");
220
+ });
221
+ });
222
+
223
+ describe("Edge Cases", () => {
224
+ it("should handle localhost addresses", () => {
225
+ expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1");
226
+ expect(normalizeIP("::1")).toBe(
227
+ "0000:0000:0000:0000:0000:0000:0000:0001",
228
+ );
229
+ });
230
+
231
+ it("should handle all-zeros address", () => {
232
+ expect(normalizeIP("0.0.0.0")).toBe("0.0.0.0");
233
+ expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000");
234
+ });
235
+
236
+ it("should handle link-local addresses", () => {
237
+ expect(normalizeIP("169.254.0.1")).toBe("169.254.0.1");
238
+ expect(normalizeIP("fe80::1")).toBe(
239
+ "fe80:0000:0000:0000:0000:0000:0000:0001",
240
+ );
241
+ });
242
+ });
243
+ });
@@ -0,0 +1,211 @@
1
+ import * as z from "zod";
2
+
3
+ /**
4
+ * Normalizes an IP address for consistent rate limiting.
5
+ *
6
+ * Features:
7
+ * - Normalizes IPv6 to canonical lowercase form
8
+ * - Converts IPv4-mapped IPv6 to IPv4
9
+ * - Supports IPv6 subnet extraction
10
+ * - Handles all edge cases (::1, ::, etc.)
11
+ */
12
+
13
+ interface NormalizeIPOptions {
14
+ /**
15
+ * For IPv6 addresses, extract the subnet prefix instead of full address.
16
+ * Common values: 32, 48, 64, 128 (default: 128 = full address)
17
+ *
18
+ * @default 128
19
+ */
20
+ ipv6Subnet?: 128 | 64 | 48 | 32;
21
+ }
22
+
23
+ /**
24
+ * Checks if an IP is valid IPv4 or IPv6
25
+ */
26
+ export function isValidIP(ip: string): boolean {
27
+ return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success;
28
+ }
29
+
30
+ /**
31
+ * Checks if an IP is IPv6
32
+ */
33
+ function isIPv6(ip: string): boolean {
34
+ return z.ipv6().safeParse(ip).success;
35
+ }
36
+
37
+ /**
38
+ * Converts IPv4-mapped IPv6 address to IPv4
39
+ * e.g., "::ffff:192.0.2.1" -> "192.0.2.1"
40
+ */
41
+ function extractIPv4FromMapped(ipv6: string): string | null {
42
+ const lower = ipv6.toLowerCase();
43
+
44
+ // Handle ::ffff:192.0.2.1 format
45
+ if (lower.startsWith("::ffff:")) {
46
+ const ipv4Part = lower.substring(7);
47
+ // Check if it's a valid IPv4
48
+ if (z.ipv4().safeParse(ipv4Part).success) {
49
+ return ipv4Part;
50
+ }
51
+ }
52
+
53
+ // Handle full form: 0:0:0:0:0:ffff:192.0.2.1
54
+ const parts = ipv6.split(":");
55
+ if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") {
56
+ const ipv4Part = parts[6];
57
+ if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) {
58
+ return ipv4Part;
59
+ }
60
+ }
61
+
62
+ // Handle hex-encoded IPv4 in mapped address
63
+ // e.g., ::ffff:c000:0201 -> 192.0.2.1
64
+ if (lower.includes("::ffff:") || lower.includes(":ffff:")) {
65
+ const groups = expandIPv6(ipv6);
66
+ if (
67
+ groups.length === 8 &&
68
+ groups[0] === "0000" &&
69
+ groups[1] === "0000" &&
70
+ groups[2] === "0000" &&
71
+ groups[3] === "0000" &&
72
+ groups[4] === "0000" &&
73
+ groups[5] === "ffff" &&
74
+ groups[6] &&
75
+ groups[7]
76
+ ) {
77
+ // Convert last two groups to IPv4
78
+ const byte1 = Number.parseInt(groups[6].substring(0, 2), 16);
79
+ const byte2 = Number.parseInt(groups[6].substring(2, 4), 16);
80
+ const byte3 = Number.parseInt(groups[7].substring(0, 2), 16);
81
+ const byte4 = Number.parseInt(groups[7].substring(2, 4), 16);
82
+ return `${byte1}.${byte2}.${byte3}.${byte4}`;
83
+ }
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Expands a compressed IPv6 address to full form
91
+ * e.g., "2001:db8::1" -> ["2001", "0db8", "0000", "0000", "0000", "0000", "0000", "0001"]
92
+ */
93
+ function expandIPv6(ipv6: string): string[] {
94
+ // Handle :: notation (zero compression)
95
+ if (ipv6.includes("::")) {
96
+ const sides = ipv6.split("::");
97
+ const left = sides[0] ? sides[0].split(":") : [];
98
+ const right = sides[1] ? sides[1].split(":") : [];
99
+
100
+ // Calculate missing groups
101
+ const totalGroups = 8;
102
+ const missingGroups = totalGroups - left.length - right.length;
103
+ const zeros = Array(missingGroups).fill("0000");
104
+
105
+ // Pad existing groups to 4 digits
106
+ const paddedLeft = left.map((g) => g.padStart(4, "0"));
107
+ const paddedRight = right.map((g) => g.padStart(4, "0"));
108
+
109
+ return [...paddedLeft, ...zeros, ...paddedRight];
110
+ }
111
+
112
+ // No compression, just pad each group
113
+ return ipv6.split(":").map((g) => g.padStart(4, "0"));
114
+ }
115
+
116
+ /**
117
+ * Normalizes an IPv6 address to canonical form
118
+ * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
119
+ */
120
+ function normalizeIPv6(
121
+ ipv6: string,
122
+ subnetPrefix?: 128 | 32 | 48 | 64,
123
+ ): string {
124
+ const groups = expandIPv6(ipv6);
125
+
126
+ if (subnetPrefix && subnetPrefix < 128) {
127
+ // Apply subnet mask
128
+ const prefix = subnetPrefix;
129
+ let bitsRemaining: number = prefix;
130
+
131
+ const maskedGroups = groups.map((group) => {
132
+ if (bitsRemaining <= 0) {
133
+ return "0000";
134
+ }
135
+ if (bitsRemaining >= 16) {
136
+ bitsRemaining -= 16;
137
+ return group;
138
+ }
139
+
140
+ // Partial mask for this group
141
+ const value = Number.parseInt(group, 16);
142
+ const mask = (0xffff << (16 - bitsRemaining)) & 0xffff;
143
+ const masked = value & mask;
144
+ bitsRemaining = 0;
145
+ return masked.toString(16).padStart(4, "0");
146
+ });
147
+
148
+ return maskedGroups.join(":").toLowerCase();
149
+ }
150
+
151
+ return groups.join(":").toLowerCase();
152
+ }
153
+
154
+ /**
155
+ * Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
156
+ *
157
+ * @param ip - The IP address to normalize
158
+ * @param options - Normalization options
159
+ * @returns Normalized IP address
160
+ *
161
+ * @example
162
+ * normalizeIP("2001:DB8::1")
163
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0001"
164
+ *
165
+ * @example
166
+ * normalizeIP("::ffff:192.0.2.1")
167
+ * // -> "192.0.2.1" (converted to IPv4)
168
+ *
169
+ * @example
170
+ * normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
171
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
172
+ */
173
+ export function normalizeIP(
174
+ ip: string,
175
+ options: NormalizeIPOptions = {},
176
+ ): string {
177
+ // IPv4 addresses are already normalized
178
+ if (z.ipv4().safeParse(ip).success) {
179
+ return ip.toLowerCase();
180
+ }
181
+
182
+ // Check if it's IPv6
183
+ if (!isIPv6(ip)) {
184
+ // Return as-is if not valid (shouldn't happen due to prior validation)
185
+ return ip.toLowerCase();
186
+ }
187
+
188
+ // Check for IPv4-mapped IPv6
189
+ const ipv4 = extractIPv4FromMapped(ip);
190
+ if (ipv4) {
191
+ return ipv4.toLowerCase();
192
+ }
193
+
194
+ // Normalize IPv6
195
+ const subnetPrefix = options.ipv6Subnet || 128;
196
+ return normalizeIPv6(ip, subnetPrefix);
197
+ }
198
+
199
+ /**
200
+ * Creates a rate limit key from IP and path
201
+ * Uses a separator to prevent collision attacks
202
+ *
203
+ * @param ip - The IP address (should be normalized)
204
+ * @param path - The request path
205
+ * @returns Rate limit key
206
+ */
207
+ export function createRateLimitKey(ip: string, path: string): string {
208
+ // Use | as separator to prevent collision attacks
209
+ // e.g., "192.0.2.1" + "/sign-in" vs "192.0.2" + ".1/sign-in"
210
+ return `${ip}|${path}`;
211
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Normalizes a request pathname by removing the basePath prefix and trailing slashes.
3
+ * This is useful for matching paths against configured path lists.
4
+ *
5
+ * @param requestUrl - The full request URL
6
+ * @param basePath - The base path of the auth API (e.g., "/api/auth")
7
+ * @returns The normalized path without basePath prefix or trailing slashes,
8
+ * or "/" if URL parsing fails
9
+ *
10
+ * @example
11
+ * normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth")
12
+ * // Returns: "/sso/saml2/callback/provider1"
13
+ *
14
+ * normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/")
15
+ * // Returns: "/sso/saml2/callback/provider1"
16
+ */
17
+ export function normalizePathname(
18
+ requestUrl: string,
19
+ basePath: string,
20
+ ): string {
21
+ let pathname: string;
22
+ try {
23
+ pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
24
+ } catch {
25
+ return "/";
26
+ }
27
+
28
+ if (basePath === "/" || basePath === "") {
29
+ return pathname;
30
+ }
31
+
32
+ // Check for exact match or proper path boundary (basePath followed by "/" or end)
33
+ // This prevents "/api/auth" from matching "/api/authevil/..."
34
+ if (pathname === basePath) {
35
+ return "/";
36
+ }
37
+
38
+ if (pathname.startsWith(basePath + "/")) {
39
+ return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
40
+ }
41
+
42
+ return pathname;
43
+ }