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