fix(oauth): a dynamic client without a declared scope holds the realm's opted-in scopes - #239
Merged
Merged
Conversation
…'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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/authorizeansweredinvalid_request— This client application is not allowed to use the specified scope (OpenIddict ID2051) for every scope butopenidandoffline_access, although all of the server's scopes hadAllowDynamicRegistrationClients = trueand the API hadAllowDynamicRegistration = 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 withAllowDynamicRegistrationClients— app-scoped or global; the flag is the boundary, the App link never was — plus the standard scopes other thanmodgud.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 declaredscopeis an upper bound intersected with that set; none declared means the whole set.CimdClientResolverCimd.EnabledOAuthAdminService(DCR create)/connect/registerechoes what was registered (§3.2.1)DynamicClientScopeHandlerValidateScopePermissions: a refused scope gets Modgud's owninvalid_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 wordingContract changes (pre-1.0), in the release-notes draft
modgud.managementis never available to a dynamic client./connect/authorizethe way OpenIddict answers every request-validation failure — an error page for the user — where Modgud used to redirect to the client witherror=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 andmodgud.managementare refused with the Modgud message, the flow completes, and a document that declaresscopecannot 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)🤖 Generated with Claude Code