@better-auth/cimd 1.7.0-beta.5 → 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.
@@ -74,8 +83,9 @@ type CimdResolver = (ctx: GenericEndpointContext, clientId: string, existing: Sc
74
83
  * Build the `resolve` function for a CIMD {@link ClientDiscovery}.
75
84
  *
76
85
  * Exposed for advanced composition. Most users should call
77
- * {@link cimdClientDiscovery} (to pass a complete discovery to
78
- * `oauthProvider({ clientDiscovery })`) or install the `cimd()` plugin.
86
+ * {@link cimdClientDiscovery} (to contribute a complete discovery through
87
+ * `oauthProvider({ extensions: [{ clientDiscovery }] })`) or install the
88
+ * `cimd()` plugin.
79
89
  */
80
90
  declare function createCimdResolver(cimdOptions?: CimdOptions): CimdResolver;
81
91
  //#endregion
@@ -85,19 +95,31 @@ interface ClientIdMetadataDocumentResult {
85
95
  error?: string;
86
96
  warnings?: string[];
87
97
  }
88
- /** Hostnames that are considered "localhost" for development flows. */
89
- 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
+ }
90
105
  /**
91
- * Detect URL-formatted client_id (Client ID Metadata Document pattern).
92
- * HTTPS always accepted; HTTP accepted for localhost variants
93
- * (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.
94
112
  */
95
- declare function isUrlClientId(clientId: string): boolean;
113
+ declare function isUrlClientId(clientId: string, options?: ClientIdUrlOptions): boolean;
96
114
  /**
97
115
  * Validate a client_id URL per IETF draft §3.
98
- * 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.
99
121
  */
100
- declare function validateClientIdUrl(url: string): string | null;
122
+ declare function validateClientIdUrl(url: string, options?: ClientIdUrlOptions): string | null;
101
123
  /**
102
124
  * Validate a fetched Client ID Metadata Document per §4.1.
103
125
  *
@@ -118,12 +140,12 @@ declare module "@better-auth/core" {
118
140
  /**
119
141
  * Build a {@link ClientDiscovery} for Client ID Metadata Documents.
120
142
  *
121
- * Users who prefer explicit composition can pass the result directly to
122
- * `oauthProvider({ clientDiscovery })`; most users should install the
123
- * {@link cimd} plugin instead, which appends this discovery to whatever
124
- * is already configured.
143
+ * Users who prefer explicit composition can contribute the result through
144
+ * `oauthProvider({ extensions: [{ clientDiscovery }] })`; most users should
145
+ * install the {@link cimd} plugin instead, which contributes this discovery
146
+ * alongside whatever else is configured.
125
147
  */
126
- declare function cimdClientDiscovery(options?: CimdOptions): ClientDiscovery<Scope[]>;
148
+ declare function cimdClientDiscovery(options?: CimdOptions): ClientDiscovery;
127
149
  /**
128
150
  * Client ID Metadata Document plugin.
129
151
  *
@@ -141,4 +163,4 @@ declare const cimd: (options?: CimdOptions) => {
141
163
  init(ctx: better_auth0.AuthContext): void;
142
164
  };
143
165
  //#endregion
144
- 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
@@ -1,9 +1,10 @@
1
+ import { checkOAuthClient, extendOAuthProvider, oauthToSchema } from "@better-auth/oauth-provider";
1
2
  import { BetterAuthError } from "@better-auth/core/error";
2
3
  import { toExpJWT } from "better-auth/plugins";
3
- import { checkOAuthClient, oauthToSchema } from "@better-auth/oauth-provider";
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
@@ -538,13 +498,14 @@ function isStale(existing, refreshRate) {
538
498
  * Build the `resolve` function for a CIMD {@link ClientDiscovery}.
539
499
  *
540
500
  * Exposed for advanced composition. Most users should call
541
- * {@link cimdClientDiscovery} (to pass a complete discovery to
542
- * `oauthProvider({ clientDiscovery })`) or install the `cimd()` plugin.
501
+ * {@link cimdClientDiscovery} (to contribute a complete discovery through
502
+ * `oauthProvider({ extensions: [{ clientDiscovery }] })`) or install the
503
+ * `cimd()` plugin.
543
504
  */
544
505
  function createCimdResolver(cimdOptions = {}) {
545
506
  const refreshRate = cimdOptions.refreshRate ?? "60m";
546
507
  return async (ctx, clientId, existing) => {
547
- if (!isUrlClientId(clientId)) return null;
508
+ if (!isUrlClientId(clientId, { allowLoopback: cimdOptions.allowLoopback })) return null;
548
509
  const provider = ctx.context.getPlugin("oauth-provider");
549
510
  if (!provider) throw new BetterAuthError("cimd discovery invoked without the oauth-provider plugin installed");
550
511
  const oauthOptions = provider.options;
@@ -555,22 +516,24 @@ function createCimdResolver(cimdOptions = {}) {
555
516
  }
556
517
  //#endregion
557
518
  //#region src/version.ts
558
- const PACKAGE_VERSION = "1.7.0-beta.5";
519
+ const PACKAGE_VERSION = "1.7.0-beta.7";
559
520
  //#endregion
560
521
  //#region src/index.ts
561
522
  /**
562
523
  * Build a {@link ClientDiscovery} for Client ID Metadata Documents.
563
524
  *
564
- * Users who prefer explicit composition can pass the result directly to
565
- * `oauthProvider({ clientDiscovery })`; most users should install the
566
- * {@link cimd} plugin instead, which appends this discovery to whatever
567
- * is already configured.
525
+ * Users who prefer explicit composition can contribute the result through
526
+ * `oauthProvider({ extensions: [{ clientDiscovery }] })`; most users should
527
+ * install the {@link cimd} plugin instead, which contributes this discovery
528
+ * alongside whatever else is configured.
568
529
  */
569
530
  function cimdClientDiscovery(options = {}) {
531
+ const resolver = createCimdResolver(options);
532
+ const allowLoopback = options.allowLoopback ?? false;
570
533
  return {
571
534
  id: "cimd",
572
- matches: isUrlClientId,
573
- resolve: createCimdResolver(options),
535
+ matches: (clientId) => isUrlClientId(clientId, { allowLoopback }),
536
+ resolve: resolver,
574
537
  discoveryMetadata: { client_id_metadata_document_supported: true }
575
538
  };
576
539
  }
@@ -591,12 +554,9 @@ const cimd = (options = {}) => {
591
554
  id: "cimd",
592
555
  version: PACKAGE_VERSION,
593
556
  init(ctx) {
594
- const provider = ctx.getPlugin("oauth-provider");
595
- if (!provider) throw new BetterAuthError("The cimd plugin requires the oauth-provider plugin.");
596
- const existing = provider.options.clientDiscovery;
597
- provider.options.clientDiscovery = Array.isArray(existing) ? [...existing, discovery] : existing ? [existing, discovery] : discovery;
557
+ extendOAuthProvider(ctx, { clientDiscovery: discovery });
598
558
  }
599
559
  };
600
560
  };
601
561
  //#endregion
602
- 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.5",
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.5",
52
- "@better-auth/oauth-provider": "1.7.0-beta.5",
53
- "better-auth": "1.7.0-beta.5"
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.5",
58
- "@better-auth/oauth-provider": "^1.7.0-beta.5",
59
- "better-auth": "^1.7.0-beta.5"
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",