mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 22:42:35 -05:00
feat(cimd): add Client ID Metadata Document plugin (#9159)
Co-authored-by: Dylan Vanmali <dylanvanmali@yahoo.com>
This commit is contained in:
co-authored by
Dylan Vanmali
parent
c7d22539ec
commit
cd8313ba00
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -33,3 +33,4 @@ Vercel
|
||||
rgba
|
||||
idtoken
|
||||
HIBP
|
||||
cimd
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+48
-24
@@ -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):
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
title: Client ID Metadata Document (CIMD)
|
||||
description: Unauthenticated dynamic client discovery for Better Auth's OAuth provider.
|
||||
---
|
||||
|
||||
`OAuth2` `MCP`
|
||||
|
||||
[Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) let clients identify themselves to your authorization server without prior registration. Instead of sending a pre-registered `client_id`, the client sends the HTTPS URL of a metadata document it hosts. Your server fetches that document, validates it, and creates (or refreshes) a public client record.
|
||||
|
||||
This is the mechanism [MCP](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow) uses so authorization servers can discover clients on the fly.
|
||||
|
||||
The plugin extends [`@better-auth/oauth-provider`](./oauth-provider) by appending a `ClientDiscovery` entry to its `clientDiscovery` option, so every OAuth endpoint that authenticates a client (authorize, token, introspect, revoke, userinfo, PAR, logout) works seamlessly with URL-format `client_id` values.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Install the plugin
|
||||
|
||||
```package-install
|
||||
@better-auth/cimd
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add the plugin to the server
|
||||
|
||||
Install `cimd()` alongside `oauthProvider()`. The plugin requires `oauth-provider` to be present and will throw at startup if it is missing.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
import { jwt } from "better-auth/plugins";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { cimd } from "@better-auth/cimd"; // [!code highlight]
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
scopes: ["openid", "profile", "email", "offline_access"],
|
||||
}),
|
||||
cimd(), // [!code highlight]
|
||||
],
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, your authorization server accepts an HTTPS URL as a `client_id` on any OAuth endpoint. The first time a URL `client_id` is seen, the server fetches the metadata document and creates a public client. Subsequent requests reuse that record until it is older than `refreshRate`, at which point it is re-fetched.
|
||||
|
||||
```http
|
||||
GET /api/auth/oauth2/authorize?
|
||||
response_type=code
|
||||
&client_id=https%3A%2F%2Fmcp-client.example.com%2Fclient-metadata.json
|
||||
&redirect_uri=https%3A%2F%2Fmcp-client.example.com%2Fcallback
|
||||
&scope=openid
|
||||
&code_challenge=...
|
||||
&code_challenge_method=S256
|
||||
```
|
||||
|
||||
The CIMD client is stored in the standard `oauthClient` table with its URL as the `clientId`, so it shows up in the same queries and admin UIs as any other registered client.
|
||||
|
||||
### Discovery metadata
|
||||
|
||||
The plugin advertises `client_id_metadata_document_supported: true` on the `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses. MCP clients and other spec-aware consumers use that flag to decide whether to attempt a URL `client_id`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts title="auth.ts"
|
||||
cimd({
|
||||
refreshRate: "10m",
|
||||
originBoundFields: ["redirect_uris", "post_logout_redirect_uris", "client_uri"],
|
||||
allowFetch(url) {
|
||||
return new URL(url).hostname.endsWith(".trusted.example");
|
||||
},
|
||||
onClientCreated({ client, metadata, ctx }) {
|
||||
// assign trust levels, prefetch logos, populate audit log, etc.
|
||||
},
|
||||
onClientRefreshed({ client, metadata, ctx }) {
|
||||
// detect field changes, update derived data, etc.
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
refreshRate: {
|
||||
type: "number | string",
|
||||
default: '"60m"',
|
||||
description:
|
||||
"How frequently to re-fetch a client's metadata document. Number values are seconds; strings are duration expressions (e.g. `\"60m\"`, `\"1d\"`). A value of `0` makes every request stale (useful for tests).",
|
||||
},
|
||||
originBoundFields: {
|
||||
type: "string[]",
|
||||
default: '["redirect_uris", "post_logout_redirect_uris", "client_uri"]',
|
||||
description:
|
||||
"Metadata fields whose URL values must share the same origin as the `client_id` URL. Localhost is accepted for redirect URI fields (for native and desktop flows) but never for `client_uri` or custom fields. Pass an empty array to disable origin binding.",
|
||||
},
|
||||
allowFetch: {
|
||||
type: "(url, ctx) => boolean | Promise<boolean>",
|
||||
default: "always allow",
|
||||
description:
|
||||
"Pre-fetch gate: return `false` to reject a `client_id` URL before the metadata document is requested. Use this for origin allowlists, per-host rate limiting, or DNS-level defenses beyond the built-in IP-literal SSRF check.",
|
||||
},
|
||||
onClientCreated: {
|
||||
type: "(data: { client, metadata, ctx }) => void | Promise<void>",
|
||||
description:
|
||||
"Called once, after a new client has been persisted. The `metadata` argument is the raw JSON body of the metadata document.",
|
||||
},
|
||||
onClientRefreshed: {
|
||||
type: "(data: { client, metadata, ctx }) => void | Promise<void>",
|
||||
description:
|
||||
"Called each time an existing client record is re-fetched and updated. Admin-controlled fields (`disabled`, `skipConsent`, `enableEndSession`) are preserved across refreshes; use this hook to detect document changes or sync derived data.",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
## Metadata document requirements
|
||||
|
||||
The fetched document must satisfy both §3 and §4.1 of the draft. Key rules enforced by the plugin:
|
||||
|
||||
* `client_id` in the body must equal the fetch URL byte-for-byte.
|
||||
* HTTPS only. HTTP is allowed only for `localhost`, `127.0.0.1`, `[::1]`, and `*.localhost` to support local development.
|
||||
* No fragments, credentials, or `./` / `../` dot segments in the URL.
|
||||
* A path component is required (`https://example.com/` is rejected; `https://example.com/meta.json` is accepted).
|
||||
* `client_secret` and `client_secret_expires_at` are prohibited.
|
||||
* `token_endpoint_auth_method` must be `"none"` or `"private_key_jwt"`. Symmetric methods (`client_secret_post`, `client_secret_basic`, `client_secret_jwt`) are rejected.
|
||||
* `redirect_uris` is required and must be a non-empty array of absolute HTTP(S) URIs.
|
||||
* `grant_types` (if present) must be a subset of `["authorization_code", "refresh_token"]`.
|
||||
* `response_types` (if present) must be a subset of `["code"]`.
|
||||
* Admin-only fields (`disabled`, `skip_consent`, `enable_end_session`) from the document are discarded.
|
||||
* Only RFC 7591 / CIMD fields are persisted; unknown fields are dropped.
|
||||
|
||||
### Example document
|
||||
|
||||
```json title="https://mcp-client.example.com/client-metadata.json"
|
||||
{
|
||||
"client_id": "https://mcp-client.example.com/client-metadata.json",
|
||||
"client_name": "Example MCP Client",
|
||||
"client_uri": "https://mcp-client.example.com",
|
||||
"redirect_uris": [
|
||||
"https://mcp-client.example.com/callback",
|
||||
"http://127.0.0.1:3000/callback"
|
||||
],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email offline_access"
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
The plugin ships defense in depth against common abuses of unauthenticated client discovery:
|
||||
|
||||
* **SSRF protection (server-side request forgery).** Private and reserved IP ranges (RFC 6890), IPv4-mapped IPv6, link-local addresses, and cloud metadata endpoints (`metadata.google.internal`, `169.254.169.254`) are all rejected. The plugin does no DNS resolution, so the same rules run identically on Node, Bun, Deno, and Cloudflare Workers.
|
||||
* **Fetch hardening.** 5s timeout, 5 KB response size limit (UTF-8 byte-counted), no redirect following, `Accept: application/json` only.
|
||||
* **Origin binding.** Fields in `originBoundFields` must share the `client_id` origin. Cross-domain redirect injection is blocked by default.
|
||||
* **No secrets from documents.** Symmetric authentication methods and `client_secret` fields are rejected before anything is written to the database.
|
||||
* **Admin-field stripping.** `disabled`, `skip_consent`, `enable_end_session`, and `require_pkce` cannot be set, escalated, or weakened from an external document.
|
||||
* **Admin overrides survive refreshes.** `disabled`, `skipConsent`, and `enableEndSession` set by an admin on a persisted CIMD client are preserved across stale refreshes: the document can't silently undo a revoke or consent override.
|
||||
* **Allowlist parsing.** Only the recognized RFC 7591 / CIMD fields are persisted. Arbitrary fields are discarded.
|
||||
|
||||
## Operational notes
|
||||
|
||||
* **Refresh runs on the OAuth hot path.** When a CIMD client's record is older than `refreshRate`, the next OAuth request for that client awaits the metadata document refresh before completing. A slow or unreachable metadata host raises tail latency on `/oauth2/authorize`, `/oauth2/token`, `/oauth2/introspect`, and friends for that client. Pick `refreshRate` generous enough that the refresh stays rare, and keep `allowFetch` aligned with your metadata hosts' reliability profile.
|
||||
* **No fetch deduplication yet.** Concurrent requests for the same stale client can all trigger independent metadata fetches. An external coordination layer or higher `refreshRate` is the practical mitigation today.
|
||||
* **HTTP allowed only for localhost.** `localhost`, `127.0.0.1`, `[::1]`, and `*.localhost` are accepted over plain HTTP so you can develop locally. Every other host must use HTTPS.
|
||||
|
||||
## Composition with `clientDiscovery`
|
||||
|
||||
Under the hood, `cimd()` appends a `ClientDiscovery` entry to `oauthProvider`'s `clientDiscovery` option. If you prefer to wire it explicitly, or want to compose CIMD with other discovery implementations, use the `cimdClientDiscovery` factory:
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { cimdClientDiscovery } from "@better-auth/cimd";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
clientDiscovery: [
|
||||
cimdClientDiscovery({ refreshRate: "60m" }),
|
||||
// other discovery implementations can go here
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The `clientDiscovery` option accepts a single `ClientDiscovery` or an array. Entries are consulted in order after the database lookup in `getClient()`; the first entry whose `matches()` returns `true` and whose `resolve()` returns a non-null client wins. Each entry can also contribute `discoveryMetadata` that the server merges into `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses.
|
||||
|
||||
## Related
|
||||
|
||||
* [OAuth Provider plugin](./oauth-provider) — the host plugin this one extends.
|
||||
* [IETF draft: Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)
|
||||
* [MCP authorization spec — CIMD flow](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow)
|
||||
@@ -25,6 +25,7 @@
|
||||
"one-time-token",
|
||||
"oauth-proxy",
|
||||
"oauth-provider",
|
||||
"cimd",
|
||||
"oidc-provider",
|
||||
"mcp",
|
||||
"device-authorization",
|
||||
|
||||
@@ -1245,6 +1245,13 @@ PKCE prevents authorization code interception attacks. Even for confidential cli
|
||||
|
||||
Only disable PKCE for confidential clients when absolutely necessary for legacy compatibility.
|
||||
|
||||
### Unauthenticated client discovery
|
||||
|
||||
Some clients (notably MCP clients) need to connect to your authorization server without being registered in advance. The OAuth Provider plugin supports this through two mechanisms:
|
||||
|
||||
* **`allowUnauthenticatedClientRegistration`**: lets anonymous callers hit `/oauth2/register` to create a public client at request time.
|
||||
* **[`@better-auth/cimd`](/docs/plugins/cimd)**: an optional plugin that lets clients identify themselves by hosting a metadata document at an HTTPS URL. The URL itself becomes the `client_id`; the server fetches and validates the document. This is the pattern MCP calls [Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/).
|
||||
|
||||
### Organizations
|
||||
|
||||
OAuth Clients are tied to either a user or `reference_id` at registration and is immutable. If you are utilizing the [organization plugin](/docs/plugins/organization), you must ensure that the [`activeOrganizationId`](/docs/plugins/organization#active-organization) is set on your active session when you create new clients.
|
||||
@@ -1557,7 +1564,7 @@ You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/s
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
If you use `allowUnauthenticatedClientRegistration`, you must ensure that your API Server is a confidential client itself:
|
||||
If you use the [`cimd`](/docs/plugins/cimd) plugin or `allowUnauthenticatedClientRegistration`, you must ensure that your API Server is a confidential client itself:
|
||||
|
||||
```ts
|
||||
await auth.api.createOAuthClient({
|
||||
@@ -1571,7 +1578,7 @@ You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/s
|
||||
These values should be used in the verify options `remoteVerify.clientId` and `remoteVerify.clientSecret`. Additionally, `remoteVerify.introspectUrl` would be something like `${BASE_URL}/${AUTH_PATH}/oauth2/introspect`.
|
||||
|
||||
<Callout type="info">
|
||||
If you choose to not support `allowUnauthenticatedClientRegistration` (and only `allowDynamicClientRegistration`), the MCP client (ie. ChatGPT, Anthropic, Gemini) would need to allow you to put in a public client\_id in their UI or at runtime while chatting with the AI.
|
||||
If you choose not to support the [`cimd`](/docs/plugins/cimd) plugin or `allowUnauthenticatedClientRegistration` (and only `allowDynamicClientRegistration`), the MCP client (ie. ChatGPT, Anthropic, Gemini) would need to allow you to put in a public client\_id in their UI or at runtime while chatting with the AI.
|
||||
</Callout>
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, it } from "node:test";
|
||||
import { cimd } from "@better-auth/cimd";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { getMigrations } from "better-auth/db/migration";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import { jwt } from "better-auth/plugins/jwt";
|
||||
|
||||
// Deterministic PKCE pair (challenge is base64url-encoded SHA-256 of verifier).
|
||||
const PKCE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
|
||||
describe("CIMD end-to-end flow", () => {
|
||||
it("runs the full authorize → consent → token → userinfo → refresh loop with a URL client_id", async (t) => {
|
||||
// 1. Host the CIMD metadata document on a local HTTP server. CIMD
|
||||
// permits HTTP for localhost so no TLS scaffolding is required.
|
||||
const metadataHost = createServer();
|
||||
metadataHost.listen(0);
|
||||
t.after(() => metadataHost.close());
|
||||
await once(metadataHost, "listening");
|
||||
const metadataAddr = metadataHost.address() as AddressInfo;
|
||||
const clientMetadataUrl = `http://localhost:${metadataAddr.port}/client-metadata.json`;
|
||||
const redirectUri = `http://localhost:${metadataAddr.port}/callback`;
|
||||
|
||||
const metadataDocument = {
|
||||
client_id: clientMetadataUrl,
|
||||
client_name: "CIMD Smoke Test Client",
|
||||
redirect_uris: [redirectUri],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
scope: "openid profile email offline_access",
|
||||
};
|
||||
|
||||
metadataHost.on("request", (req, res) => {
|
||||
if (req.url === "/client-metadata.json") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(metadataDocument));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
// 2. Pre-reserve the auth server port so `baseURL` reflects reality.
|
||||
const authPlaceholder = createServer();
|
||||
authPlaceholder.listen(0);
|
||||
await once(authPlaceholder, "listening");
|
||||
const authPort = (authPlaceholder.address() as AddressInfo).port;
|
||||
const authBaseUrl = `http://localhost:${authPort}`;
|
||||
authPlaceholder.close();
|
||||
|
||||
// 3. Build auth with oauth-provider + cimd, backed by an in-memory
|
||||
// sqlite database.
|
||||
const db = new DatabaseSync(":memory:");
|
||||
const auth = betterAuth({
|
||||
baseURL: authBaseUrl,
|
||||
secret: "smoke-test-secret-that-is-long-enough-for-validation",
|
||||
database: db,
|
||||
emailAndPassword: { enabled: true },
|
||||
trustedOrigins: [authBaseUrl],
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
scopes: ["openid", "profile", "email", "offline_access"],
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
cimd({ refreshRate: "60m" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { runMigrations } = await getMigrations(auth.options);
|
||||
await runMigrations();
|
||||
|
||||
const authServer = createServer(toNodeHandler(auth.handler));
|
||||
authServer.listen(authPort);
|
||||
t.after(() => authServer.close());
|
||||
await once(authServer, "listening");
|
||||
|
||||
// 4. Discovery metadata advertises CIMD. The `.well-known` endpoint
|
||||
// is server-only, so call it via the in-process API (the same
|
||||
// pattern oauth-provider's MCP integration uses when bridging the
|
||||
// route through a framework handler).
|
||||
const discovery = (await auth.api.getOAuthServerConfig()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(
|
||||
discovery.client_id_metadata_document_supported,
|
||||
true,
|
||||
"discovery should advertise CIMD support",
|
||||
);
|
||||
|
||||
// 5. Create a demo user and capture the session cookie.
|
||||
const signupRes = await fetch(`${authBaseUrl}/api/auth/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: authBaseUrl,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "alice@example.com",
|
||||
password: "smoke-test-password-1",
|
||||
name: "Alice",
|
||||
}),
|
||||
});
|
||||
assert.equal(signupRes.ok, true, "sign-up should succeed");
|
||||
const sessionCookies = signupRes.headers.get("set-cookie");
|
||||
assert.ok(sessionCookies, "sign-up should return session cookies");
|
||||
|
||||
// 6. Hit /oauth2/authorize with the URL client_id. The server fetches
|
||||
// the metadata document, validates it, and creates a public client
|
||||
// record keyed by the URL.
|
||||
const authorizeUrl =
|
||||
`${authBaseUrl}/api/auth/oauth2/authorize` +
|
||||
`?client_id=${encodeURIComponent(clientMetadataUrl)}` +
|
||||
`&response_type=code` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent("openid email profile offline_access")}` +
|
||||
`&code_challenge=${PKCE_CHALLENGE}` +
|
||||
`&code_challenge_method=S256`;
|
||||
|
||||
const authorizeRes = await fetch(authorizeUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: sessionCookies,
|
||||
origin: authBaseUrl,
|
||||
// Force the JSON envelope shape (`{ redirect, url }`). Without
|
||||
// an explicit Accept, a future change to server-side
|
||||
// content negotiation could switch this to a raw 302 and
|
||||
// break the test silently.
|
||||
accept: "application/json",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
const authorizePayload = (await authorizeRes.json()) as {
|
||||
redirect?: boolean;
|
||||
url?: string;
|
||||
};
|
||||
const consentRedirect = authorizePayload.url ?? "";
|
||||
assert.match(
|
||||
consentRedirect,
|
||||
/\/consent\?/,
|
||||
`authorize should redirect to /consent, got: ${consentRedirect}`,
|
||||
);
|
||||
|
||||
// 7. Accept consent. `oauth_query` is the literal query string from
|
||||
// the consent URL, signed by /authorize so /consent can verify it.
|
||||
const oauthQuery = new URL(consentRedirect, authBaseUrl).search.replace(
|
||||
/^\?/,
|
||||
"",
|
||||
);
|
||||
const consentRes = await fetch(`${authBaseUrl}/api/auth/oauth2/consent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: sessionCookies,
|
||||
"content-type": "application/json",
|
||||
origin: authBaseUrl,
|
||||
},
|
||||
body: JSON.stringify({ accept: true, oauth_query: oauthQuery }),
|
||||
});
|
||||
const consentBody = (await consentRes.json()) as {
|
||||
redirect?: boolean;
|
||||
url?: string;
|
||||
};
|
||||
const codeUrl = consentBody.url ?? "";
|
||||
const code = new URL(codeUrl).searchParams.get("code");
|
||||
assert.ok(code, `consent should return authorization code: ${codeUrl}`);
|
||||
|
||||
// 8. Exchange the code for tokens.
|
||||
const tokenRes = await fetch(`${authBaseUrl}/api/auth/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientMetadataUrl,
|
||||
code_verifier: PKCE_VERIFIER,
|
||||
}).toString(),
|
||||
});
|
||||
assert.equal(tokenRes.status, 200, "token exchange should succeed");
|
||||
const tokens = (await tokenRes.json()) as Record<string, unknown>;
|
||||
assert.equal(typeof tokens.access_token, "string");
|
||||
assert.equal(typeof tokens.refresh_token, "string");
|
||||
assert.equal(typeof tokens.id_token, "string");
|
||||
assert.equal(tokens.token_type, "Bearer");
|
||||
|
||||
// 9. Call userinfo with the CIMD-issued access token.
|
||||
const userinfoRes = await fetch(`${authBaseUrl}/api/auth/oauth2/userinfo`, {
|
||||
headers: { authorization: `Bearer ${tokens.access_token as string}` },
|
||||
});
|
||||
assert.equal(userinfoRes.status, 200, "userinfo should succeed");
|
||||
const claims = (await userinfoRes.json()) as Record<string, unknown>;
|
||||
assert.equal(typeof claims.sub, "string");
|
||||
assert.equal(claims.email, "alice@example.com");
|
||||
assert.equal(claims.name, "Alice");
|
||||
|
||||
// 10. Use the refresh_token to mint a new access token.
|
||||
const refreshRes = await fetch(`${authBaseUrl}/api/auth/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokens.refresh_token as string,
|
||||
client_id: clientMetadataUrl,
|
||||
}).toString(),
|
||||
});
|
||||
assert.equal(refreshRes.status, 200, "refresh grant should succeed");
|
||||
const refreshed = (await refreshRes.json()) as Record<string, unknown>;
|
||||
assert.equal(typeof refreshed.access_token, "string");
|
||||
assert.notEqual(refreshed.access_token, tokens.access_token);
|
||||
});
|
||||
|
||||
it("rejects a URL client_id that is blocked by allowFetch before any outbound fetch", async (t) => {
|
||||
// Reserve auth port first so baseURL is accurate.
|
||||
const placeholder = createServer();
|
||||
placeholder.listen(0);
|
||||
await once(placeholder, "listening");
|
||||
const authPort = (placeholder.address() as AddressInfo).port;
|
||||
const authBaseUrl = `http://localhost:${authPort}`;
|
||||
placeholder.close();
|
||||
|
||||
const blockedClientUrl = "http://localhost:59999/blocked.json";
|
||||
|
||||
// Stand up a metadata host that would serve a valid document if the
|
||||
// server ever reached it — the assertion is that it never does.
|
||||
const metadataHost = createServer();
|
||||
let fetchCount = 0;
|
||||
metadataHost.on("request", (req, res) => {
|
||||
fetchCount++;
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
client_id: blockedClientUrl,
|
||||
redirect_uris: ["http://localhost:59999/callback"],
|
||||
token_endpoint_auth_method: "none",
|
||||
}),
|
||||
);
|
||||
});
|
||||
metadataHost.listen(59999);
|
||||
t.after(() => metadataHost.close());
|
||||
await once(metadataHost, "listening");
|
||||
|
||||
const auth = betterAuth({
|
||||
baseURL: authBaseUrl,
|
||||
secret: "smoke-test-secret-that-is-long-enough-for-validation",
|
||||
database: new DatabaseSync(":memory:"),
|
||||
emailAndPassword: { enabled: true },
|
||||
trustedOrigins: [authBaseUrl],
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
scopes: ["openid", "profile", "email", "offline_access"],
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
cimd({
|
||||
// Block any client_id whose host is `localhost:59999`.
|
||||
allowFetch: (url) => new URL(url).host !== "localhost:59999",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { runMigrations } = await getMigrations(auth.options);
|
||||
await runMigrations();
|
||||
|
||||
const authServer = createServer(toNodeHandler(auth.handler));
|
||||
authServer.listen(authPort);
|
||||
t.after(() => authServer.close());
|
||||
await once(authServer, "listening");
|
||||
|
||||
// Sign up + get a session.
|
||||
const signup = await fetch(`${authBaseUrl}/api/auth/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: authBaseUrl,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "bob@example.com",
|
||||
password: "smoke-test-password-1",
|
||||
name: "Bob",
|
||||
}),
|
||||
});
|
||||
const sessionCookies = signup.headers.get("set-cookie") ?? "";
|
||||
|
||||
// Authorize attempt against the blocked URL.
|
||||
const authorizeRes = await fetch(
|
||||
`${authBaseUrl}/api/auth/oauth2/authorize` +
|
||||
`?client_id=${encodeURIComponent(blockedClientUrl)}` +
|
||||
`&response_type=code` +
|
||||
`&redirect_uri=${encodeURIComponent("http://localhost:59999/callback")}` +
|
||||
`&scope=openid` +
|
||||
`&code_challenge=${PKCE_CHALLENGE}` +
|
||||
`&code_challenge_method=S256`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: sessionCookies,
|
||||
origin: authBaseUrl,
|
||||
accept: "application/json",
|
||||
},
|
||||
redirect: "manual",
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
authorizeRes.status >= 400,
|
||||
`authorize should reject blocked URL, got status ${authorizeRes.status}`,
|
||||
);
|
||||
assert.equal(
|
||||
fetchCount,
|
||||
0,
|
||||
"metadata host should never be fetched when allowFetch rejects",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import type {
|
||||
OAuthClient,
|
||||
OAuthOptions,
|
||||
SchemaClient,
|
||||
Scope,
|
||||
} from "@better-auth/oauth-provider";
|
||||
import { checkOAuthClient, oauthToSchema } from "@better-auth/oauth-provider";
|
||||
import { APIError } from "better-call";
|
||||
import type { CimdOptions } from "./types";
|
||||
import {
|
||||
validateCimdMetadata,
|
||||
validateClientIdUrl,
|
||||
} from "./validate-metadata-document";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 5_000;
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024; // 5 KB per spec recommendation (§6.6)
|
||||
|
||||
/**
|
||||
* Accepts `application/json` and the draft's `application/<AS-defined>+json`
|
||||
* form. Parameters (charset, etc.) are allowed after the subtype.
|
||||
*/
|
||||
const JSON_CONTENT_TYPE_RE = /^application\/(?:[-\w.]+\+)?json\s*(?:;|$)/i;
|
||||
|
||||
function tooLargeError(): APIError {
|
||||
return new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: `Metadata document exceeds ${MAX_RESPONSE_BYTES / 1024}KB size limit`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a Response body into a decoded string, aborting as soon as the
|
||||
* running byte count exceeds `max`. Guarantees no more than `max + one
|
||||
* chunk` bytes ever sit in memory before the request is canceled.
|
||||
*/
|
||||
async function readBodyWithLimit(
|
||||
response: Response,
|
||||
max: number,
|
||||
): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
// Runtimes without streaming body support fall back to `text()`; we
|
||||
// re-apply the size check on the buffered result for defense in
|
||||
// depth.
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > max) {
|
||||
throw tooLargeError();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > max) {
|
||||
await reader.cancel();
|
||||
throw tooLargeError();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7591 / CIMD fields accepted from external metadata documents.
|
||||
*
|
||||
* Security-sensitive fields — `require_pkce`, `disabled`, `skip_consent`,
|
||||
* `enable_end_session` — are deliberately excluded. An attacker-controlled
|
||||
* document MUST NOT be able to weaken the server's PKCE policy or escalate
|
||||
* admin-only flags.
|
||||
*/
|
||||
const ALLOWED_METADATA_FIELDS = new Set([
|
||||
"client_id",
|
||||
"redirect_uris",
|
||||
"token_endpoint_auth_method",
|
||||
"grant_types",
|
||||
"response_types",
|
||||
"client_name",
|
||||
"client_uri",
|
||||
"logo_uri",
|
||||
"scope",
|
||||
"contacts",
|
||||
"tos_uri",
|
||||
"policy_uri",
|
||||
"software_id",
|
||||
"software_version",
|
||||
"software_statement",
|
||||
"post_logout_redirect_uris",
|
||||
"subject_type",
|
||||
"type",
|
||||
"jwks",
|
||||
"jwks_uri",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract only recognized RFC 7591 / CIMD fields from the metadata document.
|
||||
* Prevents arbitrary attacker-controlled fields from leaking into the DB.
|
||||
*/
|
||||
function toOAuthClientBody(metadata: Record<string, unknown>): OAuthClient {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
for (const key of ALLOWED_METADATA_FIELDS) {
|
||||
if (key in metadata) {
|
||||
filtered[key] = metadata[key];
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(filtered as OAuthClient),
|
||||
token_endpoint_auth_method:
|
||||
(filtered.token_endpoint_auth_method as OAuthClient["token_endpoint_auth_method"]) ??
|
||||
"none",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client from a Client ID Metadata Document.
|
||||
* Called when a URL-format client_id is encountered for the first time.
|
||||
*
|
||||
* Writes the DB record directly rather than routing through
|
||||
* `createOAuthClientEndpoint`, because CIMD clients must use the URL
|
||||
* as their `clientId` (not a generated random ID).
|
||||
*/
|
||||
export async function createMetadataDocumentClient(
|
||||
ctx: GenericEndpointContext,
|
||||
clientIdUrl: string,
|
||||
cimdOptions: CimdOptions,
|
||||
oauthOptions: OAuthOptions<Scope[]>,
|
||||
): Promise<SchemaClient<Scope[]>> {
|
||||
const metadata = await fetchAndValidateMetadataDocument(
|
||||
ctx,
|
||||
clientIdUrl,
|
||||
cimdOptions,
|
||||
);
|
||||
const oauthClient = toOAuthClientBody(metadata);
|
||||
|
||||
await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
|
||||
|
||||
const isPrivateKeyJwt =
|
||||
oauthClient.token_endpoint_auth_method === "private_key_jwt";
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const schema = oauthToSchema({
|
||||
...oauthClient,
|
||||
// Admin-only fields: never trust from external metadata
|
||||
disabled: undefined,
|
||||
skip_consent: undefined,
|
||||
enable_end_session: undefined,
|
||||
// Preserve jwks/jwks_uri only for private_key_jwt clients
|
||||
jwks: isPrivateKeyJwt ? oauthClient.jwks : undefined,
|
||||
jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : undefined,
|
||||
client_id: clientIdUrl,
|
||||
client_secret: undefined,
|
||||
client_secret_expires_at: undefined,
|
||||
client_id_issued_at: iat,
|
||||
public: !isPrivateKeyJwt,
|
||||
});
|
||||
|
||||
const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
|
||||
let client: SchemaClient<Scope[]>;
|
||||
try {
|
||||
client = await ctx.context.adapter.create<SchemaClient<Scope[]>>({
|
||||
model,
|
||||
data: {
|
||||
...schema,
|
||||
createdAt: new Date(iat * 1000),
|
||||
updatedAt: new Date(iat * 1000),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// A concurrent request may have created this client first. If the
|
||||
// record exists by now, return it; otherwise re-throw the original.
|
||||
const existing = await ctx.context.adapter.findOne<SchemaClient<Scope[]>>({
|
||||
model,
|
||||
where: [{ field: "clientId", value: clientIdUrl }],
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await cimdOptions.onClientCreated?.({ client, metadata, ctx });
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an existing client by re-fetching its metadata document.
|
||||
*
|
||||
* Admin-controlled fields (`disabled`, `skip_consent`, `enable_end_session`)
|
||||
* are never overwritten from the document — they are read from `existing`
|
||||
* and preserved so admin decisions survive a refresh.
|
||||
*/
|
||||
export async function refreshMetadataDocumentClient(
|
||||
ctx: GenericEndpointContext,
|
||||
clientIdUrl: string,
|
||||
existing: SchemaClient<Scope[]>,
|
||||
cimdOptions: CimdOptions,
|
||||
oauthOptions: OAuthOptions<Scope[]>,
|
||||
): Promise<SchemaClient<Scope[]>> {
|
||||
const metadata = await fetchAndValidateMetadataDocument(
|
||||
ctx,
|
||||
clientIdUrl,
|
||||
cimdOptions,
|
||||
);
|
||||
const oauthClient = toOAuthClientBody(metadata);
|
||||
|
||||
await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
|
||||
|
||||
const isPrivateKeyJwt =
|
||||
oauthClient.token_endpoint_auth_method === "private_key_jwt";
|
||||
const schema = oauthToSchema({
|
||||
...oauthClient,
|
||||
jwks: isPrivateKeyJwt ? oauthClient.jwks : undefined,
|
||||
jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : undefined,
|
||||
client_id: clientIdUrl,
|
||||
client_secret: undefined,
|
||||
client_secret_expires_at: undefined,
|
||||
public: !isPrivateKeyJwt,
|
||||
});
|
||||
|
||||
// Preserve admin-controlled flags that the document MUST NOT influence.
|
||||
const preservedAdminFields = {
|
||||
disabled: existing.disabled,
|
||||
skipConsent: existing.skipConsent,
|
||||
enableEndSession: existing.enableEndSession,
|
||||
};
|
||||
|
||||
const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
|
||||
const client = await ctx.context.adapter.update<SchemaClient<Scope[]>>({
|
||||
model,
|
||||
where: [{ field: "clientId", value: clientIdUrl }],
|
||||
update: {
|
||||
...schema,
|
||||
...preservedAdminFields,
|
||||
updatedAt: new Date(Math.floor(Date.now() / 1000) * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
// `update` returning null means no row matched — the client was
|
||||
// deleted between the read and the write. That's a race against an
|
||||
// admin delete, not a server fault, so surface it as an OAuth
|
||||
// invalid_client rather than a 500.
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: "client no longer exists",
|
||||
});
|
||||
}
|
||||
|
||||
await cimdOptions.onClientRefreshed?.({ client, metadata, ctx });
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Client ID Metadata Document, validate it against the spec,
|
||||
* and return the parsed metadata.
|
||||
*/
|
||||
async function fetchAndValidateMetadataDocument(
|
||||
ctx: GenericEndpointContext,
|
||||
clientIdUrl: string,
|
||||
cimdOptions: CimdOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// §3: validate the URL structure before fetching
|
||||
const urlError = validateClientIdUrl(clientIdUrl);
|
||||
if (urlError) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: urlError,
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-fetch gate: operators can block domains, rate-limit, or reject
|
||||
// URLs whose hostnames resolve to non-public addresses (DNS-level SSRF
|
||||
// defense beyond the IP-literal check in `validateClientIdUrl`).
|
||||
if (cimdOptions.allowFetch) {
|
||||
const allowed = await cimdOptions.allowFetch(clientIdUrl, ctx);
|
||||
if (!allowed) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description:
|
||||
"client_id URL is not permitted by the server's fetch policy",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(clientIdUrl, {
|
||||
headers: { Accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const isTimeout =
|
||||
err instanceof DOMException && err.name === "TimeoutError";
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: isTimeout
|
||||
? `Metadata document fetch timed out after ${FETCH_TIMEOUT_MS}ms`
|
||||
: "Failed to fetch metadata document (network error or redirect blocked)",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: `Metadata document fetch returned HTTP ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Content-Type must be JSON per RFC 7591. The CIMD draft also permits
|
||||
// `application/<AS-defined>+json`, so accept any `*+json` subtype.
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!JSON_CONTENT_TYPE_RE.test(contentType)) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: `Metadata document must be JSON (got Content-Type "${contentType || "(none)"}")`,
|
||||
});
|
||||
}
|
||||
|
||||
// §6.6: enforce the response size limit BEFORE buffering the body. A
|
||||
// malicious host that serves a multi-gigabyte document would otherwise
|
||||
// exhaust memory up to the 5 s timeout. Two layers:
|
||||
// 1. Reject immediately if Content-Length declares > 5 KB.
|
||||
// 2. Stream-read with a running byte counter and abort on overrun,
|
||||
// so responses without Content-Length are still bounded.
|
||||
const contentLengthHeader = response.headers.get("content-length");
|
||||
if (contentLengthHeader) {
|
||||
const declared = Number.parseInt(contentLengthHeader, 10);
|
||||
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
||||
await response.body?.cancel();
|
||||
throw tooLargeError();
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = await readBodyWithLimit(response, MAX_RESPONSE_BYTES);
|
||||
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(bodyText) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: "Metadata document is not valid JSON",
|
||||
});
|
||||
}
|
||||
|
||||
// §4.1: validate the document contents
|
||||
const validation = validateCimdMetadata(
|
||||
clientIdUrl,
|
||||
data,
|
||||
cimdOptions.originBoundFields,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
error_description: validation.error ?? "Invalid metadata document",
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import { jwt } from "better-auth/plugins/jwt";
|
||||
import { getTestInstance } from "better-auth/test";
|
||||
import type { Listener } from "listhen";
|
||||
import { listen } from "listhen";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { cimd } from "./index";
|
||||
|
||||
const PKCE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
|
||||
function buildAuthorizeUrl(
|
||||
base: string,
|
||||
clientIdUrl: string,
|
||||
redirectUri: string,
|
||||
): string {
|
||||
return (
|
||||
`${base}/api/auth/oauth2/authorize` +
|
||||
`?client_id=${encodeURIComponent(clientIdUrl)}` +
|
||||
`&response_type=code` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=openid` +
|
||||
`&code_challenge=${PKCE_CHALLENGE}` +
|
||||
`&code_challenge_method=S256`
|
||||
);
|
||||
}
|
||||
|
||||
function stubMetadataFetch(
|
||||
url: string,
|
||||
document: Record<string, unknown>,
|
||||
): void {
|
||||
const originalFetch = globalThis.fetch.bind(globalThis);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const requested =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (requested === url) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(document), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function extractAuthorizationCode(
|
||||
authedClient: ReturnType<typeof createAuthClient>,
|
||||
authorizeUrl: string,
|
||||
): Promise<string> {
|
||||
let redirect = "";
|
||||
await authedClient.$fetch(authorizeUrl, {
|
||||
method: "GET",
|
||||
onError(ctx) {
|
||||
redirect = ctx.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
|
||||
// If prior consent exists, the server skips the consent page and
|
||||
// redirects straight to the client's redirect_uri with `code=...`.
|
||||
const redirectParams = tryExtractCodeFromUrl(redirect);
|
||||
if (redirectParams) return redirectParams;
|
||||
|
||||
if (!redirect.includes("/consent")) {
|
||||
throw new Error(`Expected consent or callback redirect, got: ${redirect}`);
|
||||
}
|
||||
|
||||
const baseURL = new URL(authorizeUrl).origin;
|
||||
vi.stubGlobal("window", {
|
||||
location: { search: new URL(redirect, baseURL).search },
|
||||
});
|
||||
|
||||
const consent = await (authedClient as any).oauth2.consent(
|
||||
{ accept: true },
|
||||
{ throw: true },
|
||||
);
|
||||
const url = new URL(consent.url);
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) throw new Error(`No code in redirect: ${consent.url}`);
|
||||
return code;
|
||||
}
|
||||
|
||||
function tryExtractCodeFromUrl(urlString: string): string | null {
|
||||
if (!urlString) return null;
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return url.searchParams.get("code");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("CIMD - token exchange flow", async () => {
|
||||
const port = 3102;
|
||||
const authServerBaseUrl = `http://localhost:${port}`;
|
||||
const clientMetadataUrl =
|
||||
"https://mcp-client-token.example.com/client-metadata.json";
|
||||
const redirectUri = "http://localhost:5102/callback";
|
||||
const metadataDocument = {
|
||||
client_id: clientMetadataUrl,
|
||||
client_name: "Token Exchange Test Client",
|
||||
redirect_uris: [redirectUri],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
};
|
||||
|
||||
const {
|
||||
auth: authorizationServer,
|
||||
signInWithTestUser,
|
||||
customFetchImpl,
|
||||
} = await getTestInstance({
|
||||
baseURL: authServerBaseUrl,
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
scopes: ["openid", "profile", "email", "offline_access"],
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
cimd(),
|
||||
],
|
||||
});
|
||||
|
||||
let server: Listener;
|
||||
beforeAll(async () => {
|
||||
server = await listen(
|
||||
(req, res) => toNodeHandler(authorizationServer.handler)(req, res),
|
||||
{ port },
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("exchanges an authorization code for an access token", async () => {
|
||||
stubMetadataFetch(clientMetadataUrl, metadataDocument);
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const authedClient = createAuthClient({
|
||||
plugins: [oauthProviderClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: { customFetchImpl, headers },
|
||||
});
|
||||
|
||||
const code = await extractAuthorizationCode(
|
||||
authedClient,
|
||||
buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri),
|
||||
);
|
||||
|
||||
const tokenResponse = await fetch(
|
||||
`${authServerBaseUrl}/api/auth/oauth2/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientMetadataUrl,
|
||||
code_verifier: PKCE_VERIFIER,
|
||||
}).toString(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
const body = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
expect(typeof body.access_token).toBe("string");
|
||||
expect(body.token_type).toBe("Bearer");
|
||||
expect(typeof body.expires_in).toBe("number");
|
||||
});
|
||||
|
||||
it("returns user claims from /oauth2/userinfo with a CIMD-issued access token", async () => {
|
||||
stubMetadataFetch(clientMetadataUrl, metadataDocument);
|
||||
|
||||
const { headers, user } = await signInWithTestUser();
|
||||
const authedClient = createAuthClient({
|
||||
plugins: [oauthProviderClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: { customFetchImpl, headers },
|
||||
});
|
||||
|
||||
const authorizeWithEmail =
|
||||
`${authServerBaseUrl}/api/auth/oauth2/authorize` +
|
||||
`?client_id=${encodeURIComponent(clientMetadataUrl)}` +
|
||||
`&response_type=code` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent("openid email profile")}` +
|
||||
`&code_challenge=${PKCE_CHALLENGE}` +
|
||||
`&code_challenge_method=S256`;
|
||||
|
||||
const code = await extractAuthorizationCode(
|
||||
authedClient,
|
||||
authorizeWithEmail,
|
||||
);
|
||||
|
||||
const tokenResponse = await fetch(
|
||||
`${authServerBaseUrl}/api/auth/oauth2/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientMetadataUrl,
|
||||
code_verifier: PKCE_VERIFIER,
|
||||
}).toString(),
|
||||
},
|
||||
);
|
||||
const token = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
expect(typeof token.access_token).toBe("string");
|
||||
|
||||
const userinfoResponse = await fetch(
|
||||
`${authServerBaseUrl}/api/auth/oauth2/userinfo`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token.access_token as string}` },
|
||||
},
|
||||
);
|
||||
expect(userinfoResponse.status).toBe(200);
|
||||
const claims = (await userinfoResponse.json()) as Record<string, unknown>;
|
||||
expect(typeof claims.sub).toBe("string");
|
||||
expect(claims.email).toBe(user.email);
|
||||
});
|
||||
|
||||
it("refresh token grant mints a new access token for a CIMD client", async () => {
|
||||
stubMetadataFetch(clientMetadataUrl, metadataDocument);
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const authedClient = createAuthClient({
|
||||
plugins: [oauthProviderClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: { customFetchImpl, headers },
|
||||
});
|
||||
|
||||
// Request offline_access so the token response includes a refresh_token.
|
||||
const authorizeWithOffline =
|
||||
`${authServerBaseUrl}/api/auth/oauth2/authorize` +
|
||||
`?client_id=${encodeURIComponent(clientMetadataUrl)}` +
|
||||
`&response_type=code` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent("openid offline_access")}` +
|
||||
`&code_challenge=${PKCE_CHALLENGE}` +
|
||||
`&code_challenge_method=S256`;
|
||||
|
||||
const code = await extractAuthorizationCode(
|
||||
authedClient,
|
||||
authorizeWithOffline,
|
||||
);
|
||||
|
||||
const initial = await fetch(`${authServerBaseUrl}/api/auth/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientMetadataUrl,
|
||||
code_verifier: PKCE_VERIFIER,
|
||||
}).toString(),
|
||||
});
|
||||
const initialBody = (await initial.json()) as Record<string, unknown>;
|
||||
expect(typeof initialBody.access_token).toBe("string");
|
||||
expect(typeof initialBody.refresh_token).toBe("string");
|
||||
|
||||
const refreshed = await fetch(
|
||||
`${authServerBaseUrl}/api/auth/oauth2/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: initialBody.refresh_token as string,
|
||||
client_id: clientMetadataUrl,
|
||||
}).toString(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(refreshed.status).toBe(200);
|
||||
const refreshedBody = (await refreshed.json()) as Record<string, unknown>;
|
||||
expect(typeof refreshedBody.access_token).toBe("string");
|
||||
expect(refreshedBody.access_token).not.toBe(initialBody.access_token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CIMD - refresh preserves admin-set flags", async () => {
|
||||
const port = 3103;
|
||||
const authServerBaseUrl = `http://localhost:${port}`;
|
||||
const clientMetadataUrl =
|
||||
"https://mcp-client-refresh.example.com/client-metadata.json";
|
||||
const redirectUri = "http://localhost:5103/callback";
|
||||
const metadataDocument = {
|
||||
client_id: clientMetadataUrl,
|
||||
client_name: "Refresh Preserve Test Client",
|
||||
redirect_uris: [redirectUri],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
};
|
||||
|
||||
// refreshRate: 0 makes every request stale, so the refresh path fires
|
||||
// on the second authorize hit — giving us a deterministic way to test
|
||||
// what refresh does (or does not) write.
|
||||
const {
|
||||
auth: authorizationServer,
|
||||
signInWithTestUser,
|
||||
customFetchImpl,
|
||||
} = await getTestInstance({
|
||||
baseURL: authServerBaseUrl,
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
scopes: ["openid", "profile", "email", "offline_access"],
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
cimd({ refreshRate: 0 }),
|
||||
],
|
||||
});
|
||||
|
||||
let server: Listener;
|
||||
beforeAll(async () => {
|
||||
server = await listen(
|
||||
(req, res) => toNodeHandler(authorizationServer.handler)(req, res),
|
||||
{ port },
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("preserves admin-set `disabled`, `skipConsent`, and `enableEndSession` across a stale refresh", async () => {
|
||||
stubMetadataFetch(clientMetadataUrl, metadataDocument);
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const authedClient = createAuthClient({
|
||||
plugins: [oauthProviderClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: { customFetchImpl, headers },
|
||||
});
|
||||
|
||||
// First authorize creates the CIMD client record.
|
||||
let redirect = "";
|
||||
await authedClient.$fetch(
|
||||
buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri),
|
||||
{
|
||||
method: "GET",
|
||||
onError(ctx) {
|
||||
redirect = ctx.response.headers.get("Location") || "";
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(redirect).toContain("/consent");
|
||||
|
||||
// Admin flips all three protected flags directly via the adapter.
|
||||
const ctx = await (authorizationServer as any).$context;
|
||||
await ctx.adapter.update({
|
||||
model: "oauthClient",
|
||||
where: [{ field: "clientId", value: clientMetadataUrl }],
|
||||
update: {
|
||||
disabled: true,
|
||||
skipConsent: true,
|
||||
enableEndSession: true,
|
||||
},
|
||||
});
|
||||
|
||||
const afterAdmin = await ctx.adapter.findOne({
|
||||
model: "oauthClient",
|
||||
where: [{ field: "clientId", value: clientMetadataUrl }],
|
||||
});
|
||||
expect(afterAdmin.disabled).toBe(true);
|
||||
expect(afterAdmin.skipConsent).toBe(true);
|
||||
expect(afterAdmin.enableEndSession).toBe(true);
|
||||
|
||||
// Second authorize must trigger a stale refresh (refreshRate: 0).
|
||||
// The document does NOT carry any of these flags, so preservation is
|
||||
// the only way they survive.
|
||||
await authedClient.$fetch(
|
||||
buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri),
|
||||
{ method: "GET", onError() {} },
|
||||
);
|
||||
|
||||
const afterRefresh = await ctx.adapter.findOne({
|
||||
model: "oauthClient",
|
||||
where: [{ field: "clientId", value: clientMetadataUrl }],
|
||||
});
|
||||
expect(afterRefresh.disabled).toBe(true);
|
||||
expect(afterRefresh.skipConsent).toBe(true);
|
||||
expect(afterRefresh.enableEndSession).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CIMD - allowFetch gate", async () => {
|
||||
const port = 3104;
|
||||
const authServerBaseUrl = `http://localhost:${port}`;
|
||||
const blockedClientUrl = "https://blocked.example.com/client-metadata.json";
|
||||
const allowedClientUrl = "https://allowed.example.com/client-metadata.json";
|
||||
const redirectUriBlocked = "http://localhost:5104/callback";
|
||||
const redirectUriAllowed = "http://localhost:5105/callback";
|
||||
|
||||
const allowedDocument = {
|
||||
client_id: allowedClientUrl,
|
||||
redirect_uris: [redirectUriAllowed],
|
||||
token_endpoint_auth_method: "none",
|
||||
};
|
||||
|
||||
const {
|
||||
auth: authorizationServer,
|
||||
signInWithTestUser,
|
||||
customFetchImpl,
|
||||
} = await getTestInstance({
|
||||
baseURL: authServerBaseUrl,
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
scopes: ["openid", "profile", "email", "offline_access"],
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
cimd({
|
||||
allowFetch: (url) => new URL(url).hostname === "allowed.example.com",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
let server: Listener;
|
||||
beforeAll(async () => {
|
||||
server = await listen(
|
||||
(req, res) => toNodeHandler(authorizationServer.handler)(req, res),
|
||||
{ port },
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects a URL blocked by allowFetch before the fetch runs", async () => {
|
||||
const fetchSpy = vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
globalThis.fetch.call(globalThis, input, init),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const authedClient = createAuthClient({
|
||||
plugins: [oauthProviderClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: { customFetchImpl, headers },
|
||||
});
|
||||
|
||||
let status = 0;
|
||||
await authedClient.$fetch(
|
||||
buildAuthorizeUrl(
|
||||
authServerBaseUrl,
|
||||
blockedClientUrl,
|
||||
redirectUriBlocked,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
onError(ctx) {
|
||||
status = ctx.response.status;
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
const metadataFetches = fetchSpy.mock.calls.filter((args) => {
|
||||
const input = args[0];
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: (input as Request).url;
|
||||
return url === blockedClientUrl;
|
||||
});
|
||||
expect(metadataFetches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows a URL permitted by allowFetch", async () => {
|
||||
stubMetadataFetch(allowedClientUrl, allowedDocument);
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const authedClient = createAuthClient({
|
||||
plugins: [oauthProviderClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: { customFetchImpl, headers },
|
||||
});
|
||||
|
||||
let redirect = "";
|
||||
await authedClient.$fetch(
|
||||
buildAuthorizeUrl(
|
||||
authServerBaseUrl,
|
||||
allowedClientUrl,
|
||||
redirectUriAllowed,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
onError(ctx) {
|
||||
redirect = ctx.response.headers.get("Location") || "";
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(redirect).toContain("/consent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BetterAuthError } from "@better-auth/core/error";
|
||||
import type { ClientDiscovery, Scope } from "@better-auth/oauth-provider";
|
||||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { createCimdResolver } from "./resolver";
|
||||
import type { CimdOptions } from "./types";
|
||||
import { isUrlClientId } from "./validate-metadata-document";
|
||||
import { PACKAGE_VERSION } from "./version";
|
||||
|
||||
declare module "@better-auth/core" {
|
||||
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
||||
cimd: {
|
||||
creator: typeof cimd;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link ClientDiscovery} for Client ID Metadata Documents.
|
||||
*
|
||||
* Users who prefer explicit composition can pass the result directly to
|
||||
* `oauthProvider({ clientDiscovery })`; most users should install the
|
||||
* {@link cimd} plugin instead, which appends this discovery to whatever
|
||||
* is already configured.
|
||||
*/
|
||||
export function cimdClientDiscovery(
|
||||
options: CimdOptions = {},
|
||||
): ClientDiscovery<Scope[]> {
|
||||
const resolver = createCimdResolver(options);
|
||||
return {
|
||||
id: "cimd",
|
||||
matches: isUrlClientId,
|
||||
resolve: resolver,
|
||||
discoveryMetadata: { client_id_metadata_document_supported: true },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Client ID Metadata Document plugin.
|
||||
*
|
||||
* Adds unauthenticated dynamic client discovery over HTTPS to an
|
||||
* `oauth-provider` instance. Clients identify themselves by providing
|
||||
* an HTTPS URL as their `client_id`; the plugin fetches and validates
|
||||
* the document at that URL, then creates a public client record.
|
||||
*
|
||||
* See {@link https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ | the IETF draft}
|
||||
* and {@link https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow | the MCP authorization spec}.
|
||||
*/
|
||||
export const cimd = (options: CimdOptions = {}) => {
|
||||
const discovery = cimdClientDiscovery(options);
|
||||
|
||||
return {
|
||||
id: "cimd",
|
||||
version: PACKAGE_VERSION,
|
||||
init(ctx) {
|
||||
const provider = ctx.getPlugin("oauth-provider");
|
||||
if (!provider) {
|
||||
throw new BetterAuthError(
|
||||
"The cimd plugin requires the oauth-provider plugin.",
|
||||
);
|
||||
}
|
||||
const existing = provider.options.clientDiscovery;
|
||||
provider.options.clientDiscovery = Array.isArray(existing)
|
||||
? [...existing, discovery]
|
||||
: existing
|
||||
? [existing, discovery]
|
||||
: discovery;
|
||||
},
|
||||
} satisfies BetterAuthPlugin;
|
||||
};
|
||||
|
||||
export { createCimdResolver } from "./resolver";
|
||||
export type { CimdOptions } from "./types";
|
||||
export type { ClientIdMetadataDocumentResult } from "./validate-metadata-document";
|
||||
export {
|
||||
isLocalhost,
|
||||
isUrlClientId,
|
||||
validateCimdMetadata,
|
||||
validateClientIdUrl,
|
||||
} from "./validate-metadata-document";
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { BetterAuthError } from "@better-auth/core/error";
|
||||
import type {
|
||||
OAuthOptions,
|
||||
SchemaClient,
|
||||
Scope,
|
||||
} from "@better-auth/oauth-provider";
|
||||
import { toExpJWT } from "better-auth/plugins";
|
||||
import {
|
||||
createMetadataDocumentClient,
|
||||
refreshMetadataDocumentClient,
|
||||
} from "./client-store";
|
||||
import type { CimdOptions } from "./types";
|
||||
import { isUrlClientId } from "./validate-metadata-document";
|
||||
|
||||
/**
|
||||
* Signature of the `resolve` function on a {@link ClientDiscovery}. Kept
|
||||
* here to avoid a circular import back into `@better-auth/oauth-provider`.
|
||||
*/
|
||||
type CimdResolver = (
|
||||
ctx: GenericEndpointContext,
|
||||
clientId: string,
|
||||
existing: SchemaClient<Scope[]> | null,
|
||||
) => Promise<SchemaClient<Scope[]> | null>;
|
||||
|
||||
function toDate(value: unknown): Date | null {
|
||||
if (value instanceof Date) {
|
||||
return Number.isFinite(value.getTime()) ? value : null;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const parsed = new Date(value);
|
||||
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
const parsed = new Date(Number(value));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const asNumber = Number(value);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
const parsed = new Date(asNumber);
|
||||
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStale(
|
||||
existing: SchemaClient<Scope[]>,
|
||||
refreshRate: number | string,
|
||||
) {
|
||||
// Records with no usable timestamp are always considered stale — we
|
||||
// would rather re-fetch once than risk serving a record that never
|
||||
// refreshes for its lifetime.
|
||||
const updatedAt =
|
||||
toDate(existing.updatedAt) ?? toDate(existing.createdAt) ?? new Date(0);
|
||||
const updatedSec = Math.floor(updatedAt.getTime() / 1000);
|
||||
// `toExpJWT` treats numbers as absolute timestamps, so convert numeric
|
||||
// seconds to a relative offset for correct TTL behavior.
|
||||
const staleAt =
|
||||
typeof refreshRate === "number"
|
||||
? updatedSec + refreshRate
|
||||
: toExpJWT(refreshRate, updatedSec);
|
||||
return staleAt < Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `resolve` function for a CIMD {@link ClientDiscovery}.
|
||||
*
|
||||
* Exposed for advanced composition. Most users should call
|
||||
* {@link cimdClientDiscovery} (to pass a complete discovery to
|
||||
* `oauthProvider({ clientDiscovery })`) or install the `cimd()` plugin.
|
||||
*/
|
||||
export function createCimdResolver(
|
||||
cimdOptions: CimdOptions = {},
|
||||
): CimdResolver {
|
||||
const refreshRate = cimdOptions.refreshRate ?? "60m";
|
||||
|
||||
return async (ctx, clientId, existing) => {
|
||||
if (!isUrlClientId(clientId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = ctx.context.getPlugin("oauth-provider");
|
||||
if (!provider) {
|
||||
throw new BetterAuthError(
|
||||
"cimd discovery invoked without the oauth-provider plugin installed",
|
||||
);
|
||||
}
|
||||
const oauthOptions = provider.options as OAuthOptions<Scope[]>;
|
||||
|
||||
if (!existing) {
|
||||
return await createMetadataDocumentClient(
|
||||
ctx,
|
||||
clientId,
|
||||
cimdOptions,
|
||||
oauthOptions,
|
||||
);
|
||||
}
|
||||
|
||||
if (isStale(existing, refreshRate)) {
|
||||
return await refreshMetadataDocumentClient(
|
||||
ctx,
|
||||
clientId,
|
||||
existing,
|
||||
cimdOptions,
|
||||
oauthOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return existing;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import type { SchemaClient, Scope } from "@better-auth/oauth-provider";
|
||||
|
||||
/**
|
||||
* Options for the Client ID Metadata Document plugin.
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
|
||||
*/
|
||||
export interface CimdOptions {
|
||||
/**
|
||||
* How frequently to re-fetch a client's metadata document to pick up
|
||||
* changes from the client.
|
||||
*
|
||||
* Accepts a number of seconds or a duration string (e.g. `"60m"`,
|
||||
* `"1d"`).
|
||||
*
|
||||
* @default "60m"
|
||||
*/
|
||||
refreshRate?: number | string;
|
||||
/**
|
||||
* Metadata fields whose URL values must share the same origin as the
|
||||
* `client_id` URL. Prevents a client from claiming URIs on a different
|
||||
* domain.
|
||||
*
|
||||
* Pass an empty array to disable origin binding (not recommended for
|
||||
* production).
|
||||
*
|
||||
* @default ["redirect_uris", "post_logout_redirect_uris", "client_uri"]
|
||||
*/
|
||||
originBoundFields?: string[];
|
||||
/**
|
||||
* Pre-fetch gate called before a metadata document is requested. Return
|
||||
* `false` to reject the `client_id` URL.
|
||||
*
|
||||
* Use this for origin allowlists, per-host rate limiting, or integrating
|
||||
* with an external trust service. Hostname-based DNS defenses (beyond
|
||||
* the built-in IP-literal check) belong here, since the plugin is
|
||||
* runtime-agnostic and does not perform DNS resolution.
|
||||
*
|
||||
* @default always allow
|
||||
*/
|
||||
allowFetch?: (
|
||||
url: string,
|
||||
ctx: GenericEndpointContext,
|
||||
) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Called after a client is created from a metadata document for the
|
||||
* first time. Use this to assign trust levels, prefetch logos, or
|
||||
* perform other post-creation processing.
|
||||
*/
|
||||
onClientCreated?: (data: {
|
||||
client: SchemaClient<Scope[]>;
|
||||
metadata: Record<string, unknown>;
|
||||
ctx: GenericEndpointContext;
|
||||
}) => void | Promise<void>;
|
||||
/**
|
||||
* Called after a client is refreshed from a re-fetched metadata
|
||||
* document. Use this for change-detection logging or updating derived
|
||||
* fields.
|
||||
*/
|
||||
onClientRefreshed?: (data: {
|
||||
client: SchemaClient<Scope[]>;
|
||||
metadata: Record<string, unknown>;
|
||||
ctx: GenericEndpointContext;
|
||||
}) => void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isUrlClientId,
|
||||
validateCimdMetadata,
|
||||
validateClientIdUrl,
|
||||
} from "./validate-metadata-document";
|
||||
|
||||
function validMetadata(
|
||||
fetchUrl: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const origin = new URL(fetchUrl).origin;
|
||||
return {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: [`${origin}/callback`],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isUrlClientId", () => {
|
||||
it("accepts https:// URLs", () => {
|
||||
expect(isUrlClientId("https://example.com/meta")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts http://localhost URLs (dev mode)", () => {
|
||||
expect(isUrlClientId("http://localhost/meta")).toBe(true);
|
||||
expect(isUrlClientId("http://localhost:3000/meta")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts http://127.0.0.1 (dev mode)", () => {
|
||||
expect(isUrlClientId("http://127.0.0.1/meta")).toBe(true);
|
||||
expect(isUrlClientId("http://127.0.0.1:8080/meta")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts http://[::1] (dev mode)", () => {
|
||||
expect(isUrlClientId("http://[::1]/meta")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts localhost subdomains (dev mode)", () => {
|
||||
expect(isUrlClientId("http://app.localhost/meta")).toBe(true);
|
||||
expect(isUrlClientId("http://app.localhost:3000/meta")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http:// non-localhost URLs", () => {
|
||||
expect(isUrlClientId("http://example.com/meta")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-URL strings", () => {
|
||||
expect(isUrlClientId("my-client-id")).toBe(false);
|
||||
expect(isUrlClientId("ftp://example.com/meta")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty string", () => {
|
||||
expect(isUrlClientId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateClientIdUrl", () => {
|
||||
it("accepts valid https URL with path", () => {
|
||||
expect(
|
||||
validateClientIdUrl("https://example.com/client-metadata.json"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects URL without path", () => {
|
||||
expect(validateClientIdUrl("https://example.com")).not.toBeNull();
|
||||
expect(validateClientIdUrl("https://example.com/")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects URL with fragment", () => {
|
||||
const result = validateClientIdUrl("https://example.com/meta#frag");
|
||||
expect(result).toContain("fragment");
|
||||
});
|
||||
|
||||
it("rejects URL with dot segments", () => {
|
||||
expect(validateClientIdUrl("https://example.com/../meta.json")).toContain(
|
||||
"dot segments",
|
||||
);
|
||||
expect(validateClientIdUrl("https://example.com/./meta.json")).toContain(
|
||||
"dot segments",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects URL with credentials", () => {
|
||||
expect(validateClientIdUrl("https://user:pass@example.com/meta")).toContain(
|
||||
"credentials",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-https non-localhost", () => {
|
||||
const result = validateClientIdUrl("http://example.com/meta");
|
||||
expect(result).toContain("HTTPS");
|
||||
});
|
||||
|
||||
it("accepts http://localhost/meta (dev)", () => {
|
||||
expect(validateClientIdUrl("http://localhost/meta")).toBeNull();
|
||||
expect(validateClientIdUrl("http://localhost:8080/meta")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects private IP 10.0.0.1", () => {
|
||||
expect(validateClientIdUrl("https://10.0.0.1/meta")).toContain("private");
|
||||
});
|
||||
|
||||
it("rejects private IP 172.16.0.1", () => {
|
||||
expect(validateClientIdUrl("https://172.16.0.1/meta")).toContain("private");
|
||||
});
|
||||
|
||||
it("rejects private IP 192.168.1.1", () => {
|
||||
expect(validateClientIdUrl("https://192.168.1.1/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects link-local 169.254.169.254 (AWS metadata)", () => {
|
||||
expect(validateClientIdUrl("https://169.254.169.254/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects loopback IP 127.0.0.1 via https", () => {
|
||||
// 127.0.0.1 is localhost, so it should be allowed (localhost is exempt)
|
||||
// but only over http; https://127.0.0.1 is treated as localhost
|
||||
expect(validateClientIdUrl("https://127.0.0.1/meta")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts public IP like 8.8.8.8", () => {
|
||||
expect(validateClientIdUrl("https://8.8.8.8/meta")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects 6to4 anycast relay 192.88.99.1 (RFC 7526 deprecated)", () => {
|
||||
expect(validateClientIdUrl("https://192.88.99.1/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multicast addresses (RFC 5771, 224.0.0.0/4)", () => {
|
||||
expect(validateClientIdUrl("https://224.0.0.1/meta")).toContain("private");
|
||||
expect(validateClientIdUrl("https://239.255.255.250/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reserved/future-use and broadcast addresses (240.0.0.0/4)", () => {
|
||||
expect(validateClientIdUrl("https://240.0.0.1/meta")).toContain("private");
|
||||
expect(validateClientIdUrl("https://255.255.255.255/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects IPv4-mapped IPv6 targeting private IPs", () => {
|
||||
expect(
|
||||
validateClientIdUrl("https://[::ffff:169.254.169.254]/meta"),
|
||||
).toContain("private");
|
||||
expect(validateClientIdUrl("https://[::ffff:127.0.0.1]/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
expect(validateClientIdUrl("https://[::ffff:10.0.0.1]/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects cloud metadata hostname", () => {
|
||||
expect(
|
||||
validateClientIdUrl("https://metadata.google.internal/meta"),
|
||||
).toContain("private");
|
||||
});
|
||||
|
||||
it("accepts subdomain of localhost", () => {
|
||||
expect(validateClientIdUrl("http://app.localhost/meta")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCimdMetadata", () => {
|
||||
const fetchUrl = "https://example.com/client-metadata.json";
|
||||
|
||||
it("accepts valid metadata where client_id == fetchUrl", () => {
|
||||
const result = validateCimdMetadata(fetchUrl, validMetadata(fetchUrl));
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects when client_id != fetchUrl", () => {
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
...validMetadata(fetchUrl),
|
||||
client_id: "https://evil.com/client-metadata.json",
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("does not match");
|
||||
});
|
||||
|
||||
it("rejects when client_secret is present", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, { client_secret: "test-secret" }),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("client_secret");
|
||||
});
|
||||
|
||||
it("rejects when client_secret_expires_at is present", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, { client_secret_expires_at: 0 }),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("client_secret_expires_at");
|
||||
});
|
||||
|
||||
it("rejects symmetric auth method client_secret_post", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("symmetric");
|
||||
});
|
||||
|
||||
it("rejects symmetric auth method client_secret_basic", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("symmetric");
|
||||
});
|
||||
|
||||
it("rejects symmetric auth method client_secret_jwt", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
token_endpoint_auth_method: "client_secret_jwt",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("symmetric");
|
||||
});
|
||||
|
||||
it('accepts token_endpoint_auth_method: "none"', () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, { token_endpoint_auth_method: "none" }),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts token_endpoint_auth_method: "private_key_jwt" with jwks', () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
jwks: { keys: [{ kty: "EC" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts token_endpoint_auth_method: "private_key_jwt" with jwks_uri', () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
jwks_uri: "https://example.com/.well-known/jwks.json",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects private_key_jwt without jwks or jwks_uri", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("jwks");
|
||||
});
|
||||
|
||||
it("rejects unknown auth method", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
token_endpoint_auth_method: "custom_method",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("private_key_jwt");
|
||||
});
|
||||
|
||||
it("rejects missing redirect_uris", () => {
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("redirect_uris");
|
||||
});
|
||||
|
||||
it("rejects empty redirect_uris", () => {
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: [],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("redirect_uris");
|
||||
});
|
||||
|
||||
it("rejects non-HTTP redirect_uris", () => {
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: ["ftp://example.com/callback"],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("redirect_uris");
|
||||
});
|
||||
|
||||
it("rejects disallowed grant_types", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
grant_types: ["client_credentials"],
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("grant_types");
|
||||
});
|
||||
|
||||
it("accepts allowed grant_types", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts authorization_code alone", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
grant_types: ["authorization_code"],
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects disallowed response_types", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
response_types: ["token"],
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("response_types");
|
||||
});
|
||||
|
||||
it('accepts response_types: ["code"]', () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
response_types: ["code"],
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("allows localhost redirect_uris for local/native app flows", () => {
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: [
|
||||
"http://localhost:3000/callback",
|
||||
"http://127.0.0.1:3000/callback",
|
||||
],
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("validates origin-bound fields (redirect_uris origin must match client_id)", () => {
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: ["https://other-domain.com/callback"],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("same origin");
|
||||
});
|
||||
|
||||
it("respects custom originBoundFields parameter", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
{
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: ["https://example.com/callback"],
|
||||
custom_field: "https://evil.com/hook",
|
||||
},
|
||||
["custom_field"],
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("same origin");
|
||||
});
|
||||
|
||||
it("does not enforce origin on fields outside originBoundFields", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
{
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: ["https://example.com/callback"],
|
||||
client_uri: "https://other.com/about",
|
||||
},
|
||||
["redirect_uris"],
|
||||
);
|
||||
// client_uri is NOT in the custom originBoundFields, so origin mismatch is not checked.
|
||||
// However, client_uri still gets SSRF validation.
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-redirect origin-bound field with localhost URL", () => {
|
||||
// Localhost bypass only applies to redirect URI fields. A localhost
|
||||
// value on client_uri still fails origin binding.
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: ["https://example.com/callback"],
|
||||
client_uri: "http://localhost:3000/about",
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("same origin");
|
||||
});
|
||||
|
||||
it("accepts localhost URL on post_logout_redirect_uris", () => {
|
||||
// post_logout_redirect_uris also qualifies as a redirect URI field.
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: ["https://example.com/callback"],
|
||||
post_logout_redirect_uris: ["http://localhost:3000/logout"],
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("validates client_uri for SSRF (private address)", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
client_uri: "http://169.254.169.254/latest/meta-data/",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("private");
|
||||
});
|
||||
|
||||
it("validates logo_uri for SSRF (private address)", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
logo_uri: "http://10.0.0.1/internal-logo.png",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("private");
|
||||
});
|
||||
|
||||
it("returns warning for query string in fetchUrl", () => {
|
||||
const urlWithQuery = "https://example.com/client-metadata.json?v=1";
|
||||
const result = validateCimdMetadata(
|
||||
urlWithQuery,
|
||||
validMetadata(urlWithQuery),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings![0]).toContain("query string");
|
||||
});
|
||||
|
||||
it("rejects non-object metadata", () => {
|
||||
expect(validateCimdMetadata(fetchUrl, null).valid).toBe(false);
|
||||
expect(validateCimdMetadata(fetchUrl, "string").valid).toBe(false);
|
||||
expect(validateCimdMetadata(fetchUrl, 42).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects client_uri with non-HTTP scheme", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
client_uri: "ftp://example.com/about",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("client_uri");
|
||||
});
|
||||
|
||||
it("rejects logo_uri that is not a valid URL", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, { logo_uri: "not a url" }),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("logo_uri");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
// Pure validation for Client ID Metadata Documents.
|
||||
// Implements draft-ietf-oauth-client-id-metadata-document §3 and §4.1.
|
||||
// Zero side-effect imports: testable without building the monorepo.
|
||||
|
||||
const DOT_SEGMENT_RE = /\/\.\.?(?:\/|$|#|\?)/;
|
||||
|
||||
const PROHIBITED_FIELDS = new Set([
|
||||
"client_secret",
|
||||
"client_secret_expires_at",
|
||||
]);
|
||||
|
||||
const SYMMETRIC_AUTH_METHODS = new Set([
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
"client_secret_jwt",
|
||||
]);
|
||||
|
||||
const ALLOWED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]);
|
||||
|
||||
const ALLOWED_RESPONSE_TYPES = new Set(["code"]);
|
||||
|
||||
export interface ClientIdMetadataDocumentResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/** Hostnames that are considered "localhost" for development flows. */
|
||||
export function isLocalhost(hostname: string): boolean {
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "[::1]" ||
|
||||
hostname === "::1" ||
|
||||
hostname.endsWith(".localhost")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a dotted-decimal IPv4 address is private, reserved, or
|
||||
* otherwise non-routable for a public SSRF target. Covers the subset of
|
||||
* RFC 6890 special-purpose ranges that an adversarial `client_id` URL
|
||||
* could point at to reach internal infrastructure or disrupt fetches.
|
||||
*/
|
||||
function isPrivateIpv4(host: string): boolean {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p))) {
|
||||
return false;
|
||||
}
|
||||
const a = Number(parts[0]);
|
||||
const b = Number(parts[1]);
|
||||
const c = Number(parts[2]);
|
||||
return (
|
||||
// Loopback (127.0.0.0/8), private (RFC 1918), "this network"
|
||||
// (0.0.0.0/8), link-local (169.254.0.0/16), shared address space
|
||||
// (100.64.0.0/10).
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
a === 0 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
// Benchmarking (RFC 2544): 198.18.0.0/15.
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
// Documentation (RFC 5737).
|
||||
(a === 192 && b === 0 && c === 2) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
// 6to4 anycast relay (RFC 7526 deprecated): 192.88.99.0/24.
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
// Multicast (RFC 5771): 224.0.0.0/4.
|
||||
(a >= 224 && a <= 239) ||
|
||||
// Reserved / future use (RFC 1112 + broadcast 255.255.255.255):
|
||||
// 240.0.0.0/4.
|
||||
a >= 240
|
||||
);
|
||||
}
|
||||
|
||||
// Matches ::ffff:a.b.c.d (dotted-decimal, as written by humans)
|
||||
const V4_MAPPED_DOTTED_RE =
|
||||
/^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/;
|
||||
|
||||
// Matches hex-pair form (e.g. ::ffff:a9fe:a9fe), as normalized by the URL parser
|
||||
const V4_MAPPED_HEX_RE =
|
||||
/^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
|
||||
|
||||
/**
|
||||
* Convert two hex groups from an IPv4-mapped IPv6 address to dotted-decimal IPv4.
|
||||
* e.g. "a9fe" "a9fe" -> "169.254.169.254"
|
||||
*/
|
||||
function hexGroupsToIpv4(hi: string, lo: string): string {
|
||||
const h = Number.parseInt(hi, 16);
|
||||
const l = Number.parseInt(lo, 16);
|
||||
return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a hostname is private/reserved per RFC 6890.
|
||||
*
|
||||
* Handles bracketed IPv6 (as returned by URL.hostname), IPv4-mapped
|
||||
* IPv6 in both dotted-decimal and hex-normalized forms, and cloud
|
||||
* metadata hostnames. No DNS resolution, so it runs identically on
|
||||
* Node, Bun, Deno, and Workers.
|
||||
*/
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
const host =
|
||||
lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
||||
|
||||
if (host === "::1") {
|
||||
return true;
|
||||
}
|
||||
if (isPrivateIpv4(host)) {
|
||||
return true;
|
||||
}
|
||||
if (host.includes(":")) {
|
||||
const dottedMatch = host.match(V4_MAPPED_DOTTED_RE);
|
||||
if (dottedMatch && isPrivateIpv4(dottedMatch[1]!)) {
|
||||
return true;
|
||||
}
|
||||
const hexMatch = host.match(V4_MAPPED_HEX_RE);
|
||||
if (hexMatch) {
|
||||
const ipv4 = hexGroupsToIpv4(hexMatch[1]!, hexMatch[2]!);
|
||||
if (isPrivateIpv4(ipv4)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Link-local (fe80::/10)
|
||||
if (/^fe[89ab]/.test(host)) {
|
||||
return true;
|
||||
}
|
||||
// Unique-local (fc00::/7)
|
||||
if (host.startsWith("fc") || host.startsWith("fd")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (host === "metadata.google.internal") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect URL-formatted client_id (Client ID Metadata Document pattern).
|
||||
* HTTPS always accepted; HTTP accepted for localhost variants
|
||||
* (localhost, 127.0.0.1, [::1], *.localhost) for development.
|
||||
*/
|
||||
export function isUrlClientId(clientId: string): boolean {
|
||||
if (clientId.startsWith("https://")) {
|
||||
return true;
|
||||
}
|
||||
if (!clientId.startsWith("http://")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return isLocalhost(new URL(clientId).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a client_id URL per IETF draft §3.
|
||||
* Returns null on success, error string on failure.
|
||||
*/
|
||||
export function validateClientIdUrl(url: string): string | null {
|
||||
// §3: check the raw URL for dot segments before the URL class normalizes them
|
||||
if (DOT_SEGMENT_RE.test(url)) {
|
||||
return "client_id URL MUST NOT contain dot segments";
|
||||
}
|
||||
|
||||
// §3: MUST NOT contain fragments
|
||||
if (url.includes("#")) {
|
||||
return "client_id URL MUST NOT contain a fragment";
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return "client_id is not a valid URL";
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return "client_id URL must use HTTPS";
|
||||
}
|
||||
|
||||
if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) {
|
||||
return "client_id URL must use HTTPS (HTTP allowed only for localhost)";
|
||||
}
|
||||
|
||||
// §3: MUST NOT contain credentials
|
||||
if (parsed.username || parsed.password) {
|
||||
return "client_id URL MUST NOT contain credentials";
|
||||
}
|
||||
|
||||
// §3: MUST contain a path (not just scheme + authority)
|
||||
if (parsed.pathname === "/" || parsed.pathname === "") {
|
||||
return "client_id URL MUST contain a path component";
|
||||
}
|
||||
|
||||
// SSRF: block private/reserved hosts
|
||||
if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) {
|
||||
return "client_id URL must not resolve to a private or reserved address";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Warning: §3 SHOULD NOT have a query string. */
|
||||
function checkUrlQueryWarning(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.search) {
|
||||
return "client_id URL SHOULD NOT contain a query string (§3)";
|
||||
}
|
||||
} catch {
|
||||
// URL validation handled by validateClientIdUrl
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAbsoluteHttpUri(uri: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(uri);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a fetched Client ID Metadata Document per §4.1.
|
||||
*
|
||||
* @param fetchUrl - The URL the document was fetched from.
|
||||
* @param raw - The parsed JSON body of the response.
|
||||
* @param originBoundFields - Fields whose URL values must share the same origin as the `client_id` URL.
|
||||
*/
|
||||
export function validateCimdMetadata(
|
||||
fetchUrl: string,
|
||||
raw: unknown,
|
||||
originBoundFields?: string[],
|
||||
): ClientIdMetadataDocumentResult {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { valid: false, error: "metadata document is not a JSON object" };
|
||||
}
|
||||
|
||||
const doc = raw as Record<string, unknown>;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// §4.1: client_id MUST equal the fetch URL (simple string comparison)
|
||||
if (doc.client_id !== fetchUrl) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `client_id "${String(doc.client_id)}" does not match the metadata document URL`,
|
||||
};
|
||||
}
|
||||
|
||||
// §4.1: prohibited fields MUST NOT be present
|
||||
for (const field of PROHIBITED_FIELDS) {
|
||||
if (field in doc) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `metadata document MUST NOT contain "${field}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// §4.1: only non-secret auth methods are allowed
|
||||
const ALLOWED_AUTH_METHODS = new Set(["none", "private_key_jwt"]);
|
||||
if (
|
||||
doc.token_endpoint_auth_method !== undefined &&
|
||||
typeof doc.token_endpoint_auth_method !== "string"
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "token_endpoint_auth_method must be a string",
|
||||
};
|
||||
}
|
||||
if (typeof doc.token_endpoint_auth_method === "string") {
|
||||
if (SYMMETRIC_AUTH_METHODS.has(doc.token_endpoint_auth_method)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `symmetric auth method "${doc.token_endpoint_auth_method}" is prohibited for Client ID Metadata Document clients`,
|
||||
};
|
||||
}
|
||||
if (!ALLOWED_AUTH_METHODS.has(doc.token_endpoint_auth_method)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'token_endpoint_auth_method must be "none" or "private_key_jwt" for Client ID Metadata Document clients',
|
||||
};
|
||||
}
|
||||
if (
|
||||
doc.token_endpoint_auth_method === "private_key_jwt" &&
|
||||
!doc.jwks &&
|
||||
!doc.jwks_uri
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"private_key_jwt requires either jwks or jwks_uri in the metadata document",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// redirect_uris: required, non-empty array of absolute HTTP(S) URIs
|
||||
if (
|
||||
!Array.isArray(doc.redirect_uris) ||
|
||||
doc.redirect_uris.length === 0 ||
|
||||
!doc.redirect_uris.every(
|
||||
(uri: unknown) => typeof uri === "string" && isAbsoluteHttpUri(uri),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "redirect_uris must be a non-empty array of absolute HTTP(S) URIs",
|
||||
};
|
||||
}
|
||||
|
||||
// grant_types: must be a subset of allowed types
|
||||
if (
|
||||
doc.grant_types !== undefined &&
|
||||
!(
|
||||
Array.isArray(doc.grant_types) &&
|
||||
doc.grant_types.every(
|
||||
(g: unknown) => typeof g === "string" && ALLOWED_GRANT_TYPES.has(g),
|
||||
)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `grant_types must be a subset of [${[...ALLOWED_GRANT_TYPES].map((g) => `"${g}"`).join(", ")}]`,
|
||||
};
|
||||
}
|
||||
|
||||
// response_types: must be a subset of allowed types
|
||||
if (
|
||||
doc.response_types !== undefined &&
|
||||
!(
|
||||
Array.isArray(doc.response_types) &&
|
||||
doc.response_types.every(
|
||||
(r: unknown) => typeof r === "string" && ALLOWED_RESPONSE_TYPES.has(r),
|
||||
)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'response_types must be a subset of ["code"]',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate client_uri and logo_uri for SSRF if present
|
||||
for (const field of ["client_uri", "logo_uri"] as const) {
|
||||
if (doc[field] !== undefined && typeof doc[field] !== "string") {
|
||||
return { valid: false, error: `${field} must be a string` };
|
||||
}
|
||||
if (typeof doc[field] === "string") {
|
||||
try {
|
||||
const parsed = new URL(doc[field]);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { valid: false, error: `${field} must use HTTP(S)` };
|
||||
}
|
||||
if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field} must not point to a private or reserved address`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return { valid: false, error: `${field} is not a valid URL` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Origin-bound fields: values must share the same origin as the client_id URL
|
||||
const fieldsToCheck = originBoundFields ?? [
|
||||
"redirect_uris",
|
||||
"post_logout_redirect_uris",
|
||||
"client_uri",
|
||||
];
|
||||
|
||||
let clientIdOrigin: string;
|
||||
try {
|
||||
clientIdOrigin = new URL(fetchUrl).origin;
|
||||
} catch {
|
||||
return { valid: false, error: "client_id is not a valid URL" };
|
||||
}
|
||||
|
||||
for (const key of fieldsToCheck) {
|
||||
const value = doc[key];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
let values: string[];
|
||||
if (typeof value === "string") {
|
||||
values = [value];
|
||||
} else if (Array.isArray(value)) {
|
||||
if (!value.every((v): v is string => typeof v === "string")) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${key} must be a string or an array of strings`,
|
||||
};
|
||||
}
|
||||
values = value;
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${key} must be a string or an array of strings`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const val of values) {
|
||||
let uri: URL;
|
||||
try {
|
||||
uri = new URL(val);
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${key} contains an invalid URL: "${val}"`,
|
||||
};
|
||||
}
|
||||
|
||||
if (uri.protocol !== "https:" && uri.protocol !== "http:") {
|
||||
return {
|
||||
valid: false,
|
||||
error: `all values for ${key} must use HTTP(S)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Allow localhost redirect URIs for local/native app flows; the
|
||||
// localhost exception only applies to redirect URI fields, never
|
||||
// to client_uri or other origin-bound fields.
|
||||
const isRedirectField =
|
||||
key === "redirect_uris" || key === "post_logout_redirect_uris";
|
||||
const localhostAllowed = isRedirectField && isLocalhost(uri.hostname);
|
||||
if (uri.origin !== clientIdOrigin && !localhostAllowed) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${key} value "${val}" must have the same origin as client_id (${clientIdOrigin})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §3: SHOULD NOT have a query string
|
||||
const queryWarning = checkUrlQueryWarning(fetchUrl);
|
||||
if (queryWarning) {
|
||||
warnings.push(queryWarning);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
export const PACKAGE_VERSION = pkg.version;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
dts: { build: true, incremental: true },
|
||||
format: ["esm"],
|
||||
entry: ["./src/index.ts"],
|
||||
treeshake: true,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
@@ -6,4 +6,5 @@ export {
|
||||
oidcServerMetadata,
|
||||
} from "./metadata";
|
||||
export { getOAuthProviderState, oauthProvider } from "./oauth";
|
||||
export { checkOAuthClient, oauthToSchema } from "./register";
|
||||
export type * from "./types";
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,8 @@ import { userInfoEndpoint } from "./userinfo";
|
||||
import {
|
||||
deleteFromPrompt,
|
||||
getJwtPlugin,
|
||||
mergeDiscoveryMetadata,
|
||||
toClientDiscoveryArray,
|
||||
verifyOAuthQueryParams,
|
||||
} from "./utils";
|
||||
import { PACKAGE_VERSION } from "./version";
|
||||
@@ -333,11 +335,15 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
|
||||
scopes_supported:
|
||||
opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
||||
public_client_supported:
|
||||
opts.allowUnauthenticatedClientRegistration,
|
||||
opts.allowUnauthenticatedClientRegistration ||
|
||||
toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
||||
grant_types_supported: opts.grantTypes,
|
||||
jwt_disabled: opts.disableJwtPlugin,
|
||||
});
|
||||
return authMetadata;
|
||||
return {
|
||||
...authMetadata,
|
||||
...mergeDiscoveryMetadata(opts.clientDiscovery),
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -29,6 +29,53 @@ export type AuthorizePrompt =
|
||||
| "login consent"
|
||||
| "select_account consent";
|
||||
|
||||
/**
|
||||
* Describes how to resolve a `client_id` from an external source (a URL-based
|
||||
* metadata document, a federated registry, an attestation header, etc.) and
|
||||
* what fields that source contributes to discovery metadata.
|
||||
*
|
||||
* Plugins install one of these onto {@link OAuthOptions.clientDiscovery}.
|
||||
* The host walks the configured entries in order and returns the first
|
||||
* non-null `resolve()` result.
|
||||
*/
|
||||
export interface ClientDiscovery<
|
||||
Scopes extends readonly Scope[] = InternallySupportedScopes[],
|
||||
> {
|
||||
/**
|
||||
* Stable identifier used in error messages and diagnostics. Convention
|
||||
* is to match the plugin id (for example `"cimd"`).
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* Return `true` if this discovery handles the given `client_id`. Called
|
||||
* on every `getClient()` lookup for every configured discovery, so keep
|
||||
* it cheap and synchronous.
|
||||
*/
|
||||
matches: (clientId: string) => boolean;
|
||||
/**
|
||||
* Resolve a client when this discovery matches. Receives the existing DB
|
||||
* record (or `null`) so an implementation can decide between creating,
|
||||
* refreshing, or passing through to the database result.
|
||||
*
|
||||
* Return:
|
||||
* - a client record: `getClient()` returns it (creation / refresh / takeover).
|
||||
* - `null`: `getClient()` falls through to the next matching discovery
|
||||
* or to the database record (if any).
|
||||
*/
|
||||
resolve: (
|
||||
ctx: GenericEndpointContext,
|
||||
clientId: string,
|
||||
existing: SchemaClient<Scopes> | null,
|
||||
) => Awaitable<SchemaClient<Scopes> | null>;
|
||||
/**
|
||||
* Fields merged into `/.well-known/oauth-authorization-server` and
|
||||
* `/.well-known/openid-configuration` responses. Useful for advertising
|
||||
* RFC-registered discovery flags like
|
||||
* `client_id_metadata_document_supported`.
|
||||
*/
|
||||
discoveryMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OAuthOptions<
|
||||
Scopes extends readonly Scope[] = InternallySupportedScopes[],
|
||||
> {
|
||||
@@ -130,10 +177,13 @@ export interface OAuthOptions<
|
||||
/**
|
||||
* Allow unauthenticated dynamic client registration.
|
||||
*
|
||||
* Support for `allowUnauthenticatedClientRegistration` **will be deprecated**
|
||||
* when the MCP protocol standardizes unauthenticated dynamic client registration.
|
||||
* As of writing, both [Client ID Metadata Documents](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991)
|
||||
* and [`software_statement` and `jwks_uri`](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1032) are under debate.
|
||||
* When enabled, the `/oauth2/register` endpoint accepts requests
|
||||
* without a session, but only for public clients
|
||||
* (`token_endpoint_auth_method: "none"`).
|
||||
*
|
||||
* For verified client discovery (MCP), consider installing the
|
||||
* `@better-auth/cimd` plugin, which verifies client identity through
|
||||
* domain ownership via Client ID Metadata Documents.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
@@ -144,6 +194,21 @@ export interface OAuthOptions<
|
||||
* @default false
|
||||
*/
|
||||
allowDynamicClientRegistration?: boolean;
|
||||
/**
|
||||
* Discovery implementations consulted by `getClient()` when resolving
|
||||
* a `client_id`. Each entry decides whether it handles the `client_id`
|
||||
* via {@link ClientDiscovery.matches}, then creates, refreshes, or
|
||||
* passes on a client record. Entries run in order; the first one to
|
||||
* return a client wins.
|
||||
*
|
||||
* Each entry also contributes {@link ClientDiscovery.discoveryMetadata}
|
||||
* into the `/.well-known/oauth-authorization-server` and
|
||||
* `/.well-known/openid-configuration` responses.
|
||||
*
|
||||
* Plugins such as `@better-auth/cimd` install an entry here at init
|
||||
* time; users can also pass discovery implementations directly.
|
||||
*/
|
||||
clientDiscovery?: ClientDiscovery<Scopes> | ClientDiscovery<Scopes>[];
|
||||
/**
|
||||
* List of scopes for newly registered clients
|
||||
* if not requested.
|
||||
|
||||
@@ -179,6 +179,17 @@ export interface AuthServerMetadata {
|
||||
* @default true
|
||||
*/
|
||||
authorization_response_iss_parameter_supported?: boolean;
|
||||
/**
|
||||
* Whether the authorization server supports discovering clients via
|
||||
* [Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)
|
||||
* (an HTTPS URL as `client_id`).
|
||||
*
|
||||
* Set at runtime by the `@better-auth/cimd` plugin (or any other
|
||||
* `ClientDiscovery` that contributes this field via
|
||||
* {@link ClientDiscovery.discoveryMetadata}). oauth-provider never sets
|
||||
* it on its own.
|
||||
*/
|
||||
client_id_metadata_document_supported?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,7 +95,11 @@ export function isPrivateHostname(hostname: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateJwksUri(ctx: GenericEndpointContext, jwksUri: string): void {
|
||||
function validateJwksUri(
|
||||
ctx: GenericEndpointContext,
|
||||
jwksUri: string,
|
||||
clientIdUrlOrigin?: string,
|
||||
): void {
|
||||
const parsed = new URL(jwksUri);
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
@@ -110,6 +114,12 @@ function validateJwksUri(ctx: GenericEndpointContext, jwksUri: string): void {
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
// Trust a jwks_uri that shares origin with a URL-format client_id: the
|
||||
// discovery that installed the client has already verified the
|
||||
// client_id URL, and same-origin jwks_uri is part of that verification.
|
||||
if (clientIdUrlOrigin && parsed.origin === clientIdUrlOrigin) {
|
||||
return;
|
||||
}
|
||||
if (!ctx.context.isTrustedOrigin(parsed.href)) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "client jwks_uri is not trusted",
|
||||
@@ -118,6 +128,17 @@ function validateJwksUri(ctx: GenericEndpointContext, jwksUri: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function urlClientIdOrigin(clientId: string): string | undefined {
|
||||
if (!clientId.startsWith("https://") && !clientId.startsWith("http://")) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(clientId).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJwksFromUri(jwksUri: string): Promise<JSONWebKeySet> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), JWKS_FETCH_TIMEOUT_MS);
|
||||
@@ -155,7 +176,7 @@ async function fetchClientJwks(
|
||||
});
|
||||
}
|
||||
|
||||
validateJwksUri(ctx, client.jwksUri);
|
||||
validateJwksUri(ctx, client.jwksUri, urlClientIdOrigin(client.clientId));
|
||||
|
||||
const now = Date.now();
|
||||
const cached = jwksCache.get(client.jwksUri);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { jwt } from "better-auth/plugins";
|
||||
import { APIError } from "better-call";
|
||||
import type { oauthProvider } from "../oauth";
|
||||
import type {
|
||||
ClientDiscovery,
|
||||
OAuthOptions,
|
||||
Prompt,
|
||||
SchemaClient,
|
||||
@@ -165,11 +166,21 @@ export async function getClient(
|
||||
return Object.assign({}, trustedClient);
|
||||
}
|
||||
|
||||
const dbClient = await ctx.context.adapter.findOne<SchemaClient<Scope[]>>({
|
||||
let dbClient = await ctx.context.adapter.findOne<SchemaClient<Scope[]>>({
|
||||
model: options.schema?.oauthClient?.modelName ?? "oauthClient",
|
||||
where: [{ field: "clientId", value: clientId }],
|
||||
});
|
||||
|
||||
const discoveries = toClientDiscoveryArray(options.clientDiscovery);
|
||||
for (const discovery of discoveries) {
|
||||
if (!discovery.matches(clientId)) continue;
|
||||
const resolved = await discovery.resolve(ctx, clientId, dbClient);
|
||||
if (resolved) {
|
||||
dbClient = resolved;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dbClient && options.cachedTrustedClients?.has(clientId)) {
|
||||
cachedTrustedClients.set(clientId, Object.assign({}, dbClient));
|
||||
}
|
||||
@@ -177,6 +188,36 @@ export async function getClient(
|
||||
return dbClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the `clientDiscovery` option into an array. Accepts a single
|
||||
* {@link ClientDiscovery}, an array of them, or `undefined`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function toClientDiscoveryArray(
|
||||
discovery: OAuthOptions<Scope[]>["clientDiscovery"],
|
||||
): ClientDiscovery<Scope[]>[] {
|
||||
if (!discovery) return [];
|
||||
return Array.isArray(discovery) ? discovery : [discovery];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `discoveryMetadata` from every configured {@link ClientDiscovery}
|
||||
* into a single object. Entries are spread in order; later entries override
|
||||
* earlier ones on key collisions.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function mergeDiscoveryMetadata(
|
||||
discovery: OAuthOptions<Scope[]>["clientDiscovery"],
|
||||
): Record<string, unknown> {
|
||||
const discoveries = toClientDiscoveryArray(discovery);
|
||||
return discoveries.reduce<Record<string, unknown>>(
|
||||
(acc, d) => ({ ...acc, ...(d.discoveryMetadata ?? {}) }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default client secret hasher using SHA-256
|
||||
*
|
||||
|
||||
Generated
+34
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user