Allow operators to inject baseline scopes into DCR registrations#5233
Draft
Allow operators to inject baseline scopes into DCR registrations#5233
Conversation
Some DCR clients narrow the scope field at /oauth/register but later request additional scopes at /oauth/authorize, getting rejected with invalid_scope. RFC 7591 §3.1.1 explicitly permits the AS to override the registered scope, so let operators declare a baseline set that the embedded auth server unions into every DCR registration. This commit only adds the CRD field. The plumbing through RunConfig, the runner, the server provider, and the DCR handler comes in subsequent commits. Refs #5224
Add the BaselineClientScopes field on the on-disk RunConfig and copy it from the CRD's EmbeddedAuthServerConfig in the operator-side builder. The runtime Config and the DCR handler are wired in subsequent commits; startup validation that the baseline is a subset of ScopesSupported lands with the next commit. Refs #5224
If an operator configures baseline_client_scopes with a value missing from scopes_supported, the embedded DCR handler would later register clients with a scope the server does not advertise, and fosite would reject those clients at /oauth/authorize with invalid_scope. Catching the misconfiguration at startup gives operators a clear error instead of debugging silent rejections in production. Add RunConfig.Validate() with a subset check, and call it from the runner entry point before any secret resolution or HTTP wiring. errors.Join wraps the (currently single) sub-check so future RunConfig invariants compose without dropping existing checks. Refs #5224
Add BaselineClientScopes to the runtime Config struct and copy it from RunConfig in the runner's resolvedCfg block. The DCR handler needs the baseline at request time, so it must travel through the runtime Config the same way ScopesSupported does. The field is populated but not yet consumed by any downstream code; the next commit plumbs it from Config into the AuthorizationServerConfig that the DCR handler reads. Refs #5224
Add BaselineClientScopes to AuthorizationServerParams and AuthorizationServerConfig and copy it through Config -> Params -> ASConfig so the DCR handler can read it via h.config in the next commit. Defense-in-depth: validateParams re-checks the baseline-subset-of- supported invariant. RunConfig.Validate already enforces it for the runner-driven path, but a direct caller of authserver.New(Config) bypasses that check. Validating again at NewAuthorizationServerConfig catches the misconfiguration at the deepest layer where both slices are simultaneously available. Refs #5224
The DCR registration handler will use this in the next commit to union an operator-configured scope baseline into every newly registered client's scope set. Pulling the union into a separate helper keeps the handler readable and lets the order/dedup contract be unit-tested in isolation. Order matters: requested scopes appear first (in input order), then baseline scopes not already present. The handler uses slices.Equal(union, requested) to decide whether to log a WARN, so stable order is part of the contract. Refs #5224
This is the user-visible behavior change: between scope validation and client construction, the DCR handler now unions the operator-configured BaselineClientScopes into the registered client's scope set. RFC 7591 §3.2.1 permits the AS to replace requested client metadata, which is what fixes the Claude Code bug (anthropics/claude-code#4540): a client whose DCR request narrows the scope field can still request the baseline at /oauth/authorize without invalid_scope rejection. WARN logs only when the union actually expands the requested set (slices.Equal short-circuits the no-op path). The DCR response at the bottom of the handler already echoes the effective scope set back to the client, so clients see exactly what they got. Refs #5224
Cover the four contract paths in TestRegisterClientHandler_- BaselineClientScopes: empty client scope unions with baseline, baseline-as-subset of requested produces no expansion, partial overlap appends only non-overlapping scopes, disjoint baseline expands the registered set, and nil baseline preserves existing behavior. DoAndReturn captures the fosite client passed to RegisterClient so the test asserts not only that the DCR response echoes the expected scope set but also that the same set actually reaches storage. The disjoint case is the canonical regression test for anthropics/claude-code#4540. Refs #5224
TestRunConfigValidate covers seven contract paths: nil and empty baselines pass with non-empty scopes_supported, single and multi-element baseline subsets pass, a baseline scope missing from scopes_supported is reported with both the offending scope name and the "not in scopes_supported" phrase, a non-nil baseline with nil scopes_supported fails closed, and the helper reports a missing scope when multiple are missing. Refs #5224
This is the canonical regression test for anthropics/claude-code#4540 (Claude Code DCR-registers with a narrowed scope but later requests the full set at /oauth/authorize). Three sequential subtests on a shared client_id: 1. DCR-register with scope="openid" against a server configured with BaselineClientScopes=["offline_access"]; assert the response's scope field echoes "openid offline_access". 2. /authorize with scope="openid offline_access" against the same client; assert HTTP 302 redirect to upstream (not 400 invalid_scope, the pre-fix behavior). 3. /authorize with a scope outside scopes_supported; assert fosite rejects with invalid_scope. Guards against silent privilege escalation if BaselineClientScopes ever drifts to bypass the ScopesSupported boundary. WithBaselineClientScopes is added to the test helpers alongside the existing WithScopesSupported option. Refs #5224
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
Some MCP clients (notably Claude Code) DCR-register with a narrowed
scopefield at/oauth/registerbut later request a wider set at/oauth/authorize. fosite rejects those requests withinvalid_scopebecause the registered client's scope set does not include the requested scopes. RFC 7591 §3.2.1 explicitly permits the AS to replace requested client metadata; the embedded auth server now uses that to expand the registered scope set.EmbeddedAuthServerConfig.baselineClientScopesto the CRD — operators declare scopes that must be in every registered client's scope set.RunConfig→ runtimeConfig→AuthorizationServerConfigso the DCR handler can read it at request time.slices.Equal).baseline ⊆ scopesSupportedat three layers (CRD-loadedRunConfig,applyDefaults,validateParams) so misconfiguration fails loudly at startup.scopeParameter in Dynamic Client Registration and Authorization Requests anthropics/claude-code#4540) end-to-end.Refs #5224
Type of change
Test plan
task test)task test-e2e) — covered by the new integration test intest/integration/authserver/task lint-fix)Manual: end-to-end deploy still pending — opening as draft for that.
API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.baselineClientScopesis a new optional field withomitemptyand a nil-safe deepcopy. Existing CRs without the field decode asnil; the validator early-returns and behavior is unchanged.Does this introduce a user-facing change?
Yes — operators of `MCPExternalAuthConfig` and `VirtualMCPServer` resources gain a new optional `spec.embedded.baselineClientScopes` field. When set, every DCR-registered client's scope set is expanded to include these scopes regardless of what the client requested at `/oauth/register`.
Special notes for reviewers
🤖 Generated with Claude Code