@better-auth/cimd 1.7.0-rc.2 → 1.7.0-rc.3

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Better Auth CIMD Plugin
2
2
 
3
- Client ID Metadata Document plugin for [Better Auth](https://www.better-auth.com): unauthenticated dynamic client discovery over HTTPS, the mechanism [MCP](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow) uses for authorization servers to discover clients without prior registration.
3
+ Client ID Metadata Document draft-02 plugin for [Better Auth](https://www.better-auth.com): unauthenticated client discovery over HTTPS. An explicit metadata profile applies the draft-00 requirements pinned by [MCP 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents).
4
4
 
5
5
  ## Installation
6
6
 
package/dist/index.d.mts CHANGED
@@ -1,22 +1,114 @@
1
- import { ClientDiscovery, SchemaClient, Scope } from "@better-auth/oauth-provider";
1
+ import { ClientDiscovery, ClientMetadataResourceFetch, OAuthClientMetadata, SchemaClient, Scope } from "@better-auth/oauth-provider";
2
2
  import { GenericEndpointContext } from "@better-auth/core";
3
3
  //#region src/types.d.ts
4
+ type CimdMetadataProfile = "mcp-2026-07-28";
5
+ interface CimdClientCreatedEvent {
6
+ /** Newly persisted discovery-owned OAuth client. */
7
+ client: SchemaClient<Scope[]>;
8
+ /** Validated Client ID Metadata Document that produced the client. */
9
+ clientMetadataDocument: OAuthClientMetadata;
10
+ /** Better Auth endpoint context for the discovery request. */
11
+ context: GenericEndpointContext;
12
+ }
13
+ interface CimdClientRefreshedEvent {
14
+ /** Discovery-owned OAuth client after metadata reconciliation. */
15
+ client: SchemaClient<Scope[]>;
16
+ /** Client state captured before metadata reconciliation. */
17
+ previousClient: SchemaClient<Scope[]>;
18
+ /** Validated Client ID Metadata Document used for reconciliation. */
19
+ clientMetadataDocument: OAuthClientMetadata;
20
+ /** Better Auth endpoint context for the discovery request. */
21
+ context: GenericEndpointContext;
22
+ }
23
+ interface CimdMetadataFetchPolicy {
24
+ /**
25
+ * Minimum time between metadata fetch starts for one exact client ID.
26
+ * Fresh-cache hits and callers joining an in-flight fetch do not consume
27
+ * this interval. Numeric values are seconds. Set to `0` to disable
28
+ * per-client pacing.
29
+ *
30
+ * @default 1
31
+ */
32
+ minimumFetchInterval?: number | string;
33
+ /**
34
+ * Maximum metadata fetches in flight across the plugin instance.
35
+ *
36
+ * @default 16
37
+ */
38
+ maximumConcurrentFetches?: number;
39
+ /**
40
+ * Maximum metadata fetches in flight for one URL origin.
41
+ *
42
+ * @default 4
43
+ */
44
+ maximumConcurrentFetchesPerOrigin?: number;
45
+ /**
46
+ * Maximum fetch starts in a rolling 60-second window across the
47
+ * plugin instance.
48
+ *
49
+ * @default 120
50
+ */
51
+ maximumFetchesPerMinute?: number;
52
+ /**
53
+ * Maximum fetch starts in a rolling 60-second window for one URL
54
+ * origin.
55
+ *
56
+ * @default 30
57
+ */
58
+ maximumFetchesPerOriginPerMinute?: number;
59
+ }
4
60
  /**
5
61
  * Options for the Client ID Metadata Document plugin.
6
62
  *
7
- * @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
63
+ * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-02
8
64
  */
9
65
  interface CimdOptions {
10
66
  /**
11
- * How frequently to re-fetch a client's metadata document to pick up
12
- * changes from the client.
67
+ * Fetch transport for metadata documents and discovery-owned metadata
68
+ * resources such as `jwks_uri`.
69
+ *
70
+ * The transport MUST resolve the hostname exactly once, reject RFC 6890
71
+ * special-use addresses, pin the approved address for the connection, and
72
+ * refuse redirects. Those guarantees cannot be implemented by wrapping the
73
+ * standard Fetch API after DNS resolution, so the application must provide
74
+ * them at its runtime-specific network boundary.
75
+ */
76
+ fetchClientMetadataResource: ClientMetadataResourceFetch;
77
+ /**
78
+ * Apply an additional protocol profile to otherwise generic draft-02
79
+ * metadata validation.
80
+ *
81
+ * The MCP 2026-07-28 profile requires `client_name` and `redirect_uris`
82
+ * because that MCP revision normatively pins CIMD draft-00.
83
+ */
84
+ metadataProfile?: CimdMetadataProfile;
85
+ /**
86
+ * Maximum and fallback cache freshness lifetime for a client's metadata
87
+ * document. An expired entry is revalidated on the next client resolution;
88
+ * the plugin does not perform periodic or background fetches.
13
89
  *
14
90
  * Accepts a number of seconds or a duration string (e.g. `"60m"`,
15
91
  * `"1d"`).
16
92
  *
17
93
  * @default "60m"
18
94
  */
19
- refreshRate?: number | string;
95
+ metadataRevalidationInterval?: number | string;
96
+ /**
97
+ * Bounded request-amplification policy for metadata document fetches.
98
+ *
99
+ * A permitted fetch consumes its concurrency and rolling-window budget when
100
+ * it starts. Same-client concurrent resolutions coalesce, while fresh-cache
101
+ * hits consume no budget. Limits reject immediately rather than queueing.
102
+ */
103
+ metadataFetchPolicy?: CimdMetadataFetchPolicy;
104
+ /**
105
+ * Maximum number of validated metadata documents retained by this plugin
106
+ * instance. The least-recently-used entry is evicted when the bound is
107
+ * reached.
108
+ *
109
+ * @default 1000
110
+ */
111
+ maxCacheEntries?: number;
20
112
  /**
21
113
  * Metadata fields whose URL values must share the same origin as the
22
114
  * `client_id` URL. Prevents a client from claiming URIs on a different
@@ -25,107 +117,87 @@ interface CimdOptions {
25
117
  * Pass an empty array to disable origin binding (not recommended for
26
118
  * production).
27
119
  *
28
- * @default ["redirect_uris", "post_logout_redirect_uris", "client_uri"]
29
- */
30
- originBoundFields?: string[];
31
- /**
32
- * Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
33
- * `*.localhost`), including plain HTTP, so an auth server can fetch a
34
- * metadata document hosted on the same machine. Off by default; enable
35
- * only for local development.
120
+ * Redirect URIs are deliberately excluded by default: exact redirect URI
121
+ * matching remains mandatory at authorization time, while native and
122
+ * distributed clients commonly use a redirect origin different from their
123
+ * metadata-document origin.
36
124
  *
37
- * @default false
125
+ * @default ["post_logout_redirect_uris", "client_uri"]
38
126
  */
39
- allowLoopback?: boolean;
127
+ originBoundFields?: readonly string[];
40
128
  /**
41
129
  * Pre-fetch gate called before a metadata document is requested. Return
42
130
  * `false` to reject the `client_id` URL.
43
131
  *
44
132
  * Use this for origin allowlists, per-host rate limiting, or integrating
45
- * with an external trust service. Hostname-based DNS defenses (beyond
46
- * the built-in IP-literal check) belong here, since the plugin is
47
- * runtime-agnostic and does not perform DNS resolution.
133
+ * with an external trust service. It is application policy, not a
134
+ * substitute for the required transport's resolve-once and connection-
135
+ * pinning guarantees; resolving here would introduce a TOCTOU boundary.
48
136
  *
49
137
  * @default always allow
50
138
  */
51
- allowFetch?: (url: string, ctx: GenericEndpointContext) => boolean | Promise<boolean>;
139
+ isMetadataDocumentUrlAllowed?: (clientIdUrl: string, context: GenericEndpointContext) => boolean | Promise<boolean>;
52
140
  /**
53
141
  * Called after a client is created from a metadata document for the
54
- * first time. Use this to assign trust levels, prefetch logos, or
55
- * perform other post-creation processing.
142
+ * first time. Use this to assign local trust, emit an audit event, or
143
+ * perform other post-creation processing. Better Auth does not fetch or
144
+ * render metadata-owned remote assets such as `logo_uri`.
145
+ *
146
+ * This is a best-effort notification. A rejected callback is logged and
147
+ * does not roll back an otherwise valid registration.
56
148
  */
57
- onClientCreated?: (data: {
58
- client: SchemaClient<Scope[]>;
59
- metadata: Record<string, unknown>;
60
- ctx: GenericEndpointContext;
61
- }) => void | Promise<void>;
149
+ onClientCreated?: (event: CimdClientCreatedEvent) => void | Promise<void>;
62
150
  /**
63
151
  * Called after a client is refreshed from a re-fetched metadata
64
152
  * document. Use this for change-detection logging or updating derived
65
153
  * fields.
154
+ *
155
+ * This is a best-effort notification. A rejected callback is logged and
156
+ * does not roll back an otherwise valid refresh.
66
157
  */
67
- onClientRefreshed?: (data: {
68
- client: SchemaClient<Scope[]>;
69
- metadata: Record<string, unknown>;
70
- ctx: GenericEndpointContext;
71
- }) => void | Promise<void>;
158
+ onClientRefreshed?: (event: CimdClientRefreshedEvent) => void | Promise<void>;
72
159
  }
73
160
  //#endregion
74
- //#region src/resolver.d.ts
75
- /**
76
- * Signature of the `resolve` function on a {@link ClientDiscovery}. Kept
77
- * here to avoid a circular import back into `@better-auth/oauth-provider`.
78
- */
79
- type CimdResolver = (ctx: GenericEndpointContext, clientId: string, existing: SchemaClient<Scope[]> | null) => Promise<SchemaClient<Scope[]> | null>;
80
- /**
81
- * Build the `resolve` function for a CIMD {@link ClientDiscovery}.
82
- *
83
- * Exposed for advanced composition. Most users should call
84
- * {@link cimdClientDiscovery} (to contribute a complete discovery through
85
- * `oauthProvider({ extensions: [{ clientDiscovery }] })`) or install the
86
- * `cimd()` plugin.
87
- */
88
- declare function createCimdResolver(cimdOptions?: CimdOptions): CimdResolver;
89
- //#endregion
90
161
  //#region src/validate-metadata-document.d.ts
91
- interface ClientIdMetadataDocumentResult {
92
- valid: boolean;
93
- error?: string;
162
+ type CimdMetadataValidationResult = {
163
+ valid: true;
164
+ metadata: OAuthClientMetadata;
165
+ error?: never;
94
166
  warnings?: string[];
95
- }
96
- interface ClientIdUrlOptions {
97
- /**
98
- * Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
99
- * `*.localhost`) and plain HTTP for them. Off by default.
100
- */
101
- allowLoopback?: boolean;
167
+ } | {
168
+ valid: false;
169
+ error: string;
170
+ metadata?: never;
171
+ warnings?: string[];
172
+ };
173
+ interface CimdMetadataValidationOptions {
174
+ originBoundFields?: readonly string[];
175
+ metadataProfile?: CimdMetadataProfile;
102
176
  }
103
177
  /**
104
178
  * Detect a URL-formatted client_id (Client ID Metadata Document pattern).
105
179
  *
106
- * HTTPS URLs always match; plain HTTP matches only loopback hosts, and only
107
- * when `allowLoopback` is set. This is a routing predicate, not a security
108
- * gate: it performs no DNS resolution, so callers MUST also run
180
+ * HTTPS URLs match. This is a routing predicate, not a security gate: it
181
+ * performs no DNS resolution, so callers MUST also run
109
182
  * {@link validateClientIdUrl} (and a fetch-time policy) before fetching.
110
183
  */
111
- declare function isUrlClientId(clientId: string, options?: ClientIdUrlOptions): boolean;
184
+ declare function isCimdClientIdUrlCandidate(clientId: string): boolean;
112
185
  /**
113
- * Validate a client_id URL per IETF draft §3.
186
+ * Validate a client_id URL per Client ID Metadata Document draft-02 §3.
114
187
  * Returns null on success, an error string on failure.
115
188
  *
116
- * Loopback hosts are rejected unless `allowLoopback` is set; every other
117
- * non-public host (private, link-local, cloud-metadata, IPv6 tunnels) is
118
- * rejected.
189
+ * Loopback and every other non-public host (private, link-local,
190
+ * cloud-metadata, IPv6 tunnels) are rejected.
119
191
  */
120
- declare function validateClientIdUrl(url: string, options?: ClientIdUrlOptions): string | null;
192
+ declare function validateClientIdUrl(url: string): string | null;
121
193
  /**
122
194
  * Validate a fetched Client ID Metadata Document per §4.1.
123
195
  *
124
- * @param fetchUrl - The URL the document was fetched from.
196
+ * @param clientIdUrl - The URL the document was fetched from.
125
197
  * @param raw - The parsed JSON body of the response.
126
- * @param originBoundFields - Fields whose URL values must share the same origin as the `client_id` URL.
198
+ * @param options - Generic draft-02 validation options and an optional protocol profile.
127
199
  */
128
- declare function validateCimdMetadata(fetchUrl: string, raw: unknown, originBoundFields?: string[]): ClientIdMetadataDocumentResult;
200
+ declare function validateCimdMetadata(clientIdUrl: string, raw: unknown, options?: CimdMetadataValidationOptions): CimdMetadataValidationResult;
129
201
  //#endregion
130
202
  //#region src/index.d.ts
131
203
  declare module "@better-auth/core" {
@@ -143,22 +215,23 @@ declare module "@better-auth/core" {
143
215
  * install the {@link cimd} plugin instead, which contributes this discovery
144
216
  * alongside whatever else is configured.
145
217
  */
146
- declare function cimdClientDiscovery(options?: CimdOptions): ClientDiscovery;
218
+ declare function createCimdClientDiscovery(options: CimdOptions): ClientDiscovery;
147
219
  /**
148
220
  * Client ID Metadata Document plugin.
149
221
  *
150
222
  * Adds unauthenticated dynamic client discovery over HTTPS to an
151
223
  * `oauth-provider` instance. Clients identify themselves by providing
152
224
  * an HTTPS URL as their `client_id`; the plugin fetches and validates
153
- * the document at that URL, then creates a public client record.
225
+ * the document at that URL, then creates a client record whose authentication
226
+ * behavior is determined by `token_endpoint_auth_method`.
154
227
  *
155
- * See {@link https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ | the IETF draft}
156
- * and {@link https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow | the MCP authorization spec}.
228
+ * See {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-02 | Client ID Metadata Document draft-02}
229
+ * and {@link https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents | the MCP authorization spec}.
157
230
  */
158
- declare const cimd: (options?: CimdOptions) => {
231
+ declare const cimd: (options: CimdOptions) => {
159
232
  id: "cimd";
160
233
  version: string;
161
234
  init(ctx: import("better-auth").AuthContext): void;
162
235
  };
163
236
  //#endregion
164
- export { type CimdOptions, type ClientIdMetadataDocumentResult, type ClientIdUrlOptions, cimd, cimdClientDiscovery, createCimdResolver, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
237
+ export { type CimdClientCreatedEvent, type CimdClientRefreshedEvent, type CimdMetadataFetchPolicy, type CimdMetadataProfile, type CimdMetadataValidationOptions, type CimdMetadataValidationResult, type CimdOptions, cimd, createCimdClientDiscovery, isCimdClientIdUrlCandidate, validateCimdMetadata, validateClientIdUrl };