@better-auth/cimd 1.7.0-beta.6 → 1.7.0-beta.7

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.
package/dist/index.d.mts CHANGED
@@ -30,6 +30,15 @@ interface CimdOptions {
30
30
  * @default ["redirect_uris", "post_logout_redirect_uris", "client_uri"]
31
31
  */
32
32
  originBoundFields?: string[];
33
+ /**
34
+ * Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
35
+ * `*.localhost`), including plain HTTP, so an auth server can fetch a
36
+ * metadata document hosted on the same machine. Off by default; enable
37
+ * only for local development.
38
+ *
39
+ * @default false
40
+ */
41
+ allowLoopback?: boolean;
33
42
  /**
34
43
  * Pre-fetch gate called before a metadata document is requested. Return
35
44
  * `false` to reject the `client_id` URL.
@@ -86,19 +95,31 @@ interface ClientIdMetadataDocumentResult {
86
95
  error?: string;
87
96
  warnings?: string[];
88
97
  }
89
- /** Hostnames that are considered "localhost" for development flows. */
90
- declare function isLocalhost(hostname: string): boolean;
98
+ interface ClientIdUrlOptions {
99
+ /**
100
+ * Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
101
+ * `*.localhost`) and plain HTTP for them. Off by default.
102
+ */
103
+ allowLoopback?: boolean;
104
+ }
91
105
  /**
92
- * Detect URL-formatted client_id (Client ID Metadata Document pattern).
93
- * HTTPS always accepted; HTTP accepted for localhost variants
94
- * (localhost, 127.0.0.1, [::1], *.localhost) for development.
106
+ * Detect a URL-formatted client_id (Client ID Metadata Document pattern).
107
+ *
108
+ * HTTPS URLs always match; plain HTTP matches only loopback hosts, and only
109
+ * when `allowLoopback` is set. This is a routing predicate, not a security
110
+ * gate: it performs no DNS resolution, so callers MUST also run
111
+ * {@link validateClientIdUrl} (and a fetch-time policy) before fetching.
95
112
  */
96
- declare function isUrlClientId(clientId: string): boolean;
113
+ declare function isUrlClientId(clientId: string, options?: ClientIdUrlOptions): boolean;
97
114
  /**
98
115
  * Validate a client_id URL per IETF draft §3.
99
- * Returns null on success, error string on failure.
116
+ * Returns null on success, an error string on failure.
117
+ *
118
+ * Loopback hosts are rejected unless `allowLoopback` is set; every other
119
+ * non-public host (private, link-local, cloud-metadata, IPv6 tunnels) is
120
+ * rejected.
100
121
  */
101
- declare function validateClientIdUrl(url: string): string | null;
122
+ declare function validateClientIdUrl(url: string, options?: ClientIdUrlOptions): string | null;
102
123
  /**
103
124
  * Validate a fetched Client ID Metadata Document per §4.1.
104
125
  *
@@ -142,4 +163,4 @@ declare const cimd: (options?: CimdOptions) => {
142
163
  init(ctx: better_auth0.AuthContext): void;
143
164
  };
144
165
  //#endregion
145
- export { type CimdOptions, type ClientIdMetadataDocumentResult, cimd, cimdClientDiscovery, createCimdResolver, isLocalhost, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
166
+ export { type CimdOptions, type ClientIdMetadataDocumentResult, type ClientIdUrlOptions, cimd, cimdClientDiscovery, createCimdResolver, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
package/dist/index.mjs CHANGED
@@ -2,8 +2,9 @@ import { checkOAuthClient, extendOAuthProvider, oauthToSchema } from "@better-au
2
2
  import { BetterAuthError } from "@better-auth/core/error";
3
3
  import { toExpJWT } from "better-auth/plugins";
4
4
  import { APIError } from "better-call";
5
+ import { isLoopbackHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
5
6
  //#region src/validate-metadata-document.ts
6
- const DOT_SEGMENT_RE = /\/\.\.?(?:\/|$|#|\?)/;
7
+ const DOT_SEGMENT_RE = /\/(?:\.|%2e)(?:\.|%2e)?(?:\/|$|#|\?)/i;
7
8
  const PROHIBITED_FIELDS = new Set(["client_secret", "client_secret_expires_at"]);
8
9
  const SYMMETRIC_AUTH_METHODS = new Set([
9
10
  "client_secret_post",
@@ -12,80 +13,35 @@ const SYMMETRIC_AUTH_METHODS = new Set([
12
13
  ]);
13
14
  const ALLOWED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]);
14
15
  const ALLOWED_RESPONSE_TYPES = new Set(["code"]);
15
- /** Hostnames that are considered "localhost" for development flows. */
16
- function isLocalhost(hostname) {
17
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname.endsWith(".localhost");
18
- }
19
- /**
20
- * Check whether a dotted-decimal IPv4 address is private, reserved, or
21
- * otherwise non-routable for a public SSRF target. Covers the subset of
22
- * RFC 6890 special-purpose ranges that an adversarial `client_id` URL
23
- * could point at to reach internal infrastructure or disrupt fetches.
24
- */
25
- function isPrivateIpv4(host) {
26
- const parts = host.split(".");
27
- if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p))) return false;
28
- const a = Number(parts[0]);
29
- const b = Number(parts[1]);
30
- const c = Number(parts[2]);
31
- return a === 127 || a === 10 || a === 0 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a === 198 && (b === 18 || b === 19) || a === 192 && b === 0 && c === 2 || a === 198 && b === 51 && c === 100 || a === 203 && b === 0 && c === 113 || a === 192 && b === 88 && c === 99 || a >= 224 && a <= 239 || a >= 240;
32
- }
33
- const V4_MAPPED_DOTTED_RE = /^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/;
34
- const V4_MAPPED_HEX_RE = /^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
35
16
  /**
36
- * Convert two hex groups from an IPv4-mapped IPv6 address to dotted-decimal IPv4.
37
- * e.g. "a9fe" "a9fe" -> "169.254.169.254"
38
- */
39
- function hexGroupsToIpv4(hi, lo) {
40
- const h = Number.parseInt(hi, 16);
41
- const l = Number.parseInt(lo, 16);
42
- return `${h >> 8 & 255}.${h & 255}.${l >> 8 & 255}.${l & 255}`;
43
- }
44
- /**
45
- * Check whether a hostname is private/reserved per RFC 6890.
17
+ * Detect a URL-formatted client_id (Client ID Metadata Document pattern).
46
18
  *
47
- * Handles bracketed IPv6 (as returned by URL.hostname), IPv4-mapped
48
- * IPv6 in both dotted-decimal and hex-normalized forms, and cloud
49
- * metadata hostnames. No DNS resolution, so it runs identically on
50
- * Node, Bun, Deno, and Workers.
19
+ * HTTPS URLs always match; plain HTTP matches only loopback hosts, and only
20
+ * when `allowLoopback` is set. This is a routing predicate, not a security
21
+ * gate: it performs no DNS resolution, so callers MUST also run
22
+ * {@link validateClientIdUrl} (and a fetch-time policy) before fetching.
51
23
  */
