Skip to content

Allow operators to inject baseline scopes into DCR registrations#5233

Draft
jhrozek wants to merge 10 commits intomainfrom
worktree-as-dcr-scopes
Draft

Allow operators to inject baseline scopes into DCR registrations#5233
jhrozek wants to merge 10 commits intomainfrom
worktree-as-dcr-scopes

Conversation

@jhrozek
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek commented May 8, 2026

Summary

Some MCP clients (notably Claude Code) DCR-register with a narrowed scope field at /oauth/register but later request a wider set at /oauth/authorize. fosite rejects those requests with invalid_scope because 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.

  • Add EmbeddedAuthServerConfig.baselineClientScopes to the CRD — operators declare scopes that must be in every registered client's scope set.
  • Plumb the field through RunConfig → runtime ConfigAuthorizationServerConfig so the DCR handler can read it at request time.
  • DCR handler unions the baseline into each new registration; logs a WARN when expansion actually happens (no-op short-circuited via slices.Equal).
  • Validate baseline ⊆ scopesSupported at three layers (CRD-loaded RunConfig, applyDefaults, validateParams) so misconfiguration fails loudly at startup.
  • Integration regression test reproduces the Claude Code repro pattern ([BUG] Missing scope Parameter in Dynamic Client Registration and Authorization Requests anthropics/claude-code#4540) end-to-end.

Refs #5224

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e) — covered by the new integration test in test/integration/authserver/
  • Linting (task lint-fix)
  • Manual testing (describe below)

Manual: end-to-end deploy still pending — opening as draft for that.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

baselineClientScopes is a new optional field with omitempty and a nil-safe deepcopy. Existing CRs without the field decode as nil; 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

  • Defense-in-depth validation runs at three layers; the wire-format-boundary check (`RunConfig.Validate`) is the earliest, the `Config.applyDefaults` guard catches direct callers, and `validateParams` is a final safety net.
  • The `unionScopes` helper preserves requested-scope order; the `slices.Equal` no-op detection in the handler depends on that invariant.
  • `MaxItems=10` on the CRD field bounds operator misuse; we considered an item-level `Pattern` marker and decided runtime validation suffices for v1.

🤖 Generated with Claude Code

jhrozek added 10 commits May 8, 2026 21:44
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
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