Skip to content

Commit 8edbb82

Browse files
committed
Support OAuth Client ID Metadata Documents (CIMD)
Clients can use an HTTPS URL as their client_id; the server fetches and validates the metadata document from that URL per draft-ietf-oauth-client-id-metadata-document-00. Opt-in via the clientIdMetadataDocuments option. Documents are cached through the OAuthServerModel (saveClientIdMetadataDocument / getClientIdMetadataDocument) respecting Cache-Control headers. The draft MCP Authorization spec recommends CIMD and deprecates Dynamic Client Registration in its favor.
1 parent 65450bc commit 8edbb82

10 files changed

Lines changed: 670 additions & 12 deletions

File tree

README.md

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Based on the MCP SDK’s partial OAuth 2.1 Authorization Server implementation.
1212
- [Features](#features)
1313
- [OAuth client credentials](#oauth-client-credentials-machine-to-machine)
1414
- [Device authorization (RFC 8628)](#device-authorization-rfc-8628)
15+
- [Client ID Metadata Documents (CIMD)](#client-id-metadata-documents-cimd)
1516
- [Quick Start](#quick-start)
1617
- [API Reference](#api-reference)
1718
- [OAuthServer](#oauthserver)
@@ -28,19 +29,26 @@ npm install mcp-oauth-server@latest --save-exact
2829

2930
## Features
3031

31-
- **MCP Authorization Spec compliant**: Aligns with the [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
32-
- [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1)
33-
- Dynamic Client Registration [(RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591)
32+
- **MCP Authorization Spec compliant**: Aligns with the [MCP Authorization Spec](https://modelcontextprotocol.io/specification/draft/basic/authorization)
33+
- [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) with mandatory PKCE (`S256` only, [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636))
34+
- [Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00) - opt-in, see [CIMD](#client-id-metadata-documents-cimd)
35+
- Bearer token usage [(RFC 6750)](https://datatracker.ietf.org/doc/html/rfc6750) - `requireBearerAuth` with `WWW-Authenticate` challenges (`resource_metadata`, `scope`, `insufficient_scope`)
36+
- Resource Indicators [(RFC 8707)](https://datatracker.ietf.org/doc/html/rfc8707) with token audience validation
37+
- Dynamic Client Registration [(RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) - deprecated by the MCP spec in favor of [CIMD](#client-id-metadata-documents-cimd), kept for backwards compatibility
3438
- Token Revocation [(RFC 7009)](https://datatracker.ietf.org/doc/html/rfc7009)
3539
- Authorization Server Metadata [(RFC 8414)](https://datatracker.ietf.org/doc/html/rfc8414)
3640
- Protected Resource Metadata [(RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728)
3741
- Authorization Server Issuer Identification [(RFC 9207)](https://datatracker.ietf.org/doc/html/rfc9207)
3842
- Loopback redirect URIs with any port for native apps [(RFC 8252 §7.3)](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3)
39-
- **Grant types**: Configurable via `grantTypes` `authorization_code`, `refresh_token`, [`client_credentials`](#oauth-client-credentials-machine-to-machine), and [device authorization](https://datatracker.ietf.org/doc/html/rfc8628) (`urn:ietf:params:oauth:grant-type:device_code`)
43+
- **Grant types**: Configurable via `grantTypes` - `authorization_code`, `refresh_token`, [`client_credentials`](#oauth-client-credentials-machine-to-machine), and [device authorization](https://datatracker.ietf.org/doc/html/rfc8628) (`urn:ietf:params:oauth:grant-type:device_code`, RFC 8628)
4044
- **Compatibility**: Works with MCP clients that omit a `resource` indicator [(RFC 8707)](https://datatracker.ietf.org/doc/html/rfc8707) or requested scopes when needed (`strictResource`)
4145
- **Flexible storage**: In-memory model for development (`MemoryOAuthServerModel`) or your own `OAuthServerModel` for production
4246

43-
**Not supported:** Token introspection [(RFC 7662)](https://datatracker.ietf.org/doc/html/rfc7662) — validate access tokens via `OAuthServer.verifyAccessToken` (and `requireBearerAuth`) instead.
47+
**Not supported:**
48+
49+
- Token introspection [(RFC 7662)](https://datatracker.ietf.org/doc/html/rfc7662) - validate access tokens via `OAuthServer.verifyAccessToken` (and `requireBearerAuth`) instead.
50+
- `private_key_jwt` client authentication for CIMD clients - CIMD clients are treated as public clients (`token_endpoint_auth_method: 'none'`).
51+
- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) - RFC 8414 metadata satisfies the MCP spec's discovery requirement on its own.
4452

4553
## OAuth client credentials (machine-to-machine)
4654

@@ -65,7 +73,7 @@ const oauthServer = new OAuthServer({
6573

6674
`POST` to the token endpoint with `grant_type=client_credentials` and authenticate the client (for example `client_id` / `client_secret` per [RFC 6749 §4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)). MCP clients using `@modelcontextprotocol/client` can use `ClientCredentialsProvider` as described in the extension docs above.
6775

68-
Tokens minted for this grant typically have **no `userId`** on `AuthInfo` authorize by `clientId` and scopes where appropriate.
76+
Tokens minted for this grant typically have **no `userId`** on `AuthInfo` - authorize by `clientId` and scopes where appropriate.
6977

7078
## Device authorization (RFC 8628)
7179

@@ -87,14 +95,48 @@ const oauthServer = new OAuthServer({
8795
});
8896
```
8997

90-
3. Implement the device-related methods on `OAuthServerModel` (`saveDeviceAuthorization`, `getDeviceAuthorizationByDeviceCode`, `getDeviceAuthorizationByUserCode`, `deleteDeviceAuthorization`) see `MemoryOAuthServerModel` for a reference.
98+
3. Implement the device-related methods on `OAuthServerModel` (`saveDeviceAuthorization`, `getDeviceAuthorizationByDeviceCode`, `getDeviceAuthorizationByUserCode`, `deleteDeviceAuthorization`) - see `MemoryOAuthServerModel` for a reference.
9199

92100
The auth router exposes **`POST /device`** (under your AS base path) when the device grant and `deviceAuthorizationUrl` are configured. Metadata lists `device_authorization_endpoint` accordingly.
93101

94102
**Approving or denying a login**
95103

96104
Wire **`approveDeviceAuthorizationHandler`** and **`denyDeviceAuthorizationHandler`** on routes you choose; they accept `user_code` (and resolve the authenticated user via `getUser`) so the user can approve or reject the device login out-of-band.
97105

106+
## Client ID Metadata Documents (CIMD)
107+
108+
[Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00) let clients use an HTTPS URL as their `client_id`. The authorization server fetches a JSON metadata document from that URL (`client_id`, `client_name`, `redirect_uris`, ...) instead of requiring registration. The [draft MCP Authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#client-id-metadata-documents) recommends CIMD and deprecates Dynamic Client Registration in its favor.
109+
110+
Support is **opt-in** because it makes the server issue outbound HTTPS requests to client-supplied URLs:
111+
112+
```ts
113+
const oauthServer = new OAuthServer({
114+
// enable with defaults
115+
clientIdMetadataDocuments: true,
116+
117+
// or configure it
118+
clientIdMetadataDocuments: {
119+
// Trust policy: reject metadata URLs outside your allowlist
120+
validateClientIdUrl: (url) => trustedDomains.includes(url.hostname),
121+
defaultCacheTtlSeconds: 300,
122+
maxCacheTtlSeconds: 3600,
123+
fetchTimeoutMs: 5000,
124+
fetch: myCustomFetch, // defaults to the Node built-in fetch
125+
},
126+
// ...
127+
});
128+
```
129+
130+
When enabled, the server metadata advertises `client_id_metadata_document_supported: true` and `OAuthServer.getClient` resolves URL-formatted client ids by fetching and validating the document: the document's `client_id` must equal the URL exactly, and `client_name` and at least one `redirect_uris` entry are required.
131+
132+
Fetched documents are cached through your `OAuthServerModel` (`saveClientIdMetadataDocument` / `getClientIdMetadataDocument`, implemented by `MemoryOAuthServerModel` out of the box) respecting `Cache-Control` headers, so multiple server instances sharing a model share the cache. Enabling CIMD on a custom model requires implementing both methods.
133+
134+
**Security notes**
135+
136+
- CIMD clients are public clients - documents demanding any `token_endpoint_auth_method` other than `none` are rejected (`private_key_jwt` is not supported).
137+
- Non-HTTPS URLs, loopback/IP-literal hosts, redirects, and documents over 10 KB are always rejected. These checks do not cover DNS rebinding or internal hostnames - use `validateClientIdUrl` (or network egress filtering) if the server can reach internal services.
138+
- CIMD cannot prevent localhost redirect URI impersonation by itself; consent screens should display the redirect URI hostname to the user.
139+
98140
## Quick Start
99141

100142
A working MCP OAuth example with a memory-backed authorization server lives in [`./example`](example).
@@ -146,7 +188,8 @@ const oauthServer = new OAuthServer({
146188
- `strictResource`: (optional) Validate the RFC 8707 `resource` parameter on authorize requests. Default: `true`.
147189
- `modifyAuthorizationRedirectUrl`: (optional) Mutate the consent redirect URL (e.g. add client display hints as query parameters).
148190
- `errorHandler`: (optional) Hook for logging or handling errors inside OAuth flows.
149-
- `dynamicClientRegistration`: (optional) Enable RFC 7591 `/register`. Default: `true`. Construction fails if enabled and `model.registerClient` is missing.
191+
- `dynamicClientRegistration`: (optional) Enable RFC 7591 `/register`. Default: `true`. Construction fails if enabled and `model.registerClient` is missing. Note: the MCP spec deprecates Dynamic Client Registration in favor of [CIMD](#client-id-metadata-documents-cimd); keep it enabled for backwards compatibility with clients that do not support CIMD.
192+
- `clientIdMetadataDocuments`: (optional) Enable [Client ID Metadata Documents](#client-id-metadata-documents-cimd) - pass `true` or a `ClientIdMetadataDocumentOptions` object. Default: `false`.
150193
- `grantTypes`: (optional) Enabled grants. Default: `['authorization_code', 'refresh_token']`. Add `'client_credentials'` and/or `DEVICE_AUTHORIZATION_GRANT_TYPE` as needed.
151194
- `deviceAuthorizationUrl`: (optional) Page URL where the user enters the user code (RFC 8628). Required together with the device grant on `grantTypes`.
152195
- `deviceAuthorizationLifetime`: (optional) Device code lifetime in seconds. Default: `900`.
@@ -184,6 +227,7 @@ export class PostgresModel implements OAuthServerModel {
184227
- `registerClient`: (required if `dynamicClientRegistration` is true) Persist dynamic registration.
185228
- Authorization code grant: `saveAuthorizationCode`, `getAuthorizationCode`, `revokeAuthorizationCode` when `authorization_code` is enabled.
186229
- Device grant: `saveDeviceAuthorization`, `getDeviceAuthorizationByDeviceCode`, `getDeviceAuthorizationByUserCode`, `deleteDeviceAuthorization` when the device grant is enabled.
230+
- CIMD: `saveClientIdMetadataDocument`, `getClientIdMetadataDocument` when [`clientIdMetadataDocuments`](#client-id-metadata-documents-cimd) is enabled.
187231
- Tokens: `saveAccessToken`, `getAccessToken`, `revokeAccessToken`, `saveRefreshToken`, `getRefreshToken`, `revokeRefreshToken`.
188232

189233
### mcpAuthRouter
@@ -214,10 +258,10 @@ Endpoints (paths are relative to where you mount the router and to `baseUrl` / i
214258

215259
- `/.well-known/oauth-authorization-server` and path-specific protected-resource metadata (RFC 8414 / RFC 9728)
216260
- `/authorize` when `authorization_code` is in `grantTypes`
217-
- `/token` authorization code, refresh token, client credentials, and device code exchange (according to `grantTypes`)
261+
- `/token` - authorization code, refresh token, client credentials, and device code exchange (according to `grantTypes`)
218262
- `/device` when the device grant is enabled and `deviceAuthorizationUrl` is set
219263
- `/register` when `dynamicClientRegistration` is true
220-
- `/revoke` token revocation (RFC 7009)
264+
- `/revoke` - token revocation (RFC 7009)
221265

222266
Install at the application root (see [`src/router.ts`](src/router.ts)).
223267

example/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const mcpOAuthProvider = new OAuthServer({
1616
issuerUrl: new URL('http://localhost:3000/'),
1717
authorizationUrl: new URL('http://localhost:3000/consent'),
1818
scopesSupported: ['mcp:tools'],
19+
clientIdMetadataDocuments: true,
1920
modifyAuthorizationRedirectUrl: (url, client, params) => {
2021
// Include metadata in the query string we can display on the consent screen.
2122
// The site holding the consent screen could also query the backend for this data

src/MemoryOAuthServerModel.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { OAuthClientInformationFull } from './schemas.js';
22
import debug from 'debug';
33
import { OAuthServerModel } from './OAuthServerModel';
4-
import { AccessToken, RefreshToken, AuthorizationCode, DeviceAuthorization } from './types';
4+
import { AccessToken, RefreshToken, AuthorizationCode, DeviceAuthorization, ClientIdMetadataDocument } from './types';
55
import { normalizeDeviceUserCode } from './deviceFlow';
66

77
const log = debug('oauth:MemoryOAuthServerModel');
88

9+
const MAX_CLIENT_ID_METADATA_DOCUMENTS = 1000;
10+
911
export class MemoryOAuthServerModel implements OAuthServerModel {
1012
private accessTokens = new Map<string, AccessToken>();
1113
private refreshTokens = new Map<string, RefreshToken>();
1214
private clients = new Map<string, OAuthClientInformationFull>();
1315
private authorizationCodes = new Map<string, AuthorizationCode>();
1416
private deviceByDeviceCode = new Map<string, DeviceAuthorization>();
1517
private deviceCodeByUserCode = new Map<string, string>();
18+
private clientIdMetadataDocuments = new Map<string, ClientIdMetadataDocument>();
1619

1720
async saveAuthorizationCode(params: AuthorizationCode, _client?: OAuthClientInformationFull): Promise<void> {
1821
this.authorizationCodes.set(params.authorizationCode, params);
@@ -61,6 +64,17 @@ export class MemoryOAuthServerModel implements OAuthServerModel {
6164
return clientMetadata;
6265
}
6366

67+
async saveClientIdMetadataDocument(document: ClientIdMetadataDocument): Promise<void> {
68+
if (this.clientIdMetadataDocuments.size >= MAX_CLIENT_ID_METADATA_DOCUMENTS) {
69+
this.clientIdMetadataDocuments.delete(this.clientIdMetadataDocuments.keys().next().value!);
70+
}
71+
this.clientIdMetadataDocuments.set(document.client.client_id, document);
72+
}
73+
74+
async getClientIdMetadataDocument(clientId: string): Promise<ClientIdMetadataDocument | undefined> {
75+
return this.clientIdMetadataDocuments.get(clientId);
76+
}
77+
6478
async saveDeviceAuthorization(device: DeviceAuthorization): Promise<void> {
6579
const key = normalizeDeviceUserCode(device.userCode);
6680
this.deviceByDeviceCode.set(device.deviceCode, device);

src/OAuthServer.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
AccessDeniedError,
1717
AuthorizationPendingError,
1818
ExpiredTokenError,
19+
InvalidClientError,
1920
InvalidGrantError,
2021
InvalidRequestError,
2122
InvalidScopeError,
@@ -33,6 +34,7 @@ import { DEVICE_AUTHORIZATION_GRANT_TYPE, generateDeviceCode, generateDeviceUser
3334
import { OAuthServerModel } from './OAuthServerModel';
3435
import { MemoryOAuthServerModel } from './MemoryOAuthServerModel';
3536
import { validateChallenge } from './pkce';
37+
import { ClientIdMetadataDocumentFetcher, ClientIdMetadataDocumentOptions, isClientIdMetadataDocumentUrl } from './cimd';
3638

3739
const log = debug('oauth:OAuthServer');
3840
export const SUPPORTED_GRANT_TYPES = [
@@ -187,6 +189,20 @@ export interface OAuthServerOptions {
187189
* @default true
188190
*/
189191
dynamicClientRegistration?: boolean;
192+
193+
/**
194+
* Enable OAuth Client ID Metadata Documents (CIMD): clients use an HTTPS URL as their
195+
* `client_id` and this server fetches the client metadata from that URL.
196+
*
197+
* Enabling this makes the server issue outbound HTTPS requests to client-supplied URLs.
198+
* Loopback and IP-literal hosts are always rejected, but consider setting
199+
* {@link ClientIdMetadataDocumentOptions.validateClientIdUrl} (e.g. a domain allowlist)
200+
* if this server can reach internal services.
201+
*
202+
* @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00
203+
* @default false
204+
*/
205+
clientIdMetadataDocuments?: boolean | ClientIdMetadataDocumentOptions;
190206
}
191207

192208
/**
@@ -209,6 +225,7 @@ export class OAuthServer implements OAuthServerOptions, OAuthRegisteredClientsSt
209225
errorHandler: ErrorHandler;
210226
grantTypes: OAuthGrantType[];
211227
dynamicClientRegistration: boolean;
228+
clientIdMetadataDocumentFetcher?: ClientIdMetadataDocumentFetcher;
212229

213230
deviceAuthorizationUrl?: URL;
214231
deviceAuthorizationLifetime: number;
@@ -232,6 +249,11 @@ export class OAuthServer implements OAuthServerOptions, OAuthRegisteredClientsSt
232249
this.devicePollIntervalSeconds = options.devicePollIntervalSeconds ?? 5;
233250
this.grantTypes = options.grantTypes ?? DEFAULT_GRANT_TYPES;
234251
this.dynamicClientRegistration = options.dynamicClientRegistration ?? true;
252+
if (options.clientIdMetadataDocuments) {
253+
this.clientIdMetadataDocumentFetcher = new ClientIdMetadataDocumentFetcher(
254+
typeof options.clientIdMetadataDocuments === 'object' ? options.clientIdMetadataDocuments : {},
255+
);
256+
}
235257
this.validateGrantTypesAndModelCapabilities();
236258
}
237259

@@ -274,17 +296,62 @@ export class OAuthServer implements OAuthServerOptions, OAuthRegisteredClientsSt
274296
throw new Error('dynamic client registration is not supported by this authorization server');
275297
}
276298
}
299+
300+
if (this.clientIdMetadataDocumentFetcher) {
301+
if (!m.saveClientIdMetadataDocument || !m.getClientIdMetadataDocument) {
302+
throw new Error(
303+
'clientIdMetadataDocuments requires OAuthServerModel methods: saveClientIdMetadataDocument, getClientIdMetadataDocument',
304+
);
305+
}
306+
}
277307
}
278308

279309
async getClient(clientId: string) {
280310
try {
311+
if (this.clientIdMetadataDocumentFetcher && isClientIdMetadataDocumentUrl(clientId)) {
312+
const cached = await this.model.getClientIdMetadataDocument!(clientId);
313+
if (cached && cached.expiresAt > new Date()) {
314+
this.validateClientIdMetadataDocumentClient(cached.client);
315+
return cached.client;
316+
}
317+
318+
const document = await this.clientIdMetadataDocumentFetcher.fetchClient(clientId);
319+
this.validateClientIdMetadataDocumentClient(document.client);
320+
321+
// An expiresAt in the past means the response forbade caching (Cache-Control no-store/no-cache)
322+
if (document.expiresAt > new Date()) {
323+
await this.model.saveClientIdMetadataDocument!(document);
324+
}
325+
326+
return document.client;
327+
}
328+
281329
return await this.model.getClient!(clientId);
282330
} catch (error) {
283331
this.errorHandler('getClient', error, { clientId });
284332
throw error;
285333
}
286334
}
287335

336+
/**
337+
* Validates a client resolved from a Client ID Metadata Document against this server's
338+
* configuration. CIMD clients are public clients: they cannot hold a client_secret, and
339+
* private_key_jwt authentication is not supported by this server.
340+
*/
341+
private validateClientIdMetadataDocumentClient(client: OAuthClientInformationFull): void {
342+
if (client.token_endpoint_auth_method && client.token_endpoint_auth_method !== 'none') {
343+
throw new InvalidClientError(
344+
`Unsupported token_endpoint_auth_method for client_id metadata document client: ${client.token_endpoint_auth_method}`,
345+
);
346+
}
347+
348+
client.grant_types ||= ['authorization_code'];
349+
const unsupportedGrant = client.grant_types.find((grant) => !this.grantTypes.includes(grant as OAuthGrantType));
350+
if (unsupportedGrant) {
351+
throw new InvalidClientError(`Unsupported grant_type in client_id metadata document: ${unsupportedGrant}`);
352+
}
353+
}
354+
288355
async registerClient(clientMetadata: OAuthClientMetadata) {
289356
try {
290357
if (!this.model.registerClient) {

0 commit comments

Comments
 (0)