diff --git a/.changeset/cimd-plugin.md b/.changeset/cimd-plugin.md
new file mode 100644
index 0000000000..ff5fa05cbe
--- /dev/null
+++ b/.changeset/cimd-plugin.md
@@ -0,0 +1,58 @@
+---
+"@better-auth/cimd": minor
+"@better-auth/oauth-provider": minor
+---
+
+Add `@better-auth/cimd` plugin for [Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) support, and expose a typed `clientDiscovery` extension point on `oauthProvider()` so plugins can resolve `client_id` values from external sources.
+
+### `@better-auth/cimd` (new package)
+
+Install alongside `oauthProvider()` to let clients identify themselves by hosting an HTTPS metadata document; the URL becomes the `client_id`. This is the mechanism [MCP](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow) uses for unauthenticated dynamic client discovery.
+
+```ts
+import { oauthProvider } from "@better-auth/oauth-provider";
+import { cimd } from "@better-auth/cimd";
+
+betterAuth({
+ plugins: [
+ oauthProvider({ /* ... */ }),
+ cimd({
+ refreshRate: "60m",
+ allowFetch: (url) => new URL(url).hostname.endsWith(".trusted.example"),
+ }),
+ ],
+});
+```
+
+Ships with §3/§4.1 validation, SSRF protection for private/reserved IPs and cloud metadata endpoints, a 5-second fetch timeout, a 5 KB response size limit (UTF-8 byte-counted), origin binding for redirect URIs, and lifecycle hooks (`onClientCreated`, `onClientRefreshed`). Advertises `client_id_metadata_document_supported` in OAuth/OIDC discovery metadata.
+
+The `allowFetch` pre-fetch gate lets operators add origin allowlists, per-host rate limits, or DNS-level defenses beyond the built-in IP-literal check.
+
+Admin-controlled fields (`disabled`, `skipConsent`, `enableEndSession`) are preserved across refreshes so admin decisions survive document updates.
+
+### `@better-auth/oauth-provider`: new `clientDiscovery` option
+
+```ts
+import type { ClientDiscovery } from "@better-auth/oauth-provider";
+
+oauthProvider({
+ clientDiscovery: [
+ {
+ id: "my-resolver",
+ matches: (clientId) => clientId.startsWith("custom://"),
+ resolve: async (ctx, clientId, existing) => {
+ // create, refresh, or return null to pass through
+ },
+ discoveryMetadata: { custom_flow_supported: true },
+ },
+ ],
+});
+```
+
+`clientDiscovery` accepts a single `ClientDiscovery` or an array. `getClient()` walks the entries in order after the database lookup; the first entry whose `matches()` returns `true` and whose `resolve()` returns a non-null client wins. Each entry can also contribute `discoveryMetadata` fields that are merged into `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses.
+
+Plugins like `@better-auth/cimd` append an entry here at init time, so multiple discoveries can coexist.
+
+The `checkOAuthClient` and `oauthToSchema` helpers are now exported for plugins that create client records directly.
+
+`jwks_uri` validation now accepts a same-origin URL when the `client_id` itself is an HTTPS URL, since URL-based discovery flows verify the origin through the `client_id` itself.
diff --git a/.changeset/pre.json b/.changeset/pre.json
index 6b728c9a47..d0869d40f9 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -6,6 +6,7 @@
"@better-auth/api-key": "1.6.0",
"better-auth": "1.6.0",
"auth": "1.6.0",
+ "@better-auth/cimd": "1.6.0",
"@better-auth/core": "1.6.0",
"@better-auth/drizzle-adapter": "1.6.0",
"@better-auth/electron": "1.6.0",
@@ -25,6 +26,7 @@
"@better-auth/test-utils": "1.6.0"
},
"changesets": [
+ "cimd-plugin",
"fix-password-reset-callback-operation-id",
"honest-regions-jam",
"pr-8926",
diff --git a/.cspell/auth-terms.txt b/.cspell/auth-terms.txt
index 7ff341d88b..21c253f5a1 100644
--- a/.cspell/auth-terms.txt
+++ b/.cspell/auth-terms.txt
@@ -33,3 +33,4 @@ Vercel
rgba
idtoken
HIBP
+cimd
diff --git a/demo/nextjs/lib/auth.ts b/demo/nextjs/lib/auth.ts
index 12a1e3cf7e..e7a97fe8ed 100644
--- a/demo/nextjs/lib/auth.ts
+++ b/demo/nextjs/lib/auth.ts
@@ -1,3 +1,4 @@
+import { cimd } from "@better-auth/cimd";
import { electron } from "@better-auth/electron";
import { dash, sendEmail, sentinel } from "@better-auth/infra";
import { oauthProvider } from "@better-auth/oauth-provider";
@@ -436,6 +437,7 @@ const authOptions = {
oauthAuthServerConfig: true,
},
}),
+ cimd(),
electron(),
],
trustedOrigins: [
diff --git a/demo/nextjs/package.json b/demo/nextjs/package.json
index 8c661df27e..c8df09c0f5 100644
--- a/demo/nextjs/package.json
+++ b/demo/nextjs/package.json
@@ -12,6 +12,7 @@
"lint": "next lint"
},
"dependencies": {
+ "@better-auth/cimd": "link:../../packages/cimd",
"@better-auth/dash": "^0.1.6",
"@better-auth/electron": "link:../../packages/electron",
"@better-auth/infra": "^0.1.8",
diff --git a/demo/nextjs/pnpm-lock.yaml b/demo/nextjs/pnpm-lock.yaml
index e851ae10aa..6dae1156ed 100644
--- a/demo/nextjs/pnpm-lock.yaml
+++ b/demo/nextjs/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@better-auth/cimd':
+ specifier: link:../../packages/cimd
+ version: link:../../packages/cimd
'@better-auth/dash':
specifier: ^0.1.6
version: 0.1.6(better-auth@..+packages+better-auth)(zod@4.3.6)
@@ -263,14 +266,14 @@ packages:
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
engines: {node: '>=12'}
- '@better-auth/core@1.6.0-beta.0':
- resolution: {integrity: sha512-ZP3buQ93QHLIBKI4Zmxd4dz5LIlLp1iom3l52z6RkNJhfoD7BcQ63DDsPedBQCOKJBwVm7AYDh9PbB34s3Fe8A==}
+ '@better-auth/core@1.7.0-beta.0':
+ resolution: {integrity: sha512-MeBS5BknDqetWP8HoDPblFLm+WAy8n8vFAT12GVX2uBtVFo3oi6LLwiWI2hoYi7xeCHQBPtdlKBAsC4eiAyi6g==}
peerDependencies:
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
'@cloudflare/workers-types': '>=4'
'@opentelemetry/api': ^1.9.0
- better-call: 2.0.3
+ better-call: 1.3.5
jose: ^6.1.0
kysely: ^0.28.5
nanostores: ^1.0.1
@@ -289,14 +292,14 @@ packages:
better-auth: '>=1.4.15'
zod: '>=4.1.12'
- '@better-auth/sso@1.6.0-beta.0':
- resolution: {integrity: sha512-syDxdIQ3zV5xfnRm3b/XoJxTeYIdYdIvRkv/A9OFANXvOFIcFCBNjP1x3MxeBRI7AmyUJeflSJfpGNXym0UEMw==}
+ '@better-auth/sso@1.7.0-beta.0':
+ resolution: {integrity: sha512-rHnly9XQdz0cc+xp6Vm3MFhdN1eixZMQ3K8dTMU7+Xg/FHYu769P343cHTXYvmE9sDxOrffqH4A2/nx/n1+tpA==}
peerDependencies:
- '@better-auth/core': ^1.6.0-beta.0
+ '@better-auth/core': ^1.7.0-beta.0
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
- better-auth: ^1.6.0-beta.0
- better-call: 2.0.3
+ better-auth: ^1.7.0-beta.0
+ better-call: 1.3.5
'@better-auth/utils@0.3.1':
resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==}
@@ -1762,6 +1765,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
+ camelcase@6.3.0:
+ resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
+ engines: {node: '>=10'}
+
caniuse-lite@1.0.30001775:
resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==}
@@ -2390,6 +2397,10 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ node-forge@1.4.0:
+ resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
+ engines: {node: '>= 6.13.0'}
+
node-rsa@1.1.1:
resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==}
@@ -2411,6 +2422,9 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
@@ -2606,8 +2620,8 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
- samlify@2.12.0:
- resolution: {integrity: sha512-ewGsHyY4kInDH0BfprlAZ1rHpH1jBmbqYiXDbuI3t1Y8h71gqEt4Z7jdCFyPHFR8jItJkbdckTijUZGg14CDlg==}
+ samlify@2.10.2:
+ resolution: {integrity: sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -2791,6 +2805,10 @@ packages:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
+ uuid@8.3.2:
+ resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ hasBin: true
+
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -2846,10 +2864,6 @@ packages:
resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==}
engines: {node: '>=0.6.0'}
- xpath@0.0.34:
- resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==}
- engines: {node: '>=0.6.0'}
-
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
@@ -2871,7 +2885,7 @@ snapshots:
escape-html: 1.0.3
xpath: 0.0.32
- '@better-auth/core@1.6.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)':
+ '@better-auth/core@1.7.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)':
dependencies:
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.19-beta.1
@@ -2898,8 +2912,8 @@ snapshots:
'@better-auth/infra@0.1.8(@better-auth/utils@0.4.0)(@opentelemetry/api@1.9.1)(better-auth@..+packages+better-auth)(kysely@0.28.11)(nanostores@1.1.1)(zod@4.3.6)':
dependencies:
- '@better-auth/core': 1.6.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
- '@better-auth/sso': 1.6.0-beta.0(@better-auth/core@1.6.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(better-auth@..+packages+better-auth)(better-call@2.0.0-beta.4(zod@4.3.6))
+ '@better-auth/core': 1.7.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
+ '@better-auth/sso': 1.7.0-beta.0(@better-auth/core@1.7.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(better-auth@..+packages+better-auth)(better-call@2.0.0-beta.4(zod@4.3.6))
'@better-fetch/fetch': 1.1.19-beta.1
better-auth: link:../../packages/better-auth
better-call: 2.0.0-beta.4(zod@4.3.6)
@@ -2913,16 +2927,16 @@ snapshots:
- kysely
- nanostores
- '@better-auth/sso@1.6.0-beta.0(@better-auth/core@1.6.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(better-auth@..+packages+better-auth)(better-call@2.0.0-beta.4(zod@4.3.6))':
+ '@better-auth/sso@1.7.0-beta.0(@better-auth/core@1.7.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(better-auth@..+packages+better-auth)(better-call@2.0.0-beta.4(zod@4.3.6))':
dependencies:
- '@better-auth/core': 1.6.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
+ '@better-auth/core': 1.7.0-beta.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.19-beta.1)(@opentelemetry/api@1.9.1)(better-call@2.0.0-beta.4(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.19-beta.1
better-auth: link:../../packages/better-auth
better-call: 2.0.0-beta.4(zod@4.3.6)
fast-xml-parser: 5.5.9
jose: 6.1.3
- samlify: 2.12.0
+ samlify: 2.10.2
tldts: 6.1.86
zod: 4.3.6
@@ -4296,6 +4310,8 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
+ camelcase@6.3.0: {}
+
caniuse-lite@1.0.30001775: {}
canvas-confetti@1.9.4: {}
@@ -4854,6 +4870,8 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
+ node-forge@1.4.0: {}
+
node-rsa@1.1.1:
dependencies:
asn1: 0.2.6
@@ -4874,6 +4892,8 @@ snapshots:
dependencies:
wrappy: 1.0.2
+ pako@1.0.11: {}
+
parseley@0.12.1:
dependencies:
leac: 0.6.0
@@ -5067,15 +5087,19 @@ snapshots:
safer-buffer@2.1.2: {}
- samlify@2.12.0:
+ samlify@2.10.2:
dependencies:
'@authenio/xml-encryption': 2.0.2
'@xmldom/xmldom': 0.8.12
+ camelcase: 6.3.0
+ node-forge: 1.4.0
node-rsa: 1.1.1
+ pako: 1.0.11
+ uuid: 8.3.2
xml: 1.0.1
xml-crypto: 6.1.2
xml-escape: 1.1.0
- xpath: 0.0.34
+ xpath: 0.0.32
scheduler@0.27.0: {}
@@ -5274,6 +5298,8 @@ snapshots:
uuid@10.0.0: {}
+ uuid@8.3.2: {}
+
vary@1.1.2: {}
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@@ -5326,8 +5352,6 @@ snapshots:
xpath@0.0.33: {}
- xpath@0.0.34: {}
-
yallist@4.0.0: {}
zod-to-json-schema@3.25.1(zod@4.3.6):
diff --git a/docs/content/docs/plugins/cimd.mdx b/docs/content/docs/plugins/cimd.mdx
new file mode 100644
index 0000000000..6c16c5d47a
--- /dev/null
+++ b/docs/content/docs/plugins/cimd.mdx
@@ -0,0 +1,203 @@
+---
+title: Client ID Metadata Document (CIMD)
+description: Unauthenticated dynamic client discovery for Better Auth's OAuth provider.
+---
+
+`OAuth2` `MCP`
+
+[Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) let clients identify themselves to your authorization server without prior registration. Instead of sending a pre-registered `client_id`, the client sends the HTTPS URL of a metadata document it hosts. Your server fetches that document, validates it, and creates (or refreshes) a public client record.
+
+This is the mechanism [MCP](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow) uses so authorization servers can discover clients on the fly.
+
+The plugin extends [`@better-auth/oauth-provider`](./oauth-provider) by appending a `ClientDiscovery` entry to its `clientDiscovery` option, so every OAuth endpoint that authenticates a client (authorize, token, introspect, revoke, userinfo, PAR, logout) works seamlessly with URL-format `client_id` values.
+
+## Installation
+
+
+
+ ### Install the plugin
+
+ ```package-install
+ @better-auth/cimd
+ ```
+
+
+
+ ### Add the plugin to the server
+
+ Install `cimd()` alongside `oauthProvider()`. The plugin requires `oauth-provider` to be present and will throw at startup if it is missing.
+
+ ```ts title="auth.ts"
+ import { betterAuth } from "better-auth";
+ import { jwt } from "better-auth/plugins";
+ import { oauthProvider } from "@better-auth/oauth-provider";
+ import { cimd } from "@better-auth/cimd"; // [!code highlight]
+
+ export const auth = betterAuth({
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid", "profile", "email", "offline_access"],
+ }),
+ cimd(), // [!code highlight]
+ ],
+ });
+ ```
+
+
+
+## Usage
+
+Once installed, your authorization server accepts an HTTPS URL as a `client_id` on any OAuth endpoint. The first time a URL `client_id` is seen, the server fetches the metadata document and creates a public client. Subsequent requests reuse that record until it is older than `refreshRate`, at which point it is re-fetched.
+
+```http
+GET /api/auth/oauth2/authorize?
+ response_type=code
+ &client_id=https%3A%2F%2Fmcp-client.example.com%2Fclient-metadata.json
+ &redirect_uri=https%3A%2F%2Fmcp-client.example.com%2Fcallback
+ &scope=openid
+ &code_challenge=...
+ &code_challenge_method=S256
+```
+
+The CIMD client is stored in the standard `oauthClient` table with its URL as the `clientId`, so it shows up in the same queries and admin UIs as any other registered client.
+
+### Discovery metadata
+
+The plugin advertises `client_id_metadata_document_supported: true` on the `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses. MCP clients and other spec-aware consumers use that flag to decide whether to attempt a URL `client_id`.
+
+## Configuration
+
+```ts title="auth.ts"
+cimd({
+ refreshRate: "10m",
+ originBoundFields: ["redirect_uris", "post_logout_redirect_uris", "client_uri"],
+ allowFetch(url) {
+ return new URL(url).hostname.endsWith(".trusted.example");
+ },
+ onClientCreated({ client, metadata, ctx }) {
+ // assign trust levels, prefetch logos, populate audit log, etc.
+ },
+ onClientRefreshed({ client, metadata, ctx }) {
+ // detect field changes, update derived data, etc.
+ },
+});
+```
+
+### Options
+
+ boolean | Promise",
+ default: "always allow",
+ description:
+ "Pre-fetch gate: return `false` to reject a `client_id` URL before the metadata document is requested. Use this for origin allowlists, per-host rate limiting, or DNS-level defenses beyond the built-in IP-literal SSRF check.",
+},
+onClientCreated: {
+ type: "(data: { client, metadata, ctx }) => void | Promise",
+ description:
+ "Called once, after a new client has been persisted. The `metadata` argument is the raw JSON body of the metadata document.",
+},
+onClientRefreshed: {
+ type: "(data: { client, metadata, ctx }) => void | Promise",
+ description:
+ "Called each time an existing client record is re-fetched and updated. Admin-controlled fields (`disabled`, `skipConsent`, `enableEndSession`) are preserved across refreshes; use this hook to detect document changes or sync derived data.",
+},
+}}
+/>
+
+## Metadata document requirements
+
+The fetched document must satisfy both §3 and §4.1 of the draft. Key rules enforced by the plugin:
+
+* `client_id` in the body must equal the fetch URL byte-for-byte.
+* HTTPS only. HTTP is allowed only for `localhost`, `127.0.0.1`, `[::1]`, and `*.localhost` to support local development.
+* No fragments, credentials, or `./` / `../` dot segments in the URL.
+* A path component is required (`https://example.com/` is rejected; `https://example.com/meta.json` is accepted).
+* `client_secret` and `client_secret_expires_at` are prohibited.
+* `token_endpoint_auth_method` must be `"none"` or `"private_key_jwt"`. Symmetric methods (`client_secret_post`, `client_secret_basic`, `client_secret_jwt`) are rejected.
+* `redirect_uris` is required and must be a non-empty array of absolute HTTP(S) URIs.
+* `grant_types` (if present) must be a subset of `["authorization_code", "refresh_token"]`.
+* `response_types` (if present) must be a subset of `["code"]`.
+* Admin-only fields (`disabled`, `skip_consent`, `enable_end_session`) from the document are discarded.
+* Only RFC 7591 / CIMD fields are persisted; unknown fields are dropped.
+
+### Example document
+
+```json title="https://mcp-client.example.com/client-metadata.json"
+{
+ "client_id": "https://mcp-client.example.com/client-metadata.json",
+ "client_name": "Example MCP Client",
+ "client_uri": "https://mcp-client.example.com",
+ "redirect_uris": [
+ "https://mcp-client.example.com/callback",
+ "http://127.0.0.1:3000/callback"
+ ],
+ "token_endpoint_auth_method": "none",
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "scope": "openid profile email offline_access"
+}
+```
+
+## Security
+
+The plugin ships defense in depth against common abuses of unauthenticated client discovery:
+
+* **SSRF protection (server-side request forgery).** Private and reserved IP ranges (RFC 6890), IPv4-mapped IPv6, link-local addresses, and cloud metadata endpoints (`metadata.google.internal`, `169.254.169.254`) are all rejected. The plugin does no DNS resolution, so the same rules run identically on Node, Bun, Deno, and Cloudflare Workers.
+* **Fetch hardening.** 5s timeout, 5 KB response size limit (UTF-8 byte-counted), no redirect following, `Accept: application/json` only.
+* **Origin binding.** Fields in `originBoundFields` must share the `client_id` origin. Cross-domain redirect injection is blocked by default.
+* **No secrets from documents.** Symmetric authentication methods and `client_secret` fields are rejected before anything is written to the database.
+* **Admin-field stripping.** `disabled`, `skip_consent`, `enable_end_session`, and `require_pkce` cannot be set, escalated, or weakened from an external document.
+* **Admin overrides survive refreshes.** `disabled`, `skipConsent`, and `enableEndSession` set by an admin on a persisted CIMD client are preserved across stale refreshes: the document can't silently undo a revoke or consent override.
+* **Allowlist parsing.** Only the recognized RFC 7591 / CIMD fields are persisted. Arbitrary fields are discarded.
+
+## Operational notes
+
+* **Refresh runs on the OAuth hot path.** When a CIMD client's record is older than `refreshRate`, the next OAuth request for that client awaits the metadata document refresh before completing. A slow or unreachable metadata host raises tail latency on `/oauth2/authorize`, `/oauth2/token`, `/oauth2/introspect`, and friends for that client. Pick `refreshRate` generous enough that the refresh stays rare, and keep `allowFetch` aligned with your metadata hosts' reliability profile.
+* **No fetch deduplication yet.** Concurrent requests for the same stale client can all trigger independent metadata fetches. An external coordination layer or higher `refreshRate` is the practical mitigation today.
+* **HTTP allowed only for localhost.** `localhost`, `127.0.0.1`, `[::1]`, and `*.localhost` are accepted over plain HTTP so you can develop locally. Every other host must use HTTPS.
+
+## Composition with `clientDiscovery`
+
+Under the hood, `cimd()` appends a `ClientDiscovery` entry to `oauthProvider`'s `clientDiscovery` option. If you prefer to wire it explicitly, or want to compose CIMD with other discovery implementations, use the `cimdClientDiscovery` factory:
+
+```ts title="auth.ts"
+import { oauthProvider } from "@better-auth/oauth-provider";
+import { cimdClientDiscovery } from "@better-auth/cimd";
+
+export const auth = betterAuth({
+ plugins: [
+ jwt(),
+ oauthProvider({
+ clientDiscovery: [
+ cimdClientDiscovery({ refreshRate: "60m" }),
+ // other discovery implementations can go here
+ ],
+ }),
+ ],
+});
+```
+
+The `clientDiscovery` option accepts a single `ClientDiscovery` or an array. Entries are consulted in order after the database lookup in `getClient()`; the first entry whose `matches()` returns `true` and whose `resolve()` returns a non-null client wins. Each entry can also contribute `discoveryMetadata` that the server merges into `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses.
+
+## Related
+
+* [OAuth Provider plugin](./oauth-provider) — the host plugin this one extends.
+* [IETF draft: Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)
+* [MCP authorization spec — CIMD flow](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow)
diff --git a/docs/content/docs/plugins/meta.json b/docs/content/docs/plugins/meta.json
index 98328a7305..7c2367ae5c 100644
--- a/docs/content/docs/plugins/meta.json
+++ b/docs/content/docs/plugins/meta.json
@@ -25,6 +25,7 @@
"one-time-token",
"oauth-proxy",
"oauth-provider",
+ "cimd",
"oidc-provider",
"mcp",
"device-authorization",
diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx
index 37b585defc..ecaf538ad0 100644
--- a/docs/content/docs/plugins/oauth-provider.mdx
+++ b/docs/content/docs/plugins/oauth-provider.mdx
@@ -1245,6 +1245,13 @@ PKCE prevents authorization code interception attacks. Even for confidential cli
Only disable PKCE for confidential clients when absolutely necessary for legacy compatibility.
+### Unauthenticated client discovery
+
+Some clients (notably MCP clients) need to connect to your authorization server without being registered in advance. The OAuth Provider plugin supports this through two mechanisms:
+
+* **`allowUnauthenticatedClientRegistration`**: lets anonymous callers hit `/oauth2/register` to create a public client at request time.
+* **[`@better-auth/cimd`](/docs/plugins/cimd)**: an optional plugin that lets clients identify themselves by hosting a metadata document at an HTTPS URL. The URL itself becomes the `client_id`; the server fetches and validates the document. This is the pattern MCP calls [Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/).
+
### Organizations
OAuth Clients are tied to either a user or `reference_id` at registration and is immutable. If you are utilizing the [organization plugin](/docs/plugins/organization), you must ensure that the [`activeOrganizationId`](/docs/plugins/organization#active-organization) is set on your active session when you create new clients.
@@ -1557,7 +1564,7 @@ You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/s
- If you use `allowUnauthenticatedClientRegistration`, you must ensure that your API Server is a confidential client itself:
+ If you use the [`cimd`](/docs/plugins/cimd) plugin or `allowUnauthenticatedClientRegistration`, you must ensure that your API Server is a confidential client itself:
```ts
await auth.api.createOAuthClient({
@@ -1571,7 +1578,7 @@ You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/s
These values should be used in the verify options `remoteVerify.clientId` and `remoteVerify.clientSecret`. Additionally, `remoteVerify.introspectUrl` would be something like `${BASE_URL}/${AUTH_PATH}/oauth2/introspect`.
- If you choose to not support `allowUnauthenticatedClientRegistration` (and only `allowDynamicClientRegistration`), the MCP client (ie. ChatGPT, Anthropic, Gemini) would need to allow you to put in a public client\_id in their UI or at runtime while chatting with the AI.
+ If you choose not to support the [`cimd`](/docs/plugins/cimd) plugin or `allowUnauthenticatedClientRegistration` (and only `allowDynamicClientRegistration`), the MCP client (ie. ChatGPT, Anthropic, Gemini) would need to allow you to put in a public client\_id in their UI or at runtime while chatting with the AI.
diff --git a/e2e/smoke/package.json b/e2e/smoke/package.json
index e642a67a26..b939881770 100644
--- a/e2e/smoke/package.json
+++ b/e2e/smoke/package.json
@@ -7,7 +7,9 @@
"better-auth": "workspace:*"
},
"devDependencies": {
+ "@better-auth/cimd": "workspace:*",
"@better-auth/core": "workspace:*",
+ "@better-auth/oauth-provider": "workspace:*",
"@better-auth/redis-storage": "workspace:*",
"@better-auth/sso": "workspace:*",
"better-auth": "workspace:*",
diff --git a/e2e/smoke/test/cimd.spec.ts b/e2e/smoke/test/cimd.spec.ts
new file mode 100644
index 0000000000..a776f68901
--- /dev/null
+++ b/e2e/smoke/test/cimd.spec.ts
@@ -0,0 +1,325 @@
+import assert from "node:assert/strict";
+import { once } from "node:events";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import { DatabaseSync } from "node:sqlite";
+import { describe, it } from "node:test";
+import { cimd } from "@better-auth/cimd";
+import { oauthProvider } from "@better-auth/oauth-provider";
+import { betterAuth } from "better-auth";
+import { getMigrations } from "better-auth/db/migration";
+import { toNodeHandler } from "better-auth/node";
+import { jwt } from "better-auth/plugins/jwt";
+
+// Deterministic PKCE pair (challenge is base64url-encoded SHA-256 of verifier).
+const PKCE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+const PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+describe("CIMD end-to-end flow", () => {
+ it("runs the full authorize → consent → token → userinfo → refresh loop with a URL client_id", async (t) => {
+ // 1. Host the CIMD metadata document on a local HTTP server. CIMD
+ // permits HTTP for localhost so no TLS scaffolding is required.
+ const metadataHost = createServer();
+ metadataHost.listen(0);
+ t.after(() => metadataHost.close());
+ await once(metadataHost, "listening");
+ const metadataAddr = metadataHost.address() as AddressInfo;
+ const clientMetadataUrl = `http://localhost:${metadataAddr.port}/client-metadata.json`;
+ const redirectUri = `http://localhost:${metadataAddr.port}/callback`;
+
+ const metadataDocument = {
+ client_id: clientMetadataUrl,
+ client_name: "CIMD Smoke Test Client",
+ redirect_uris: [redirectUri],
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ scope: "openid profile email offline_access",
+ };
+
+ metadataHost.on("request", (req, res) => {
+ if (req.url === "/client-metadata.json") {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify(metadataDocument));
+ return;
+ }
+ res.writeHead(404).end();
+ });
+
+ // 2. Pre-reserve the auth server port so `baseURL` reflects reality.
+ const authPlaceholder = createServer();
+ authPlaceholder.listen(0);
+ await once(authPlaceholder, "listening");
+ const authPort = (authPlaceholder.address() as AddressInfo).port;
+ const authBaseUrl = `http://localhost:${authPort}`;
+ authPlaceholder.close();
+
+ // 3. Build auth with oauth-provider + cimd, backed by an in-memory
+ // sqlite database.
+ const db = new DatabaseSync(":memory:");
+ const auth = betterAuth({
+ baseURL: authBaseUrl,
+ secret: "smoke-test-secret-that-is-long-enough-for-validation",
+ database: db,
+ emailAndPassword: { enabled: true },
+ trustedOrigins: [authBaseUrl],
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid", "profile", "email", "offline_access"],
+ silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
+ }),
+ cimd({ refreshRate: "60m" }),
+ ],
+ });
+
+ const { runMigrations } = await getMigrations(auth.options);
+ await runMigrations();
+
+ const authServer = createServer(toNodeHandler(auth.handler));
+ authServer.listen(authPort);
+ t.after(() => authServer.close());
+ await once(authServer, "listening");
+
+ // 4. Discovery metadata advertises CIMD. The `.well-known` endpoint
+ // is server-only, so call it via the in-process API (the same
+ // pattern oauth-provider's MCP integration uses when bridging the
+ // route through a framework handler).
+ const discovery = (await auth.api.getOAuthServerConfig()) as Record<
+ string,
+ unknown
+ >;
+ assert.equal(
+ discovery.client_id_metadata_document_supported,
+ true,
+ "discovery should advertise CIMD support",
+ );
+
+ // 5. Create a demo user and capture the session cookie.
+ const signupRes = await fetch(`${authBaseUrl}/api/auth/sign-up/email`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ origin: authBaseUrl,
+ },
+ body: JSON.stringify({
+ email: "alice@example.com",
+ password: "smoke-test-password-1",
+ name: "Alice",
+ }),
+ });
+ assert.equal(signupRes.ok, true, "sign-up should succeed");
+ const sessionCookies = signupRes.headers.get("set-cookie");
+ assert.ok(sessionCookies, "sign-up should return session cookies");
+
+ // 6. Hit /oauth2/authorize with the URL client_id. The server fetches
+ // the metadata document, validates it, and creates a public client
+ // record keyed by the URL.
+ const authorizeUrl =
+ `${authBaseUrl}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(clientMetadataUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
+ `&scope=${encodeURIComponent("openid email profile offline_access")}` +
+ `&code_challenge=${PKCE_CHALLENGE}` +
+ `&code_challenge_method=S256`;
+
+ const authorizeRes = await fetch(authorizeUrl, {
+ method: "GET",
+ headers: {
+ cookie: sessionCookies,
+ origin: authBaseUrl,
+ // Force the JSON envelope shape (`{ redirect, url }`). Without
+ // an explicit Accept, a future change to server-side
+ // content negotiation could switch this to a raw 302 and
+ // break the test silently.
+ accept: "application/json",
+ },
+ redirect: "manual",
+ });
+ const authorizePayload = (await authorizeRes.json()) as {
+ redirect?: boolean;
+ url?: string;
+ };
+ const consentRedirect = authorizePayload.url ?? "";
+ assert.match(
+ consentRedirect,
+ /\/consent\?/,
+ `authorize should redirect to /consent, got: ${consentRedirect}`,
+ );
+
+ // 7. Accept consent. `oauth_query` is the literal query string from
+ // the consent URL, signed by /authorize so /consent can verify it.
+ const oauthQuery = new URL(consentRedirect, authBaseUrl).search.replace(
+ /^\?/,
+ "",
+ );
+ const consentRes = await fetch(`${authBaseUrl}/api/auth/oauth2/consent`, {
+ method: "POST",
+ headers: {
+ cookie: sessionCookies,
+ "content-type": "application/json",
+ origin: authBaseUrl,
+ },
+ body: JSON.stringify({ accept: true, oauth_query: oauthQuery }),
+ });
+ const consentBody = (await consentRes.json()) as {
+ redirect?: boolean;
+ url?: string;
+ };
+ const codeUrl = consentBody.url ?? "";
+ const code = new URL(codeUrl).searchParams.get("code");
+ assert.ok(code, `consent should return authorization code: ${codeUrl}`);
+
+ // 8. Exchange the code for tokens.
+ const tokenRes = await fetch(`${authBaseUrl}/api/auth/oauth2/token`, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: redirectUri,
+ client_id: clientMetadataUrl,
+ code_verifier: PKCE_VERIFIER,
+ }).toString(),
+ });
+ assert.equal(tokenRes.status, 200, "token exchange should succeed");
+ const tokens = (await tokenRes.json()) as Record;
+ assert.equal(typeof tokens.access_token, "string");
+ assert.equal(typeof tokens.refresh_token, "string");
+ assert.equal(typeof tokens.id_token, "string");
+ assert.equal(tokens.token_type, "Bearer");
+
+ // 9. Call userinfo with the CIMD-issued access token.
+ const userinfoRes = await fetch(`${authBaseUrl}/api/auth/oauth2/userinfo`, {
+ headers: { authorization: `Bearer ${tokens.access_token as string}` },
+ });
+ assert.equal(userinfoRes.status, 200, "userinfo should succeed");
+ const claims = (await userinfoRes.json()) as Record;
+ assert.equal(typeof claims.sub, "string");
+ assert.equal(claims.email, "alice@example.com");
+ assert.equal(claims.name, "Alice");
+
+ // 10. Use the refresh_token to mint a new access token.
+ const refreshRes = await fetch(`${authBaseUrl}/api/auth/oauth2/token`, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: tokens.refresh_token as string,
+ client_id: clientMetadataUrl,
+ }).toString(),
+ });
+ assert.equal(refreshRes.status, 200, "refresh grant should succeed");
+ const refreshed = (await refreshRes.json()) as Record;
+ assert.equal(typeof refreshed.access_token, "string");
+ assert.notEqual(refreshed.access_token, tokens.access_token);
+ });
+
+ it("rejects a URL client_id that is blocked by allowFetch before any outbound fetch", async (t) => {
+ // Reserve auth port first so baseURL is accurate.
+ const placeholder = createServer();
+ placeholder.listen(0);
+ await once(placeholder, "listening");
+ const authPort = (placeholder.address() as AddressInfo).port;
+ const authBaseUrl = `http://localhost:${authPort}`;
+ placeholder.close();
+
+ const blockedClientUrl = "http://localhost:59999/blocked.json";
+
+ // Stand up a metadata host that would serve a valid document if the
+ // server ever reached it — the assertion is that it never does.
+ const metadataHost = createServer();
+ let fetchCount = 0;
+ metadataHost.on("request", (req, res) => {
+ fetchCount++;
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(
+ JSON.stringify({
+ client_id: blockedClientUrl,
+ redirect_uris: ["http://localhost:59999/callback"],
+ token_endpoint_auth_method: "none",
+ }),
+ );
+ });
+ metadataHost.listen(59999);
+ t.after(() => metadataHost.close());
+ await once(metadataHost, "listening");
+
+ const auth = betterAuth({
+ baseURL: authBaseUrl,
+ secret: "smoke-test-secret-that-is-long-enough-for-validation",
+ database: new DatabaseSync(":memory:"),
+ emailAndPassword: { enabled: true },
+ trustedOrigins: [authBaseUrl],
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid", "profile", "email", "offline_access"],
+ silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
+ }),
+ cimd({
+ // Block any client_id whose host is `localhost:59999`.
+ allowFetch: (url) => new URL(url).host !== "localhost:59999",
+ }),
+ ],
+ });
+
+ const { runMigrations } = await getMigrations(auth.options);
+ await runMigrations();
+
+ const authServer = createServer(toNodeHandler(auth.handler));
+ authServer.listen(authPort);
+ t.after(() => authServer.close());
+ await once(authServer, "listening");
+
+ // Sign up + get a session.
+ const signup = await fetch(`${authBaseUrl}/api/auth/sign-up/email`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ origin: authBaseUrl,
+ },
+ body: JSON.stringify({
+ email: "bob@example.com",
+ password: "smoke-test-password-1",
+ name: "Bob",
+ }),
+ });
+ const sessionCookies = signup.headers.get("set-cookie") ?? "";
+
+ // Authorize attempt against the blocked URL.
+ const authorizeRes = await fetch(
+ `${authBaseUrl}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(blockedClientUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent("http://localhost:59999/callback")}` +
+ `&scope=openid` +
+ `&code_challenge=${PKCE_CHALLENGE}` +
+ `&code_challenge_method=S256`,
+ {
+ method: "GET",
+ headers: {
+ cookie: sessionCookies,
+ origin: authBaseUrl,
+ accept: "application/json",
+ },
+ redirect: "manual",
+ },
+ );
+
+ assert.ok(
+ authorizeRes.status >= 400,
+ `authorize should reject blocked URL, got status ${authorizeRes.status}`,
+ );
+ assert.equal(
+ fetchCount,
+ 0,
+ "metadata host should never be fetched when allowFetch rejects",
+ );
+ });
+});
diff --git a/packages/cimd/README.md b/packages/cimd/README.md
new file mode 100644
index 0000000000..b6c1c1e5e2
--- /dev/null
+++ b/packages/cimd/README.md
@@ -0,0 +1,17 @@
+# Better Auth CIMD Plugin
+
+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.
+
+## Installation
+
+```bash
+npm install @better-auth/cimd
+```
+
+## Documentation
+
+For full documentation, visit [better-auth.com/docs/plugins/cimd](https://www.better-auth.com/docs/plugins/cimd).
+
+## License
+
+MIT
diff --git a/packages/cimd/package.json b/packages/cimd/package.json
new file mode 100644
index 0000000000..bd68f01c8a
--- /dev/null
+++ b/packages/cimd/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@better-auth/cimd",
+ "version": "1.7.0-beta.0",
+ "description": "Client ID Metadata Document plugin for Better Auth",
+ "type": "module",
+ "license": "MIT",
+ "homepage": "https://www.better-auth.com/docs/plugins/cimd",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/better-auth/better-auth.git",
+ "directory": "packages/cimd"
+ },
+ "keywords": [
+ "auth",
+ "cimd",
+ "client-id-metadata-document",
+ "mcp",
+ "oauth",
+ "oauth2",
+ "oidc",
+ "typescript",
+ "better-auth"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "sideEffects": false,
+ "scripts": {
+ "build": "tsdown",
+ "dev": "tsdown --watch",
+ "lint:package": "publint run --strict --pack false",
+ "lint:types": "attw --profile esm-only --pack .",
+ "typecheck": "tsc --project tsconfig.json",
+ "test": "vitest",
+ "coverage": "vitest run --coverage --coverage.provider=istanbul"
+ },
+ "files": [
+ "dist"
+ ],
+ "main": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.mts",
+ "exports": {
+ ".": {
+ "dev-source": "./src/index.ts",
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ }
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "./dist/index.d.mts"
+ ]
+ }
+ },
+ "devDependencies": {
+ "@better-auth/core": "workspace:*",
+ "@better-auth/oauth-provider": "workspace:*",
+ "better-auth": "workspace:*",
+ "listhen": "^1.9.0",
+ "tsdown": "catalog:"
+ },
+ "peerDependencies": {
+ "@better-auth/core": "workspace:^",
+ "@better-auth/oauth-provider": "workspace:^",
+ "better-auth": "workspace:^",
+ "better-call": "catalog:"
+ }
+}
diff --git a/packages/cimd/src/cimd.test.ts b/packages/cimd/src/cimd.test.ts
new file mode 100644
index 0000000000..55287be201
--- /dev/null
+++ b/packages/cimd/src/cimd.test.ts
@@ -0,0 +1,277 @@
+import { oauthProvider } from "@better-auth/oauth-provider";
+import { oauthProviderClient } from "@better-auth/oauth-provider/client";
+import { createAuthClient } from "better-auth/client";
+import { toNodeHandler } from "better-auth/node";
+import { jwt } from "better-auth/plugins/jwt";
+import { getTestInstance } from "better-auth/test";
+import type { Listener } from "listhen";
+import { listen } from "listhen";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { cimd } from "./index";
+
+describe("Client ID Metadata Document - integration", async () => {
+ const port = 3002;
+ const authServerBaseUrl = `http://localhost:${port}`;
+ const rpBaseUrl = "http://localhost:5002";
+ const providerId = "cimd-test";
+ const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`;
+
+ const clientMetadataUrl =
+ "https://mcp-client.example.com/client-metadata.json";
+ const metadataDocument = {
+ client_id: clientMetadataUrl,
+ client_name: "Test MCP Client",
+ redirect_uris: [redirectUri],
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code"],
+ response_types: ["code"],
+ };
+
+ const {
+ auth: authorizationServer,
+ signInWithTestUser,
+ customFetchImpl,
+ } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ allowDynamicClientRegistration: true,
+ scopes: ["openid", "profile", "email", "offline_access"],
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ cimd(),
+ ],
+ });
+
+ let server: Listener;
+
+ beforeAll(async () => {
+ server = await listen(
+ async (req, res) => {
+ if (req.url === "/.well-known/openid-configuration") {
+ const config = await authorizationServer.api.getOpenIdConfig();
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify(config));
+ } else {
+ await toNodeHandler(authorizationServer.handler)(req, res);
+ }
+ },
+ { port },
+ );
+ });
+
+ afterAll(async () => {
+ await server.close();
+ });
+
+ it("should auto-create a public client from a URL client_id on authorize", async ({
+ onTestFinished,
+ }) => {
+ // Stub fetch to serve the metadata document for the external URL
+ const originalFetch = globalThis.fetch.bind(globalThis);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url =
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+ if (url === clientMetadataUrl) {
+ return Promise.resolve(
+ new Response(JSON.stringify(metadataDocument), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ return originalFetch(input, init);
+ }),
+ );
+ onTestFinished(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const { headers } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ // Hit /authorize with the URL as client_id
+ const authorizeUrl =
+ `${authServerBaseUrl}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(clientMetadataUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
+ `&scope=openid` +
+ `&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM` +
+ `&code_challenge_method=S256`;
+
+ // The authorize endpoint should redirect to the consent page
+ // (not error), which proves the CIMD client was created
+ let loginRedirect = "";
+ await authedClient.$fetch(authorizeUrl, {
+ method: "GET",
+ onError(ctx) {
+ loginRedirect = ctx.response.headers.get("Location") || "";
+ },
+ });
+
+ // Should redirect to consent (not login, since we're signed in)
+ expect(loginRedirect).toContain("/consent");
+ expect(loginRedirect).toContain(
+ `client_id=${encodeURIComponent(clientMetadataUrl)}`,
+ );
+ });
+
+ it("should complete authorize and consent flow with a CIMD client", async ({
+ onTestFinished,
+ }) => {
+ const originalFetch = globalThis.fetch.bind(globalThis);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url =
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+ if (url === clientMetadataUrl) {
+ return Promise.resolve(
+ new Response(JSON.stringify(metadataDocument), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ return originalFetch(input, init);
+ }),
+ );
+ onTestFinished(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const { headers: userHeaders } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers: userHeaders },
+ });
+
+ // Hit authorize with the URL client_id
+ const authorizeUrl =
+ `${authServerBaseUrl}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(clientMetadataUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
+ `&scope=openid` +
+ `&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM` +
+ `&code_challenge_method=S256`;
+
+ let consentRedirect = "";
+ await authedClient.$fetch(authorizeUrl, {
+ method: "GET",
+ onError(ctx) {
+ consentRedirect = ctx.response.headers.get("Location") || "";
+ },
+ });
+ expect(consentRedirect).toContain("/consent");
+
+ // Accept consent
+ vi.stubGlobal("window", {
+ location: {
+ search: new URL(consentRedirect, authServerBaseUrl).search,
+ },
+ });
+
+ const consentResponse = await authedClient.oauth2.consent(
+ { accept: true },
+ { throw: true },
+ );
+ expect(consentResponse.redirect).toBe(true);
+ expect(consentResponse.url).toContain(redirectUri);
+ expect(consentResponse.url).toContain("code=");
+ });
+
+ it("should advertise client_id_metadata_document_supported in discovery", async () => {
+ const config =
+ (await authorizationServer.api.getOAuthServerConfig()) as Record<
+ string,
+ unknown
+ >;
+ expect(config.client_id_metadata_document_supported).toBe(true);
+ });
+
+ it("should reject metadata document where client_id does not match URL", async ({
+ onTestFinished,
+ }) => {
+ const originalFetch = globalThis.fetch.bind(globalThis);
+ const mismatchedUrl = "https://mismatch.example.com/client-metadata.json";
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url =
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+ if (url === mismatchedUrl) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ client_id: "https://wrong.example.com/other.json",
+ redirect_uris: ["https://mismatch.example.com/callback"],
+ token_endpoint_auth_method: "none",
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+ }
+ return originalFetch(input, init);
+ }),
+ );
+ onTestFinished(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const { headers } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ const authorizeUrl =
+ `${authServerBaseUrl}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(mismatchedUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent("https://mismatch.example.com/callback")}` +
+ `&scope=openid` +
+ `&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM` +
+ `&code_challenge_method=S256`;
+
+ // Should get an error, not a consent redirect
+ let errorStatus = 0;
+ await authedClient.$fetch(authorizeUrl, {
+ method: "GET",
+ onError(ctx) {
+ errorStatus = ctx.response.status;
+ },
+ });
+ expect(errorStatus).toBeGreaterThanOrEqual(400);
+ });
+});
diff --git a/packages/cimd/src/client-store.ts b/packages/cimd/src/client-store.ts
new file mode 100644
index 0000000000..27dfaa73cd
--- /dev/null
+++ b/packages/cimd/src/client-store.ts
@@ -0,0 +1,374 @@
+import type { GenericEndpointContext } from "@better-auth/core";
+import type {
+ OAuthClient,
+ OAuthOptions,
+ SchemaClient,
+ Scope,
+} from "@better-auth/oauth-provider";
+import { checkOAuthClient, oauthToSchema } from "@better-auth/oauth-provider";
+import { APIError } from "better-call";
+import type { CimdOptions } from "./types";
+import {
+ validateCimdMetadata,
+ validateClientIdUrl,
+} from "./validate-metadata-document";
+
+const FETCH_TIMEOUT_MS = 5_000;
+const MAX_RESPONSE_BYTES = 5 * 1024; // 5 KB per spec recommendation (§6.6)
+
+/**
+ * Accepts `application/json` and the draft's `application/+json`
+ * form. Parameters (charset, etc.) are allowed after the subtype.
+ */
+const JSON_CONTENT_TYPE_RE = /^application\/(?:[-\w.]+\+)?json\s*(?:;|$)/i;
+
+function tooLargeError(): APIError {
+ return new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: `Metadata document exceeds ${MAX_RESPONSE_BYTES / 1024}KB size limit`,
+ });
+}
+
+/**
+ * Stream a Response body into a decoded string, aborting as soon as the
+ * running byte count exceeds `max`. Guarantees no more than `max + one
+ * chunk` bytes ever sit in memory before the request is canceled.
+ */
+async function readBodyWithLimit(
+ response: Response,
+ max: number,
+): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) {
+ // Runtimes without streaming body support fall back to `text()`; we
+ // re-apply the size check on the buffered result for defense in
+ // depth.
+ const text = await response.text();
+ if (new TextEncoder().encode(text).byteLength > max) {
+ throw tooLargeError();
+ }
+ return text;
+ }
+
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > max) {
+ await reader.cancel();
+ throw tooLargeError();
+ }
+ chunks.push(value);
+ }
+
+ const merged = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ merged.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return new TextDecoder().decode(merged);
+}
+
+/**
+ * RFC 7591 / CIMD fields accepted from external metadata documents.
+ *
+ * Security-sensitive fields — `require_pkce`, `disabled`, `skip_consent`,
+ * `enable_end_session` — are deliberately excluded. An attacker-controlled
+ * document MUST NOT be able to weaken the server's PKCE policy or escalate
+ * admin-only flags.
+ */
+const ALLOWED_METADATA_FIELDS = new Set([
+ "client_id",
+ "redirect_uris",
+ "token_endpoint_auth_method",
+ "grant_types",
+ "response_types",
+ "client_name",
+ "client_uri",
+ "logo_uri",
+ "scope",
+ "contacts",
+ "tos_uri",
+ "policy_uri",
+ "software_id",
+ "software_version",
+ "software_statement",
+ "post_logout_redirect_uris",
+ "subject_type",
+ "type",
+ "jwks",
+ "jwks_uri",
+]);
+
+/**
+ * Extract only recognized RFC 7591 / CIMD fields from the metadata document.
+ * Prevents arbitrary attacker-controlled fields from leaking into the DB.
+ */
+function toOAuthClientBody(metadata: Record): OAuthClient {
+ const filtered: Record = {};
+ for (const key of ALLOWED_METADATA_FIELDS) {
+ if (key in metadata) {
+ filtered[key] = metadata[key];
+ }
+ }
+ return {
+ ...(filtered as OAuthClient),
+ token_endpoint_auth_method:
+ (filtered.token_endpoint_auth_method as OAuthClient["token_endpoint_auth_method"]) ??
+ "none",
+ };
+}
+
+/**
+ * Create a new client from a Client ID Metadata Document.
+ * Called when a URL-format client_id is encountered for the first time.
+ *
+ * Writes the DB record directly rather than routing through
+ * `createOAuthClientEndpoint`, because CIMD clients must use the URL
+ * as their `clientId` (not a generated random ID).
+ */
+export async function createMetadataDocumentClient(
+ ctx: GenericEndpointContext,
+ clientIdUrl: string,
+ cimdOptions: CimdOptions,
+ oauthOptions: OAuthOptions,
+): Promise> {
+ const metadata = await fetchAndValidateMetadataDocument(
+ ctx,
+ clientIdUrl,
+ cimdOptions,
+ );
+ const oauthClient = toOAuthClientBody(metadata);
+
+ await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
+
+ const isPrivateKeyJwt =
+ oauthClient.token_endpoint_auth_method === "private_key_jwt";
+ const iat = Math.floor(Date.now() / 1000);
+ const schema = oauthToSchema({
+ ...oauthClient,
+ // Admin-only fields: never trust from external metadata
+ disabled: undefined,
+ skip_consent: undefined,
+ enable_end_session: undefined,
+ // Preserve jwks/jwks_uri only for private_key_jwt clients
+ jwks: isPrivateKeyJwt ? oauthClient.jwks : undefined,
+ jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : undefined,
+ client_id: clientIdUrl,
+ client_secret: undefined,
+ client_secret_expires_at: undefined,
+ client_id_issued_at: iat,
+ public: !isPrivateKeyJwt,
+ });
+
+ const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
+ let client: SchemaClient;
+ try {
+ client = await ctx.context.adapter.create>({
+ model,
+ data: {
+ ...schema,
+ createdAt: new Date(iat * 1000),
+ updatedAt: new Date(iat * 1000),
+ },
+ });
+ } catch (err) {
+ // A concurrent request may have created this client first. If the
+ // record exists by now, return it; otherwise re-throw the original.
+ const existing = await ctx.context.adapter.findOne>({
+ model,
+ where: [{ field: "clientId", value: clientIdUrl }],
+ });
+ if (existing) {
+ return existing;
+ }
+ throw err;
+ }
+
+ await cimdOptions.onClientCreated?.({ client, metadata, ctx });
+
+ return client;
+}
+
+/**
+ * Refresh an existing client by re-fetching its metadata document.
+ *
+ * Admin-controlled fields (`disabled`, `skip_consent`, `enable_end_session`)
+ * are never overwritten from the document — they are read from `existing`
+ * and preserved so admin decisions survive a refresh.
+ */
+export async function refreshMetadataDocumentClient(
+ ctx: GenericEndpointContext,
+ clientIdUrl: string,
+ existing: SchemaClient,
+ cimdOptions: CimdOptions,
+ oauthOptions: OAuthOptions,
+): Promise> {
+ const metadata = await fetchAndValidateMetadataDocument(
+ ctx,
+ clientIdUrl,
+ cimdOptions,
+ );
+ const oauthClient = toOAuthClientBody(metadata);
+
+ await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
+
+ const isPrivateKeyJwt =
+ oauthClient.token_endpoint_auth_method === "private_key_jwt";
+ const schema = oauthToSchema({
+ ...oauthClient,
+ jwks: isPrivateKeyJwt ? oauthClient.jwks : undefined,
+ jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : undefined,
+ client_id: clientIdUrl,
+ client_secret: undefined,
+ client_secret_expires_at: undefined,
+ public: !isPrivateKeyJwt,
+ });
+
+ // Preserve admin-controlled flags that the document MUST NOT influence.
+ const preservedAdminFields = {
+ disabled: existing.disabled,
+ skipConsent: existing.skipConsent,
+ enableEndSession: existing.enableEndSession,
+ };
+
+ const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
+ const client = await ctx.context.adapter.update>({
+ model,
+ where: [{ field: "clientId", value: clientIdUrl }],
+ update: {
+ ...schema,
+ ...preservedAdminFields,
+ updatedAt: new Date(Math.floor(Date.now() / 1000) * 1000),
+ },
+ });
+
+ if (!client) {
+ // `update` returning null means no row matched — the client was
+ // deleted between the read and the write. That's a race against an
+ // admin delete, not a server fault, so surface it as an OAuth
+ // invalid_client rather than a 500.
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: "client no longer exists",
+ });
+ }
+
+ await cimdOptions.onClientRefreshed?.({ client, metadata, ctx });
+
+ return client;
+}
+
+/**
+ * Fetch a Client ID Metadata Document, validate it against the spec,
+ * and return the parsed metadata.
+ */
+async function fetchAndValidateMetadataDocument(
+ ctx: GenericEndpointContext,
+ clientIdUrl: string,
+ cimdOptions: CimdOptions,
+): Promise> {
+ // §3: validate the URL structure before fetching
+ const urlError = validateClientIdUrl(clientIdUrl);
+ if (urlError) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: urlError,
+ });
+ }
+
+ // Pre-fetch gate: operators can block domains, rate-limit, or reject
+ // URLs whose hostnames resolve to non-public addresses (DNS-level SSRF
+ // defense beyond the IP-literal check in `validateClientIdUrl`).
+ if (cimdOptions.allowFetch) {
+ const allowed = await cimdOptions.allowFetch(clientIdUrl, ctx);
+ if (!allowed) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description:
+ "client_id URL is not permitted by the server's fetch policy",
+ });
+ }
+ }
+
+ let response: Response;
+ try {
+ response = await fetch(clientIdUrl, {
+ headers: { Accept: "application/json" },
+ redirect: "error",
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+ });
+ } catch (err) {
+ const isTimeout =
+ err instanceof DOMException && err.name === "TimeoutError";
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: isTimeout
+ ? `Metadata document fetch timed out after ${FETCH_TIMEOUT_MS}ms`
+ : "Failed to fetch metadata document (network error or redirect blocked)",
+ });
+ }
+
+ if (!response.ok) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: `Metadata document fetch returned HTTP ${response.status}`,
+ });
+ }
+
+ // Content-Type must be JSON per RFC 7591. The CIMD draft also permits
+ // `application/+json`, so accept any `*+json` subtype.
+ const contentType = response.headers.get("content-type") ?? "";
+ if (!JSON_CONTENT_TYPE_RE.test(contentType)) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: `Metadata document must be JSON (got Content-Type "${contentType || "(none)"}")`,
+ });
+ }
+
+ // §6.6: enforce the response size limit BEFORE buffering the body. A
+ // malicious host that serves a multi-gigabyte document would otherwise
+ // exhaust memory up to the 5 s timeout. Two layers:
+ // 1. Reject immediately if Content-Length declares > 5 KB.
+ // 2. Stream-read with a running byte counter and abort on overrun,
+ // so responses without Content-Length are still bounded.
+ const contentLengthHeader = response.headers.get("content-length");
+ if (contentLengthHeader) {
+ const declared = Number.parseInt(contentLengthHeader, 10);
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
+ await response.body?.cancel();
+ throw tooLargeError();
+ }
+ }
+
+ const bodyText = await readBodyWithLimit(response, MAX_RESPONSE_BYTES);
+
+ let data: Record;
+ try {
+ data = JSON.parse(bodyText) as Record;
+ } catch {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: "Metadata document is not valid JSON",
+ });
+ }
+
+ // §4.1: validate the document contents
+ const validation = validateCimdMetadata(
+ clientIdUrl,
+ data,
+ cimdOptions.originBoundFields,
+ );
+
+ if (!validation.valid) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: validation.error ?? "Invalid metadata document",
+ });
+ }
+
+ return data;
+}
diff --git a/packages/cimd/src/flows.test.ts b/packages/cimd/src/flows.test.ts
new file mode 100644
index 0000000000..2389a8399c
--- /dev/null
+++ b/packages/cimd/src/flows.test.ts
@@ -0,0 +1,536 @@
+import { oauthProvider } from "@better-auth/oauth-provider";
+import { oauthProviderClient } from "@better-auth/oauth-provider/client";
+import { createAuthClient } from "better-auth/client";
+import { toNodeHandler } from "better-auth/node";
+import { jwt } from "better-auth/plugins/jwt";
+import { getTestInstance } from "better-auth/test";
+import type { Listener } from "listhen";
+import { listen } from "listhen";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import { cimd } from "./index";
+
+const PKCE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+const PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+function buildAuthorizeUrl(
+ base: string,
+ clientIdUrl: string,
+ redirectUri: string,
+): string {
+ return (
+ `${base}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(clientIdUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
+ `&scope=openid` +
+ `&code_challenge=${PKCE_CHALLENGE}` +
+ `&code_challenge_method=S256`
+ );
+}
+
+function stubMetadataFetch(
+ url: string,
+ document: Record,
+): void {
+ const originalFetch = globalThis.fetch.bind(globalThis);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const requested =
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+ if (requested === url) {
+ return Promise.resolve(
+ new Response(JSON.stringify(document), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ return originalFetch(input, init);
+ }),
+ );
+}
+
+async function extractAuthorizationCode(
+ authedClient: ReturnType,
+ authorizeUrl: string,
+): Promise {
+ let redirect = "";
+ await authedClient.$fetch(authorizeUrl, {
+ method: "GET",
+ onError(ctx) {
+ redirect = ctx.response.headers.get("Location") || "";
+ },
+ });
+
+ // If prior consent exists, the server skips the consent page and
+ // redirects straight to the client's redirect_uri with `code=...`.
+ const redirectParams = tryExtractCodeFromUrl(redirect);
+ if (redirectParams) return redirectParams;
+
+ if (!redirect.includes("/consent")) {
+ throw new Error(`Expected consent or callback redirect, got: ${redirect}`);
+ }
+
+ const baseURL = new URL(authorizeUrl).origin;
+ vi.stubGlobal("window", {
+ location: { search: new URL(redirect, baseURL).search },
+ });
+
+ const consent = await (authedClient as any).oauth2.consent(
+ { accept: true },
+ { throw: true },
+ );
+ const url = new URL(consent.url);
+ const code = url.searchParams.get("code");
+ if (!code) throw new Error(`No code in redirect: ${consent.url}`);
+ return code;
+}
+
+function tryExtractCodeFromUrl(urlString: string): string | null {
+ if (!urlString) return null;
+ try {
+ const url = new URL(urlString);
+ return url.searchParams.get("code");
+ } catch {
+ return null;
+ }
+}
+
+describe("CIMD - token exchange flow", async () => {
+ const port = 3102;
+ const authServerBaseUrl = `http://localhost:${port}`;
+ const clientMetadataUrl =
+ "https://mcp-client-token.example.com/client-metadata.json";
+ const redirectUri = "http://localhost:5102/callback";
+ const metadataDocument = {
+ client_id: clientMetadataUrl,
+ client_name: "Token Exchange Test Client",
+ redirect_uris: [redirectUri],
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code"],
+ response_types: ["code"],
+ };
+
+ const {
+ auth: authorizationServer,
+ signInWithTestUser,
+ customFetchImpl,
+ } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid", "profile", "email", "offline_access"],
+ silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
+ }),
+ cimd(),
+ ],
+ });
+
+ let server: Listener;
+ beforeAll(async () => {
+ server = await listen(
+ (req, res) => toNodeHandler(authorizationServer.handler)(req, res),
+ { port },
+ );
+ });
+ afterAll(async () => {
+ await server.close();
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("exchanges an authorization code for an access token", async () => {
+ stubMetadataFetch(clientMetadataUrl, metadataDocument);
+
+ const { headers } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ const code = await extractAuthorizationCode(
+ authedClient,
+ buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri),
+ );
+
+ const tokenResponse = await fetch(
+ `${authServerBaseUrl}/api/auth/oauth2/token`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: redirectUri,
+ client_id: clientMetadataUrl,
+ code_verifier: PKCE_VERIFIER,
+ }).toString(),
+ },
+ );
+
+ expect(tokenResponse.status).toBe(200);
+ const body = (await tokenResponse.json()) as Record;
+ expect(typeof body.access_token).toBe("string");
+ expect(body.token_type).toBe("Bearer");
+ expect(typeof body.expires_in).toBe("number");
+ });
+
+ it("returns user claims from /oauth2/userinfo with a CIMD-issued access token", async () => {
+ stubMetadataFetch(clientMetadataUrl, metadataDocument);
+
+ const { headers, user } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ const authorizeWithEmail =
+ `${authServerBaseUrl}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(clientMetadataUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
+ `&scope=${encodeURIComponent("openid email profile")}` +
+ `&code_challenge=${PKCE_CHALLENGE}` +
+ `&code_challenge_method=S256`;
+
+ const code = await extractAuthorizationCode(
+ authedClient,
+ authorizeWithEmail,
+ );
+
+ const tokenResponse = await fetch(
+ `${authServerBaseUrl}/api/auth/oauth2/token`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: redirectUri,
+ client_id: clientMetadataUrl,
+ code_verifier: PKCE_VERIFIER,
+ }).toString(),
+ },
+ );
+ const token = (await tokenResponse.json()) as Record;
+ expect(typeof token.access_token).toBe("string");
+
+ const userinfoResponse = await fetch(
+ `${authServerBaseUrl}/api/auth/oauth2/userinfo`,
+ {
+ headers: { Authorization: `Bearer ${token.access_token as string}` },
+ },
+ );
+ expect(userinfoResponse.status).toBe(200);
+ const claims = (await userinfoResponse.json()) as Record;
+ expect(typeof claims.sub).toBe("string");
+ expect(claims.email).toBe(user.email);
+ });
+
+ it("refresh token grant mints a new access token for a CIMD client", async () => {
+ stubMetadataFetch(clientMetadataUrl, metadataDocument);
+
+ const { headers } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ // Request offline_access so the token response includes a refresh_token.
+ const authorizeWithOffline =
+ `${authServerBaseUrl}/api/auth/oauth2/authorize` +
+ `?client_id=${encodeURIComponent(clientMetadataUrl)}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
+ `&scope=${encodeURIComponent("openid offline_access")}` +
+ `&code_challenge=${PKCE_CHALLENGE}` +
+ `&code_challenge_method=S256`;
+
+ const code = await extractAuthorizationCode(
+ authedClient,
+ authorizeWithOffline,
+ );
+
+ const initial = await fetch(`${authServerBaseUrl}/api/auth/oauth2/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: redirectUri,
+ client_id: clientMetadataUrl,
+ code_verifier: PKCE_VERIFIER,
+ }).toString(),
+ });
+ const initialBody = (await initial.json()) as Record;
+ expect(typeof initialBody.access_token).toBe("string");
+ expect(typeof initialBody.refresh_token).toBe("string");
+
+ const refreshed = await fetch(
+ `${authServerBaseUrl}/api/auth/oauth2/token`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: initialBody.refresh_token as string,
+ client_id: clientMetadataUrl,
+ }).toString(),
+ },
+ );
+
+ expect(refreshed.status).toBe(200);
+ const refreshedBody = (await refreshed.json()) as Record;
+ expect(typeof refreshedBody.access_token).toBe("string");
+ expect(refreshedBody.access_token).not.toBe(initialBody.access_token);
+ });
+});
+
+describe("CIMD - refresh preserves admin-set flags", async () => {
+ const port = 3103;
+ const authServerBaseUrl = `http://localhost:${port}`;
+ const clientMetadataUrl =
+ "https://mcp-client-refresh.example.com/client-metadata.json";
+ const redirectUri = "http://localhost:5103/callback";
+ const metadataDocument = {
+ client_id: clientMetadataUrl,
+ client_name: "Refresh Preserve Test Client",
+ redirect_uris: [redirectUri],
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code"],
+ response_types: ["code"],
+ };
+
+ // refreshRate: 0 makes every request stale, so the refresh path fires
+ // on the second authorize hit — giving us a deterministic way to test
+ // what refresh does (or does not) write.
+ const {
+ auth: authorizationServer,
+ signInWithTestUser,
+ customFetchImpl,
+ } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid", "profile", "email", "offline_access"],
+ silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
+ }),
+ cimd({ refreshRate: 0 }),
+ ],
+ });
+
+ let server: Listener;
+ beforeAll(async () => {
+ server = await listen(
+ (req, res) => toNodeHandler(authorizationServer.handler)(req, res),
+ { port },
+ );
+ });
+ afterAll(async () => {
+ await server.close();
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("preserves admin-set `disabled`, `skipConsent`, and `enableEndSession` across a stale refresh", async () => {
+ stubMetadataFetch(clientMetadataUrl, metadataDocument);
+
+ const { headers } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ // First authorize creates the CIMD client record.
+ let redirect = "";
+ await authedClient.$fetch(
+ buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri),
+ {
+ method: "GET",
+ onError(ctx) {
+ redirect = ctx.response.headers.get("Location") || "";
+ },
+ },
+ );
+ expect(redirect).toContain("/consent");
+
+ // Admin flips all three protected flags directly via the adapter.
+ const ctx = await (authorizationServer as any).$context;
+ await ctx.adapter.update({
+ model: "oauthClient",
+ where: [{ field: "clientId", value: clientMetadataUrl }],
+ update: {
+ disabled: true,
+ skipConsent: true,
+ enableEndSession: true,
+ },
+ });
+
+ const afterAdmin = await ctx.adapter.findOne({
+ model: "oauthClient",
+ where: [{ field: "clientId", value: clientMetadataUrl }],
+ });
+ expect(afterAdmin.disabled).toBe(true);
+ expect(afterAdmin.skipConsent).toBe(true);
+ expect(afterAdmin.enableEndSession).toBe(true);
+
+ // Second authorize must trigger a stale refresh (refreshRate: 0).
+ // The document does NOT carry any of these flags, so preservation is
+ // the only way they survive.
+ await authedClient.$fetch(
+ buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri),
+ { method: "GET", onError() {} },
+ );
+
+ const afterRefresh = await ctx.adapter.findOne({
+ model: "oauthClient",
+ where: [{ field: "clientId", value: clientMetadataUrl }],
+ });
+ expect(afterRefresh.disabled).toBe(true);
+ expect(afterRefresh.skipConsent).toBe(true);
+ expect(afterRefresh.enableEndSession).toBe(true);
+ });
+});
+
+describe("CIMD - allowFetch gate", async () => {
+ const port = 3104;
+ const authServerBaseUrl = `http://localhost:${port}`;
+ const blockedClientUrl = "https://blocked.example.com/client-metadata.json";
+ const allowedClientUrl = "https://allowed.example.com/client-metadata.json";
+ const redirectUriBlocked = "http://localhost:5104/callback";
+ const redirectUriAllowed = "http://localhost:5105/callback";
+
+ const allowedDocument = {
+ client_id: allowedClientUrl,
+ redirect_uris: [redirectUriAllowed],
+ token_endpoint_auth_method: "none",
+ };
+
+ const {
+ auth: authorizationServer,
+ signInWithTestUser,
+ customFetchImpl,
+ } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid", "profile", "email", "offline_access"],
+ silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
+ }),
+ cimd({
+ allowFetch: (url) => new URL(url).hostname === "allowed.example.com",
+ }),
+ ],
+ });
+
+ let server: Listener;
+ beforeAll(async () => {
+ server = await listen(
+ (req, res) => toNodeHandler(authorizationServer.handler)(req, res),
+ { port },
+ );
+ });
+ afterAll(async () => {
+ await server.close();
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("rejects a URL blocked by allowFetch before the fetch runs", async () => {
+ const fetchSpy = vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
+ globalThis.fetch.call(globalThis, input, init),
+ );
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const { headers } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ let status = 0;
+ await authedClient.$fetch(
+ buildAuthorizeUrl(
+ authServerBaseUrl,
+ blockedClientUrl,
+ redirectUriBlocked,
+ ),
+ {
+ method: "GET",
+ onError(ctx) {
+ status = ctx.response.status;
+ },
+ },
+ );
+ expect(status).toBeGreaterThanOrEqual(400);
+
+ const metadataFetches = fetchSpy.mock.calls.filter((args) => {
+ const input = args[0];
+ const url =
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : (input as Request).url;
+ return url === blockedClientUrl;
+ });
+ expect(metadataFetches).toHaveLength(0);
+ });
+
+ it("allows a URL permitted by allowFetch", async () => {
+ stubMetadataFetch(allowedClientUrl, allowedDocument);
+
+ const { headers } = await signInWithTestUser();
+ const authedClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: { customFetchImpl, headers },
+ });
+
+ let redirect = "";
+ await authedClient.$fetch(
+ buildAuthorizeUrl(
+ authServerBaseUrl,
+ allowedClientUrl,
+ redirectUriAllowed,
+ ),
+ {
+ method: "GET",
+ onError(ctx) {
+ redirect = ctx.response.headers.get("Location") || "";
+ },
+ },
+ );
+ expect(redirect).toContain("/consent");
+ });
+});
diff --git a/packages/cimd/src/index.ts b/packages/cimd/src/index.ts
new file mode 100644
index 0000000000..6ac862cfb0
--- /dev/null
+++ b/packages/cimd/src/index.ts
@@ -0,0 +1,79 @@
+import { BetterAuthError } from "@better-auth/core/error";
+import type { ClientDiscovery, Scope } from "@better-auth/oauth-provider";
+import type { BetterAuthPlugin } from "better-auth";
+import { createCimdResolver } from "./resolver";
+import type { CimdOptions } from "./types";
+import { isUrlClientId } from "./validate-metadata-document";
+import { PACKAGE_VERSION } from "./version";
+
+declare module "@better-auth/core" {
+ interface BetterAuthPluginRegistry {
+ cimd: {
+ creator: typeof cimd;
+ };
+ }
+}
+
+/**
+ * Build a {@link ClientDiscovery} for Client ID Metadata Documents.
+ *
+ * Users who prefer explicit composition can pass the result directly to
+ * `oauthProvider({ clientDiscovery })`; most users should install the
+ * {@link cimd} plugin instead, which appends this discovery to whatever
+ * is already configured.
+ */
+export function cimdClientDiscovery(
+ options: CimdOptions = {},
+): ClientDiscovery {
+ const resolver = createCimdResolver(options);
+ return {
+ id: "cimd",
+ matches: isUrlClientId,
+ resolve: resolver,
+ discoveryMetadata: { client_id_metadata_document_supported: true },
+ };
+}
+
+/**
+ * Client ID Metadata Document plugin.
+ *
+ * Adds unauthenticated dynamic client discovery over HTTPS to an
+ * `oauth-provider` instance. Clients identify themselves by providing
+ * an HTTPS URL as their `client_id`; the plugin fetches and validates
+ * the document at that URL, then creates a public client record.
+ *
+ * See {@link https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ | the IETF draft}
+ * and {@link https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow | the MCP authorization spec}.
+ */
+export const cimd = (options: CimdOptions = {}) => {
+ const discovery = cimdClientDiscovery(options);
+
+ return {
+ id: "cimd",
+ version: PACKAGE_VERSION,
+ init(ctx) {
+ const provider = ctx.getPlugin("oauth-provider");
+ if (!provider) {
+ throw new BetterAuthError(
+ "The cimd plugin requires the oauth-provider plugin.",
+ );
+ }
+ const existing = provider.options.clientDiscovery;
+ provider.options.clientDiscovery = Array.isArray(existing)
+ ? [...existing, discovery]
+ : existing
+ ? [existing, discovery]
+ : discovery;
+ },
+ } satisfies BetterAuthPlugin;
+};
+
+export { createCimdResolver } from "./resolver";
+export type { CimdOptions } from "./types";
+export type { ClientIdMetadataDocumentResult } from "./validate-metadata-document";
+export {
+ isLocalhost,
+ isUrlClientId,
+ validateCimdMetadata,
+ validateClientIdUrl,
+} from "./validate-metadata-document";
diff --git a/packages/cimd/src/resolver.ts b/packages/cimd/src/resolver.ts
new file mode 100644
index 0000000000..8730cb47f0
--- /dev/null
+++ b/packages/cimd/src/resolver.ts
@@ -0,0 +1,115 @@
+import type { GenericEndpointContext } from "@better-auth/core";
+import { BetterAuthError } from "@better-auth/core/error";
+import type {
+ OAuthOptions,
+ SchemaClient,
+ Scope,
+} from "@better-auth/oauth-provider";
+import { toExpJWT } from "better-auth/plugins";
+import {
+ createMetadataDocumentClient,
+ refreshMetadataDocumentClient,
+} from "./client-store";
+import type { CimdOptions } from "./types";
+import { isUrlClientId } from "./validate-metadata-document";
+
+/**
+ * Signature of the `resolve` function on a {@link ClientDiscovery}. Kept
+ * here to avoid a circular import back into `@better-auth/oauth-provider`.
+ */
+type CimdResolver = (
+ ctx: GenericEndpointContext,
+ clientId: string,
+ existing: SchemaClient | null,
+) => Promise | null>;
+
+function toDate(value: unknown): Date | null {
+ if (value instanceof Date) {
+ return Number.isFinite(value.getTime()) ? value : null;
+ }
+ if (typeof value === "number" && Number.isFinite(value)) {
+ const parsed = new Date(value);
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
+ }
+ if (typeof value === "bigint") {
+ const parsed = new Date(Number(value));
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
+ }
+ if (typeof value === "string") {
+ const asNumber = Number(value);
+ if (Number.isFinite(asNumber)) {
+ const parsed = new Date(asNumber);
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
+ }
+ const parsed = new Date(value);
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
+ }
+ return null;
+}
+
+function isStale(
+ existing: SchemaClient,
+ refreshRate: number | string,
+) {
+ // Records with no usable timestamp are always considered stale — we
+ // would rather re-fetch once than risk serving a record that never
+ // refreshes for its lifetime.
+ const updatedAt =
+ toDate(existing.updatedAt) ?? toDate(existing.createdAt) ?? new Date(0);
+ const updatedSec = Math.floor(updatedAt.getTime() / 1000);
+ // `toExpJWT` treats numbers as absolute timestamps, so convert numeric
+ // seconds to a relative offset for correct TTL behavior.
+ const staleAt =
+ typeof refreshRate === "number"
+ ? updatedSec + refreshRate
+ : toExpJWT(refreshRate, updatedSec);
+ return staleAt < Math.floor(Date.now() / 1000);
+}
+
+/**
+ * Build the `resolve` function for a CIMD {@link ClientDiscovery}.
+ *
+ * Exposed for advanced composition. Most users should call
+ * {@link cimdClientDiscovery} (to pass a complete discovery to
+ * `oauthProvider({ clientDiscovery })`) or install the `cimd()` plugin.
+ */
+export function createCimdResolver(
+ cimdOptions: CimdOptions = {},
+): CimdResolver {
+ const refreshRate = cimdOptions.refreshRate ?? "60m";
+
+ return async (ctx, clientId, existing) => {
+ if (!isUrlClientId(clientId)) {
+ return null;
+ }
+
+ const provider = ctx.context.getPlugin("oauth-provider");
+ if (!provider) {
+ throw new BetterAuthError(
+ "cimd discovery invoked without the oauth-provider plugin installed",
+ );
+ }
+ const oauthOptions = provider.options as OAuthOptions;
+
+ if (!existing) {
+ return await createMetadataDocumentClient(
+ ctx,
+ clientId,
+ cimdOptions,
+ oauthOptions,
+ );
+ }
+
+ if (isStale(existing, refreshRate)) {
+ return await refreshMetadataDocumentClient(
+ ctx,
+ clientId,
+ existing,
+ cimdOptions,
+ oauthOptions,
+ );
+ }
+
+ return existing;
+ };
+}
diff --git a/packages/cimd/src/types.ts b/packages/cimd/src/types.ts
new file mode 100644
index 0000000000..085321ac20
--- /dev/null
+++ b/packages/cimd/src/types.ts
@@ -0,0 +1,66 @@
+import type { GenericEndpointContext } from "@better-auth/core";
+import type { SchemaClient, Scope } from "@better-auth/oauth-provider";
+
+/**
+ * Options for the Client ID Metadata Document plugin.
+ *
+ * @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
+ */
+export interface CimdOptions {
+ /**
+ * How frequently to re-fetch a client's metadata document to pick up
+ * changes from the client.
+ *
+ * Accepts a number of seconds or a duration string (e.g. `"60m"`,
+ * `"1d"`).
+ *
+ * @default "60m"
+ */
+ refreshRate?: number | string;
+ /**
+ * Metadata fields whose URL values must share the same origin as the
+ * `client_id` URL. Prevents a client from claiming URIs on a different
+ * domain.
+ *
+ * Pass an empty array to disable origin binding (not recommended for
+ * production).
+ *
+ * @default ["redirect_uris", "post_logout_redirect_uris", "client_uri"]
+ */
+ originBoundFields?: string[];
+ /**
+ * Pre-fetch gate called before a metadata document is requested. Return
+ * `false` to reject the `client_id` URL.
+ *
+ * Use this for origin allowlists, per-host rate limiting, or integrating
+ * with an external trust service. Hostname-based DNS defenses (beyond
+ * the built-in IP-literal check) belong here, since the plugin is
+ * runtime-agnostic and does not perform DNS resolution.
+ *
+ * @default always allow
+ */
+ allowFetch?: (
+ url: string,
+ ctx: GenericEndpointContext,
+ ) => boolean | Promise;
+ /**
+ * Called after a client is created from a metadata document for the
+ * first time. Use this to assign trust levels, prefetch logos, or
+ * perform other post-creation processing.
+ */
+ onClientCreated?: (data: {
+ client: SchemaClient;
+ metadata: Record;
+ ctx: GenericEndpointContext;
+ }) => void | Promise;
+ /**
+ * Called after a client is refreshed from a re-fetched metadata
+ * document. Use this for change-detection logging or updating derived
+ * fields.
+ */
+ onClientRefreshed?: (data: {
+ client: SchemaClient;
+ metadata: Record;
+ ctx: GenericEndpointContext;
+ }) => void | Promise;
+}
diff --git a/packages/cimd/src/validate-metadata-document.test.ts b/packages/cimd/src/validate-metadata-document.test.ts
new file mode 100644
index 0000000000..0d93feb907
--- /dev/null
+++ b/packages/cimd/src/validate-metadata-document.test.ts
@@ -0,0 +1,501 @@
+import { describe, expect, it } from "vitest";
+import {
+ isUrlClientId,
+ validateCimdMetadata,
+ validateClientIdUrl,
+} from "./validate-metadata-document";
+
+function validMetadata(
+ fetchUrl: string,
+ overrides: Record = {},
+) {
+ const origin = new URL(fetchUrl).origin;
+ return {
+ client_id: fetchUrl,
+ redirect_uris: [`${origin}/callback`],
+ ...overrides,
+ };
+}
+
+describe("isUrlClientId", () => {
+ it("accepts https:// URLs", () => {
+ expect(isUrlClientId("https://example.com/meta")).toBe(true);
+ });
+
+ it("accepts http://localhost URLs (dev mode)", () => {
+ expect(isUrlClientId("http://localhost/meta")).toBe(true);
+ expect(isUrlClientId("http://localhost:3000/meta")).toBe(true);
+ });
+
+ it("accepts http://127.0.0.1 (dev mode)", () => {
+ expect(isUrlClientId("http://127.0.0.1/meta")).toBe(true);
+ expect(isUrlClientId("http://127.0.0.1:8080/meta")).toBe(true);
+ });
+
+ it("accepts http://[::1] (dev mode)", () => {
+ expect(isUrlClientId("http://[::1]/meta")).toBe(true);
+ });
+
+ it("accepts localhost subdomains (dev mode)", () => {
+ expect(isUrlClientId("http://app.localhost/meta")).toBe(true);
+ expect(isUrlClientId("http://app.localhost:3000/meta")).toBe(true);
+ });
+
+ it("rejects http:// non-localhost URLs", () => {
+ expect(isUrlClientId("http://example.com/meta")).toBe(false);
+ });
+
+ it("rejects non-URL strings", () => {
+ expect(isUrlClientId("my-client-id")).toBe(false);
+ expect(isUrlClientId("ftp://example.com/meta")).toBe(false);
+ });
+
+ it("rejects empty string", () => {
+ expect(isUrlClientId("")).toBe(false);
+ });
+});
+
+describe("validateClientIdUrl", () => {
+ it("accepts valid https URL with path", () => {
+ expect(
+ validateClientIdUrl("https://example.com/client-metadata.json"),
+ ).toBeNull();
+ });
+
+ it("rejects URL without path", () => {
+ expect(validateClientIdUrl("https://example.com")).not.toBeNull();
+ expect(validateClientIdUrl("https://example.com/")).not.toBeNull();
+ });
+
+ it("rejects URL with fragment", () => {
+ const result = validateClientIdUrl("https://example.com/meta#frag");
+ expect(result).toContain("fragment");
+ });
+
+ it("rejects URL with dot segments", () => {
+ expect(validateClientIdUrl("https://example.com/../meta.json")).toContain(
+ "dot segments",
+ );
+ expect(validateClientIdUrl("https://example.com/./meta.json")).toContain(
+ "dot segments",
+ );
+ });
+
+ it("rejects URL with credentials", () => {
+ expect(validateClientIdUrl("https://user:pass@example.com/meta")).toContain(
+ "credentials",
+ );
+ });
+
+ it("rejects non-https non-localhost", () => {
+ const result = validateClientIdUrl("http://example.com/meta");
+ expect(result).toContain("HTTPS");
+ });
+
+ it("accepts http://localhost/meta (dev)", () => {
+ expect(validateClientIdUrl("http://localhost/meta")).toBeNull();
+ expect(validateClientIdUrl("http://localhost:8080/meta")).toBeNull();
+ });
+
+ it("rejects private IP 10.0.0.1", () => {
+ expect(validateClientIdUrl("https://10.0.0.1/meta")).toContain("private");
+ });
+
+ it("rejects private IP 172.16.0.1", () => {
+ expect(validateClientIdUrl("https://172.16.0.1/meta")).toContain("private");
+ });
+
+ it("rejects private IP 192.168.1.1", () => {
+ expect(validateClientIdUrl("https://192.168.1.1/meta")).toContain(
+ "private",
+ );
+ });
+
+ it("rejects link-local 169.254.169.254 (AWS metadata)", () => {
+ expect(validateClientIdUrl("https://169.254.169.254/meta")).toContain(
+ "private",
+ );
+ });
+
+ it("rejects loopback IP 127.0.0.1 via https", () => {
+ // 127.0.0.1 is localhost, so it should be allowed (localhost is exempt)
+ // but only over http; https://127.0.0.1 is treated as localhost
+ expect(validateClientIdUrl("https://127.0.0.1/meta")).toBeNull();
+ });
+
+ it("accepts public IP like 8.8.8.8", () => {
+ expect(validateClientIdUrl("https://8.8.8.8/meta")).toBeNull();
+ });
+
+ it("rejects 6to4 anycast relay 192.88.99.1 (RFC 7526 deprecated)", () => {
+ expect(validateClientIdUrl("https://192.88.99.1/meta")).toContain(
+ "private",
+ );
+ });
+
+ it("rejects multicast addresses (RFC 5771, 224.0.0.0/4)", () => {
+ expect(validateClientIdUrl("https://224.0.0.1/meta")).toContain("private");
+ expect(validateClientIdUrl("https://239.255.255.250/meta")).toContain(
+ "private",
+ );
+ });
+
+ it("rejects reserved/future-use and broadcast addresses (240.0.0.0/4)", () => {
+ expect(validateClientIdUrl("https://240.0.0.1/meta")).toContain("private");
+ expect(validateClientIdUrl("https://255.255.255.255/meta")).toContain(
+ "private",
+ );
+ });
+
+ it("rejects IPv4-mapped IPv6 targeting private IPs", () => {
+ expect(
+ validateClientIdUrl("https://[::ffff:169.254.169.254]/meta"),
+ ).toContain("private");
+ expect(validateClientIdUrl("https://[::ffff:127.0.0.1]/meta")).toContain(
+ "private",
+ );
+ expect(validateClientIdUrl("https://[::ffff:10.0.0.1]/meta")).toContain(
+ "private",
+ );
+ });
+
+ it("rejects cloud metadata hostname", () => {
+ expect(
+ validateClientIdUrl("https://metadata.google.internal/meta"),
+ ).toContain("private");
+ });
+
+ it("accepts subdomain of localhost", () => {
+ expect(validateClientIdUrl("http://app.localhost/meta")).toBeNull();
+ });
+});
+
+describe("validateCimdMetadata", () => {
+ const fetchUrl = "https://example.com/client-metadata.json";
+
+ it("accepts valid metadata where client_id == fetchUrl", () => {
+ const result = validateCimdMetadata(fetchUrl, validMetadata(fetchUrl));
+ expect(result.valid).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+
+ it("rejects when client_id != fetchUrl", () => {
+ const result = validateCimdMetadata(fetchUrl, {
+ ...validMetadata(fetchUrl),
+ client_id: "https://evil.com/client-metadata.json",
+ });
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("does not match");
+ });
+
+ it("rejects when client_secret is present", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, { client_secret: "test-secret" }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("client_secret");
+ });
+
+ it("rejects when client_secret_expires_at is present", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, { client_secret_expires_at: 0 }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("client_secret_expires_at");
+ });
+
+ it("rejects symmetric auth method client_secret_post", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ token_endpoint_auth_method: "client_secret_post",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("symmetric");
+ });
+
+ it("rejects symmetric auth method client_secret_basic", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ token_endpoint_auth_method: "client_secret_basic",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("symmetric");
+ });
+
+ it("rejects symmetric auth method client_secret_jwt", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ token_endpoint_auth_method: "client_secret_jwt",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("symmetric");
+ });
+
+ it('accepts token_endpoint_auth_method: "none"', () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, { token_endpoint_auth_method: "none" }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ it('accepts token_endpoint_auth_method: "private_key_jwt" with jwks', () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ token_endpoint_auth_method: "private_key_jwt",
+ jwks: { keys: [{ kty: "EC" }] },
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ it('accepts token_endpoint_auth_method: "private_key_jwt" with jwks_uri', () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ token_endpoint_auth_method: "private_key_jwt",
+ jwks_uri: "https://example.com/.well-known/jwks.json",
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ it("rejects private_key_jwt without jwks or jwks_uri", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ token_endpoint_auth_method: "private_key_jwt",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("jwks");
+ });
+
+ it("rejects unknown auth method", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ token_endpoint_auth_method: "custom_method",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("private_key_jwt");
+ });
+
+ it("rejects missing redirect_uris", () => {
+ const result = validateCimdMetadata(fetchUrl, {
+ client_id: fetchUrl,
+ });
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("redirect_uris");
+ });
+
+ it("rejects empty redirect_uris", () => {
+ const result = validateCimdMetadata(fetchUrl, {
+ client_id: fetchUrl,
+ redirect_uris: [],
+ });
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("redirect_uris");
+ });
+
+ it("rejects non-HTTP redirect_uris", () => {
+ const result = validateCimdMetadata(fetchUrl, {
+ client_id: fetchUrl,
+ redirect_uris: ["ftp://example.com/callback"],
+ });
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("redirect_uris");
+ });
+
+ it("rejects disallowed grant_types", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ grant_types: ["client_credentials"],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("grant_types");
+ });
+
+ it("accepts allowed grant_types", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ grant_types: ["authorization_code", "refresh_token"],
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ it("accepts authorization_code alone", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ grant_types: ["authorization_code"],
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ it("rejects disallowed response_types", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ response_types: ["token"],
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("response_types");
+ });
+
+ it('accepts response_types: ["code"]', () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ response_types: ["code"],
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ it("allows localhost redirect_uris for local/native app flows", () => {
+ const result = validateCimdMetadata(fetchUrl, {
+ client_id: fetchUrl,
+ redirect_uris: [
+ "http://localhost:3000/callback",
+ "http://127.0.0.1:3000/callback",
+ ],
+ });
+ expect(result.valid).toBe(true);
+ });
+
+ it("validates origin-bound fields (redirect_uris origin must match client_id)", () => {
+ const result = validateCimdMetadata(fetchUrl, {
+ client_id: fetchUrl,
+ redirect_uris: ["https://other-domain.com/callback"],
+ });
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("same origin");
+ });
+
+ it("respects custom originBoundFields parameter", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ {
+ client_id: fetchUrl,
+ redirect_uris: ["https://example.com/callback"],
+ custom_field: "https://evil.com/hook",
+ },
+ ["custom_field"],
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("same origin");
+ });
+
+ it("does not enforce origin on fields outside originBoundFields", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ {
+ client_id: fetchUrl,
+ redirect_uris: ["https://example.com/callback"],
+ client_uri: "https://other.com/about",
+ },
+ ["redirect_uris"],
+ );
+ // client_uri is NOT in the custom originBoundFields, so origin mismatch is not checked.
+ // However, client_uri still gets SSRF validation.
+ expect(result.valid).toBe(true);
+ });
+
+ it("rejects non-redirect origin-bound field with localhost URL", () => {
+ // Localhost bypass only applies to redirect URI fields. A localhost
+ // value on client_uri still fails origin binding.
+ const result = validateCimdMetadata(fetchUrl, {
+ client_id: fetchUrl,
+ redirect_uris: ["https://example.com/callback"],
+ client_uri: "http://localhost:3000/about",
+ });
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("same origin");
+ });
+
+ it("accepts localhost URL on post_logout_redirect_uris", () => {
+ // post_logout_redirect_uris also qualifies as a redirect URI field.
+ const result = validateCimdMetadata(fetchUrl, {
+ client_id: fetchUrl,
+ redirect_uris: ["https://example.com/callback"],
+ post_logout_redirect_uris: ["http://localhost:3000/logout"],
+ });
+ expect(result.valid).toBe(true);
+ });
+
+ it("validates client_uri for SSRF (private address)", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ client_uri: "http://169.254.169.254/latest/meta-data/",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("private");
+ });
+
+ it("validates logo_uri for SSRF (private address)", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ logo_uri: "http://10.0.0.1/internal-logo.png",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("private");
+ });
+
+ it("returns warning for query string in fetchUrl", () => {
+ const urlWithQuery = "https://example.com/client-metadata.json?v=1";
+ const result = validateCimdMetadata(
+ urlWithQuery,
+ validMetadata(urlWithQuery),
+ );
+ expect(result.valid).toBe(true);
+ expect(result.warnings).toBeDefined();
+ expect(result.warnings![0]).toContain("query string");
+ });
+
+ it("rejects non-object metadata", () => {
+ expect(validateCimdMetadata(fetchUrl, null).valid).toBe(false);
+ expect(validateCimdMetadata(fetchUrl, "string").valid).toBe(false);
+ expect(validateCimdMetadata(fetchUrl, 42).valid).toBe(false);
+ });
+
+ it("rejects client_uri with non-HTTP scheme", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, {
+ client_uri: "ftp://example.com/about",
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("client_uri");
+ });
+
+ it("rejects logo_uri that is not a valid URL", () => {
+ const result = validateCimdMetadata(
+ fetchUrl,
+ validMetadata(fetchUrl, { logo_uri: "not a url" }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("logo_uri");
+ });
+});
diff --git a/packages/cimd/src/validate-metadata-document.ts b/packages/cimd/src/validate-metadata-document.ts
new file mode 100644
index 0000000000..0707a399f7
--- /dev/null
+++ b/packages/cimd/src/validate-metadata-document.ts
@@ -0,0 +1,457 @@
+// Pure validation for Client ID Metadata Documents.
+// Implements draft-ietf-oauth-client-id-metadata-document §3 and §4.1.
+// Zero side-effect imports: testable without building the monorepo.
+
+const DOT_SEGMENT_RE = /\/\.\.?(?:\/|$|#|\?)/;
+
+const PROHIBITED_FIELDS = new Set([
+ "client_secret",
+ "client_secret_expires_at",
+]);
+
+const SYMMETRIC_AUTH_METHODS = new Set([
+ "client_secret_post",
+ "client_secret_basic",
+ "client_secret_jwt",
+]);
+
+const ALLOWED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]);
+
+const ALLOWED_RESPONSE_TYPES = new Set(["code"]);
+
+export interface ClientIdMetadataDocumentResult {
+ valid: boolean;
+ error?: string;
+ warnings?: string[];
+}
+
+/** Hostnames that are considered "localhost" for development flows. */
+export function isLocalhost(hostname: string): boolean {
+ return (
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "[::1]" ||
+ hostname === "::1" ||
+ hostname.endsWith(".localhost")
+ );
+}
+
+/**
+ * Check whether a dotted-decimal IPv4 address is private, reserved, or
+ * otherwise non-routable for a public SSRF target. Covers the subset of
+ * RFC 6890 special-purpose ranges that an adversarial `client_id` URL
+ * could point at to reach internal infrastructure or disrupt fetches.
+ */
+function isPrivateIpv4(host: string): boolean {
+ const parts = host.split(".");
+ if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p))) {
+ return false;
+ }
+ const a = Number(parts[0]);
+ const b = Number(parts[1]);
+ const c = Number(parts[2]);
+ return (
+ // Loopback (127.0.0.0/8), private (RFC 1918), "this network"
+ // (0.0.0.0/8), link-local (169.254.0.0/16), shared address space
+ // (100.64.0.0/10).
+ 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) ||
+ // Benchmarking (RFC 2544): 198.18.0.0/15.
+ (a === 198 && (b === 18 || b === 19)) ||
+ // Documentation (RFC 5737).
+ (a === 192 && b === 0 && c === 2) ||
+ (a === 198 && b === 51 && c === 100) ||
+ (a === 203 && b === 0 && c === 113) ||
+ // 6to4 anycast relay (RFC 7526 deprecated): 192.88.99.0/24.
+ (a === 192 && b === 88 && c === 99) ||
+ // Multicast (RFC 5771): 224.0.0.0/4.
+ (a >= 224 && a <= 239) ||
+ // Reserved / future use (RFC 1112 + broadcast 255.255.255.255):
+ // 240.0.0.0/4.
+ a >= 240
+ );
+}
+
+// Matches ::ffff:a.b.c.d (dotted-decimal, as written by humans)
+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})$/;
+
+// Matches hex-pair form (e.g. ::ffff:a9fe:a9fe), as normalized by the URL parser
+const V4_MAPPED_HEX_RE =
+ /^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
+
+/**
+ * Convert two hex groups from an IPv4-mapped IPv6 address to dotted-decimal IPv4.
+ * e.g. "a9fe" "a9fe" -> "169.254.169.254"
+ */
+function hexGroupsToIpv4(hi: string, lo: string): string {
+ const h = Number.parseInt(hi, 16);
+ const l = Number.parseInt(lo, 16);
+ return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
+}
+
+/**
+ * Check whether a hostname is private/reserved per RFC 6890.
+ *
+ * Handles bracketed IPv6 (as returned by URL.hostname), IPv4-mapped
+ * IPv6 in both dotted-decimal and hex-normalized forms, and cloud
+ * metadata hostnames. No DNS resolution, so it runs identically on
+ * Node, Bun, Deno, and Workers.
+ */
+function isPrivateHost(hostname: string): boolean {
+ const lower = hostname.toLowerCase();
+ const host =
+ lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
+
+ if (host === "::1") {
+ return true;
+ }
+ if (isPrivateIpv4(host)) {
+ return true;
+ }
+ if (host.includes(":")) {
+ const dottedMatch = host.match(V4_MAPPED_DOTTED_RE);
+ if (dottedMatch && isPrivateIpv4(dottedMatch[1]!)) {
+ return true;
+ }
+ const hexMatch = host.match(V4_MAPPED_HEX_RE);
+ if (hexMatch) {
+ const ipv4 = hexGroupsToIpv4(hexMatch[1]!, hexMatch[2]!);
+ if (isPrivateIpv4(ipv4)) {
+ return true;
+ }
+ }
+ // Link-local (fe80::/10)
+ if (/^fe[89ab]/.test(host)) {
+ return true;
+ }
+ // Unique-local (fc00::/7)
+ if (host.startsWith("fc") || host.startsWith("fd")) {
+ return true;
+ }
+ }
+ if (host === "metadata.google.internal") {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Detect URL-formatted client_id (Client ID Metadata Document pattern).
+ * HTTPS always accepted; HTTP accepted for localhost variants
+ * (localhost, 127.0.0.1, [::1], *.localhost) for development.
+ */
+export function isUrlClientId(clientId: string): boolean {
+ if (clientId.startsWith("https://")) {
+ return true;
+ }
+ if (!clientId.startsWith("http://")) {
+ return false;
+ }
+ try {
+ return isLocalhost(new URL(clientId).hostname);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Validate a client_id URL per IETF draft §3.
+ * Returns null on success, error string on failure.
+ */
+export function validateClientIdUrl(url: string): string | null {
+ // §3: check the raw URL for dot segments before the URL class normalizes them
+ if (DOT_SEGMENT_RE.test(url)) {
+ return "client_id URL MUST NOT contain dot segments";
+ }
+
+ // §3: MUST NOT contain fragments
+ if (url.includes("#")) {
+ return "client_id URL MUST NOT contain a fragment";
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return "client_id is not a valid URL";
+ }
+
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ return "client_id URL must use HTTPS";
+ }
+
+ if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) {
+ return "client_id URL must use HTTPS (HTTP allowed only for localhost)";
+ }
+
+ // §3: MUST NOT contain credentials
+ if (parsed.username || parsed.password) {
+ return "client_id URL MUST NOT contain credentials";
+ }
+
+ // §3: MUST contain a path (not just scheme + authority)
+ if (parsed.pathname === "/" || parsed.pathname === "") {
+ return "client_id URL MUST contain a path component";
+ }
+
+ // SSRF: block private/reserved hosts
+ if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) {
+ return "client_id URL must not resolve to a private or reserved address";
+ }
+
+ return null;
+}
+
+/** Warning: §3 SHOULD NOT have a query string. */
+function checkUrlQueryWarning(url: string): string | null {
+ try {
+ const parsed = new URL(url);
+ if (parsed.search) {
+ return "client_id URL SHOULD NOT contain a query string (§3)";
+ }
+ } catch {
+ // URL validation handled by validateClientIdUrl
+ }
+ return null;
+}
+
+function isAbsoluteHttpUri(uri: string): boolean {
+ try {
+ const parsed = new URL(uri);
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Validate a fetched Client ID Metadata Document per §4.1.
+ *
+ * @param fetchUrl - The URL the document was fetched from.
+ * @param raw - The parsed JSON body of the response.
+ * @param originBoundFields - Fields whose URL values must share the same origin as the `client_id` URL.
+ */
+export function validateCimdMetadata(
+ fetchUrl: string,
+ raw: unknown,
+ originBoundFields?: string[],
+): ClientIdMetadataDocumentResult {
+ if (!raw || typeof raw !== "object") {
+ return { valid: false, error: "metadata document is not a JSON object" };
+ }
+
+ const doc = raw as Record;
+ const warnings: string[] = [];
+
+ // §4.1: client_id MUST equal the fetch URL (simple string comparison)
+ if (doc.client_id !== fetchUrl) {
+ return {
+ valid: false,
+ error: `client_id "${String(doc.client_id)}" does not match the metadata document URL`,
+ };
+ }
+
+ // §4.1: prohibited fields MUST NOT be present
+ for (const field of PROHIBITED_FIELDS) {
+ if (field in doc) {
+ return {
+ valid: false,
+ error: `metadata document MUST NOT contain "${field}"`,
+ };
+ }
+ }
+
+ // §4.1: only non-secret auth methods are allowed
+ const ALLOWED_AUTH_METHODS = new Set(["none", "private_key_jwt"]);
+ if (
+ doc.token_endpoint_auth_method !== undefined &&
+ typeof doc.token_endpoint_auth_method !== "string"
+ ) {
+ return {
+ valid: false,
+ error: "token_endpoint_auth_method must be a string",
+ };
+ }
+ if (typeof doc.token_endpoint_auth_method === "string") {
+ if (SYMMETRIC_AUTH_METHODS.has(doc.token_endpoint_auth_method)) {
+ return {
+ valid: false,
+ error: `symmetric auth method "${doc.token_endpoint_auth_method}" is prohibited for Client ID Metadata Document clients`,
+ };
+ }
+ if (!ALLOWED_AUTH_METHODS.has(doc.token_endpoint_auth_method)) {
+ return {
+ valid: false,
+ error:
+ 'token_endpoint_auth_method must be "none" or "private_key_jwt" for Client ID Metadata Document clients',
+ };
+ }
+ if (
+ doc.token_endpoint_auth_method === "private_key_jwt" &&
+ !doc.jwks &&
+ !doc.jwks_uri
+ ) {
+ return {
+ valid: false,
+ error:
+ "private_key_jwt requires either jwks or jwks_uri in the metadata document",
+ };
+ }
+ }
+
+ // redirect_uris: required, non-empty array of absolute HTTP(S) URIs
+ if (
+ !Array.isArray(doc.redirect_uris) ||
+ doc.redirect_uris.length === 0 ||
+ !doc.redirect_uris.every(
+ (uri: unknown) => typeof uri === "string" && isAbsoluteHttpUri(uri),
+ )
+ ) {
+ return {
+ valid: false,
+ error: "redirect_uris must be a non-empty array of absolute HTTP(S) URIs",
+ };
+ }
+
+ // grant_types: must be a subset of allowed types
+ if (
+ doc.grant_types !== undefined &&
+ !(
+ Array.isArray(doc.grant_types) &&
+ doc.grant_types.every(
+ (g: unknown) => typeof g === "string" && ALLOWED_GRANT_TYPES.has(g),
+ )
+ )
+ ) {
+ return {
+ valid: false,
+ error: `grant_types must be a subset of [${[...ALLOWED_GRANT_TYPES].map((g) => `"${g}"`).join(", ")}]`,
+ };
+ }
+
+ // response_types: must be a subset of allowed types
+ if (
+ doc.response_types !== undefined &&
+ !(
+ Array.isArray(doc.response_types) &&
+ doc.response_types.every(
+ (r: unknown) => typeof r === "string" && ALLOWED_RESPONSE_TYPES.has(r),
+ )
+ )
+ ) {
+ return {
+ valid: false,
+ error: 'response_types must be a subset of ["code"]',
+ };
+ }
+
+ // Validate client_uri and logo_uri for SSRF if present
+ for (const field of ["client_uri", "logo_uri"] as const) {
+ if (doc[field] !== undefined && typeof doc[field] !== "string") {
+ return { valid: false, error: `${field} must be a string` };
+ }
+ if (typeof doc[field] === "string") {
+ try {
+ const parsed = new URL(doc[field]);
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ return { valid: false, error: `${field} must use HTTP(S)` };
+ }
+ if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) {
+ return {
+ valid: false,
+ error: `${field} must not point to a private or reserved address`,
+ };
+ }
+ } catch {
+ return { valid: false, error: `${field} is not a valid URL` };
+ }
+ }
+ }
+
+ // Origin-bound fields: values must share the same origin as the client_id URL
+ const fieldsToCheck = originBoundFields ?? [
+ "redirect_uris",
+ "post_logout_redirect_uris",
+ "client_uri",
+ ];
+
+ let clientIdOrigin: string;
+ try {
+ clientIdOrigin = new URL(fetchUrl).origin;
+ } catch {
+ return { valid: false, error: "client_id is not a valid URL" };
+ }
+
+ for (const key of fieldsToCheck) {
+ const value = doc[key];
+ if (value === undefined) {
+ continue;
+ }
+ let values: string[];
+ if (typeof value === "string") {
+ values = [value];
+ } else if (Array.isArray(value)) {
+ if (!value.every((v): v is string => typeof v === "string")) {
+ return {
+ valid: false,
+ error: `${key} must be a string or an array of strings`,
+ };
+ }
+ values = value;
+ } else {
+ return {
+ valid: false,
+ error: `${key} must be a string or an array of strings`,
+ };
+ }
+
+ for (const val of values) {
+ let uri: URL;
+ try {
+ uri = new URL(val);
+ } catch {
+ return {
+ valid: false,
+ error: `${key} contains an invalid URL: "${val}"`,
+ };
+ }
+
+ if (uri.protocol !== "https:" && uri.protocol !== "http:") {
+ return {
+ valid: false,
+ error: `all values for ${key} must use HTTP(S)`,
+ };
+ }
+
+ // Allow localhost redirect URIs for local/native app flows; the
+ // localhost exception only applies to redirect URI fields, never
+ // to client_uri or other origin-bound fields.
+ const isRedirectField =
+ key === "redirect_uris" || key === "post_logout_redirect_uris";
+ const localhostAllowed = isRedirectField && isLocalhost(uri.hostname);
+ if (uri.origin !== clientIdOrigin && !localhostAllowed) {
+ return {
+ valid: false,
+ error: `${key} value "${val}" must have the same origin as client_id (${clientIdOrigin})`,
+ };
+ }
+ }
+ }
+
+ // §3: SHOULD NOT have a query string
+ const queryWarning = checkUrlQueryWarning(fetchUrl);
+ if (queryWarning) {
+ warnings.push(queryWarning);
+ }
+
+ return {
+ valid: true,
+ ...(warnings.length > 0 ? { warnings } : {}),
+ };
+}
diff --git a/packages/cimd/src/version.ts b/packages/cimd/src/version.ts
new file mode 100644
index 0000000000..cc40657f77
--- /dev/null
+++ b/packages/cimd/src/version.ts
@@ -0,0 +1,3 @@
+import pkg from "../package.json" with { type: "json" };
+
+export const PACKAGE_VERSION = pkg.version;
diff --git a/packages/cimd/tsconfig.json b/packages/cimd/tsconfig.json
new file mode 100644
index 0000000000..66b5be1813
--- /dev/null
+++ b/packages/cimd/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["./src", "./package.json"],
+ "references": [
+ {
+ "path": "../better-auth/tsconfig.json"
+ },
+ {
+ "path": "../core/tsconfig.json"
+ },
+ {
+ "path": "../oauth-provider/tsconfig.json"
+ }
+ ]
+}
diff --git a/packages/cimd/tsdown.config.ts b/packages/cimd/tsdown.config.ts
new file mode 100644
index 0000000000..5e9168e8e9
--- /dev/null
+++ b/packages/cimd/tsdown.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "tsdown";
+
+export default defineConfig({
+ dts: { build: true, incremental: true },
+ format: ["esm"],
+ entry: ["./src/index.ts"],
+ treeshake: true,
+});
diff --git a/packages/cimd/vitest.config.ts b/packages/cimd/vitest.config.ts
new file mode 100644
index 0000000000..bbb5405668
--- /dev/null
+++ b/packages/cimd/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineProject } from "vitest/config";
+
+export default defineProject({
+ test: {
+ clearMocks: true,
+ restoreMocks: true,
+ testTimeout: 10_000,
+ },
+});
diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts
index b38f50400f..a3f6ff7977 100644
--- a/packages/oauth-provider/src/index.ts
+++ b/packages/oauth-provider/src/index.ts
@@ -6,4 +6,5 @@ export {
oidcServerMetadata,
} from "./metadata";
export { getOAuthProviderState, oauthProvider } from "./oauth";
+export { checkOAuthClient, oauthToSchema } from "./register";
export type * from "./types";
diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts
index 7fe376bd58..35f5b2e952 100644
--- a/packages/oauth-provider/src/metadata.ts
+++ b/packages/oauth-provider/src/metadata.ts
@@ -9,7 +9,11 @@ import type {
OIDCMetadata,
TokenEndpointAuthMethod,
} from "./types/oauth";
-import { getJwtPlugin } from "./utils";
+import {
+ getJwtPlugin,
+ mergeDiscoveryMetadata,
+ toClientDiscoveryArray,
+} from "./utils";
export function authServerMetadata(
ctx: GenericEndpointContext,
@@ -88,7 +92,13 @@ export function oidcServerMetadata(
: getJwtPlugin(ctx.context).options;
const authMetadata = authServerMetadata(ctx, jwtPluginOptions, {
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
- public_client_supported: opts.allowUnauthenticatedClientRegistration,
+ // `public_client_supported` flips `"none"` into the advertised auth
+ // methods. Any configured `clientDiscovery` implicitly produces public
+ // clients (CIMD, wallet attestation, etc.), so the flag must reflect
+ // that in addition to unauthenticated DCR.
+ public_client_supported:
+ opts.allowUnauthenticatedClientRegistration ||
+ toClientDiscoveryArray(opts.clientDiscovery).length > 0,
grant_types_supported: opts.grantTypes,
jwt_disabled: opts.disableJwtPlugin,
});
@@ -121,7 +131,10 @@ export function oidcServerMetadata(
"none",
],
};
- return metadata;
+ return {
+ ...metadata,
+ ...mergeDiscoveryMetadata(opts.clientDiscovery),
+ };
}
/**
diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts
index ffa1a449d1..1ea7034c05 100644
--- a/packages/oauth-provider/src/oauth.ts
+++ b/packages/oauth-provider/src/oauth.ts
@@ -30,6 +30,8 @@ import { userInfoEndpoint } from "./userinfo";
import {
deleteFromPrompt,
getJwtPlugin,
+ mergeDiscoveryMetadata,
+ toClientDiscoveryArray,
verifyOAuthQueryParams,
} from "./utils";
import { PACKAGE_VERSION } from "./version";
@@ -333,11 +335,15 @@ export const oauthProvider = >(options: O) => {
scopes_supported:
opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
public_client_supported:
- opts.allowUnauthenticatedClientRegistration,
+ opts.allowUnauthenticatedClientRegistration ||
+ toClientDiscoveryArray(opts.clientDiscovery).length > 0,
grant_types_supported: opts.grantTypes,
jwt_disabled: opts.disableJwtPlugin,
});
- return authMetadata;
+ return {
+ ...authMetadata,
+ ...mergeDiscoveryMetadata(opts.clientDiscovery),
+ };
}
},
),
diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts
index 605f69fd48..92ac994104 100644
--- a/packages/oauth-provider/src/types/index.ts
+++ b/packages/oauth-provider/src/types/index.ts
@@ -29,6 +29,53 @@ export type AuthorizePrompt =
| "login consent"
| "select_account consent";
+/**
+ * Describes how to resolve a `client_id` from an external source (a URL-based
+ * metadata document, a federated registry, an attestation header, etc.) and
+ * what fields that source contributes to discovery metadata.
+ *
+ * Plugins install one of these onto {@link OAuthOptions.clientDiscovery}.
+ * The host walks the configured entries in order and returns the first
+ * non-null `resolve()` result.
+ */
+export interface ClientDiscovery<
+ Scopes extends readonly Scope[] = InternallySupportedScopes[],
+> {
+ /**
+ * Stable identifier used in error messages and diagnostics. Convention
+ * is to match the plugin id (for example `"cimd"`).
+ */
+ readonly id: string;
+ /**
+ * Return `true` if this discovery handles the given `client_id`. Called
+ * on every `getClient()` lookup for every configured discovery, so keep
+ * it cheap and synchronous.
+ */
+ matches: (clientId: string) => boolean;
+ /**
+ * Resolve a client when this discovery matches. Receives the existing DB
+ * record (or `null`) so an implementation can decide between creating,
+ * refreshing, or passing through to the database result.
+ *
+ * Return:
+ * - a client record: `getClient()` returns it (creation / refresh / takeover).
+ * - `null`: `getClient()` falls through to the next matching discovery
+ * or to the database record (if any).
+ */
+ resolve: (
+ ctx: GenericEndpointContext,
+ clientId: string,
+ existing: SchemaClient | null,
+ ) => Awaitable | null>;
+ /**
+ * Fields merged into `/.well-known/oauth-authorization-server` and
+ * `/.well-known/openid-configuration` responses. Useful for advertising
+ * RFC-registered discovery flags like
+ * `client_id_metadata_document_supported`.
+ */
+ discoveryMetadata?: Record;
+}
+
export interface OAuthOptions<
Scopes extends readonly Scope[] = InternallySupportedScopes[],
> {
@@ -130,10 +177,13 @@ export interface OAuthOptions<
/**
* Allow unauthenticated dynamic client registration.
*
- * Support for `allowUnauthenticatedClientRegistration` **will be deprecated**
- * when the MCP protocol standardizes unauthenticated dynamic client registration.
- * As of writing, both [Client ID Metadata Documents](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991)
- * and [`software_statement` and `jwks_uri`](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1032) are under debate.
+ * When enabled, the `/oauth2/register` endpoint accepts requests
+ * without a session, but only for public clients
+ * (`token_endpoint_auth_method: "none"`).
+ *
+ * For verified client discovery (MCP), consider installing the
+ * `@better-auth/cimd` plugin, which verifies client identity through
+ * domain ownership via Client ID Metadata Documents.
*
* @default false
*/
@@ -144,6 +194,21 @@ export interface OAuthOptions<
* @default false
*/
allowDynamicClientRegistration?: boolean;
+ /**
+ * Discovery implementations consulted by `getClient()` when resolving
+ * a `client_id`. Each entry decides whether it handles the `client_id`
+ * via {@link ClientDiscovery.matches}, then creates, refreshes, or
+ * passes on a client record. Entries run in order; the first one to
+ * return a client wins.
+ *
+ * Each entry also contributes {@link ClientDiscovery.discoveryMetadata}
+ * into the `/.well-known/oauth-authorization-server` and
+ * `/.well-known/openid-configuration` responses.
+ *
+ * Plugins such as `@better-auth/cimd` install an entry here at init
+ * time; users can also pass discovery implementations directly.
+ */
+ clientDiscovery?: ClientDiscovery | ClientDiscovery[];
/**
* List of scopes for newly registered clients
* if not requested.
diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts
index dc63666782..7458b1a6b6 100644
--- a/packages/oauth-provider/src/types/oauth.ts
+++ b/packages/oauth-provider/src/types/oauth.ts
@@ -179,6 +179,17 @@ export interface AuthServerMetadata {
* @default true
*/
authorization_response_iss_parameter_supported?: boolean;
+ /**
+ * Whether the authorization server supports discovering clients via
+ * [Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)
+ * (an HTTPS URL as `client_id`).
+ *
+ * Set at runtime by the `@better-auth/cimd` plugin (or any other
+ * `ClientDiscovery` that contributes this field via
+ * {@link ClientDiscovery.discoveryMetadata}). oauth-provider never sets
+ * it on its own.
+ */
+ client_id_metadata_document_supported?: boolean;
}
/**
diff --git a/packages/oauth-provider/src/utils/client-assertion.ts b/packages/oauth-provider/src/utils/client-assertion.ts
index 41fb2dc3a5..a73dc963c4 100644
--- a/packages/oauth-provider/src/utils/client-assertion.ts
+++ b/packages/oauth-provider/src/utils/client-assertion.ts
@@ -95,7 +95,11 @@ export function isPrivateHostname(hostname: string): boolean {
return false;
}
-function validateJwksUri(ctx: GenericEndpointContext, jwksUri: string): void {
+function validateJwksUri(
+ ctx: GenericEndpointContext,
+ jwksUri: string,
+ clientIdUrlOrigin?: string,
+): void {
const parsed = new URL(jwksUri);
if (parsed.protocol !== "https:") {
throw new APIError("BAD_REQUEST", {
@@ -110,6 +114,12 @@ function validateJwksUri(ctx: GenericEndpointContext, jwksUri: string): void {
error: "invalid_client",
});
}
+ // Trust a jwks_uri that shares origin with a URL-format client_id: the
+ // discovery that installed the client has already verified the
+ // client_id URL, and same-origin jwks_uri is part of that verification.
+ if (clientIdUrlOrigin && parsed.origin === clientIdUrlOrigin) {
+ return;
+ }
if (!ctx.context.isTrustedOrigin(parsed.href)) {
throw new APIError("BAD_REQUEST", {
error_description: "client jwks_uri is not trusted",
@@ -118,6 +128,17 @@ function validateJwksUri(ctx: GenericEndpointContext, jwksUri: string): void {
}
}
+function urlClientIdOrigin(clientId: string): string | undefined {
+ if (!clientId.startsWith("https://") && !clientId.startsWith("http://")) {
+ return undefined;
+ }
+ try {
+ return new URL(clientId).origin;
+ } catch {
+ return undefined;
+ }
+}
+
async function fetchJwksFromUri(jwksUri: string): Promise {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JWKS_FETCH_TIMEOUT_MS);
@@ -155,7 +176,7 @@ async function fetchClientJwks(
});
}
- validateJwksUri(ctx, client.jwksUri);
+ validateJwksUri(ctx, client.jwksUri, urlClientIdOrigin(client.clientId));
const now = Date.now();
const cached = jwksCache.get(client.jwksUri);
diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts
index 55ffbb04cb..285487d8e3 100644
--- a/packages/oauth-provider/src/utils/index.ts
+++ b/packages/oauth-provider/src/utils/index.ts
@@ -12,6 +12,7 @@ import type { jwt } from "better-auth/plugins";
import { APIError } from "better-call";
import type { oauthProvider } from "../oauth";
import type {
+ ClientDiscovery,
OAuthOptions,
Prompt,
SchemaClient,
@@ -165,11 +166,21 @@ export async function getClient(
return Object.assign({}, trustedClient);
}
- const dbClient = await ctx.context.adapter.findOne>({
+ let dbClient = await ctx.context.adapter.findOne>({
model: options.schema?.oauthClient?.modelName ?? "oauthClient",
where: [{ field: "clientId", value: clientId }],
});
+ const discoveries = toClientDiscoveryArray(options.clientDiscovery);
+ for (const discovery of discoveries) {
+ if (!discovery.matches(clientId)) continue;
+ const resolved = await discovery.resolve(ctx, clientId, dbClient);
+ if (resolved) {
+ dbClient = resolved;
+ break;
+ }
+ }
+
if (dbClient && options.cachedTrustedClients?.has(clientId)) {
cachedTrustedClients.set(clientId, Object.assign({}, dbClient));
}
@@ -177,6 +188,36 @@ export async function getClient(
return dbClient;
}
+/**
+ * Normalize the `clientDiscovery` option into an array. Accepts a single
+ * {@link ClientDiscovery}, an array of them, or `undefined`.
+ *
+ * @internal
+ */
+export function toClientDiscoveryArray(
+ discovery: OAuthOptions["clientDiscovery"],
+): ClientDiscovery[] {
+ if (!discovery) return [];
+ return Array.isArray(discovery) ? discovery : [discovery];
+}
+
+/**
+ * Merge `discoveryMetadata` from every configured {@link ClientDiscovery}
+ * into a single object. Entries are spread in order; later entries override
+ * earlier ones on key collisions.
+ *
+ * @internal
+ */
+export function mergeDiscoveryMetadata(
+ discovery: OAuthOptions["clientDiscovery"],
+): Record {
+ const discoveries = toClientDiscoveryArray(discovery);
+ return discoveries.reduce>(
+ (acc, d) => ({ ...acc, ...(d.discoveryMetadata ?? {}) }),
+ {},
+ );
+}
+
/**
* Default client secret hasher using SHA-256
*
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 784fea8e70..113ca73eea 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -660,9 +660,15 @@ importers:
specifier: workspace:*
version: link:../../packages/better-auth
devDependencies:
+ '@better-auth/cimd':
+ specifier: workspace:*
+ version: link:../../packages/cimd
'@better-auth/core':
specifier: workspace:*
version: link:../../packages/core
+ '@better-auth/oauth-provider':
+ specifier: workspace:*
+ version: link:../../packages/oauth-provider
'@better-auth/redis-storage':
specifier: workspace:*
version: link:../../packages/redis-storage
@@ -957,6 +963,28 @@ importers:
specifier: ^3.5.29
version: 3.5.29(typescript@5.9.3)
+ packages/cimd:
+ dependencies:
+ better-call:
+ specifier: 'catalog:'
+ version: 1.3.5(zod@4.3.6)
+ devDependencies:
+ '@better-auth/core':
+ specifier: workspace:*
+ version: link:../core
+ '@better-auth/oauth-provider':
+ specifier: workspace:*
+ version: link:../oauth-provider
+ better-auth:
+ specifier: workspace:*
+ version: link:../better-auth
+ listhen:
+ specifier: ^1.9.0
+ version: 1.9.0
+ tsdown:
+ specifier: 'catalog:'
+ version: 0.21.1(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.0)(publint@0.3.17)(synckit@0.11.11)(typescript@5.9.3)
+
packages/cli:
dependencies:
'@babel/core':
@@ -15463,7 +15491,7 @@ snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.6.0
- tinyexec: 1.0.4
+ tinyexec: 1.1.1
'@arethetypeswrong/cli@0.18.2':
dependencies:
@@ -27939,7 +27967,7 @@ snapshots:
dependencies:
citty: 0.2.1
pathe: 2.0.3
- tinyexec: 1.0.4
+ tinyexec: 1.1.1
oauth2-mock-server@8.2.2:
dependencies:
@@ -30528,12 +30556,12 @@ snapshots:
hookable: 6.0.1
import-without-cache: 0.2.5
obug: 2.1.1
- picomatch: 4.0.3
+ picomatch: 4.0.4
rolldown: 1.0.0-rc.8
rolldown-plugin-dts: 0.22.4(oxc-resolver@11.19.0)(rolldown@1.0.0-rc.8)(typescript@5.9.3)
semver: 7.7.4
- tinyexec: 1.0.2
- tinyglobby: 0.2.15
+ tinyexec: 1.1.1
+ tinyglobby: 0.2.16
tree-kill: 1.2.2
unconfig-core: 7.5.0
unrun: 0.2.31(synckit@0.11.11)
@@ -30554,7 +30582,7 @@ snapshots:
tsx@4.21.0:
dependencies:
- esbuild: 0.27.3
+ esbuild: 0.27.7
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3