diff --git a/docs/admin/client-id-metadata-documents.md b/docs/admin/client-id-metadata-documents.md index c08d8456..6ba72848 100644 --- a/docs/admin/client-id-metadata-documents.md +++ b/docs/admin/client-id-metadata-documents.md @@ -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. @@ -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 @@ -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 diff --git a/docs/admin/dynamic-client-registration.md b/docs/admin/dynamic-client-registration.md index 7f666603..5652e2df 100644 --- a/docs/admin/dynamic-client-registration.md +++ b/docs/admin/dynamic-client-registration.md @@ -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 diff --git a/docs/admin/oauth-clients.md b/docs/admin/oauth-clients.md index 579b6e1b..e98c4f7f 100644 --- a/docs/admin/oauth-clients.md +++ b/docs/admin/oauth-clients.md @@ -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; @@ -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 diff --git a/docs/decisions/0008-cimd-client-id-metadata-documents.md b/docs/decisions/0008-cimd-client-id-metadata-documents.md index d724b266..e07273e2 100644 --- a/docs/decisions/0008-cimd-client-id-metadata-documents.md +++ b/docs/decisions/0008-cimd-client-id-metadata-documents.md @@ -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). @@ -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. @@ -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 @@ -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 diff --git a/docs/reference/oauth-api.md b/docs/reference/oauth-api.md index 81edb596..cc4a9465 100644 --- a/docs/reference/oauth-api.md +++ b/docs/reference/oauth-api.md @@ -151,7 +151,7 @@ Authorization: Basic # 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= +# iss=sub=client_id, aud=issuer (not the token endpoint), jti, exp ≤ 5 min> response_type=code client_id=acme-web diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/CimdFullFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/CimdFullFlowTests.cs index ef90b38c..4260781c 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/CimdFullFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/CimdFullFlowTests.cs @@ -163,6 +163,146 @@ public async Task A_document_without_scope_holds_the_realms_dynamic_client_scope await AssertRefusedScopeAsync(declaring, $"openid {notOptedIn}", RedirectUri, "AllowDynamicRegistrationClients"); } + /// + /// A CIMD document describes the client for every authorization server, so it + /// may list grants Modgud does not offer. claude.ai's connector document lists + /// urn:ietf:params:oauth:grant-type:jwt-bearer next to + /// authorization_code and refresh_token; the whole document used + /// to be rejected for it, and the authorize request failed as an unknown client + /// (ID2052, reported from the field). The unoffered grant is dropped; the client + /// holds exactly what Modgud offers and the flow completes, refresh included. + /// + [Fact] + public async Task A_document_listing_an_unoffered_grant_still_resolves_like_claude_ai() + { + await SeedAsync(); + Factory.CimdDocuments[_clientIdUrl] = JsonSerializer.Serialize(new Dictionary + { + ["client_id"] = _clientIdUrl, + ["client_name"] = "Claude", + ["client_uri"] = "https://cimd-app.test", + ["redirect_uris"] = new[] { RedirectUri }, + ["grant_types"] = new[] { "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer" }, + ["response_types"] = new[] { "code" }, + ["token_endpoint_auth_method"] = "none", + }); + + var (accessToken, refreshToken) = await DriveCimdAuthCodeFlowAsync( + _clientIdUrl, $"openid offline_access {ScopeName}", AllowedAudience, redirectUri: RedirectUri); + Assert.Contains(AllowedAudience, new JwtSecurityTokenHandler().ReadJwtToken(accessToken).Audiences); + + Assert.False(string.IsNullOrEmpty(refreshToken), "refresh_token is offered and listed — it must be issued."); + var refreshed = await RefreshAsync(refreshToken, _clientIdUrl, AllowedAudience); + Assert.Contains(AllowedAudience, new JwtSecurityTokenHandler().ReadJwtToken(refreshed).Audiences); + } + + /// + /// VS Code's document names its loopback redirect WITH a port + /// (http://127.0.0.1:33418/) and falls back to another one when that + /// port is taken. RFC 8252 §7.3: the server MUST allow any port for a loopback + /// redirect — the synthesized client carries the port-less twin so OpenIddict + /// does. The document also lists the device_code grant, which is dropped. + /// + [Fact] + public async Task A_document_with_a_ported_loopback_uri_accepts_any_port_like_vs_code() + { + await SeedAsync(); + Factory.CimdDocuments[_clientIdUrl] = JsonSerializer.Serialize(new Dictionary + { + ["client_id"] = _clientIdUrl, + ["client_name"] = "Visual Studio Code", + ["redirect_uris"] = new[] { "http://127.0.0.1:33418/", RedirectUri }, + ["grant_types"] = new[] { "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code" }, + ["response_types"] = new[] { "code" }, + ["token_endpoint_auth_method"] = "none", + ["application_type"] = "native", + }); + + Assert.StartsWith("/consent?ticket=", await DriveAuthorizeAsync(_clientIdUrl, Scope, "http://127.0.0.1:33418/")); + Assert.StartsWith("/consent?ticket=", await DriveAuthorizeAsync(_clientIdUrl, Scope, "http://127.0.0.1:58123/")); + Assert.DoesNotContain("/consent?ticket=", await DriveAuthorizeAsync(_clientIdUrl, Scope, "http://127.0.0.1:58123/other") ?? string.Empty); + + var (accessToken, _) = await DriveCimdAuthCodeFlowAsync( + _clientIdUrl, Scope, AllowedAudience, redirectUri: "http://127.0.0.1:58123/"); + Assert.Contains(AllowedAudience, new JwtSecurityTokenHandler().ReadJwtToken(accessToken).Audiences); + } + + /// + /// ChatGPT's connector document authenticates with private_key_jwt and + /// publishes its keys at a jwks_uri — the one real CIMD client that is not + /// public, and the draft allows exactly that (only shared secrets are forbidden). + /// The synthesized client is confidential: the code exchange and every refresh + /// need an assertion signed by a key from the published set. No assertion, or + /// one signed by a foreign key under the same kid, is invalid_client. When the + /// client rotates, the first assertion with the new kid refetches the set at + /// once; further unknown kids within a minute do not hit the client's host. + /// + [Fact] + public async Task A_private_key_jwt_document_authenticates_with_keys_from_its_jwks_uri_like_chatgpt() + { + await SeedAsync(); + var ct = TestContext.Current.CancellationToken; + using var keys = new TestJwks("chatgpt-1"); + using var rogue = new TestJwks("chatgpt-1"); // same kid, different key + var jwksUri = $"https://cimd-app.test/oauth/{Guid.NewGuid():N}/jwks.json"; + Factory.CimdDocuments[jwksUri] = keys.PublicJwks; + Factory.CimdDocuments[_clientIdUrl] = JsonSerializer.Serialize(new Dictionary + { + ["client_id"] = _clientIdUrl, + ["client_name"] = "ChatGPT", + ["redirect_uris"] = new[] { RedirectUri }, + ["token_endpoint_auth_method"] = "private_key_jwt", + ["token_endpoint_auth_methods_supported"] = new[] { "none", "private_key_jwt" }, + ["token_endpoint_auth_signing_alg"] = "RS256", + ["jwks_uri"] = jwksUri, + ["grant_types"] = new[] { "authorization_code", "refresh_token" }, + ["response_types"] = new[] { "code" }, + }); + var issuer = await IssuerAsync(); + var scope = $"openid offline_access {ScopeName}"; + + // Without an assertion the code exchange is refused: the client is confidential. + var anonymous = await DriveCimdFlowThroughToTokenAsync(_clientIdUrl, scope, AllowedAudience, [AllowedAudience]); + Assert.Contains("invalid_client", await anonymous.Content.ReadAsStringAsync(ct)); + + // A foreign key under the published kid is refused as well. + var forged = await DriveCimdFlowThroughToTokenAsync(_clientIdUrl, scope, AllowedAudience, [AllowedAudience], + clientAssertion: rogue.MintAssertion(_clientIdUrl, issuer)); + Assert.Contains("invalid_client", await forged.Content.ReadAsStringAsync(ct)); + + // Signed with the published key, the flow completes, refresh included. + var ok = await DriveCimdFlowThroughToTokenAsync(_clientIdUrl, scope, AllowedAudience, [AllowedAudience], + clientAssertion: keys.MintAssertion(_clientIdUrl, issuer)); + var okBody = await ok.Content.ReadAsStringAsync(ct); + Assert.True(ok.IsSuccessStatusCode, okBody); + string refreshToken; + using (var doc = JsonDocument.Parse(okBody)) + { + Assert.Contains(AllowedAudience, new JwtSecurityTokenHandler().ReadJwtToken(doc.RootElement.GetProperty("access_token").GetString()).Audiences); + refreshToken = doc.RootElement.GetProperty("refresh_token").GetString()!; + } + var unauthenticatedRefresh = await RefreshResponseAsync(refreshToken, _clientIdUrl, AllowedAudience, clientAssertion: null); + Assert.Contains("invalid_client", await unauthenticatedRefresh.Content.ReadAsStringAsync(ct)); + var refreshed = await RefreshResponseAsync(refreshToken, _clientIdUrl, AllowedAudience, keys.MintAssertion(_clientIdUrl, issuer)); + Assert.True(refreshed.IsSuccessStatusCode, await refreshed.Content.ReadAsStringAsync(ct)); + var fetchesBeforeRotation = Factory.CimdFetchCounts[jwksUri]; + + // The client rotates to a new key under a new kid: picked up on first use. + using var rotatedKeys = new TestJwks("chatgpt-2"); + Factory.CimdDocuments[jwksUri] = rotatedKeys.PublicJwks; + var afterRotation = await DriveCimdFlowThroughToTokenAsync(_clientIdUrl, scope, AllowedAudience, [AllowedAudience], + clientAssertion: rotatedKeys.MintAssertion(_clientIdUrl, issuer)); + Assert.True(afterRotation.IsSuccessStatusCode, await afterRotation.Content.ReadAsStringAsync(ct)); + Assert.Equal(fetchesBeforeRotation + 1, Factory.CimdFetchCounts[jwksUri]); + + // Another unknown kid right after is refused without fetching again. + using var madeUp = new TestJwks("chatgpt-made-up"); + var probe = await DriveCimdFlowThroughToTokenAsync(_clientIdUrl, scope, AllowedAudience, [AllowedAudience], + clientAssertion: madeUp.MintAssertion(_clientIdUrl, issuer)); + Assert.Contains("invalid_client", await probe.Content.ReadAsStringAsync(ct)); + Assert.Equal(fetchesBeforeRotation + 1, Factory.CimdFetchCounts[jwksUri]); + } + private async Task AssertRefusedScopeAsync(string clientId, string scope, string redirectUri, string expectedDescriptionPart) { var resp = await DriveAuthorizeResponseAsync(clientId, scope, redirectUri); @@ -364,7 +504,7 @@ private async Task RefreshAsync(string refreshToken, string clientId, st private async Task DriveCimdFlowThroughToTokenAsync( string clientId, string scope, string authorizeResource, IReadOnlyList tokenResources, - string? redirectUri = null) + string? redirectUri = null, string? clientAssertion = null) { redirectUri ??= RedirectUri; var verifier = GeneratePkceVerifier(); @@ -407,12 +547,43 @@ private async Task DriveCimdFlowThroughToTokenAsync( new("code_verifier", verifier), }; foreach (var r in tokenResources) tokenForm.Add(new KeyValuePair("resource", r)); + AddClientAssertion(tokenForm, clientAssertion); return await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(tokenForm), TestContext.Current.CancellationToken); } /// Drives authorize → consent GET and returns (ticket, parsed /// ConsentModel) so a test can inspect the consent payload. + private static void AddClientAssertion(List> form, string? assertion) + { + if (assertion is null) return; + form.Add(new("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")); + form.Add(new("client_assertion", assertion)); + } + + /// A refresh request, raw — the private_key_jwt tests decide on the + /// response code rather than asserting success. + private async Task RefreshResponseAsync(string refreshToken, string clientId, string resource, string? clientAssertion) + { + var form = new List> + { + new("grant_type", "refresh_token"), + new("refresh_token", refreshToken), + new("client_id", clientId), + new("resource", resource), + }; + AddClientAssertion(form, clientAssertion); + return await Factory.CreateClient().PostAsync("/connect/token", new FormUrlEncodedContent(form), TestContext.Current.CancellationToken); + } + + /// The audience of a client assertion: the issuer identifier, nothing + /// else (draft-ietf-oauth-rfc7523bis §4; OpenIddict 7 refuses the token endpoint). + private async Task IssuerAsync() + { + using var doc = JsonDocument.Parse(await Factory.CreateClient().GetStringAsync("/.well-known/openid-configuration", TestContext.Current.CancellationToken)); + return doc.RootElement.GetProperty("issuer").GetString()!; + } + private async Task<(string Ticket, JsonElement Model)> DriveToConsentModelAsync(string clientId, string scope) { var challenge = GeneratePkceS256Challenge(GeneratePkceVerifier()); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/DcrFullFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/DcrFullFlowTests.cs index 36fa168f..434f8572 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/DcrFullFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/DcrFullFlowTests.cs @@ -139,6 +139,56 @@ public async Task Loopback_redirect_with_an_ephemeral_port_is_accepted_for_a_dcr /// is an upper bound intersected with that set, and the response echoes what /// was actually registered (§3.2.1). /// + /// + /// Zed registers the ephemeral port its callback server happened to get + /// (http://127.0.0.1:49152/callback) and keeps the client_id; the next + /// session binds another port. RFC 8252 §7.3 says the server MUST allow any port + /// for a loopback redirect, but OpenIddict relaxes the port only against a + /// registered URI without one — so the registration also carries the port-less + /// twin, and the response echoes both. Path and host still have to match. + /// + [Fact] + public async Task A_loopback_uri_registered_with_a_port_still_matches_the_next_port() + { + await SeedAsync(); + var ct = TestContext.Current.CancellationToken; + var http = Factory.CreateClient(); + + var reg = await http.PostAsync("/connect/register", new StringContent( + $$"""{"client_name":"Zed","redirect_uris":["http://127.0.0.1:49152/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none","scope":"openid {{ScopeName}}"}""", + System.Text.Encoding.UTF8, "application/json"), ct); + var regBody = await reg.Content.ReadAsStringAsync(ct); + Assert.True(reg.StatusCode == HttpStatusCode.Created, regBody); + string clientId; + using (var doc = JsonDocument.Parse(regBody)) + { + clientId = doc.RootElement.GetProperty("client_id").GetString()!; + var uris = doc.RootElement.GetProperty("redirect_uris").EnumerateArray().Select(u => u.GetString()).ToList(); + Assert.Equal(new[] { "http://127.0.0.1:49152/callback", "http://127.0.0.1/callback" }, uris); + } + + Assert.StartsWith("/consent?ticket=", await AuthorizeLocationAsync(clientId, "http://127.0.0.1:49152/callback")); + Assert.StartsWith("/consent?ticket=", await AuthorizeLocationAsync(clientId, "http://127.0.0.1:50731/callback")); + Assert.DoesNotContain("/consent?ticket=", await AuthorizeLocationAsync(clientId, "http://127.0.0.1:50731/other") ?? string.Empty); + } + + /// RFC 7591 §3.2.2: every registration error is a JSON object with + /// error — a body that is not JSON, or has a field of the wrong type, + /// included. Model binding used to answer those with an empty 400. + [Theory] + [InlineData("this is not json")] + [InlineData("""{"redirect_uris":"https://app.example.test/cb"}""")] + public async Task A_malformed_registration_body_gets_the_rfc_error_object(string body) + { + await SeedAsync(); + var ct = TestContext.Current.CancellationToken; + var resp = await Factory.CreateClient().PostAsync("/connect/register", + new StringContent(body, System.Text.Encoding.UTF8, "application/json"), ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + Assert.Equal("invalid_client_metadata", doc.RootElement.GetProperty("error").GetString()); + } + [Fact] public async Task Registration_without_scope_gets_the_realms_dynamic_client_scopes() { diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/PrivateKeyJwtClientAuthTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/PrivateKeyJwtClientAuthTests.cs index ab57d81e..0a3ae9c7 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/PrivateKeyJwtClientAuthTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/PrivateKeyJwtClientAuthTests.cs @@ -35,13 +35,25 @@ public async Task A_client_with_a_key_set_and_no_secret_authenticates_with_a_sig Assert.NotNull(created.Client.JsonWebKeySet); using var doc = await TokenAsync(clientId, MintAssertion(clientId, keys.SigningCredentials, await IssuerAsync())); - Assert.Equal("invalid_grant", doc.RootElement.GetProperty("error").GetString()); + AssertAuthenticatedButBogusGrant(doc); // No secret exists: client_secret_post is refused. using var withSecret = await TokenAsync(clientId, assertion: null, clientSecret: "anything"); Assert.Equal("invalid_client", withSecret.RootElement.GetProperty("error").GetString()); } + /// RFC 7523 once allowed the token endpoint as the audience; since the + /// 2025 audience-injection findings, draft-ietf-oauth-rfc7523bis §4 allows only the + /// issuer, and OpenIddict 7 enforces it. Pinned so an upgrade that changes it shows. + [Fact] + public async Task An_assertion_aimed_at_the_token_endpoint_instead_of_the_issuer_is_refused() + { + using var keys = new TestJwks("pkj-aud"); + var (clientId, _) = await CreateClientAsync(keys.PublicJwks, secret: null); + using var doc = await TokenAsync(clientId, MintAssertion(clientId, keys.SigningCredentials, (await DiscoveryAsync()).TokenEndpoint)); + Assert.Contains("'aud'", doc.RootElement.GetProperty("error_description").GetString()); + } + [Fact] public async Task An_assertion_signed_with_an_unregistered_key_is_refused() { @@ -64,7 +76,7 @@ public async Task A_secret_and_a_key_set_may_coexist_and_the_key_set_can_be_repl var issuer = await IssuerAsync(); using (var doc = await TokenAsync(clientId, MintAssertion(clientId, first.SigningCredentials, issuer))) - Assert.Equal("invalid_grant", doc.RootElement.GetProperty("error").GetString()); + AssertAuthenticatedButBogusGrant(doc); // Rotate to a new key: the old one stops working, the new one works. using var scope = Factory.Services.CreateScope(); @@ -74,7 +86,7 @@ public async Task A_secret_and_a_key_set_may_coexist_and_the_key_set_can_be_repl using (var doc = await TokenAsync(clientId, MintAssertion(clientId, first.SigningCredentials, issuer))) Assert.Equal("invalid_client", doc.RootElement.GetProperty("error").GetString()); using (var doc = await TokenAsync(clientId, MintAssertion(clientId, second.SigningCredentials, issuer))) - Assert.Equal("invalid_grant", doc.RootElement.GetProperty("error").GetString()); + AssertAuthenticatedButBogusGrant(doc); // Remove the key set (a secret remains, so the client keeps a credential). var removed = await admin.UpdateClientAsync(created.Client.Id, new UpdateOAuthClientDto { JsonWebKeySet = new Optional(null) }, ct); @@ -83,7 +95,7 @@ public async Task A_secret_and_a_key_set_may_coexist_and_the_key_set_can_be_repl using (var doc = await TokenAsync(clientId, MintAssertion(clientId, second.SigningCredentials, issuer))) Assert.Equal("invalid_client", doc.RootElement.GetProperty("error").GetString()); using (var doc = await TokenAsync(clientId, assertion: null, clientSecret: "pkj-shared-secret-123456")) - Assert.Equal("invalid_grant", doc.RootElement.GetProperty("error").GetString()); + AssertAuthenticatedButBogusGrant(doc); } [Fact] @@ -201,10 +213,24 @@ public async Task A_trusted_forwarder_may_authenticate_its_caller_context_with_a return (clientId, result.Value); } - private async Task IssuerAsync() + /// The one audience a client assertion may name: the issuer identifier + /// (draft-ietf-oauth-rfc7523bis §4). This used to return the token endpoint, which + /// OpenIddict 7 refuses with ID2173 — also invalid_grant, so the "authenticated" + /// assertions below passed without the assertion ever being accepted. + private async Task IssuerAsync() => (await DiscoveryAsync()).Issuer; + + private async Task<(string Issuer, string TokenEndpoint)> DiscoveryAsync() { using var doc = JsonDocument.Parse(await Factory.CreateClient().GetStringAsync("/.well-known/openid-configuration", TestContext.Current.CancellationToken)); - return doc.RootElement.GetProperty("token_endpoint").GetString()!; + return (doc.RootElement.GetProperty("issuer").GetString()!, doc.RootElement.GetProperty("token_endpoint").GetString()!); + } + + /// An authenticated client's refresh with the bogus token fails on the + /// token itself — not on anything about the assertion. + private static void AssertAuthenticatedButBogusGrant(JsonDocument doc) + { + Assert.Equal("invalid_grant", doc.RootElement.GetProperty("error").GetString()); + Assert.DoesNotContain("client assertion", doc.RootElement.GetProperty("error_description").GetString()); } private static string MintAssertion(string clientId, SigningCredentials credentials, string audience) diff --git a/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs b/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs index 21d4e606..508cdbd7 100644 --- a/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs +++ b/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs @@ -79,6 +79,10 @@ protected ModgudWebApplicationFactory() /// public System.Collections.Concurrent.ConcurrentDictionary CimdDocuments { get; } = new(); + /// How often the CIMD stub served each URL — lets a test prove a + /// cache or a refetch cooldown held. + public System.Collections.Concurrent.ConcurrentDictionary CimdFetchCounts { get; } = new(); + public JsonSerializerOptions JsonOptions { get; } = new JsonSerializerOptions { PropertyNamingPolicy = null, // Match API's behavior (no camelCase) @@ -208,7 +212,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // exercise the full resolve→synthesize→token flow without real // outbound HTTP. The last ConfigurePrimaryHttpMessageHandler wins. services.AddHttpClient(Modgud.Infrastructure.OpenIddict.Cimd.CimdClientResolver.HttpClientName) - .ConfigurePrimaryHttpMessageHandler(() => new StubCimdHandler(CimdDocuments)); + .ConfigurePrimaryHttpMessageHandler(() => new StubCimdHandler(CimdDocuments, CimdFetchCounts)); }); } @@ -234,12 +238,14 @@ private static HttpContext CreateFallbackContext(string tenantId) /// In-memory stand-in for the CIMD metadata endpoint. Returns the /// document registered for the exact request URL, or 404. private sealed class StubCimdHandler( - System.Collections.Concurrent.ConcurrentDictionary documents) : HttpMessageHandler + System.Collections.Concurrent.ConcurrentDictionary documents, + System.Collections.Concurrent.ConcurrentDictionary fetchCounts) : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var url = request.RequestUri?.ToString() ?? string.Empty; + fetchCounts.AddOrUpdate(url, 1, (_, n) => n + 1); if (documents.TryGetValue(url, out var json)) { return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) diff --git a/src/dotnet/Modgud.Api.Tests/Infrastructure/TestJwks.cs b/src/dotnet/Modgud.Api.Tests/Infrastructure/TestJwks.cs index fe4e6bd7..f9894a32 100644 --- a/src/dotnet/Modgud.Api.Tests/Infrastructure/TestJwks.cs +++ b/src/dotnet/Modgud.Api.Tests/Infrastructure/TestJwks.cs @@ -1,5 +1,7 @@ +using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Modgud.Api.Tests.Infrastructure; @@ -48,5 +50,25 @@ public TestJwks(string keyId) public string PrivateJwk { get; } public SigningCredentials SigningCredentials { get; } + /// A private_key_jwt client assertion (RFC 7523) for + /// , signed with this key, aimed at + /// (the token endpoint). + public string MintAssertion(string clientId, string audience) + { + var now = DateTime.UtcNow; + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = clientId, + Audience = audience, + Subject = new ClaimsIdentity([new Claim("sub", clientId)]), + IssuedAt = now, + NotBefore = now, + Expires = now.AddMinutes(2), + SigningCredentials = SigningCredentials, + TokenType = "client-authentication+jwt", + Claims = new Dictionary { ["jti"] = Guid.NewGuid().ToString("N") }, + }); + } + public void Dispose() => _rsa.Dispose(); } diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs index 2cc16150..4655f0a7 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using BuildingBlocks.EventDispatcher; using Marten; using Modgud.Application.Dcr; @@ -7,7 +8,6 @@ using Modgud.Domain.Realms; using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Observability; -using Microsoft.AspNetCore.Mvc; namespace Modgud.Api.Features.Auth.OAuth; @@ -47,7 +47,6 @@ public static WebApplication MapDcrRegistrationEndpoints(this WebApplication app } private static async Task RegisterAsync( - [FromBody] DcrRegistrationRequest? request, HttpContext httpContext, IRealmSettingsService realmSettingsService, Modgud.Authentication.Applications.IApplicationSettingsResolver settingsResolver, @@ -60,6 +59,24 @@ private static async Task RegisterAsync( DataEventDispatcher dispatcher, CancellationToken ct) { + // Bound by hand rather than [FromBody]: a malformed body must still get + // the RFC 7591 §3.2.2 error object, not the framework's empty 400. + DcrRegistrationRequest? request; + try + { + request = await httpContext.Request.ReadFromJsonAsync(ct); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + // JsonException: not JSON, or a field of the wrong type. + // InvalidOperationException: no JSON content type. + return Results.BadRequest(new DcrErrorResponse + { + Error = DcrErrorCodes.InvalidClientMetadata, + ErrorDescription = "Request body must be a JSON object of RFC 7591 client metadata.", + }); + } + if (request is null) { return Results.BadRequest(new DcrErrorResponse diff --git a/src/dotnet/Modgud.Application/Dcr/DcrRequest.cs b/src/dotnet/Modgud.Application/Dcr/DcrRequest.cs index f442dae3..7ed452e8 100644 --- a/src/dotnet/Modgud.Application/Dcr/DcrRequest.cs +++ b/src/dotnet/Modgud.Application/Dcr/DcrRequest.cs @@ -202,6 +202,8 @@ public enum DcrRejectionReason InvalidApplicationType, InvalidGrantType, InvalidResponseType, + // No longer produced — client_name is optional and an unusable one is + // replaced, not rejected. Kept so historical audit records still map. ClientNameMissing, ClientNameTooLong, ClientNameNonLatin1, diff --git a/src/dotnet/Modgud.Application/Dcr/IDcrRegistrationValidator.cs b/src/dotnet/Modgud.Application/Dcr/IDcrRegistrationValidator.cs index ee5a8b77..e53897e1 100644 --- a/src/dotnet/Modgud.Application/Dcr/IDcrRegistrationValidator.cs +++ b/src/dotnet/Modgud.Application/Dcr/IDcrRegistrationValidator.cs @@ -97,15 +97,18 @@ public DcrValidationResult Validate( DcrRejectionReason.MissingRedirectUri); } - foreach (var uri in request.RedirectUris) + // Forms Modgud does not accept are dropped (RFC 7591 §3.2.1 — the + // response echoes what was registered); at least one must survive. + // Loopback URIs with a port also get their port-less twin, so the next + // run's ephemeral port still matches (RFC 8252 §7.3). + var redirectUris = request.RedirectUris.Where(IsAllowedRedirectUri).ToList(); + if (redirectUris.Count == 0) { - if (!IsAllowedRedirectUri(uri)) - { - return Reject(DcrErrorCodes.InvalidRedirectUri, - $"redirect_uri '{uri}' is invalid. Allowed forms: https URIs, http://localhost, http://127.0.0.1, http://[::1]. Custom URI schemes (com.example.app://) are not supported in v1.", - DcrRejectionReason.InvalidRedirectUri); - } + return Reject(DcrErrorCodes.InvalidRedirectUri, + "no usable redirect_uri. Allowed forms: https URIs, http://localhost, http://127.0.0.1, http://[::1]. Private-use URI schemes (com.example.app:/) are not accepted for dynamically registered clients.", + DcrRejectionReason.InvalidRedirectUri); } + redirectUris = OAuthApplicationTypes.WithPortlessLoopbackTwins(redirectUris); // ───────── token_endpoint_auth_method ─────────────────────── // Default to "none" if omitted (RFC 7591 leaves the default @@ -135,63 +138,48 @@ public DcrValidationResult Validate( } // ───────── grant_types ────────────────────────────────────── - var grantTypes = request.GrantTypes is { Count: > 0 } + // RFC 7591 §3.2.1 lets the server replace requested metadata with + // what it will actually register; the response echoes the result. + // Grants Modgud does not offer are dropped rather than failing the + // registration — same policy as CIMD. authorization_code must survive. + var requestedGrants = request.GrantTypes is { Count: > 0 } ? request.GrantTypes : new List { "authorization_code" }; // RFC 7591 §2 default + var grantTypes = requestedGrants + .Where(AllowedGrantTypes.Contains) + .Distinct(StringComparer.Ordinal) + .ToList(); - foreach (var grant in grantTypes) - { - if (!AllowedGrantTypes.Contains(grant)) - { - return Reject(DcrErrorCodes.InvalidClientMetadata, - $"grant_type '{grant}' is not allowed. Allowed: {string.Join(", ", AllowedGrantTypes)}.", - DcrRejectionReason.InvalidGrantType); - } - } - - // ───────── response_types ─────────────────────────────────── - var responseTypes = request.ResponseTypes is { Count: > 0 } - ? request.ResponseTypes - : new List { "code" }; - - foreach (var rt in responseTypes) - { - if (!AllowedResponseTypes.Contains(rt)) - { - return Reject(DcrErrorCodes.InvalidClientMetadata, - $"response_type '{rt}' is not allowed. Only 'code' is supported (implicit/hybrid flows are out of scope).", - DcrRejectionReason.InvalidResponseType); - } - } - - // ───────── client_name ────────────────────────────────────── - var clientName = request.ClientName?.Trim(); - if (string.IsNullOrEmpty(clientName)) + if (!grantTypes.Contains("authorization_code")) { return Reject(DcrErrorCodes.InvalidClientMetadata, - "client_name is required.", - DcrRejectionReason.ClientNameMissing); + $"grant_types must include authorization_code. Supported: {string.Join(", ", AllowedGrantTypes)}.", + DcrRejectionReason.InvalidGrantType); } - if (clientName.Length > ClientNameMaxLength) + // ───────── response_types ─────────────────────────────────── + // Only 'code' is registered (the response always echoes ["code"]); + // extra values are ignored as long as 'code' is among them. + if (request.ResponseTypes is { Count: > 0 } responseTypes + && !responseTypes.Any(AllowedResponseTypes.Contains)) { return Reject(DcrErrorCodes.InvalidClientMetadata, - $"client_name must be {ClientNameMaxLength} characters or fewer.", - DcrRejectionReason.ClientNameTooLong); + "response_types must include 'code' (implicit/hybrid flows are out of scope).", + DcrRejectionReason.InvalidResponseType); } - // NFKC normalisation collapses compatibility-equivalent forms - // (e.g. zero-width-joiner + character → bare character) so - // visual lookalikes can't bypass the substring blocklist by - // inserting invisible glyphs. - var normalisedName = clientName.Normalize(NormalizationForm.FormKC); - - if (!IsLatin1Only(normalisedName)) - { - return Reject(DcrErrorCodes.InvalidClientMetadata, - "client_name must use ASCII or Latin-1 characters only (after NFKC normalisation).", - DcrRejectionReason.ClientNameNonLatin1); - } + // ───────── client_name ────────────────────────────────────── + // Optional in RFC 7591 §2, so its absence never fails a registration. + // A name the consent screen will not show as-is — missing, or outside + // Latin-1 after NFKC (the confusable defence: Cyrillic А for Latin A, + // fullwidth glyphs) — is replaced by the redirect host, which is where + // the tokens actually go. Over-long names are truncated. NFKC first so + // invisible glyphs can't slip past the reserved-name blocklist below. + var normalisedName = request.ClientName?.Trim().Normalize(NormalizationForm.FormKC); + if (string.IsNullOrEmpty(normalisedName) || !IsLatin1Only(normalisedName)) + normalisedName = FallbackDisplayName(redirectUris); + if (normalisedName.Length > ClientNameMaxLength) + normalisedName = normalisedName[..ClientNameMaxLength].TrimEnd(); if (settings.ReservedNames is { Length: > 0 }) { @@ -221,7 +209,7 @@ public DcrValidationResult Validate( ClientType = isConfidential ? OAuthClientTypes.Confidential : OAuthClientTypes.Public, ConsentType = OAuthConsentTypes.Explicit, // DCR clients always go through consent ApplicationType = request.ApplicationType, - RedirectUris = request.RedirectUris.ToList(), + RedirectUris = redirectUris, PostLogoutRedirectUris = new List(), AllowedGrantTypes = grantTypes.ToList(), Scopes = requestedScopes, @@ -272,6 +260,20 @@ private static bool IsAllowedRedirectUri(string raw) return false; } + /// Display name for a client that brought no usable + /// client_name: the host of its first https redirect URI (where + /// its tokens are delivered), else a neutral label — the consent screen + /// marks every DCR client unverified either way. + private static string FallbackDisplayName(IEnumerable redirectUris) + { + foreach (var raw in redirectUris) + { + if (Uri.TryCreate(raw, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) + return uri.Host; + } + return "Unnamed application"; + } + /// Reject anything outside the Latin-1 supplement range /// (U+0000–U+00FF) after NFKC. Cuts the bulk of confusable-attack /// surface (Cyrillic А vs Latin A, fullwidth glyphs, etc.) with no diff --git a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs index 9be3c79f..407fd93c 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs @@ -114,6 +114,16 @@ public static class OAuthApplicationPropertyKeys /// set . public const string CimdIsResolvedClient = "modgud:cimd:is_resolved_client"; + /// Set on a CIMD client that authenticates with private_key_jwt + /// and publishes its keys at a jwks_uri — the resolver fetches (and + /// caches) the set from there when OpenIddict asks for it. + public const string CimdJwksUri = "modgud:cimd:jwks_uri"; + + /// Set on a CIMD client that authenticates with private_key_jwt + /// and embeds its public key set in the document (jwks), already + /// filtered to usable signing keys. + public const string CimdJwks = "modgud:cimd:jwks"; + // ─────── Back-channel logout (ADR 0021) ────── /// Boolean, default true — logout tokens carry the sid claim diff --git a/src/dotnet/Modgud.Domain/OAuth/Common/OAuthConstants.cs b/src/dotnet/Modgud.Domain/OAuth/Common/OAuthConstants.cs index ee77635a..eddebb77 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Common/OAuthConstants.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Common/OAuthConstants.cs @@ -148,6 +148,30 @@ public static class OAuthApplicationTypes public static string? Effective(string? declared, IEnumerable? redirectUris) => redirectUris?.Any(IsLoopbackHttp) == true ? Native : declared; + /// + /// RFC 8252 §7.3: the server MUST allow any port for a loopback redirect URI — + /// including when the client registered one WITH a port. VS Code's metadata + /// names http://127.0.0.1:33418/ and Zed registers the ephemeral port it + /// happened to get; the next run comes back on another one. OpenIddict relaxes + /// the port only against a registered URI that carries none, so every loopback + /// URI with a port is registered alongside its port-less twin. Scheme, host and + /// path still match exactly; non-loopback URIs pass through untouched. + /// + public static List WithPortlessLoopbackTwins(IEnumerable redirectUris) + { + var result = new List(); + foreach (var raw in redirectUris) + { + if (!result.Contains(raw, StringComparer.Ordinal)) result.Add(raw); + if (!IsLoopbackHttp(raw) || !Uri.TryCreate(raw, UriKind.Absolute, out var uri) || uri.IsDefaultPort) + continue; + var twin = new UriBuilder(uri) { Port = -1 }.Uri.GetComponents( + UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped); + if (!result.Contains(twin, StringComparer.Ordinal)) result.Add(twin); + } + return result; + } + /// RFC 8252 §7.3 loopback redirect: http on localhost, /// 127.0.0.1 or [::1] — the one place plain http is a valid redirect. public static bool IsLoopbackHttp(string? raw) diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdClientResolver.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdClientResolver.cs index b98ab442..4ca7e1d5 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdClientResolver.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdClientResolver.cs @@ -13,7 +13,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; +using Microsoft.AspNetCore; using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Infrastructure.OpenIddict.Cimd; @@ -31,7 +34,9 @@ namespace Modgud.Infrastructure.OpenIddict.Cimd; /// means the store's first resolve in a request /// warms the cache for every later handler call in the same request. /// -/// v1 is public-only (token_endpoint_auth_method=none + PKCE); +/// A client is public (token_endpoint_auth_method=none + PKCE) or, +/// with private_key_jwt, confidential against the public keys it +/// publishes (); either way PKCE applies, /// the synthesized client gets JWT access tokens and is marked /// DcrIsDynamicallyRegistered so the existing DCR audience-containment /// + "unverified" consent treatment apply unchanged. @@ -44,6 +49,8 @@ public sealed class CimdClientResolver public const string HttpClientName = "Modgud.Cimd.MetadataFetcher"; private const int MaxBodyBytes = 5 * 1024; + private const int MaxJwksBytes = 64 * 1024; + private static readonly TimeSpan JwksRefetchCooldown = TimeSpan.FromMinutes(1); private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(1); private static readonly TimeSpan MinTtl = TimeSpan.FromMinutes(5); private static readonly TimeSpan MaxTtl = TimeSpan.FromHours(24); @@ -192,7 +199,7 @@ private async Task> LoadRequestableScopesAsync(IQuerySessi return (null, default); } - var json = await ReadBoundedAsync(response.Content, cancellationToken); + var json = await ReadBoundedAsync(response.Content, MaxBodyBytes, cancellationToken); if (json is null) { _logger.LogWarning("CIMD document for {ClientId} exceeds {Max} bytes", requestedClientId, MaxBodyBytes); @@ -223,10 +230,10 @@ private async Task> LoadRequestableScopesAsync(IQuerySessi } } - private static async Task ReadBoundedAsync(HttpContent content, CancellationToken cancellationToken) + private static async Task ReadBoundedAsync(HttpContent content, int maxBytes, CancellationToken cancellationToken) { await using var stream = await content.ReadAsStreamAsync(cancellationToken); - var buffer = new byte[MaxBodyBytes + 1]; + var buffer = new byte[maxBytes + 1]; var total = 0; int read; while (total < buffer.Length && @@ -235,8 +242,8 @@ private async Task> LoadRequestableScopesAsync(IQuerySessi total += read; } // We deliberately read one byte past the cap: if we filled the whole - // buffer the body is at least MaxBodyBytes+1 → over the limit. - if (total > MaxBodyBytes) return null; + // buffer the body is at least maxBytes+1 → over the limit. + if (total > maxBytes) return null; return Encoding.UTF8.GetString(buffer, 0, total); } @@ -263,7 +270,12 @@ private static OAuthApplicationState Synthesize(CachedCimd entry, IReadOnlyList< Id = DeterministicId(meta.ClientId), ClientId = meta.ClientId, DisplayName = DisplayNameFor(meta), - ClientType = OAuthClientTypes.Public, + // private_key_jwt makes it confidential: OpenIddict then demands the + // signed assertion at the token endpoint and validates it against the + // key set GetJsonWebKeySetAsync hands the store. + ClientType = meta.TokenEndpointAuthMethod == "private_key_jwt" + ? OAuthClientTypes.Confidential + : OAuthClientTypes.Public, ConsentType = OAuthConsentTypes.Explicit, // What the document declares, else what its redirect URIs imply: a loopback // http URI makes the client native, which is what lets OpenIddict accept the @@ -287,7 +299,7 @@ private static OAuthApplicationState Synthesize(CachedCimd entry, IReadOnlyList< [OpenIddictConstants.Settings.TokenLifetimes.RefreshToken] = entry.RefreshTokenLifetime.ToString("c", CultureInfo.InvariantCulture), }, - Properties = new Dictionary + Properties = new Dictionary(KeySourceProperties(meta)) { [OAuthApplicationPropertyKeys.Enabled] = JsonSerializer.SerializeToElement(true), [OAuthApplicationPropertyKeys.DcrIsDynamicallyRegistered] = JsonSerializer.SerializeToElement(true), @@ -301,6 +313,122 @@ private static OAuthApplicationState Synthesize(CachedCimd entry, IReadOnlyList< }; } + private static IEnumerable> KeySourceProperties(CimdMetadata meta) + { + if (meta.JwksUri is not null) + yield return new(OAuthApplicationPropertyKeys.CimdJwksUri, JsonSerializer.SerializeToElement(meta.JwksUri)); + if (meta.Jwks is not null) + yield return new(OAuthApplicationPropertyKeys.CimdJwks, JsonSerializer.SerializeToElement(meta.Jwks)); + } + + // ─── private_key_jwt key set ───────────────────────────────────────── + + /// + /// The public key set a private_key_jwt CIMD client authenticates + /// with — the store's GetJsonWebKeySetAsync lands here because a + /// synthesized client has no security record in the database. Inline sets + /// come straight off the client; a jwks_uri is fetched through the + /// same SSRF-guarded client as the document, cached per its Cache-Control + /// (5 min – 24 h), and fetched again when an assertion names a kid the + /// cached set lacks — the client has rotated — at most once a minute. + /// Errors are never cached; a set that cannot be had reads as no keys, and + /// OpenIddict refuses the assertion (invalid_client). + /// + public async Task GetJsonWebKeySetAsync(OAuthApplicationState application, CancellationToken cancellationToken) + { + if (ReadStringProperty(application, OAuthApplicationPropertyKeys.CimdJwks) is { } inline) + return JsonWebKeySet.Create(inline); + if (ReadStringProperty(application, OAuthApplicationPropertyKeys.CimdJwksUri) is not { } jwksUri + || !Uri.TryCreate(jwksUri, UriKind.Absolute, out var uri)) + return null; + + var cacheKey = $"cimd:jwks:{jwksUri}"; + DateTimeOffset? kidRefetchAt = null; + if (_cache.TryGetValue(cacheKey, out var cached) && cached is not null) + { + // A kid the cached set lacks means the client rotated: fetch again at + // once, but at most once a minute, so a stream of made-up kids cannot + // turn Modgud into a load generator against the client's host. + var kid = AssertionKeyId(); + var rotated = kid is not null && !cached.Set.Keys.Any(k => k.Kid == kid); + var coolingDown = cached.KidRefetchAt is { } last && DateTimeOffset.UtcNow - last < JwksRefetchCooldown; + if (!rotated || coolingDown) + return cached.Set; + kidRefetchAt = DateTimeOffset.UtcNow; + } + + var (set, ttl) = await FetchJwksAsync(uri, cancellationToken); + if (set is null) return cached?.Set; + if (ttl > TimeSpan.Zero) + _cache.Set(cacheKey, new CachedJwks(set, kidRefetchAt), ttl); + return set; + } + + private async Task<(JsonWebKeySet? Set, TimeSpan Ttl)> FetchJwksAsync(Uri uri, CancellationToken cancellationToken) + { + try + { + var client = _httpClientFactory.CreateClient(HttpClientName); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("CIMD jwks_uri {JwksUri} returned {Status}", uri, (int)response.StatusCode); + return (null, default); + } + + var json = response.Content.Headers.ContentLength is > MaxJwksBytes + ? null + : await ReadBoundedAsync(response.Content, MaxJwksBytes, cancellationToken); + if (json is null) + { + _logger.LogWarning("CIMD jwks_uri {JwksUri} exceeds {Max} bytes", uri, MaxJwksBytes); + return (null, default); + } + + if (!CimdJwks.TryFilter(json, out var filtered, out var error)) + { + _logger.LogWarning("CIMD jwks_uri {JwksUri} rejected: {Reason}", uri, error); + return (null, default); + } + + return (JsonWebKeySet.Create(filtered!), ResolveTtl(response.Headers.CacheControl)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CIMD jwks_uri fetch failed for {JwksUri}", uri); + return (null, default); + } + } + + /// The kid in the header of the client assertion on the current + /// token request, if any — read without validation, only to notice rotation. + private string? AssertionKeyId() + { + var assertion = _httpContextAccessor.HttpContext?.GetOpenIddictServerRequest()?.ClientAssertion; + if (string.IsNullOrEmpty(assertion)) return null; + try + { + return new JsonWebToken(assertion).Kid; + } + catch (ArgumentException) + { + return null; // not a JWT — OpenIddict will refuse it on its own + } + } + + private static string? ReadStringProperty(OAuthApplicationState application, string key) => + application.Properties.TryGetValue(key, out var value) && value is JsonElement { ValueKind: JsonValueKind.String } element + ? element.GetString() + : null; + + private sealed record CachedJwks(JsonWebKeySet Set, DateTimeOffset? KidRefetchAt); + private static List BuildPermissions(CimdMetadata meta, IReadOnlyList requestableScopes) { var permissions = new List diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdJwks.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdJwks.cs new file mode 100644 index 00000000..d2f92708 --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdJwks.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Modgud.Infrastructure.OpenIddict.Cimd; + +/// +/// Reads the public key set a private_key_jwt CIMD client publishes — +/// inline (jwks) or at its jwks_uri. Unlike an admin-registered +/// set, this one is maintained by the client for every server it talks to, so +/// it may carry keys Modgud has no use for (encryption keys, key types it does +/// not verify with); those are skipped, not fatal. What does fail the set is +/// private key material — the draft forbids it, and its presence means the +/// publisher has leaked a key, so nothing signed by that set can be trusted. +/// +public static class CimdJwks +{ + private const int MaxKeys = 20; + + private static readonly string[] PrivateMembers = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"]; + + /// Filters (an RFC 7517 set) to the public + /// RSA/EC signing keys and returns them re-serialized, or sets + /// . + public static bool TryFilter(string json, out string? filtered, out string? error) + { + filtered = null; + error = null; + + JsonNode? root; + try + { + root = JsonNode.Parse(json.TrimStart((char)0xFEFF)); + } + catch (JsonException) + { + error = "key set is not valid JSON."; + return false; + } + + if (root is not JsonObject obj || obj["keys"] is not JsonArray keys) + { + error = "key set is not an object with a \"keys\" array (RFC 7517)."; + return false; + } + + var usable = new JsonArray(); + foreach (var node in keys) + { + if (node is not JsonObject key) continue; + if (PrivateMembers.Any(key.ContainsKey)) + { + error = "key set contains private key material."; + return false; + } + + var kty = (key["kty"] as JsonValue)?.TryGetValue(out var k) == true ? k : null; + var use = (key["use"] as JsonValue)?.TryGetValue(out var u) == true ? u : null; + if (use is not null && use != "sig") continue; + var complete = kty switch + { + "RSA" => key.ContainsKey("n") && key.ContainsKey("e"), + "EC" => key.ContainsKey("crv") && key.ContainsKey("x") && key.ContainsKey("y"), + _ => false, + }; + if (!complete) continue; + + usable.Add(key.DeepClone()); + if (usable.Count == MaxKeys) break; + } + + if (usable.Count == 0) + { + error = "key set has no usable signing key (public RSA or EC)."; + return false; + } + + filtered = new JsonObject { ["keys"] = usable }.ToJsonString(); + return true; + } +} diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdMetadata.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdMetadata.cs index 723f029c..3300b783 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdMetadata.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/Cimd/CimdMetadata.cs @@ -4,7 +4,7 @@ namespace Modgud.Infrastructure.OpenIddict.Cimd; /// -/// The validated, public-client subset of a CIMD metadata document +/// The validated subset of a CIMD metadata document /// (draft-ietf-oauth-client-id-metadata-document + RFC 7591). Only /// the fields Modgud needs to synthesize an OAuthApplicationState are /// retained; everything else in the document is ignored. @@ -21,10 +21,20 @@ public sealed record CimdMetadata /// null when omitted — most MCP clients omit it and rely on their loopback redirect /// URIs to say "native" (see OAuthApplicationTypes.Effective). public string? ApplicationType { get; init; } + + /// none (public, PKCE) or private_key_jwt (confidential, + /// authenticates with an assertion signed by a key in or + /// — exactly one of the two is set). + public string TokenEndpointAuthMethod { get; init; } = "none"; + + public string? JwksUri { get; init; } + + /// The inline key set, already filtered by . + public string? Jwks { get; init; } } /// Outcome of validating a fetched CIMD document against the -/// client_id URL and the v1 (public-only) policy. +/// client_id URL and Modgud's CIMD policy. public abstract record CimdValidationResult { public sealed record Valid(CimdMetadata Metadata) : CimdValidationResult; @@ -44,6 +54,7 @@ public sealed record Invalid(string Reason) : CimdValidationResult; public static class CimdMetadataParser { private const string AuthMethodNone = "none"; + private const string AuthMethodPrivateKeyJwt = "private_key_jwt"; private static readonly HashSet AllowedGrantTypes = new(StringComparer.Ordinal) { @@ -64,7 +75,9 @@ public static CimdValidationResult Parse(string json, string requestedClientId) JsonElement root; try { - using var doc = JsonDocument.Parse(json); + // A UTF-8 BOM survives the byte→string decode as U+FEFF, which + // JsonDocument refuses; static hosts do serve files with one. + using var doc = JsonDocument.Parse(json.TrimStart((char)0xFEFF)); root = doc.RootElement.Clone(); } catch (JsonException) @@ -81,49 +94,75 @@ public static CimdValidationResult Parse(string json, string requestedClientId) if (!string.Equals(docClientId, requestedClientId, StringComparison.Ordinal)) return Invalid("document client_id does not match the client_id URL."); - // ── public-only (v1): no shared-secret auth, no secret at rest ──── + // ── client authentication: none, or private_key_jwt with public keys ─ + // The draft forbids every shared-secret method and any client_secret in + // the document (a published document cannot keep a secret). A client + // that can hold a private key authenticates with private_key_jwt, its + // public keys at jwks_uri or inline in jwks — never both (RFC 7591 §2). if (root.TryGetProperty("client_secret", out _)) - return Invalid("document must not contain a client_secret (CIMD clients are public)."); - - if (TryGetString(root, "token_endpoint_auth_method", out var authMethod) && authMethod is not null - && !string.Equals(authMethod, AuthMethodNone, StringComparison.Ordinal)) + return Invalid("document must not contain a client_secret (a published document cannot keep one)."); + + var authMethod = TryGetString(root, "token_endpoint_auth_method", out var declaredAuth) && declaredAuth is not null + ? declaredAuth + : AuthMethodNone; + string? jwksUri = null; + string? jwks = null; + if (authMethod == AuthMethodPrivateKeyJwt) { - return Invalid($"token_endpoint_auth_method '{authMethod}' is not supported; CIMD v1 is public-only (none)."); + var hasUri = TryGetString(root, "jwks_uri", out var declaredJwksUri) && declaredJwksUri is not null; + var hasInline = root.TryGetProperty("jwks", out var inlineJwks) && inlineJwks.ValueKind == JsonValueKind.Object; + if (hasUri == hasInline) + return Invalid("private_key_jwt needs exactly one of jwks_uri or jwks."); + if (hasUri) + { + if (!IsAllowedJwksUri(declaredJwksUri!)) + return Invalid("jwks_uri must be an absolute https URL without userinfo or fragment."); + jwksUri = declaredJwksUri; + } + else if (!CimdJwks.TryFilter(inlineJwks.GetRawText(), out jwks, out var jwksError)) + { + return Invalid($"jwks: {jwksError}"); + } } - - // ── redirect_uris: required, each https-or-loopback, exact-match ── - var redirectUris = GetStringArray(root, "redirect_uris"); - if (redirectUris.Count == 0) - return Invalid("document is missing the required redirect_uris."); - foreach (var uri in redirectUris) + else if (authMethod != AuthMethodNone) { - if (!IsAllowedRedirectUri(uri)) - return Invalid($"redirect_uri '{uri}' is invalid (https URIs or http loopback only)."); + return Invalid($"token_endpoint_auth_method '{authMethod}' is not supported (none or private_key_jwt; shared secrets are forbidden for CIMD)."); } - // ── grant_types: subset of {authorization_code, refresh_token} ──── + // ── redirect_uris: https or http loopback; the rest is dropped ──── + // Same reasoning as grant_types: the document serves every server, so a + // URI form Modgud does not accept (a private-use scheme, say) costs the + // client that one URI, not the whole registration. At least one must + // survive. Loopback URIs with a port also get their port-less twin. + var declaredRedirectUris = GetStringArray(root, "redirect_uris"); + if (declaredRedirectUris.Count == 0) + return Invalid("document is missing the required redirect_uris."); + var redirectUris = declaredRedirectUris.Where(IsAllowedRedirectUri).ToList(); + if (redirectUris.Count == 0) + return Invalid("no usable redirect_uri (https URIs or http loopback only)."); + redirectUris = OAuthApplicationTypes.WithPortlessLoopbackTwins(redirectUris); + + // ── grant_types: intersected with {authorization_code, refresh_token} ─ + // A CIMD document is the client's self-description for EVERY + // authorization server, not an order placed with this one: it lists + // the grants the client can use (RFC 7591 §2). Grants Modgud does not + // offer (claude.ai lists jwt-bearer) are dropped, not fatal — the + // client simply never holds them. authorization_code must survive. var grantTypes = root.TryGetProperty("grant_types", out _) ? GetStringArray(root, "grant_types") : new List { "authorization_code" }; if (grantTypes.Count == 0) grantTypes = new List { "authorization_code" }; - foreach (var grant in grantTypes) - { - if (!AllowedGrantTypes.Contains(grant)) - return Invalid($"grant_type '{grant}' is not allowed (authorization_code, refresh_token only)."); - } + grantTypes = grantTypes.Where(AllowedGrantTypes.Contains).ToList(); if (!grantTypes.Contains("authorization_code")) return Invalid("grant_types must include authorization_code."); - // ── response_types: subset of {code} ────────────────────────────── + // ── response_types: must include code; anything else is ignored ─── var responseTypes = root.TryGetProperty("response_types", out _) ? GetStringArray(root, "response_types") : new List { "code" }; - foreach (var rt in responseTypes) - { - if (!AllowedResponseTypes.Contains(rt)) - return Invalid($"response_type '{rt}' is not allowed (code only)."); - } + if (responseTypes.Count > 0 && !responseTypes.Any(AllowedResponseTypes.Contains)) + return Invalid("response_types must include code."); // ── application_type (optional): web | native, nothing else ─────── string? applicationType = null; @@ -146,6 +185,9 @@ public static CimdValidationResult Parse(string json, string requestedClientId) GrantTypes = grantTypes.Distinct(StringComparer.Ordinal).ToList(), Scopes = scopes, ApplicationType = applicationType, + TokenEndpointAuthMethod = authMethod, + JwksUri = jwksUri, + Jwks = jwks, }); } @@ -172,6 +214,13 @@ private static bool IsAllowedRedirectUri(string raw) return false; } + private static bool IsAllowedJwksUri(string raw) => + Uri.TryCreate(raw, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && !string.IsNullOrEmpty(uri.Host) + && string.IsNullOrEmpty(uri.UserInfo) + && string.IsNullOrEmpty(uri.Fragment); + private static List ParseScope(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return new List(); diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/MartenApplicationStore.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/MartenApplicationStore.cs index 9afbe134..adaacaf5 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/MartenApplicationStore.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/MartenApplicationStore.cs @@ -191,6 +191,11 @@ public ValueTask> GetDisplayNamesAsync( public async ValueTask GetJsonWebKeySetAsync(OAuthApplicationState application, CancellationToken cancellationToken) { + // A CIMD client is synthesized, never stored: its keys come from the + // document (inline jwks or jwks_uri), not a security record. + if (application.Properties.ContainsKey(OAuthApplicationPropertyKeys.CimdIsResolvedClient)) + return await _cimdResolver.GetJsonWebKeySetAsync(application, cancellationToken); + await using var session = _sessionFactory.OpenQuerySession(); var securityData = await session.LoadAsync(application.Id, cancellationToken); if (string.IsNullOrEmpty(securityData?.JsonWebKeySet)) return null; diff --git a/src/dotnet/Modgud.Tests.Unit/OAuth/Cimd/CimdMetadataParserTests.cs b/src/dotnet/Modgud.Tests.Unit/OAuth/Cimd/CimdMetadataParserTests.cs index a58fbd63..37111e01 100644 --- a/src/dotnet/Modgud.Tests.Unit/OAuth/Cimd/CimdMetadataParserTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/OAuth/Cimd/CimdMetadataParserTests.cs @@ -99,10 +99,42 @@ public void Rejects_unknown_application_type(string declared) => [Theory] [InlineData("client_secret_basic")] [InlineData("client_secret_post")] - [InlineData("private_key_jwt")] - public void Rejects_non_public_auth_methods(string method) => + [InlineData("client_secret_jwt")] // a shared secret too — forbidden by the draft + [InlineData("tls_client_auth")] // Modgud does not do mTLS client auth + public void Rejects_shared_secret_and_unsupported_auth_methods(string method) => AssertInvalid(Doc(authMethod: method)); + [Fact] + public void Accepts_the_chatgpt_private_key_jwt_document() + { + var meta = AssertValid(RealWorldClientMetadata.ChatGpt, RealWorldClientMetadata.ChatGptId); + Assert.Equal("private_key_jwt", meta.TokenEndpointAuthMethod); + Assert.Equal("https://chatgpt.com/oauth/jwks.json", meta.JwksUri); + Assert.Null(meta.Jwks); + } + + private const string InlineRsaKey = """{"kty":"RSA","kid":"k1","use":"sig","n":"sXch","e":"AQAB"}"""; + + private static string PrivateKeyJwtDoc(string keySource) => + Doc().TrimEnd('}') + ",\"token_endpoint_auth_method\":\"private_key_jwt\"" + keySource + "}"; + + [Fact] + public void Accepts_private_key_jwt_with_an_inline_key_set() + { + var meta = AssertValid(PrivateKeyJwtDoc($",\"jwks\":{{\"keys\":[{InlineRsaKey}]}}")); + Assert.Null(meta.JwksUri); + Assert.Contains("\"kid\":\"k1\"", meta.Jwks); + } + + [Theory] + [InlineData("")] // no key source + [InlineData(",\"jwks_uri\":\"https://app.example.com/jwks\",\"jwks\":{\"keys\":[]}")] // both + [InlineData(",\"jwks_uri\":\"http://app.example.com/jwks\"")] // not https + [InlineData(",\"jwks\":{\"keys\":[{\"kty\":\"RSA\",\"n\":\"x\",\"e\":\"AQAB\",\"d\":\"secret\"}]}")] // private material + [InlineData(",\"jwks\":{\"keys\":[{\"kty\":\"RSA\",\"use\":\"enc\",\"n\":\"x\",\"e\":\"AQAB\"}]}")] // no signing key + public void Rejects_private_key_jwt_without_exactly_one_usable_key_source(string keySource) => + AssertInvalid(PrivateKeyJwtDoc(keySource)); + [Fact] public void Rejects_document_carrying_a_client_secret() => AssertInvalid(Doc(includeClientSecret: true)); @@ -121,18 +153,75 @@ public void Rejects_client_id_mismatch() => public void Rejects_non_loopback_http_redirect() => AssertInvalid(Doc(redirectUris: ["http://app.example.com/cb"])); + [Theory] + [InlineData("client_credentials")] + [InlineData("urn:ietf:params:oauth:grant-type:jwt-bearer")] + public void Drops_grant_types_modgud_does_not_offer(string grant) + { + // The document describes the client for every AS; it is not an order. + var meta = AssertValid(Doc(grantTypes: ["authorization_code", "refresh_token", grant])); + Assert.Equal(["authorization_code", "refresh_token"], meta.GrantTypes); + } + [Fact] - public void Rejects_disallowed_grant_type() => - AssertInvalid(Doc(grantTypes: ["authorization_code", "client_credentials"])); + public void Rejects_when_only_unoffered_grants_remain() => + AssertInvalid(Doc(grantTypes: ["client_credentials", "refresh_token"])); [Fact] public void Rejects_grant_types_without_authorization_code() => AssertInvalid(Doc(grantTypes: ["refresh_token"])); [Fact] - public void Rejects_disallowed_response_type() => + public void Rejects_response_types_without_code() => AssertInvalid(Doc(responseTypes: ["token"])); + [Fact] + public void Ignores_extra_response_type_alongside_code() => + AssertValid(Doc(responseTypes: ["code", "token"])); + + // ── Real-world documents (see RealWorldClientMetadata) ────────────── + + [Theory] + [MemberData(nameof(RealWorldClientMetadata.PublicCimdDocuments), MemberType = typeof(RealWorldClientMetadata))] + public void Accepts_every_real_public_client_document(string clientId, string json, string expectedName) + { + var meta = AssertValid(json, clientId); + Assert.Equal(expectedName, meta.ClientName); + Assert.Equal(["authorization_code", "refresh_token"], meta.GrantTypes); + } + + [Fact] + public void Claude_ai_loses_jwt_bearer_and_keeps_its_callback() + { + var meta = AssertValid(RealWorldClientMetadata.ClaudeAi, RealWorldClientMetadata.ClaudeAiId); + Assert.Equal(["https://claude.ai/api/mcp/auth_callback"], meta.RedirectUris); + Assert.Empty(meta.Scopes); // no scope → the resolver applies the realm default + } + + [Fact] + public void Vs_code_ported_loopback_uri_gets_its_portless_twin() + { + // VS Code names http://127.0.0.1:33418/ but may come back on another port. + var meta = AssertValid(RealWorldClientMetadata.VsCode, RealWorldClientMetadata.VsCodeId); + Assert.Equal(["http://127.0.0.1:33418/", "http://127.0.0.1/", "https://vscode.dev/redirect"], meta.RedirectUris); + Assert.Equal("native", meta.ApplicationType); + } + + [Fact] + public void Drops_a_redirect_uri_form_modgud_does_not_accept() + { + var meta = AssertValid(Doc(redirectUris: ["com.example.app:/cb", "https://app.example.com/callback"])); + Assert.Equal(["https://app.example.com/callback"], meta.RedirectUris); + } + + [Fact] + public void Rejects_when_no_redirect_uri_survives() => + AssertInvalid(Doc(redirectUris: ["com.example.app:/cb", "http://app.example.com/cb"])); + + [Fact] + public void Tolerates_a_utf8_byte_order_mark() => + AssertValid((char)0xFEFF + Doc()); + [Fact] public void Rejects_non_json() => AssertInvalid("this is not json"); diff --git a/src/dotnet/Modgud.Tests.Unit/OAuth/Dcr/DcrRegistrationValidatorTests.cs b/src/dotnet/Modgud.Tests.Unit/OAuth/Dcr/DcrRegistrationValidatorTests.cs index 243bb3c6..f43ebc48 100644 --- a/src/dotnet/Modgud.Tests.Unit/OAuth/Dcr/DcrRegistrationValidatorTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/OAuth/Dcr/DcrRegistrationValidatorTests.cs @@ -150,9 +150,21 @@ public void Token_endpoint_auth_method_defaults_to_none_public_when_omitted() [InlineData("password")] [InlineData("implicit")] [InlineData("urn:ietf:params:oauth:grant-type:device_code")] - public void Disallowed_grant_type_rejected(string grant) + [InlineData("urn:ietf:params:oauth:grant-type:jwt-bearer")] // claude.ai lists it + public void Unoffered_grant_type_is_dropped_not_fatal(string grant) { - var req = ValidRequest() with { GrantTypes = new() { "authorization_code", grant } }; + // RFC 7591 §3.2.1: the server registers what it offers and echoes it. + var req = ValidRequest() with { GrantTypes = new() { "authorization_code", grant, "refresh_token" } }; + var allow = Assert.IsType(Sut.Validate(req, Settings(), "ip")); + Assert.Equal(new[] { "authorization_code", "refresh_token" }, allow.Normalized.AllowedGrantTypes); + } + + [Theory] + [InlineData("client_credentials")] + [InlineData("refresh_token")] + public void Grant_types_without_authorization_code_rejected(string grant) + { + var req = ValidRequest() with { GrantTypes = new() { grant } }; var reject = Assert.IsType(Sut.Validate(req, Settings(), "ip")); Assert.Equal(DcrRejectionReason.InvalidGrantType, reject.Reason); } @@ -177,27 +189,65 @@ public void Disallowed_response_type_rejected(string responseType) } [Fact] - public void Missing_client_name_rejected() + public void Extra_response_type_alongside_code_is_ignored() { - var req = ValidRequest() with { ClientName = null }; - var reject = Assert.IsType(Sut.Validate(req, Settings(), "ip")); - Assert.Equal(DcrRejectionReason.ClientNameMissing, reject.Reason); + var req = ValidRequest() with { ResponseTypes = new() { "code", "token" } }; + Assert.IsType(Sut.Validate(req, Settings(), "ip")); + } + + // ── Real-world registration bodies (see RealWorldClientMetadata) ──── + + private static DcrRegistrationRequest Body(string json) => + System.Text.Json.JsonSerializer.Deserialize(json)!; + + [Theory] + [MemberData(nameof(RealWorldClientMetadata.DcrBodies), MemberType = typeof(RealWorldClientMetadata))] + public void Accepts_every_real_client_registration(string client, string json) + { + var allow = Assert.IsType(Sut.Validate(Body(json), Settings(), "ip")); + Assert.Equal(client, allow.Normalized.DisplayName); } [Fact] - public void Whitespace_client_name_rejected_as_missing() + public void Zed_ephemeral_port_registration_also_matches_the_next_port() { - var req = ValidRequest() with { ClientName = " " }; - var reject = Assert.IsType(Sut.Validate(req, Settings(), "ip")); - Assert.Equal(DcrRejectionReason.ClientNameMissing, reject.Reason); + var allow = Assert.IsType(Sut.Validate(Body(RealWorldClientMetadata.ZedDcr), Settings(), "ip")); + Assert.Equal(new[] { "http://127.0.0.1:49152/callback", "http://127.0.0.1/callback" }, allow.Normalized.RedirectUris); + } + + [Fact] + public void Drops_a_redirect_uri_form_modgud_does_not_accept() + { + var req = ValidRequest() with { RedirectUris = new() { "com.example.app:/cb", "https://example.com/callback" } }; + var allow = Assert.IsType(Sut.Validate(req, Settings(), "ip")); + Assert.Equal(new[] { "https://example.com/callback" }, allow.Normalized.RedirectUris); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + public void Missing_client_name_falls_back_to_the_redirect_host(string? name) + { + // RFC 7591 §2: client_name is optional — its absence never fails. + var req = ValidRequest() with { ClientName = name, RedirectUris = new() { "https://mcp-client.example/cb" } }; + var allow = Assert.IsType(Sut.Validate(req, Settings(), "ip")); + Assert.Equal("mcp-client.example", allow.Normalized.DisplayName); + } + + [Fact] + public void Missing_client_name_on_a_loopback_only_client_gets_a_neutral_label() + { + var req = ValidRequest() with { ClientName = null, RedirectUris = new() { "http://127.0.0.1/cb" } }; + var allow = Assert.IsType(Sut.Validate(req, Settings(), "ip")); + Assert.Equal("Unnamed application", allow.Normalized.DisplayName); } [Fact] - public void Client_name_over_80_chars_rejected() + public void Client_name_over_80_chars_is_truncated() { var req = ValidRequest() with { ClientName = new string('a', 81) }; - var reject = Assert.IsType(Sut.Validate(req, Settings(), "ip")); - Assert.Equal(DcrRejectionReason.ClientNameTooLong, reject.Reason); + var allow = Assert.IsType(Sut.Validate(req, Settings(), "ip")); + Assert.Equal(new string('a', 80), allow.Normalized.DisplayName); } [Fact] @@ -222,11 +272,12 @@ public void Latin1_or_ascii_client_name_accepted(string name) [InlineData("テスト")] // Japanese [InlineData("Аpple")] // Cyrillic А confused with Latin A [InlineData("Foo😀")] // Emoji - public void Non_latin1_client_name_rejected(string name) + public void Non_latin1_client_name_is_never_displayed_but_does_not_fail(string name) { - var req = ValidRequest() with { ClientName = name }; - var reject = Assert.IsType(Sut.Validate(req, Settings(), "ip")); - Assert.Equal(DcrRejectionReason.ClientNameNonLatin1, reject.Reason); + // Confusable defence: the name is replaced, the registration stands. + var req = ValidRequest() with { ClientName = name, RedirectUris = new() { "https://mcp-client.example/cb" } }; + var allow = Assert.IsType(Sut.Validate(req, Settings(), "ip")); + Assert.Equal("mcp-client.example", allow.Normalized.DisplayName); } [Fact] diff --git a/src/dotnet/Modgud.Tests.Unit/OAuth/RealWorldClientMetadata.cs b/src/dotnet/Modgud.Tests.Unit/OAuth/RealWorldClientMetadata.cs new file mode 100644 index 00000000..1d3840c1 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/OAuth/RealWorldClientMetadata.cs @@ -0,0 +1,87 @@ +namespace Modgud.Tests.Unit.OAuth; + +/// +/// Client metadata that real MCP clients actually present — verbatim, not +/// idealised. The CIMD and DCR policies were first written against documents +/// we wrote ourselves, and each real client that tried broke a release +/// (ephemeral loopback port, missing scope, an extra grant type). When a +/// client changes its metadata, refresh the copy here and keep the date. +/// +/// CIMD documents were fetched live from their client_id URL on +/// 2026-09-21. DCR bodies are reconstructed from each client's open source +/// (file named per entry) on the same date; placeholders the client fills at +/// run time (port, scope) are filled with representative values. +/// +public static class RealWorldClientMetadata +{ + // ── CIMD documents (fetched 2026-09-21) ───────────────────────────── + + public const string ClaudeAiId = "https://claude.ai/oauth/mcp-oauth-client-metadata"; + public const string ClaudeAi = """ + {"client_id":"https://claude.ai/oauth/mcp-oauth-client-metadata","client_name":"Claude","client_uri":"https://claude.ai","redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"grant_types":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:jwt-bearer"],"response_types":["code"],"token_endpoint_auth_method":"none"} + """; + + public const string ClaudeCodeId = "https://claude.ai/oauth/claude-code-client-metadata"; + public const string ClaudeCode = """ + {"client_id":"https://claude.ai/oauth/claude-code-client-metadata","client_name":"Claude Code","client_uri":"https://claude.ai","redirect_uris":["http://localhost/callback","http://127.0.0.1/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"} + """; + + public const string VsCodeId = "https://vscode.dev/oauth/client-metadata.json"; + public const string VsCode = """ + {"client_name":"Visual Studio Code","logo_uri":"https://code.visualstudio.com/assets/branding/code-stable.png","grant_types":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:device_code"],"response_types":["code"],"token_endpoint_auth_method":"none","application_type":"native","client_id":"https://vscode.dev/oauth/client-metadata.json","client_uri":"https://vscode.dev/product","redirect_uris":["http://127.0.0.1:33418/","https://vscode.dev/redirect"]} + """; + + public const string ZedId = "https://zed.dev/oauth/client-metadata.json"; + public const string Zed = """ + {"client_id":"https://zed.dev/oauth/client-metadata.json","client_name":"Zed","client_uri":"https://zed.dev","redirect_uris":["http://127.0.0.1/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none","logo_uri":"https://cdn.zed.dev/images/logo-dark.png"} + """; + + public const string GooseId = "https://goose-docs.ai/oauth/client-metadata.json"; + public const string Goose = """ + {"client_id":"https://goose-docs.ai/oauth/client-metadata.json","client_name":"goose","logo_uri":"https://goose-docs.ai/img/logo_light.png","redirect_uris":["http://127.0.0.1/oauth_callback","http://[::1]/oauth_callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none","code_challenge_methods_supported":["S256"]} + """; + + /// A private_key_jwt client with a remote jwks_uri — the one + /// real CIMD client that is not public (redirect as published; per-connector + /// callbacks are not part of the document). + public const string ChatGptId = "https://chatgpt.com/oauth/client.json"; + public const string ChatGpt = """ + {"client_id":"https://chatgpt.com/oauth/client.json","client_uri":"https://chatgpt.com/","redirect_uris":["https://chatgpt.com/connector_platform_oauth_redirect"],"token_endpoint_auth_method":"private_key_jwt","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"client_name":"ChatGPT","logo_uri":"https://persistent.oaistatic.com/sonic/misc/openai-logo.png","token_endpoint_auth_signing_alg":"RS256","jwks_uri":"https://chatgpt.com/oauth/jwks.json"} + """; + + /// The public CIMD documents, as (client_id, document, display name). + public static TheoryData PublicCimdDocuments => new() + { + { ClaudeAiId, ClaudeAi, "Claude" }, + { ClaudeCodeId, ClaudeCode, "Claude Code" }, + { VsCodeId, VsCode, "Visual Studio Code" }, + { ZedId, Zed, "Zed" }, + { GooseId, Goose, "goose" }, + }; + + // ── DCR request bodies (from source, 2026-09-21) ──────────────────── + + /// microsoft/vscode — src/vs/base/common/oauth.ts, fetchDynamicRegistration(). + public const string VsCodeDcr = """ + {"client_name":"Visual Studio Code","client_uri":"https://code.visualstudio.com","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"redirect_uris":["https://insiders.vscode.dev/redirect","https://vscode.dev/redirect","http://127.0.0.1/","http://127.0.0.1:33418/"],"scope":"openid","token_endpoint_auth_method":"none","application_type":"native"} + """; + + /// zed-industries/zed — crates/context_server/src/oauth.rs, + /// dcr_registration_body(): registers the ephemeral port it got. + public const string ZedDcr = """ + {"client_name":"Zed","redirect_uris":["http://127.0.0.1:49152/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"} + """; + + /// modelcontextprotocol/inspector — core/auth/providers.ts; the + /// scope is an empty string when none is configured. + public const string McpInspectorDcr = """ + {"redirect_uris":["http://localhost:6274/oauth/callback"],"token_endpoint_auth_method":"none","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"client_name":"MCP Inspector","client_uri":"https://github.com/modelcontextprotocol/inspector","scope":"","application_type":"native"} + """; + + public static TheoryData DcrBodies => new() + { + { "Visual Studio Code", VsCodeDcr }, + { "Zed", ZedDcr }, + { "MCP Inspector", McpInspectorDcr }, + }; +} diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 5899cfd9..5d898b75 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -2174,7 +2174,7 @@ "accessTokenMinutes": "Access-Token-Lebensdauer (Minuten)", "enabled": "Dynamic Client Registration aktivieren", "gcTtlDays": "Aufräumen nach Inaktivität (Tage)", - "hint": "Wenn aktiviert, können KI-Agents und andere Software OAuth-Clients selbst über POST /connect/register (RFC 7591) registrieren. Nur öffentliche PKCE-Clients, es wird kein client_secret ausgestellt. Standardmäßig aus.", + "hint": "Wenn aktiviert, können KI-Agents und andere Software OAuth-Clients selbst über POST /connect/register (RFC 7591) registrieren. Öffentliche PKCE-Clients oder vertrauliche, denen einmalig ein client_secret ausgestellt wird. Standardmäßig aus.", "rateLimitIp": "Rate-Limit pro Quell-IP (pro Stunde)", "rateLimitRealm": "Rate-Limit pro Realm (pro Tag)", "refreshTokenDays": "Refresh-Token-Lebensdauer (Tage)", @@ -2185,11 +2185,11 @@ "tripleOptInWarning": "Dreifaches Opt-in: Hier registrierte Clients können Access-Tokens nur für OAuth-APIs mit aktiviertem AllowDynamicRegistration UND für Scopes mit aktiviertem AllowDynamicRegistrationClients anfordern. Solange du nicht mindestens eine API und einen Scope freigibst, können DCR-Clients keine nutzbaren Tokens ausstellen." }, "cimd": { - "hint": "Wenn aktiviert, wird ein Client, dessen client_id eine https-URL ist (ein Client-ID-Metadaten-Dokument, der von MCP bevorzugte Weg), bei Bedarf aufgelöst: Der Server lädt + validiert das Dokument und behandelt es als öffentlichen PKCE-Client — keine Registrierungs-Anfrage, kein client_secret, Identität an die Domain der URL gebunden. Standardmäßig aus.", + "hint": "Wenn aktiviert, wird ein Client, dessen client_id eine https-URL ist (ein Client-ID-Metadaten-Dokument, der von MCP bevorzugte Weg), bei Bedarf aufgelöst: Der Server lädt + validiert das Dokument und behandelt es als Client — öffentlich mit PKCE, oder vertraulich per private_key_jwt gegen die Schlüssel, die das Dokument veröffentlicht. Keine Registrierungs-Anfrage, kein client_secret, Identität an die Domain der URL gebunden. Standardmäßig aus.", "enabled": "Client-ID-Metadaten-Dokumente aktivieren", "accessTokenMinutes": "Access-Token-Lebensdauer (Minuten)", "refreshTokenDays": "Refresh-Token-Lebensdauer (Tage)", - "optInWarning": "Wie bei DCR kann ein CIMD-Client Access-Tokens nur für OAuth-APIs mit aktiviertem AllowDynamicRegistration und für die im Metadaten-Dokument deklarierten Scopes anfordern. Solange du nicht mindestens eine API freigibst, können CIMD-Clients keine nutzbaren Tokens ausstellen. Der Server lädt die Metadaten-URL des Clients — aktiviere dies nur, wenn du dem ausgehenden Netzwerk des Realms vertraust." + "optInWarning": "Wie bei DCR kann ein CIMD-Client Access-Tokens nur für OAuth-APIs mit aktiviertem AllowDynamicRegistration und für Scopes mit aktiviertem AllowDynamicRegistrationClients anfordern (ein im Dokument deklarierter scope schränkt nur weiter ein). Solange du nicht mindestens eine API freigibst, können CIMD-Clients keine nutzbaren Tokens ausstellen. Der Server lädt die Metadaten-URL des Clients — aktiviere dies nur, wenn du dem ausgehenden Netzwerk des Realms vertraust." }, "nativeGrants": { "hint": "Wenn aktiviert, können native Apps einen passwortlosen Nachweis direkt unter POST /connect/token gegen Tokens eintauschen — ohne Browser-Redirect, ohne Cookie. Drei Grants: urn:cocoar:otp (Einmalcode per E-Mail), urn:cocoar:magic (Magic-Link-Token), urn:cocoar:passkey (WebAuthn-Assertion). Standardmäßig aus.", diff --git a/src/frontend-vue/src/views/admin/RealmSettingsView.vue b/src/frontend-vue/src/views/admin/RealmSettingsView.vue index 636ce57b..bbdc6f47 100644 --- a/src/frontend-vue/src/views/admin/RealmSettingsView.vue +++ b/src/frontend-vue/src/views/admin/RealmSettingsView.vue @@ -1097,7 +1097,7 @@ async function rotateSigningKey() {

{{ t('admin.realmSettings.sections.dcr', {}, 'Dynamic Client Registration') }}

-

{{ t('admin.realmSettings.dcr.hint', {}, 'Allow software to register public PKCE clients through RFC 7591.') }}

+

{{ t('admin.realmSettings.dcr.hint', {}, 'Allow software to register OAuth clients through RFC 7591 — public PKCE clients, or confidential ones with a client secret issued once.') }}

@@ -1140,7 +1140,7 @@ async function rotateSigningKey() {

{{ t('admin.realmSettings.sections.cimd', {}, 'Client-ID Metadata Documents (CIMD)') }}

-

{{ t('admin.realmSettings.cimd.hint', {}, 'Resolve HTTPS client IDs as metadata documents and treat them as public PKCE clients.') }}

+

{{ t('admin.realmSettings.cimd.hint', {}, 'Resolve HTTPS client IDs as metadata documents — public PKCE clients, or confidential ones that authenticate with private_key_jwt against the keys their document publishes.') }}