Skip to content

fix(oauth): a dynamic client without a declared scope holds the realm's opted-in scopes - #239

Merged
windischb merged 1 commit into
developfrom
fix/dynamic-client-scope-default
Sep 20, 2026
Merged

windischb merged 1 commit into
developfrom
fix/dynamic-client-scope-default

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Summary

Reported from the field (amZettel, priority high) — the follow-up to #238, one check deeper. With the loopback port accepted, Claude Code still could not log in against an MCP server behind Modgud: /connect/authorize answered invalid_request — This client application is not allowed to use the specified scope (OpenIddict ID2051) for every scope but openid and offline_access, although all of the server's scopes had AllowDynamicRegistrationClients = true and the API had AllowDynamicRegistration = true.

Cause

A dynamic client held exactly the scopes its CIMD document or DCR registration declared. Claude Code's document — like every static MCP-client document, one file for every server in the world — declares no scope: it cannot know one server's scopes, the client reads them from the server's protected-resource metadata at run time. So the synthesized client had no scope permission at all, and OpenIddict's permission check refused everything before Modgud's per-scope opt-in was ever consulted. The opt-in built for exactly this case was unreachable. DCR had the same gap (RFC 7591 §2: "if omitted, an authorization server MAY register a client with a default set of scopes" — Modgud's default was the empty set).

Fix — one rule, three seams

DynamicClientScopePolicy: a dynamic client (DCR-minted or CIMD-synthesized) may hold every enabled scope with AllowDynamicRegistrationClients — app-scoped or global; the flag is the boundary, the App link never was — plus the standard scopes other than modgud.management. Standard scopes are immutable, so they need a built-in verdict: the identity and claim-gate scopes name no resource privilege of their own; the management selector is exactly the "tenant.admin.*" case the flag exists to keep away from a habit-click consent (the realm seeder already strips the flag from it). A declared scope is an upper bound intersected with that set; none declared means the whole set.

Seam Behaviour
CimdClientResolver permissions computed on every resolve from the live set (memoized per request); the document stays cached — ticking the flag takes effect at once, like Cimd.Enabled
OAuthAdminService (DCR create) the resolved set is stored; /connect/register echoes what was registered (§3.2.1)
DynamicClientScopeHandler ordered just before OpenIddict's ValidateScopePermissions: a refused scope gets Modgud's own invalid_scope, naming the scope and the flag, instead of the opaque ID2051; the authorize endpoint's own check (still reached from the consent continuation) uses the same policy and wording

Contract changes (pre-1.0), in the release-notes draft

  • A custom global scope now needs the flag for dynamic clients, like an app-scoped one (the docs said "global scopes are always reachable" in one paragraph and "the flag controls which scopes a DCR client can ever request" in the next). Existing DCR clients keep their stored permissions; a new registration or a CIMD document sees the rule at once. modgud.management is never available to a dynamic client.
  • A refused scope is answered at /connect/authorize the way OpenIddict answers every request-validation failure — an error page for the user — where Modgud used to redirect to the client with error=invalid_scope. (That is also why the field report saw a 400 for ID2051.)

Test plan

  • CimdFullFlowTests.A_document_without_scope_holds_the_realms_dynamic_client_scopes — the exact Claude Code document: an opted-in scope and the OIDC scopes reach consent, a scope without the flag and modgud.management are refused with the Modgud message, the flow completes, and a document that declares scope cannot exceed the set. Red with the resolver / service / handler reverted, green with the fix.
  • DcrFullFlowTests.Registration_without_scope_gets_the_realms_dynamic_client_scopes — response echoes and store holds the set; a declared list is intersected (unknown and non-opted-in names dropped); authorize on a non-opted-in scope refused with the flag named. The pre-existing negative twin now asserts the validation-page shape.
  • DynamicClientScopePolicyTests — 15 unit cases (standard verdicts, flag reading, disabled/deleted, intersection order)
  • Full backend suite green: 1708 unit + 839 integration
  • CI on this PR

🤖 Generated with Claude Code

…'s opted-in scopes

Reported from the field (amZettel, priority high), the follow-up to the
loopback fix one check deeper: Claude Code's CIMD document — like every
static MCP-client document, one file for every server in the world — declares
no `scope`, and Modgud granted a dynamic client exactly the scopes its
document or DCR registration declared. Such a client held no scope permission
at all, and OpenIddict refused everything but openid/offline_access with
invalid_request (ID2051) before the per-scope opt-in was ever consulted. The
opt-in built for exactly this case was unreachable.

One rule, DynamicClientScopePolicy, behind three seams: a dynamic client may
hold every enabled scope with AllowDynamicRegistrationClients — app-scoped or
global; the flag is the boundary, the App link never was — plus the standard
scopes other than modgud.management (immutable, so judged by a built-in
verdict; the management selector is the "tenant.admin.*" case the flag exists
for, and the seeder already strips the flag from it). A declared scope is an
upper bound intersected with that set; none declared means the whole set
(RFC 7591 §2, "a default set of scopes").

- CimdClientResolver: permissions are computed per resolve from the live set
  (memoized per request), the document stays cached — ticking the flag takes
  effect at once, like Cimd.Enabled.
- OAuthAdminService: a DCR registration stores the resolved set; the
  /connect/register response echoes what was registered (§3.2.1).
- DynamicClientScopeHandler, ordered just before OpenIddict's
  ValidateScopePermissions: a refused scope is answered with Modgud's own
  invalid_scope naming the scope and the flag, not the opaque ID2051. The
  authorize endpoint's own check (still reached from the consent
  continuation) uses the same policy and wording.

Contract change (pre-1.0): a custom global scope needs the flag for dynamic
clients; existing DCR clients keep their stored permissions. A refused scope
is now an error page from request validation (as every OpenIddict validation
failure is) instead of a redirect with error=invalid_scope.

Tests: CIMD document without scope (the exact Claude Code document) reaches
consent for an opted-in scope and the OIDC scopes, is refused for one without
the flag and for modgud.management, completes the flow, and a declaring
document cannot exceed the set; DCR registration without scope echoes and
stores the set, a declared list is intersected. Red without the fix, green
with it; 1708 unit + 839 integration green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@windischb
windischb merged commit 46387e4 into develop Sep 20, 2026
8 checks passed
@windischb
windischb deleted the fix/dynamic-client-scope-default branch September 20, 2026 06:38
windischb added a commit that referenced this pull request Sep 21, 2026
…aude.ai, VS Code, Zed, ChatGPT) (#240)

* fix(oauth): drop grant and response types Modgud does not offer instead 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>

* fix(oauth): measure CIMD and DCR against the documents real clients publish

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>

* feat(oauth): private_key_jwt for CIMD clients, keys from jwks_uri or 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>

* ui(realm-settings): DCR and CIMD hints say what the clients can be

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>

* test(private-key-jwt): the grant helper asserted by calling itself

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>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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