Skip to content

fix(oauth): CIMD and DCR accept the metadata real clients publish (claude.ai, VS Code, Zed, ChatGPT) - #240

Merged
windischb merged 5 commits into
developfrom
fix/cimd-dcr-narrow-unknown-grants
Sep 21, 2026
Merged

windischb merged 5 commits into
developfrom
fix/cimd-dcr-narrow-unknown-grants

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Why

claude.ai could not sign in to any Modgud: its CIMD document lists urn:ietf:params:oauth:grant-type:jwt-bearer next to authorization_code and refresh_token, and the parser rejected the whole document for it (ID2052, "unknown client"). It was the third CIMD field report in a row (loopback port → beta.6, missing scope → beta.7). All three had the same root: the CIMD/DCR rules were written against documents we composed ourselves, never against the ones real clients publish.

So this PR fixes the report and then audits every CIMD/DCR rejection rule against RFC 7591, RFC 8252 and draft-ietf-oauth-client-id-metadata-document-02, with the live metadata of real clients as test fixtures.

Policy: narrow, don't reject

A CIMD document describes the client for every authorization server; it is not an order placed with this one. A rule now rejects only what Modgud cannot honour at all. Values it merely does not offer are dropped. DCR follows the same policy (RFC 7591 §3.2.1: the response echoes what was registered).

Client Before After
claude.ai (CIMD) rejected: jwt-bearer grant grants narrowed to authorization_code + refresh_token
VS Code (CIMD) rejected: device_code grant; only port 33418 matched narrowed; any loopback port matches
Zed (DCR) registered ephemeral port only matched once any loopback port matches
ChatGPT (CIMD) rejected: private_key_jwt confidential client, keys from jwks_uri
Claude Code, Zed, goose (CIMD); MCP Inspector (DCR) worked still work, now pinned as fixtures

Changes

  • Grant / response types (CIMD + DCR): intersected with what Modgud offers; authorization_code and code must survive.
  • Redirect URIs (CIMD + DCR): an unusable one is dropped, not fatal; at least one must survive. A loopback URI registered with a port gets its port-less twin, so any port matches, as RFC 8252 §7.3 requires (OpenIddict relaxes the port only against a port-less registration).
  • private_key_jwt for CIMD: the draft forbids only shared secrets. private_key_jwt with exactly one of jwks_uri / jwks synthesizes a confidential client; MartenApplicationStore.GetJsonWebKeySetAsync gets the set from CimdClientResolver (SSRF-guarded fetch, 64 KB, cached per Cache-Control, refetched at once on an unknown kid, at most once a minute). Unusable keys are skipped; private key material fails the set.
  • DCR client_name is optional (RFC 7591 §2): missing or non-Latin-1 names are replaced by the redirect host instead of failing; over-long names are truncated; reserved names still reject.
  • DCR malformed body → RFC 7591 §3.2.2 error object instead of the framework's empty 400.
  • CIMD document with a UTF-8 BOM parses.
  • ADR 0008 amended: narrowing policy, loopback twins, private_key_jwt, and no revocation on key rotation.

Found on the way

  • OpenIddict 7 accepts only the issuer as a client assertion's aud (draft-ietf-oauth-rfc7523bis §4). PrivateKeyJwtClientAuthTests signed for the token endpoint and read the resulting ID2173 invalid_grant as "client authenticated", so they were green without an assertion ever being accepted. They now use the issuer and check the error concerns the grant; a new test pins the token-endpoint refusal. docs/admin/oauth-clients.md and docs/reference/oauth-api.md told integrators to use the token endpoint as aud; fixed.
  • The realm-settings UI hints said DCR and CIMD were public-only, and the CIMD opt-in warning described the pre-fix(oauth): a dynamic client without a declared scope holds the realm's opted-in scopes #239 scope rule; fixed.

Tests

  • RealWorldClientMetadata (unit): live CIMD documents of claude.ai, Claude Code, VS Code, Zed, goose, ChatGPT (fetched 2026-09-21) and the DCR bodies of VS Code, Zed, MCP Inspector (from source).
  • Integration: claude.ai-shaped flow incl. refresh (red on the old parser with exactly ID2052); VS Code ported loopback + Zed ephemeral DCR port (red without the twin); ChatGPT-shaped private_key_jwt flow: no assertion / foreign key → invalid_client, signed flow + refresh work, rotation picked up on first use, a second unknown kid within a minute does not refetch; malformed DCR body.
  • Unit 1739/1739; integration 846/846 executed and passed (trx executed = total, no host crash).