52
- function isPrivateHost(hostname) {
53
- const lower = hostname.toLowerCase();
54
- const host = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
55
- if (host === "::1") return true;
56
- if (isPrivateIpv4(host)) return true;
57
- if (host.includes(":")) {
58
- const dottedMatch = host.match(V4_MAPPED_DOTTED_RE);
59
- if (dottedMatch && isPrivateIpv4(dottedMatch[1])) return true;
60
- const hexMatch = host.match(V4_MAPPED_HEX_RE);
61
- if (hexMatch) {
62
- if (isPrivateIpv4(hexGroupsToIpv4(hexMatch[1], hexMatch[2]))) return true;
63
- }
64
- if (/^fe[89ab]/.test(host)) return true;
65
- if (host.startsWith("fc") || host.startsWith("fd")) return true;
66
- }
67
- if (host === "metadata.google.internal") return true;
68
- return false;
69
- }
70
- /**
71
- * Detect URL-formatted client_id (Client ID Metadata Document pattern).
72
- * HTTPS always accepted; HTTP accepted for localhost variants
73
- * (localhost, 127.0.0.1, [::1], *.localhost) for development.
74
- */
75
- function isUrlClientId(clientId) {
76
- if (clientId.startsWith("https://")) return true;
77
- if (!clientId.startsWith("http://")) return false;
24
+ function isUrlClientId(clientId, options) {
25
+ let parsed;
78
26
  try {
79
- return isLocalhost(new URL(clientId).hostname);
27
+ parsed = new URL(clientId);
80
28
  } catch {
81
29
  return false;
82
30
  }
31
+ if (parsed.protocol === "https:") return true;
32
+ if (parsed.protocol !== "http:") return false;
33
+ if (!options?.allowLoopback) return false;
34
+ return isLoopbackHost(parsed.hostname);
83
35
  }
84
36
  /**
85
37
  * Validate a client_id URL per IETF draft §3.
86
- * Returns null on success, error string on failure.
38
+ * Returns null on success, an error string on failure.
39
+ *
40
+ * Loopback hosts are rejected unless `allowLoopback` is set; every other
41
+ * non-public host (private, link-local, cloud-metadata, IPv6 tunnels) is
42
+ * rejected.
87
43
  */
88
- function validateClientIdUrl(url) {
44
+ function validateClientIdUrl(url, options) {
89
45
  if (DOT_SEGMENT_RE.test(url)) return "client_id URL MUST NOT contain dot segments";
90
46
  if (url.includes("#")) return "client_id URL MUST NOT contain a fragment";
91
47
  let parsed;
@@ -95,10 +51,14 @@ function validateClientIdUrl(url) {
95
51
  return "client_id is not a valid URL";
96
52
  }
97
53
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "client_id URL must use HTTPS";
98
- if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) return "client_id URL must use HTTPS (HTTP allowed only for localhost)";
99
54
  if (parsed.username || parsed.password) return "client_id URL MUST NOT contain credentials";
100
55
  if (parsed.pathname === "/" || parsed.pathname === "") return "client_id URL MUST contain a path component";
101
- if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) return "client_id URL must not resolve to a private or reserved address";
56
+ if (isLoopbackHost(parsed.hostname)) {
57
+ if (!options?.allowLoopback) return "client_id URL must not target a loopback address (set allowLoopback to enable local development)";
58
+ return null;
59
+ }
60
+ if (parsed.protocol !== "https:") return "client_id URL must use HTTPS (HTTP is allowed only for loopback in development)";
61
+ if (!isPublicRoutableHost(parsed.hostname)) return "client_id URL must not target a private or reserved address";
102
62
  return null;
103
63
  }
