feat(cimd): add Client ID Metadata Document plugin (#9159)

Co-authored-by: Dylan Vanmali <dylanvanmali@yahoo.com>
This commit is contained in:
Gustavo Valverde
2026-04-13 20:52:34 +00:00
committed by GitHub
co-authored by Dylan Vanmali
parent c7d22539ec
commit cd8313ba00
33 changed files with 3383 additions and 44 deletions
+58
View File
@@ -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.
+2
View File
@@ -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",
+1
View File
@@ -33,3 +33,4 @@ Vercel
rgba
idtoken
HIBP
cimd
+2
View File
@@ -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: [
+1
View File
@@ -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",
+48 -24
View File
@@ -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):
+203
View File
@@ -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
<Steps>
<Step>
### Install the plugin
```package-install
@better-auth/cimd
```
</Step>
<Step>
### 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]
],
});
```
</Step>
</Steps>
## 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
<TypeTable
type={{
refreshRate: {
type: "number | string",
default: '"60m"',
description:
"How frequently to re-fetch a client's metadata document. Number values are seconds; strings are duration expressions (e.g. `\"60m\"`, `\"1d\"`). A value of `0` makes every request stale (useful for tests).",
},
originBoundFields: {
type: "string[]",
default: '["redirect_uris", "post_logout_redirect_uris", "client_uri"]',
description:
"Metadata fields whose URL values must share the same origin as the `client_id` URL. Localhost is accepted for redirect URI fields (for native and desktop flows) but never for `client_uri` or custom fields. Pass an empty array to disable origin binding.",
},
allowFetch: {
type: "(url, ctx) => boolean | Promise<boolean>",
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<void>",
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<void>",
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)
+1
View File
@@ -25,6 +25,7 @@
"one-time-token",
"oauth-proxy",
"oauth-provider",
"cimd",
"oidc-provider",
"mcp",
"device-authorization",
+9 -2
View File
@@ -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
</Step>
<Step>
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`.
<Callout type="info">
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.
</Callout>
</Step>
+2
View File
@@ -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:*",
+325
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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",
);
});
});
+17
View File
@@ -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
+70
View File
@@ -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:"
}
}
+277
View File
@@ -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);
});
});
+374
View File
@@ -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/<AS-defined>+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<string> {
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<string, unknown>): OAuthClient {
const filtered: Record<string, unknown> = {};
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<Scope[]>,
): Promise<SchemaClient<Scope[]>> {
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<Scope[]>;
try {
client = await ctx.context.adapter.create<SchemaClient<Scope[]>>({
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<SchemaClient<Scope[]>>({
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<Scope[]>,
cimdOptions: CimdOptions,
oauthOptions: OAuthOptions<Scope[]>,
): Promise<SchemaClient<Scope[]>> {
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<SchemaClient<Scope[]>>({
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<Record<string, unknown>> {
// §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/<AS-defined>+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<string, unknown>;
try {
data = JSON.parse(bodyText) as Record<string, unknown>;
} 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;
}
+536
View File
@@ -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<string, unknown>,
): 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<typeof createAuthClient>,
authorizeUrl: string,
): Promise<string> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});
});
+79
View File
@@ -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<AuthOptions, Options> {
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<Scope[]> {
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";
+115
View File
@@ -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<Scope[]> | null,
) => Promise<SchemaClient<Scope[]> | 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<Scope[]>,
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<Scope[]>;
if (!existing) {
return await createMetadataDocumentClient(
ctx,
clientId,
cimdOptions,
oauthOptions,
);
}
if (isStale(existing, refreshRate)) {
return await refreshMetadataDocumentClient(
ctx,
clientId,
existing,
cimdOptions,
oauthOptions,
);
}
return existing;
};
}
+66
View File
@@ -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<boolean>;
/**
* 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<Scope[]>;
metadata: Record<string, unknown>;
ctx: GenericEndpointContext;
}) => void | Promise<void>;
/**
* 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<Scope[]>;
metadata: Record<string, unknown>;
ctx: GenericEndpointContext;
}) => void | Promise<void>;
}
@@ -0,0 +1,501 @@
import { describe, expect, it } from "vitest";
import {
isUrlClientId,
validateCimdMetadata,
validateClientIdUrl,
} from "./validate-metadata-document";
function validMetadata(
fetchUrl: string,
overrides: Record<string, unknown> = {},
) {
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");
});
});
@@ -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<string, unknown>;
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 } : {}),
};
}
+3
View File
@@ -0,0 +1,3 @@
import pkg from "../package.json" with { type: "json" };
export const PACKAGE_VERSION = pkg.version;
+15
View File
@@ -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"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "tsdown";
export default defineConfig({
dts: { build: true, incremental: true },
format: ["esm"],
entry: ["./src/index.ts"],
treeshake: true,
});
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from "vitest/config";
export default defineProject({
test: {
clearMocks: true,
restoreMocks: true,
testTimeout: 10_000,
},
});
+1
View File
@@ -6,4 +6,5 @@ export {
oidcServerMetadata,
} from "./metadata";
export { getOAuthProviderState, oauthProvider } from "./oauth";
export { checkOAuthClient, oauthToSchema } from "./register";
export type * from "./types";
+16 -3
View File
@@ -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),
};
}
/**
+8 -2
View File
@@ -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 = <O extends OAuthOptions<Scope[]>>(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),
};
}
},
),
+69 -4
View File
@@ -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<Scopes> | null,
) => Awaitable<SchemaClient<Scopes> | 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<string, unknown>;
}
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<Scopes> | ClientDiscovery<Scopes>[];
/**
* List of scopes for newly registered clients
* if not requested.
@@ -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;
}
/**
@@ -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<JSONWebKeySet> {
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);
+42 -1
View File
@@ -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<SchemaClient<Scope[]>>({
let dbClient = await ctx.context.adapter.findOne<SchemaClient<Scope[]>>({
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<Scope[]>["clientDiscovery"],
): ClientDiscovery<Scope[]>[] {
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<Scope[]>["clientDiscovery"],
): Record<string, unknown> {
const discoveries = toClientDiscoveryArray(discovery);
return discoveries.reduce<Record<string, unknown>>(
(acc, d) => ({ ...acc, ...(d.discoveryMetadata ?? {}) }),
{},
);
}
/**
* Default client secret hasher using SHA-256
*
+34 -6
View File
@@ -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