diff --git a/docs/toolhive/concepts/cimd.mdx b/docs/toolhive/concepts/cimd.mdx new file mode 100644 index 00000000..de3fcf17 --- /dev/null +++ b/docs/toolhive/concepts/cimd.mdx @@ -0,0 +1,183 @@ +--- +title: Client ID Metadata Documents (CIMD) +description: + How ToolHive uses Client ID Metadata Documents to eliminate OAuth app + pre-registration when connecting to remote MCP servers and accepting + connections from MCP clients. +--- + +Client ID Metadata Documents (CIMD) are defined in +[draft-ietf-oauth-client-id-metadata-document](https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-06.txt). +Instead of registering an OAuth application in advance to obtain an opaque +`client_id` string, a client presents an HTTPS URL as its `client_id`. The +authorization server fetches the document at that URL and uses it to resolve the +client's metadata on the fly. The +[MCP authorization specification (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +designates CIMD as the preferred client registration mechanism for clients and +servers that do not have a prior relationship. + +CIMD eliminates the manual app-registration step on both sides of an OAuth +interaction. A client that hosts a stable metadata document at a public HTTPS +URL can interact with any authorization server that supports CIMD without +pre-registration, and an authorization server that supports CIMD can accept new +clients without requiring them to call a registration endpoint first. + +## How ToolHive uses CIMD + +ToolHive supports CIMD in two independent directions: as a client connecting to +remote MCP servers, and as a server accepting connections from MCP clients such +as VS Code or Claude Code. + +### ToolHive as a CIMD client + +When you run `thv run` against a remote MCP server that requires OAuth +authentication, ToolHive discovers the server's authorization server and checks +whether it advertises CIMD support via the +`client_id_metadata_document_supported` field in its OAuth discovery metadata +(RFC 8414). If it does, ToolHive presents +`https://toolhive.dev/oauth/client-metadata.json` as its `client_id` instead of +calling the Dynamic Client Registration (DCR) endpoint. + +ToolHive follows this priority order when authenticating to a remote server: + +1. **Stored credentials** — if a previous session left a cached refresh token or + DCR `client_id`, those are used directly. +2. **CIMD** — if the remote authorization server advertises + `client_id_metadata_document_supported: true` and no credentials are stored, + ToolHive presents its CIMD URL. +3. **DCR** — if the remote authorization server does not support CIMD, ToolHive + falls back to RFC 7591 Dynamic Client Registration. + +If the remote authorization server advertises CIMD support but rejects the CIMD +`client_id` (for example, because the server is still rolling out support), +ToolHive automatically retries using DCR. + +:::tip + +Linear's MCP server (`https://mcp.linear.app/mcp`) advertises +`client_id_metadata_document_supported: true`. When you connect to it with +`thv run`, ToolHive uses its CIMD URL automatically and no OAuth app +pre-registration on the Linear side is required. + +::: + +### ToolHive as a CIMD server (embedded AS) + +The ToolHive embedded authorization server can accept HTTPS URLs as `client_id` +values from MCP clients. When CIMD is enabled on the embedded AS, a client such +as VS Code presents `https://vscode.dev/oauth/client-metadata.json` as its +`client_id`. The embedded AS fetches that document, validates it, and uses the +declared metadata (redirect URIs, grant types, scopes) to authorize the client +without any prior DCR call. + +When CIMD is disabled (the default), the embedded AS only accepts `client_id` +values that were issued through DCR. Enabling CIMD adds a second lookup path +alongside DCR; existing DCR-registered clients continue to work. + +:::note + +The embedded authorization server and its CIMD support are available only for +Kubernetes deployments using the ToolHive Operator. See +[Enable CIMD on the embedded authorization server](../guides-k8s/cimd-embedded-as.mdx) +for setup instructions and version requirements. + +::: + +## Two-layer architecture + +When the embedded AS is deployed and CIMD is enabled, the OAuth flow involves +two independent legs. The client's CIMD identity is used only within the +embedded AS. The embedded AS uses its own pre-configured identity when talking +to the upstream identity provider. + +```mermaid +sequenceDiagram + participant Client as VS Code
(CIMD client_id) + participant EmbeddedAS as ToolHive Embedded AS + participant IDP as Upstream IDP
(e.g. GitHub) + + Note over Client,EmbeddedAS: Leg 1 — client to embedded AS (CIMD) + Client->>EmbeddedAS: Authorization request
client_id = https://vscode.dev/oauth/client-metadata.json + EmbeddedAS->>EmbeddedAS: Fetch and validate CIMD document + EmbeddedAS-->>Client: Redirect to upstream IDP + + Note over EmbeddedAS,IDP: Leg 2 — embedded AS to upstream IDP (pre-configured identity) + Client->>IDP: Authenticate (via redirect) + IDP-->>EmbeddedAS: Authorization code + EmbeddedAS->>IDP: Exchange code for token
(AS's own client_id + secret) + IDP-->>EmbeddedAS: Upstream access token + EmbeddedAS-->>Client: Issue ToolHive JWT +``` + +The upstream IDP never sees the client's CIMD URL. The embedded AS uses its own +pre-configured `client_id` and `client_secret` (or DCR-obtained credentials) +when talking to the upstream IDP. The two OAuth legs are completely independent. + +## Security model + +### Document validation + +When the embedded AS receives a CIMD `client_id`, it fetches the document and +enforces the following rules: + +- The URL must use `https`. Loopback `http://localhost` is accepted only in + development and test environments. +- The `client_id` field inside the fetched document must exactly match the URL + used to fetch it. No normalization is applied — allowing normalization would + permit subtle spoofing attacks where a document at URL A claims the identity + of URL B. +- The document must declare at least one `redirect_uris` entry, and all redirect + URIs must pass strict validation (RFC 8252). +- Symmetric shared-secret `token_endpoint_auth_method` values + (`client_secret_post`, `client_secret_basic`, `client_secret_jwt`) are + forbidden. CIMD clients have no pre-shared secret by definition. +- `grant_types` must be a subset of `[authorization_code, refresh_token]` and + must include `authorization_code`. +- `response_types` must be a subset of `[code]`. +- If the embedded AS has a restricted `scopes_supported` list, the document's + declared scopes must be a subset of it. + +### SSRF protection + +The HTTP client used to fetch CIMD documents includes SSRF protection: + +- Keep-alive connections are disabled so the IP check runs on every request. +- DNS resolution is performed first and the resulting IP is checked against + private and special-use ranges (RFC 1918). Loopback addresses are allowed for + development use only. +- Redirects are not followed. Per the CIMD specification, the authorization + server must not automatically follow redirects when retrieving a client + metadata document. +- The fetch timeout is five seconds and the response body is capped at 10 KB. + +### Caching + +To avoid fetching the CIMD document on every request, the embedded AS caches +documents in an LRU cache. Two parameters control the cache: + +| Parameter | Default | Description | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `cacheMaxSize` | `256` | Maximum number of documents held in the cache. When full, the least-recently-used entry is evicted. | +| `cacheFallbackTtl` | `5m` | Fixed TTL applied to every cached entry. Cache-Control header parsing is not yet implemented; all entries use this value regardless. | + +If a fetch fails (network error, invalid document, policy violation), the +request is rejected and the failure is not cached. + +## Limitations + +- **Cache-Control header parsing is not yet implemented.** All cached CIMD + documents use the `cacheFallbackTtl` value regardless of the `Cache-Control` + header in the response. This means a document that declares a short max-age is + still cached for `cacheFallbackTtl`. +- **Kubernetes only for the embedded AS.** CIMD support on the embedded AS is + configured via the `MCPExternalAuthConfig` CRD and is not available for + standalone `thv run` deployments. + +## Related information + +- [Enable CIMD on the embedded authorization server](../guides-k8s/cimd-embedded-as.mdx) + — step-by-step Kubernetes setup +- [Embedded authorization server](./embedded-auth-server.mdx) — conceptual + overview of the embedded AS, including DCR and token forwarding +- [Authentication and authorization](./auth-framework.mdx) — the overall + authentication framework diff --git a/docs/toolhive/concepts/embedded-auth-server.mdx b/docs/toolhive/concepts/embedded-auth-server.mdx index a7da9740..6861e32c 100644 --- a/docs/toolhive/concepts/embedded-auth-server.mdx +++ b/docs/toolhive/concepts/embedded-auth-server.mdx @@ -166,6 +166,28 @@ DCR-registered client gains the ability to request these scopes, including public clients like Claude Code, Cursor, and VS Code, so privileged scopes do not belong in the baseline. +## CIMD support + +The embedded authorization server can accept HTTPS URLs as `client_id` values +from MCP clients using the Client ID Metadata Document (CIMD) protocol +([draft-ietf-oauth-client-id-metadata-document](https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-06.txt)). +When CIMD is enabled, a client such as VS Code presents +`https://vscode.dev/oauth/client-metadata.json` as its `client_id`. The embedded +AS fetches and validates that document instead of requiring the client to call +`/oauth/register` first. + +CIMD is disabled by default. When enabled, it adds a second client-lookup path +alongside DCR; DCR-registered clients continue to work without changes. You +configure CIMD through the `cimd` block on the `MCPExternalAuthConfig` resource. + +For the complete explanation of how CIMD works, the two-layer architecture that +separates client identity from upstream IDP identity, and the security model for +document fetching and caching, see +[Client ID Metadata Documents (CIMD)](./cimd.mdx). + +For setup instructions, see +[Enable CIMD on the embedded authorization server](../guides-k8s/cimd-embedded-as.mdx). + ## Session storage By default, session storage is in-memory. Upstream tokens are lost when pods diff --git a/docs/toolhive/guides-k8s/cimd-embedded-as.mdx b/docs/toolhive/guides-k8s/cimd-embedded-as.mdx new file mode 100644 index 00000000..53c2f217 --- /dev/null +++ b/docs/toolhive/guides-k8s/cimd-embedded-as.mdx @@ -0,0 +1,227 @@ +--- +title: Enable CIMD on the embedded authorization server +description: + How to configure Client ID Metadata Document (CIMD) support on the ToolHive + embedded authorization server so MCP clients such as VS Code can authenticate + without prior Dynamic Client Registration. +--- + +This guide shows you how to enable Client ID Metadata Document (CIMD) support on +the ToolHive embedded authorization server. With CIMD enabled, MCP clients that +host a public metadata document — such as VS Code +(`https://vscode.dev/oauth/client-metadata.json`) — can authenticate without +registering through the `/oauth/register` endpoint first. + +:::note + +CIMD support on the embedded AS requires Kubernetes and the ToolHive Operator. +It is not available for standalone `thv run` deployments. + +For the conceptual background on CIMD, including the two-layer architecture and +security model, see [Client ID Metadata Documents (CIMD)](../concepts/cimd.mdx). + +::: + +:::info[Availability] + +The `cimd` field on `MCPExternalAuthConfig` is available from the version of the +ToolHive Operator that includes this feature. Check the +[ToolHive release notes](https://github.com/stacklok/toolhive/releases) to +confirm the minimum required version before applying this configuration. + +::: + +## Prerequisites + +- Kubernetes cluster with the ToolHive Operator installed +- An existing `MCPExternalAuthConfig` resource with `type: embeddedAuthServer` + and at least one upstream provider configured (see + [Set up embedded authorization server authentication](./auth-k8s.mdx#set-up-embedded-authorization-server-authentication)) +- `kubectl` access to your cluster + +## Enable CIMD + +Add a `cimd` block to the `embeddedAuthServer` section of your +`MCPExternalAuthConfig` resource and set `enabled: true`. + +```yaml +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: my-embedded-auth-server + namespace: toolhive-system +spec: + type: embeddedAuthServer + embeddedAuthServer: + issuer: https://auth.example.com + upstreamProviders: + - name: github + type: oidc + oidcConfig: + issuerUrl: https://github.com + clientId: your-github-client-id + clientSecretRef: + name: github-client-secret + key: client_secret + cimd: + enabled: true +``` + +Apply the updated resource: + +```bash +kubectl apply -f my-embedded-auth-server.yaml +``` + +The embedded AS begins advertising `client_id_metadata_document_supported: true` +in its OAuth discovery documents (`/.well-known/oauth-authorization-server` and +`/.well-known/openid-configuration`). MCP clients that read discovery metadata +will use CIMD automatically. + +## Configure cache settings + +The embedded AS caches fetched CIMD documents to avoid an outbound HTTP request +on every authorization. Two fields control the cache: + +| Field | Default | Description | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------- | +| `cacheMaxSize` | `256` | Maximum number of CIMD documents held in the LRU cache. When the cache is full, the least-recently-used entry is evicted. | +| `cacheFallbackTtl` | `5m` | Fixed TTL for each cached document. Cache-Control header parsing is not yet implemented; all entries use this value. | + +Adjust these values based on the number of distinct MCP clients you expect and +how quickly you need metadata changes to take effect: + +```yaml +cimd: + enabled: true + cacheMaxSize: 512 + cacheFallbackTtl: '10m' +``` + +:::info + +Setting `cacheFallbackTtl` to a shorter value causes more frequent outbound +fetches to the client's metadata URL. Setting it to a longer value means a +client's metadata changes (for example, adding a new redirect URI) take longer +to propagate. Until Cache-Control header parsing is implemented, +`cacheFallbackTtl` is the only way to control cache lifetime. + +::: + +## Verify the configuration + +Confirm that the embedded AS is advertising CIMD support in its discovery +document. Port-forward to the proxy service and query the well-known endpoint: + +```bash +# Replace with the name of your MCPServer resource +kubectl port-forward -n toolhive-system svc/mcp--proxy 8080:8080 & +curl -s http://localhost:8080/.well-known/oauth-authorization-server | \ + python3 -m json.tool | grep client_id_metadata_document +``` + +You should see: + +```json +"client_id_metadata_document_supported": true +``` + +## Complete example + +The following example shows a full `MCPExternalAuthConfig` with CIMD enabled, +custom cache settings, and an OIDC upstream provider: + +```yaml +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: my-embedded-auth-server + namespace: toolhive-system +spec: + type: embeddedAuthServer + embeddedAuthServer: + issuer: https://auth.example.com + upstreamProviders: + - name: default + type: oidc + oidcConfig: + issuerUrl: https://accounts.google.com + clientId: your-google-client-id + clientSecretRef: + name: google-oauth-secret + key: client_secret + baselineClientScopes: + - openid + - offline_access + cimd: + enabled: true + cacheMaxSize: 256 + cacheFallbackTtl: '5m' +``` + +:::tip + +`baselineClientScopes` and CIMD work independently. Baseline scopes are unioned +into every client's registered scope set — this applies equally to +DCR-registered clients and to CIMD-resolved clients. Keep your baseline scopes +narrow to avoid granting unexpected permissions to dynamically resolved +third-party clients. + +::: + +## Next steps + +- [Client ID Metadata Documents (CIMD)](../concepts/cimd.mdx) — understand the + two-layer architecture and full security model +- [Configure Redis session storage](./redis-session-storage.mdx) — for + production deployments where token persistence across pod restarts is required +- [Set up rate limiting](./rate-limiting.mdx) — protect the embedded AS + authorization endpoint from abuse + +## Related information + +- [Client ID Metadata Documents (CIMD)](../concepts/cimd.mdx) — conceptual + overview, two-layer architecture, and security model +- [Embedded authorization server](../concepts/embedded-auth-server.mdx) — full + description of the embedded AS, DCR, token forwarding, and session storage +- [Set up embedded authorization server authentication](./auth-k8s.mdx#set-up-embedded-authorization-server-authentication) + — initial embedded AS setup before adding CIMD + +## Troubleshooting + +### VS Code is not using CIMD + +VS Code reads the OAuth discovery document when it first connects to an MCP +server. If `client_id_metadata_document_supported` is absent or `false`, VS Code +falls back to DCR. Check that: + +1. `cimd.enabled` is set to `true` in the `MCPExternalAuthConfig`. +2. The resource has been applied and the operator has reconciled it: + ```bash + kubectl describe mcpexternalauthconfig my-embedded-auth-server -n toolhive-system + ``` +3. The proxy runner pod has restarted after the configuration change if you + updated an existing resource. + +### CIMD authentication is failing with `invalid_client` + +The embedded AS rejects CIMD documents that fail validation. Common causes: + +- The `client_id` field inside the fetched document does not exactly match the + URL used to fetch it. The client's metadata document must be self-consistent. +- The document declares a `token_endpoint_auth_method` that uses a symmetric + shared secret (`client_secret_post`, `client_secret_basic`, + `client_secret_jwt`). CIMD clients cannot use shared-secret authentication + methods. +- The document declares `grant_types` that do not include `authorization_code`, + or declares `response_types` that include a value other than `code`. +- The `redirect_uris` field is empty or contains a URI that fails strict + validation. + +### The embedded AS cannot reach the client's metadata URL + +The embedded AS makes an outbound HTTPS request to fetch the CIMD document. If +your cluster has egress restrictions, ensure that the proxy runner pod has +network access to `https://vscode.dev` (or whichever host the client uses). The +fetch uses a five-second timeout; if the fetch times out, the authorization +request fails with an error. diff --git a/sidebars.ts b/sidebars.ts index bd77117e..895ea4fb 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -180,6 +180,7 @@ const sidebars: SidebarsConfig = { 'toolhive/guides-k8s/connect-clients', 'toolhive/guides-k8s/customize-tools', 'toolhive/guides-k8s/auth-k8s', + 'toolhive/guides-k8s/cimd-embedded-as', 'toolhive/guides-k8s/redis-session-storage', 'toolhive/guides-k8s/rate-limiting', 'toolhive/guides-k8s/token-exchange-k8s', @@ -288,6 +289,7 @@ const sidebars: SidebarsConfig = { 'toolhive/concepts/cedar-policies', 'toolhive/concepts/backend-auth', 'toolhive/concepts/embedded-auth-server', + 'toolhive/concepts/cimd', 'toolhive/concepts/vmcp', 'toolhive/concepts/skills', ],