104
64
  /** Warning: §3 SHOULD NOT have a query string. */
@@ -180,7 +140,7 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
180
140
  valid: false,
181
141
  error: `${field} must use HTTP(S)`
182
142
  };
183
- if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) return {
143
+ if (!isPublicRoutableHost(parsed.hostname)) return {
184
144
  valid: false,
185
145
  error: `${field} must not point to a private or reserved address`
186
146
  };
@@ -234,7 +194,7 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
234
194
  valid: false,
235
195
  error: `all values for ${key} must use HTTP(S)`
236
196
  };
237
- const localhostAllowed = (key === "redirect_uris" || key === "post_logout_redirect_uris") && isLocalhost(uri.hostname);
197
+ const localhostAllowed = (key === "redirect_uris" || key === "post_logout_redirect_uris") && isLoopbackHost(uri.hostname);
238
198
  if (uri.origin !== clientIdOrigin && !localhostAllowed) return {
239
199
  valid: false,
240
200
  error: `${key} value "${val}" must have the same origin as client_id (${clientIdOrigin})`
@@ -448,7 +408,7 @@ async function refreshMetadataDocumentClient(ctx, clientIdUrl, existing, cimdOpt
448
408
  * and return the parsed metadata.
449
409
  */
450
410
  async function fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions) {
451
- const urlError = validateClientIdUrl(clientIdUrl);
411
+ const urlError = validateClientIdUrl(clientIdUrl, { allowLoopback: cimdOptions.allowLoopback });
452
412
  if (urlError) throw new APIError("BAD_REQUEST", {
453
413
  error: "invalid_client",
454
414
  error_description: urlError
@@ -545,7 +505,7 @@ function isStale(existing, refreshRate) {
545
505
  function createCimdResolver(cimdOptions = {}) {
546
506
  const refreshRate = cimdOptions.refreshRate ?? "60m";
547
507
  return async (ctx, clientId, existing) => {
548
- if (!isUrlClientId(clientId)) return null;
508
+ if (!isUrlClientId(clientId, { allowLoopback: cimdOptions.allowLoopback })) return null;
549
509
  const provider = ctx.context.getPlugin("oauth-provider");
550
510
  if (!provider) throw new BetterAuthError("cimd discovery invoked without the oauth-provider plugin installed");
551
511
  const oauthOptions = provider.options;
@@ -556,7 +516,7 @@ function createCimdResolver(cimdOptions = {}) {
556
516
  }
557
517
  //#endregion
558
518
  //#region src/version.ts
559
- const PACKAGE_VERSION = "1.7.0-beta.6";
519
+ const PACKAGE_VERSION = "1.7.0-beta.7";
560
520
  //#endregion
561
521
  //#region src/index.ts
562
522
  /**
@@ -568,10 +528,12 @@ const PACKAGE_VERSION = "1.7.0-beta.6";
568
528
  * alongside whatever else is configured.
569
529
  */
570
530
  function cimdClientDiscovery(options = {}) {
531
+ const resolver = createCimdResolver(options);
532
+ const allowLoopback = options.allowLoopback ?? false;
571
533
  return {
572
534
  id: "cimd",
573
- matches: isUrlClientId,
574
- resolve: createCimdResolver(options),
535
+ matches: (clientId) => isUrlClientId(clientId, { allowLoopback }),
536
+ resolve: resolver,
575
537
  discoveryMetadata: { client_id_metadata_document_supported: true }
576
538
  };
577
539
  }
@@ -597,4 +559,4 @@ const cimd = (options = {}) => {
597
559
  };
598
560
  };
599
561
  //#endregion
600
- export { cimd, cimdClientDiscovery, createCimdResolver, isLocalhost, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
562
+ export { cimd, cimdClientDiscovery, createCimdResolver, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/cimd",
3
- "version": "1.7.0-beta.6",
3
+ "version": "1.7.0-beta.7",
4
4
  "description": "Client ID Metadata Document plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,15 +48,15 @@
48
48
  "devDependencies": {
49
49
  "listhen": "^1.9.0",
50
50
  "tsdown": "0.21.1",
51
- "@better-auth/core": "1.7.0-beta.6",
52
- "@better-auth/oauth-provider": "1.7.0-beta.6",
53
- "better-auth": "1.7.0-beta.6"
51
+ "@better-auth/core": "1.7.0-beta.7",
52
+ "@better-auth/oauth-provider": "1.7.0-beta.7",
53
+ "better-auth": "1.7.0-beta.7"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "better-call": "1.3.6",
57
- "@better-auth/core": "^1.7.0-beta.6",
58
- "@better-auth/oauth-provider": "^1.7.0-beta.6",
59
- "better-auth": "^1.7.0-beta.6"
57
+ "@better-auth/core": "^1.7.0-beta.7",
58
+ "@better-auth/oauth-provider": "^1.7.0-beta.7",
59
+ "better-auth": "^1.7.0-beta.7"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsdown",