Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions docs/admin/client-id-metadata-documents.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Client ID Metadata Documents (CIMD)

**Client ID Metadata Documents** ([`draft-ietf-oauth-client-id-metadata-document`](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/), adopted by the IETF OAuth WG) let a piece of software identify itself as an OAuth client by **publishing a metadata document at an HTTPS URL** — and using that URL *as* its `client_id`. The authorization server fetches and validates the document on demand. There is no registration request, no client secret, and no stored client record: the client's **metadata and display identity** are anchored to the HTTPS origin hosting the document. The client itself remains a public PKCE client and performs no cryptographic client authentication in v1 — see [What's NOT in v1](#what-s-not-in-v1) for the `private_key_jwt` option under consideration for v2.
**Client ID Metadata Documents** ([`draft-ietf-oauth-client-id-metadata-document`](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/), adopted by the IETF OAuth WG) let a piece of software identify itself as an OAuth client by **publishing a metadata document at an HTTPS URL** — and using that URL *as* its `client_id`. The authorization server fetches and validates the document on demand. There is no registration request, no client secret, and no stored client record: the client's **metadata and display identity** are anchored to the HTTPS origin hosting the document. Most clients are public PKCE clients (`token_endpoint_auth_method: none`); a client that can hold a private key authenticates with `private_key_jwt` against the public keys its document points to — see [Confidential CIMD clients](#confidential-cimd-clients-private-key-jwt).

CIMD is the **MCP-preferred** client-onboarding path; both claude.ai and ChatGPT support it and fall back to [Dynamic Client Registration](./dynamic-client-registration) when a server doesn't advertise CIMD.

Expand Down Expand Up @@ -76,15 +76,15 @@ A document **without** `scope` — the normal case: Claude Code's document is on
| Field | Rule |
| --- | --- |
| `client_id` | Required. Must string-equal the URL the server dereferenced (RFC 3986 §6.2.1 exact match). |
| `redirect_uris` | At least one. Each must be HTTPS, OR `http://localhost`, `http://127.0.0.1`, `http://[::1]`. No fragments. Exact-match at `/connect/authorize` — except the **port of a loopback URI**: a native client takes an ephemeral port at request time, so a registered `http://localhost/callback` matches `http://localhost:40489/callback` ([RFC 8252 §7.3](https://www.rfc-editor.org/rfc/rfc8252#section-7.3)). Scheme, host and path still have to match. |
| `application_type` | Optional, `web` or `native`. A document with a loopback `http` redirect URI is treated as `native` whatever it says — only a native app can have such a URI. Claude Code, Cursor, VS Code and the MCP Inspector all omit the field and rely on this. |
| `token_endpoint_auth_method` | `none` or omitted. **v1 is public-only** — a `client_secret*` method or any `client_secret` field is rejected. |
| `grant_types` | Subset of `{authorization_code, refresh_token}`; must include `authorization_code`. |
| `response_types` | Subset of `{code}`. |
| `redirect_uris` | At least one usable. Usable means HTTPS, OR `http://localhost`, `http://127.0.0.1`, `http://[::1]`, without a fragment; other forms (private-use schemes such as `com.example.app:/cb`) are dropped from the client, not fatal. Exact-match at `/connect/authorize` — except the **port of a loopback URI**: a native client takes an ephemeral port at request time, so any port matches ([RFC 8252 §7.3](https://www.rfc-editor.org/rfc/rfc8252#section-7.3)), whether the document lists `http://localhost/callback` or `http://127.0.0.1:33418/` (as VS Code does). Scheme, host and path still have to match. |
| `application_type` | Optional, `web` or `native`. A document with a loopback `http` redirect URI is treated as `native` whatever it says — only a native app can have such a URI. Claude Code, Zed and goose omit the field and rely on this. |
| `token_endpoint_auth_method` | `none` (or omitted) — a public PKCE client; or `private_key_jwt` with exactly one of `jwks_uri` (https) or `jwks` — a confidential client. Shared-secret methods (`client_secret_basic`, `client_secret_post`, `client_secret_jwt`) and any `client_secret` field are rejected: a published document cannot keep a secret. |
| `grant_types` | Must include `authorization_code`. The document describes the client for every server, so grants Modgud does not offer (claude.ai lists `urn:ietf:params:oauth:grant-type:jwt-bearer`) are ignored; the client holds the intersection with `{authorization_code, refresh_token}`. |
| `response_types` | Must include `code`; other values are ignored. |
| `scope` | Optional, space-delimited. An **upper bound**: the client holds these scopes intersected with the realm's dynamic-client scopes. Omitted (as every static MCP-client document does), the client holds the whole set. |
| `client_name` | Optional. Used as the display name; the consent screen also shows the URL hostname regardless. |

A document that fails any rule is rejected and never cached; the authorize request fails as "unknown client".
A document that fails any rule is rejected and never cached; the authorize request fails as "unknown client". The rules reject only what Modgud cannot honour at all — values it merely does not offer are narrowed away, because the document describes the client for every authorization server. The test suite carries the live documents of claude.ai, Claude Code, VS Code, Zed and goose.

## SSRF hardening

Expand All @@ -105,9 +105,14 @@ A CIMD client always reaches the explicit consent screen on first authorize, wit

Like a DCR client, a CIMD client never skips this screen on a remembered authorization: every fresh authorize flow shows it again. The authorization itself is reused for the same user, client and scope set, so the token's `oi_au_id` stays stable across re-consents.

## What's NOT in v1
## Confidential CIMD clients (`private_key_jwt`)

- **`private_key_jwt`** — confidential CIMD clients (asymmetric client auth via a `jwks_uri` in the document). v1 is public PKCE only. Deferred to v2, which will also revoke on `jwks_uri` change.
ChatGPT's connector document, for one, declares `token_endpoint_auth_method: private_key_jwt` and a `jwks_uri`. Such a client is **confidential**: the code exchange and every refresh must carry a client assertion ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) signed with a key from its published set, or the token endpoint answers `invalid_client`. PKCE still applies.

- **Where the keys come from.** An inline `jwks` is read from the document. A `jwks_uri` is fetched with the same SSRF protection as the document (below), up to 64 KB, and cached per its `Cache-Control` (5 minutes to 24 hours).
- **Rotation.** When an assertion names a `kid` the cached set lacks, Modgud fetches the set again right away — at most once a minute, so made-up key ids cannot turn it into a load generator against the client's host. Tokens already issued stay valid; the next refresh is checked against the new set.
- **What counts as a usable key.** Public RSA or EC keys for signing (`use` absent or `sig`). Other keys in the set — encryption keys, key types Modgud does not verify with — are skipped. A set that contains **private key material** is refused outright.
- **Audience.** The assertion's `aud` must be the realm's **issuer** (as in discovery), not the token endpoint — [draft-ietf-oauth-rfc7523bis §4](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/), enforced since OpenIddict 7.

## Accepted risks

Expand Down
12 changes: 6 additions & 6 deletions docs/admin/dynamic-client-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ After these four steps, an agent that POSTs to `/connect/register` with a valid

| Field | Rule |
| --- | --- |
| `redirect_uris` | At least one. Each must be HTTPS, OR `http://localhost`, `http://127.0.0.1`, `http://[::1]`. No custom URI schemes (`com.example.app://`). No fragments. A loopback URI is matched **without regard to its port** at `/connect/authorize` — register `http://localhost/callback`, call back on whatever port you got ([RFC 8252 §7.3](https://www.rfc-editor.org/rfc/rfc8252#section-7.3)); scheme, host and path still have to match. |
| `redirect_uris` | At least one usable: HTTPS, OR `http://localhost`, `http://127.0.0.1`, `http://[::1]`, no fragment. Other forms (private-use schemes such as `com.example.app:/cb`) are dropped and the response echoes what was registered; none usable is `invalid_redirect_uri`. A loopback URI is matched **without regard to its port** at `/connect/authorize` ([RFC 8252 §7.3](https://www.rfc-editor.org/rfc/rfc8252#section-7.3)) — including one registered with a port (`http://127.0.0.1:49152/callback`, as Zed does): Modgud registers its port-less twin too, and the response lists both. Scheme, host and path still have to match. |
| `application_type` | Optional, `web` or `native` (OIDC DCR). A registration with a loopback `http` redirect URI is `native` whatever it declares, and the response says so. Anything but the two literal values is `invalid_client_metadata`. |
| `client_name` | Required. ≤ 80 chars. ASCII / Latin-1 only after NFKC normalisation. Must not match a substring on the realm's reserved-names list (case-insensitive). |
| `token_endpoint_auth_method` | Must be `none` (or omitted). Public PKCE only — no secret-storage. |
| `grant_types` | Subset of `{authorization_code, refresh_token}`. |
| `response_types` | Subset of `{code}`. No implicit / hybrid flows. |
| `client_name` | Optional (RFC 7591 §2). Truncated to 80 chars. A missing name, or one outside ASCII / Latin-1 after NFKC normalisation (the confusable-glyph defence), is not shown — the client is displayed under the host of its first https redirect URI, else "Unnamed application". A name matching a substring on the realm's reserved-names list (case-insensitive) is rejected. |
| `token_endpoint_auth_method` | `none` (default — public PKCE client), or `client_secret_basic` / `client_secret_post` (confidential client; the secret is generated and returned once in the response). `private_key_jwt` needs a registered JWKS and requires admin pre-registration. |
| `grant_types` | Must include `authorization_code`. Modgud registers the intersection with `{authorization_code, refresh_token}`; anything else a client lists (`client_credentials`, `jwt-bearer`, …) is dropped rather than rejected, and the response echoes what was registered ([RFC 7591 §3.2.1](https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1)). |
| `response_types` | Must include `code`; only `code` is registered. No implicit / hybrid flows. |
| `scope` | Optional, space-delimited. An upper bound intersected with the realm's dynamic-client scopes; omitted, the client gets the whole set. The response echoes what was registered. |

On success the endpoint returns `201 Created` with the assigned `client_id` per RFC 7591 §3.2.1. On rejection it returns `400 Bad Request` with `{ error, error_description }` per §3.2.2. Hitting the rate-limit returns `429`.
On success the endpoint returns `201 Created` with the assigned `client_id` per RFC 7591 §3.2.1. On rejection — a body that is not JSON or has a field of the wrong type included — it returns `400 Bad Request` with `{ error, error_description }` per §3.2.2. Hitting the rate-limit returns `429`.

## Consent screen for DCR clients

Expand Down
9 changes: 6 additions & 3 deletions docs/admin/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ revocation and PAR endpoints) in one of two ways:
(`JsonWebKeySet` in the admin API and the realm manifest): RSA or EC keys,
public parts only, each with a `kid`. The client then sends a JWT it signed
with the matching private key (header `typ: client-authentication+jwt`,
`iss` = `sub` = its `client_id`, `aud` = the token endpoint, `jti`, short
`exp`) as `client_assertion` with
`iss` = `sub` = its `client_id`, `aud` = the realm's **issuer** as published
in discovery — not the token endpoint, which is refused
([draft-ietf-oauth-rfc7523bis §4](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/)) —
`jti`, short `exp`) as `client_assertion` with
`client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
No shared secret leaves the client. Create a confidential client with a key
set and **no** secret to get a client that authenticates with assertions only;
Expand All @@ -79,7 +81,8 @@ revocation and PAR endpoints) in one of two ways:

Both may coexist. Service-account credentials (M2M clients) keep their own
secret lifecycle and do not take a key set; dynamic client registration does
not accept `private_key_jwt` either (see the OAuth API reference).
not accept `private_key_jwt` either (see the OAuth API reference) — a client
that publishes its keys can use a [Client ID Metadata Document](./client-id-metadata-documents#confidential-cimd-clients-private-key-jwt) instead.

::: tip Machine-to-machine? Link a Service Account
There is no separate "service" client type. For server-to-server flows with no
Expand Down
17 changes: 13 additions & 4 deletions docs/decisions/0008-cimd-client-id-metadata-documents.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CIMD (Client ID Metadata Documents): authorization-server design

**Status:** Accepted — shipped 2026-06-14 (branch `feat/cimd-client-id-metadata-documents`) · **Decided:** 2026-06-13
**Status:** Accepted — shipped 2026-06-14 (branch `feat/cimd-client-id-metadata-documents`) · **Decided:** 2026-06-13 · **Amended:** 2026-09-21 (`private_key_jwt`, narrowing policy — see [Amendment](#amendment-2026-09-21-real-client-interop))

Complements ADR-0001 (CIMD = preferred MCP client-registration path; DCR = fallback).

Expand All @@ -25,7 +25,7 @@ ADR-0001 chose CIMD as the preferred MCP client-registration path. With CIMD the

1. **Integrate via the application store.** `MartenApplicationStore.FindByClientIdAsync` detects a CIMD URL → fetch+validate+cache → returns a **synthesized, non-persisted `OAuthApplicationState`** (Public, RequireClientSecret=false; RedirectUris/Grants/Scopes from the doc; `AccessTokenType=Jwt`). Normal client_ids take the existing stored path; DCR untouched (fallback).
2. **No persisted client record (Option A).** The synthesized app uses a **deterministic Id = stable hash of the client_id URL** (SHA256→Guid), so all its authorizations/tokens share a consistent ApplicationId without any DB write.
3. **v1 = public only (`none` + PKCE).** Covers claude.ai / ChatGPT CIMD. `private_key_jwt` deferred to v2; advertise only `none` for CIMD.
3. **v1 = public only (`none` + PKCE).** Covers claude.ai / ChatGPT CIMD. `private_key_jwt` deferred to v2; advertise only `none` for CIMD. *(Superseded by the 2026-09-21 amendment: ChatGPT's document is `private_key_jwt`, and it is now supported.)*
4. **SSRF-hardened resolver (`CimdClientResolver`):** https-only; resolve DNS and **block by resolved IP** (private/loopback/link-local/unique-local/CGNAT/multicast/documentation) at connect time to defend DNS-rebinding; no redirects; ~5 s timeout; **5 KB** body cap; `Accept: application/json`. Validation: `client_id`==URL exact; auth_method==`none`; `redirect_uris` present + each https-or-loopback.
5. **Cache** (per fetched URL): respect `Cache-Control` with own min/max clamp (5 min–24 h); **never** cache error/invalid; re-fetch on expiry (refresh re-validates the live doc).
6. **Discovery handler** adds `client_id_metadata_document_supported: true` (analogous to `TokenEndpointAuthMethodsSupportedHandler`), gated on the realm toggle.
Expand Down Expand Up @@ -55,10 +55,19 @@ Per-client lifetime uses OpenIddict-native keys, not the `modgud:` ones. The tok

**Touch-points (code):** `Modgud.Infrastructure/OpenIddict/Cimd/` (`CimdClientId`, `CimdIpGuard`, `CimdMetadata`+parser, `CimdHttpMessageHandlerFactory` [SocketsHttpHandler.ConnectCallback SSRF guard], `CimdClientResolver`); `MartenApplicationStore.FindByClientIdAsync` (stored-first, then resolver); `CimdMetadataDocumentSupportedHandler` + registration in `OpenIddictExtensions`; the 5 CIMD-aware handler/endpoint edits above; `CimdSettings` on `RealmSettings` + DTOs + `RealmSettingsService` + the SPA realm-settings CIMD tab + consent-hostname display.

## Amendment 2026-09-21: real-client interop

The v1 rules were written against documents we composed ourselves. Three releases in a row then broke on the documents real clients publish (loopback port → beta.6, missing `scope` → beta.7, an extra grant type from claude.ai → this change). The live documents of claude.ai, Claude Code, VS Code, Zed, goose and ChatGPT — and the DCR bodies of VS Code, Zed and the MCP Inspector — are now test fixtures (`RealWorldClientMetadata`), and every rejection rule was re-checked against RFC 7591, RFC 8252 and draft-02.

1. **Narrow, don't reject.** A CIMD document describes the client for *every* authorization server; it is not an order placed with this one. Values Modgud does not offer are dropped — grant types (claude.ai's `jwt-bearer`, VS Code's `device_code`), extra response types, redirect URI forms it does not accept — as long as what remains is usable (`authorization_code`, `code`, one redirect URI). A rule rejects only what Modgud cannot honour at all. DCR follows the same policy under RFC 7591 §3.2.1 (the response echoes what was registered).
2. **Loopback ports, fully.** RFC 8252 §7.3 requires any port for a loopback redirect, also when the registered URI carries one (VS Code's `127.0.0.1:33418`, Zed's ephemeral DCR port). OpenIddict relaxes the port only against a port-less registered URI, so each ported loopback URI is registered with its port-less twin.
3. **`private_key_jwt` is supported** (replaces decision 3 and the v2 follow-up). The draft forbids only shared-secret methods. A document with `private_key_jwt` and exactly one of `jwks_uri` / `jwks` synthesizes a **confidential** client; `MartenApplicationStore.GetJsonWebKeySetAsync` asks `CimdClientResolver`, which fetches `jwks_uri` through the same SSRF-guarded client (64 KB cap), caches it per Cache-Control (5 min–24 h) and refetches immediately when an assertion names an unknown `kid`, at most once a minute. A published key set may carry keys Modgud cannot use (encryption keys, other key types) — skipped; private key material fails the set. The assertion's `aud` must be the issuer (draft-ietf-oauth-rfc7523bis §4, enforced by OpenIddict 7).
4. **No revocation on key change.** The v2 note planned "on jwks change → revoke". Rotation is routine for a key-publishing client, and every token request — refresh included — re-checks the assertion against the current set, so a withdrawn key stops working at the next fetch. Revoking on every rotation would log out every user of the client for no gain.

## Alternatives considered (and rejected)

- **Option B — thin persisted pointer minted on first use:** rejected after the spike; kept as the documented fallback if an impl flow ever needs `FindByIdAsync`-without-client_id. (Not needed.)
- **private_key_jwt in v1:** deferred.
- **private_key_jwt in v1:** deferred (shipped by the 2026-09-21 amendment).
- **Always-on (no opt-in):** rejected — new SSRF surface; gate per realm like DCR.

## Consequences
Expand All @@ -70,7 +79,7 @@ Per-client lifetime uses OpenIddict-native keys, not the `modgud:` ones. The tok

## Follow-up

- **v2:** `private_key_jwt` + `jwks_uri`; on jwks change → revoke.
- ~~**v2:** `private_key_jwt` + `jwks_uri`; on jwks change → revoke.~~ Done 2026-09-21, without revocation on rotation — see the amendment.
- **MCP `iss` (RFC 9207)** — fixed in PR #70 (`RealmAuthorizationResponseIssuerHandler`); prerequisite for clean MCP interop. See ADR-0002.

## References
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/oauth-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Authorization: Basic <base64(client_id:client_secret)> # confidential clients
# or, for a client with a registered JSON Web Key Set (private_key_jwt, RFC 7523):
# client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
# client_assertion=<JWT signed with the client's private key: header typ=client-authentication+jwt,
# iss=sub=client_id, aud=token endpoint, jti, exp ≤ 5 min>
# iss=sub=client_id, aud=issuer (not the token endpoint), jti, exp ≤ 5 min>

response_type=code
client_id=acme-web
Expand Down
Loading
Loading