🤖 Generated with Claude Code

windischb and others added 5 commits September 21, 2026 08:16
…ad of rejecting the client

A CIMD document is the client's self-description for every authorization
server, not an order placed with this one (RFC 7591 §2: the grants the
client "can use"). claude.ai's connector document lists
urn:ietf:params:oauth:grant-type:jwt-bearer next to authorization_code and
refresh_token; the parser rejected the whole document for it, so the
authorize request failed as an unknown client (ID2052) and claude.ai could
not sign in to any Modgud.

CIMD and DCR now register the intersection with {authorization_code,
refresh_token} and require authorization_code to survive; response_types
must include code, other values are ignored. DCR follows the same policy
(RFC 7591 §3.2.1 lets the server replace requested values; the response
already echoes the registered grants), which also closes the gap where a
refresh_token-only DCR registration was accepted.

Tests: the live claude.ai document as a parser fixture, and a full CIMD
flow (authorize, token, refresh) on a document shaped like it — red on the
old parser with exactly ID2052. Docs: CIMD/DCR accepted-fields tables, and
the stale "DCR is public-only" row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ublish

Audit of every CIMD/DCR rejection rule against RFC 7591, RFC 8252 and
draft-ietf-oauth-client-id-metadata-document-02, with the live metadata of
claude.ai, Claude Code, VS Code, Zed and goose (CIMD) and the registration
bodies of VS Code, Zed and the MCP Inspector (DCR) as fixtures. A rule now
rejects only what Modgud cannot honour; what it merely does not offer is
narrowed away.

- Loopback redirect registered WITH a port (VS Code's 127.0.0.1:33418,
  Zed's ephemeral DCR port) matched only that port; RFC 8252 §7.3 says any
  port MUST be accepted. The port-less twin is registered alongside, which
  is what OpenIddict relaxes against. Scheme, host and path still match.
- One unusable redirect URI failed the whole document/registration; it is
  now dropped (DCR echoes what was registered), at least one must survive.
  Private-use schemes stay out for dynamic clients.
- DCR client_name is optional (RFC 7591 §2). Missing or non-Latin-1 names
  are replaced by the redirect host instead of failing; over-long names
  are truncated. Reserved names still reject.
- A malformed DCR body got the framework's empty 400; it now gets the RFC
  7591 §3.2.2 error object.
- A CIMD document with a UTF-8 BOM no longer fails to parse.

Integration tests for the port rule are red without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…jwks

ChatGPT's connector document authenticates with private_key_jwt and a
jwks_uri; Modgud rejected it outright (ADR 0008 v1: public-only), although
the CIMD draft forbids only shared-secret methods. It was the last real
CIMD client in the fixture set that could not connect.

- Parser: token_endpoint_auth_method none, or private_key_jwt with exactly
  one of jwks_uri (https) / jwks. Shared secrets stay forbidden.
- The synthesized client is confidential; MartenApplicationStore asks
  CimdClientResolver for its key set, since it has no security record.
- jwks_uri is fetched through the SSRF-guarded client (64 KB), cached per
  Cache-Control, and refetched at once when an assertion names an unknown
  kid — at most once a minute. Unusable keys are skipped; private key
  material fails the set.
- ADR 0008 amended: narrowing policy, loopback twins, private_key_jwt, and
  no revocation on key rotation (the assertion is re-checked on every
  token request).

Also: OpenIddict 7 accepts only the issuer as a client assertion's aud
(draft-ietf-oauth-rfc7523bis §4). PrivateKeyJwtClientAuthTests signed for
the token endpoint and read the resulting ID2173 invalid_grant as "client
authenticated"; they now use the issuer and check the error is about the
grant, plus a test pinning the token-endpoint refusal. The admin and API
reference docs told integrators to use the token endpoint as aud — fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CIMD hint still said public PKCE only, the DCR hint too although DCR
has issued confidential clients for months, and the CIMD opt-in warning
described the pre-#239 scope rule (declared scopes) instead of the
per-scope AllowDynamicRegistrationClients opt-in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A search-and-replace turned the helper's own invalid_grant assertion into a
recursive call; the stack overflow killed the test host mid-suite, and the
runner still printed Passed for the tests it had finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@windischb
windischb merged commit 07681ed into develop Sep 21, 2026
8 checks passed
@windischb
windischb deleted the fix/cimd-dcr-narrow-unknown-grants branch September 21, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant