Skip to content

Intersect default scopes with scopes_supported when scope is omitted - #6283

Draft
alex-feel wants to merge 2 commits into
stacklok:mainfrom
alex-feel:default-scope-intersection
Draft

Intersect default scopes with scopes_supported when scope is omitted#6283
alex-feel wants to merge 2 commits into
stacklok:mainfrom
alex-feel:default-scope-intersection

Conversation

@alex-feel

@alex-feel alex-feel commented Aug 11, 2026

Copy link
Copy Markdown

Closes #6186

Problem

When a registration omits scope, registration.ValidateScopes required every entry of DefaultScopes to be present in the server's scopes_supported and rejected the client otherwise. Any server whose scopes_supported lacks even one default (e.g. profile) therefore rejected every scope-less registration — on both the DCR and the CIMD paths — and on the CIMD path the failure surfaced to the client as a generic invalid_client with no server-side log line at any level (see #6186 for the analysis and repro).

Change

  • The ValidateScopes empty-input fallback now returns the intersection of DefaultScopes with allowedScopes, plus the list of dropped defaults (RFC 7591 §2 permits the server to register a client with a default set of scopes). Only an empty intersection is rejected, with an error description that names the full default set. Explicit-scope validation is unchanged and strict.
  • The dropped defaults are recorded per registration at Debug: on the DCR path as a dropped_defaults attribute of the existing registered new DCR client record (after the baseline union and the client_id mint, so scopes is the final set and the record correlates by client_id); on the CIMD path as a post-union Debug record keyed on the client_id URL. The operator-facing signal is a one-time startup log in Config.applyDefaults reusing ValidateScopes(nil, ...): Info naming the intersection and the dropped defaults when scopes_supported does not cover the default set, Warn when the intersection is empty (scope-omitting clients will be rejected).
  • Diagnosability: every rejection path in the CIMD decorator's fetch() (fetch failure, token_endpoint_auth_method, grant_types, response_types, scope) WARN-logs the client_id and the reason, since fosite's production error rendering drops the hint and these failures previously left no server-side trace.
  • The stale BaselineClientScopes doc comments (the RunConfig swagger source and the server config struct) now describe the intersection fallback; docs/server/ regenerated.

Behavior

Registration Before After
omits scope, scopes_supported carries all defaults defaults granted unchanged
omits scope, scopes_supported lacks some defaults rejected (invalid_client) intersection granted (Debug record; one-time startup log)
omits scope, scopes_supported disjoint from defaults rejected still rejected, clearer message
declares scope strict validation unchanged
baseline_client_scopes union after validation unchanged

User-facing change

A registration that omits scope against a server whose scopes_supported lacks part of the default set previously failed with HTTP 400 (invalid_client_metadata on DCR, invalid_client on CIMD); it now succeeds and registers the intersection. The DCR response's scope field reflects the granted set.

Known residual gap (deliberately out of scope)

The omitted-scope fallback draws from DefaultScopes only, never from the rest of scopes_supported. A scope-omitting client against a server advertising non-default entries (e.g. mcp:tools) registers without them — and since protected-resource metadata advertises scopes_supported and MCP guidance tells clients to request all of it absent a narrower hint, such a client can still fail at /oauth/authorize with invalid_scope. Defaulting to allowedScopes itself would close that case but hands every anonymous registration the full advertised set — a policy change with an existing explicit opt-in (baseline_client_scopes = scopes_supported). The gap is also named in a code comment at the fallback site.

Tests

As proposed in #6186 (comment). Happy to split the diagnosability WARNs into a separate PR if preferred.

When a registration omits scope, ValidateScopes required every entry of
DefaultScopes to be present in scopes_supported and rejected the client
otherwise, so any server whose scopes_supported lacks even one default
(e.g. profile) rejected every scope-less registration with a generic
invalid_client, on both the DCR and the CIMD registration paths.

Fall back to the intersection of DefaultScopes with scopes_supported
instead, and report the dropped defaults so both callers WARN-log them
with the client identity. An empty intersection is still rejected, with
an error description that names the full default set. CIMD rejection
paths now also WARN-log the client_id and the reason, which previously
left no server-side trace at any level.

Closes stacklok#6186

Signed-off-by: Aleksandr Filippov <71711753+alex-feel@users.noreply.github.com>

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core fix looks right to me. RFC 7591 §2 says the AS MAY register a client with a default set of scopes and doesn't constrain what that set is, and §3.2.1 lets the server substitute metadata, so narrowing to the intersection instead of rejecting is well within spec. The registered scope is a ceiling rather than a grant, and the DCR response echoes it, so a conformant client can see what it got. Tests pin the new behaviour at all three layers, including the empty-intersection rejection and the exact dropped set. One nice side effect: the old code returned the package-level DefaultScopes slice itself, and the new code builds a fresh one.

Most of my comments are about the logging that came along with the fix rather than the fix itself. The one I'd most like to see addressed is the level: droppedDefaults is a pure function of operator config, so the WARN carries no per-request information, and it's non-empty by default in the operator path — controllerutil/authserver.go passes oidcConfig.Scopes straight through as ScopesSupported, and the CRD documents that default as ["openid", "offline_access"], so profile and email are always dropped. That's one WARN per unauthenticated /oauth/register call forever on a correct config.

One thing outside the diff: the BaselineClientScopes doc comment at pkg/authserver/config.go:86-87 still says "registered scope = client-requested, or DefaultScopes if empty", which this change makes inaccurate. That comment is a swagger source, so it's already published in docs/server/swagger.yaml:621, swagger.json:511 and docs.go:518 — fixing it means regenerating docs/server/. No docs/arch/ page describes DCR scope resolution (grepped for scopes_supported and DefaultScopes, no hits), so nothing there needs updating.

Also worth a note in the PR body under user-facing changes: a registration that used to fail with 400 invalid_client_metadata now succeeds with a narrower scope set.

Comment thread pkg/authserver/server/handlers/dcr.go Outdated
// The request omitted scope and scopes_supported does not carry the
// full default set: registration proceeds with the intersection.
// Warn so operators can see which defaults the client did not get.
slog.Warn("DCR request omitted scope; registering the intersection of default scopes with scopes_supported",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this wants to be Debug, with the operator-facing WARN moved to startup.

droppedDefaults is exactly DefaultScopes \ ScopesSupported — fully determined by config resolved at startup, so this line is byte-identical on every request. And it's non-empty by default in Kubernetes: controllerutil/authserver.go:459 passes oidcConfig.Scopes through as ScopesSupported, and mcpexternalauthconfig_types.go:709 documents that default as ["openid", "offline_access"], so profile and email are always dropped. /oauth/register is unauthenticated, so that's one WARN per anonymous request, at steady state, on a config that's entirely correct. Operators who tune alerting off WARN will learn to ignore the level, which is the opposite of what #6186 wants.

This is also the argument already made 20 lines down for the structurally identical case:

Baseline-driven expansion is the intended behavior whenever baseline_client_scopes is configured, so per-registration audit lives at Debug rather than Warn. Operator-visible signal that the baseline is in effect comes from a one-time Info log at server startup.

Config.applyDefaults (pkg/authserver/config.go:1268) already handles the ScopesSupported default and knows the whole condition, so it seems like the natural home for a one-time "configured scopes_supported does not cover the default set; clients omitting scope will register with X".

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4f6f612. The per-request line is gone entirely rather than demoted: the drop now rides the existing registered new DCR client Debug record as a dropped_defaults attribute, so there is one record per registration instead of two overlapping ones. The one-time signal lives in Config.applyDefaults, right after the scopes_supported default is applied, and reuses ValidateScopes(nil, ...) so the startup message cannot drift from what the request path does. I went with Info rather than Warn for the narrowing case since it fires on the operator's default config — the same argument you quoted for the baseline log — and Warn for the empty-intersection case, where scope-omitting clients will be rejected outright.

Comment thread pkg/authserver/server/handlers/dcr.go Outdated
// Warn so operators can see which defaults the client did not get.
slog.Warn("DCR request omitted scope; registering the intersection of default scopes with scopes_supported",
"client_name", validated.ClientName,
"granted", scopes,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about this line's attributes, both fixed by moving it further down the function.

granted is logged before the BaselineClientScopes union at lines 101-117, so when a baseline is configured this understates what the client actually ends up with. Since this log is meant to be the diagnostic surface for #6186, an operator reading granted and concluding a scope wasn't granted would go looking in the wrong place. Note dcr.go:196 already uses the key scopes for the true final set, so there are now two keys naming overlapping-but-different things. (dropped_defaults stays accurate either way — the baseline is startup-validated as a subset of ScopesSupported, so a dropped default can never come back.)

Separately, client_name is optional free-form client-supplied metadata, and clientID isn't minted until line 120, so this record can't be tied to the "registered new DCR client" line at 201 or to anything downstream. If two of these show up with client_name="" there's no way to tell whether it's one client retrying or two different ones.

Moving the log below the union and below clientID := uuid.NewString(), keyed on client_id, fixes both.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4f6f612 by folding the record into registered new DCR client: it is emitted after the baseline union and the client_id mint, so scopes there is the final set, dropped_defaults is attached only when non-empty, and the two-overlapping-keys problem disappears because there is only that one record now.

}
if len(dropped) > 0 {
slog.WarnContext(ctx, "CIMD document omits scope; granting the intersection of default scopes with scopes_supported",
"client_id", id, "granted", computed, "dropped_defaults", dropped)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same pre-union issue as the DCR handler: granted is logged here at 209 but resolvedScopes gets widened by UnionScopes(resolvedScopes, d.baselineClientScopes) at 217-219, so with a baseline configured this claims a narrower set than the client receives. Either move the log below the union or rename the key to something that doesn't read as the final grant.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4f6f612 — the CIMD record is Debug, emitted after the UnionScopes call, and carries scopes (the final set) instead of the pre-union granted.

// with the hint dropped by fosite's production error rendering, so
// without a server-side log line these failures are undiagnosable
// (see issue #6186).
slog.WarnContext(ctx, "CIMD document fetch failed",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one gives an unauthenticated caller control over WARN volume, so it's worth either a negative cache or a lower level.

fetch() returns before d.cache.Add, so failures never cache, and singleflight keys on the literal id, so varying the URL per request defeats dedup too. GetClient is reachable from fosite's unauthenticated /oauth/authorize and /oauth/token handling. The cheapest rejections are the ones in validateCIMDClientURL (pkg/oauthproto/cimd/fetch.go:150-177 — bad scheme, fragment, dot-segments, no host) which fail before any DNS or TCP, so client_id=https://x/1#f, then /2, /3 … generates one WARN line per request essentially for free.

To be clear about scope: this is log amplification, not request amplification — the outbound-fetch side is pre-existing. And it isn't log injection either; slog's handlers escape control characters in attribute values, so a CRLF payload can't split a line. It's just volume.

A short-TTL negative cache keyed on the raw id would fix it and would also cut the redundant refetches. Debug plus a metrics counter would work too if the goal is really operator diagnosability. Fine as a follow-up rather than in this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, including on the scoping — left out of this PR. Happy to follow up with the short-TTL negative cache keyed on the raw id after this lands; it would also remove the redundant refetches for repeated invalid ids.

// reject any client that omits scope against a server whose
// scopes_supported does not carry the full default set, even though a
// perfectly usable subset exists (see issue #6186).
for _, s := range DefaultScopes {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth flagging that this still leaves a slice of #6186 unfixed, because the loop iterates DefaultScopes rather than allowedScopes — so anything in scopes_supported that isn't one of the four defaults can never be reached on the omitted-scope path.

Using a config shape that already appears in the repo's tests (mcpserver_externalauth_runconfig_test.go:612 sets Scopes = ["openid", "offline_access", "mcp:tools"]): a scope-omitting CIMD client now registers successfully with [openid, offline_access]. But PRM advertises all three (pkg/runner/config_builder.go:1045-1053 derives PRM scopes from ScopesSupported), and MCP 2025-11-25 says that absent a WWW-Authenticate scope hint clients SHOULD use all scopes from scopes_supported. fosite doesn't narrow — it returns invalid_scope for anything outside the client's registered set. So the failure moves from registration to /oauth/authorize, and the ChatGPT connector is still stuck with no lever on its side. The empty-intersection branch below has the same shape for a fully disjoint scopes_supported, which is reachable since the operator passes oidcConfig.Scopes verbatim.

Defaulting to allowedScopes itself would close both cases and delete the reject branch — §2 lets the AS pick any default set, the value is a ceiling and consent still applies at /authorize, and scopes_supported is by construction what this AS is willing to issue. That said, handing every anonymous DCR client the full advertised set is a policy change and BaselineClientScopes is currently the explicit opt-in for it, so I don't think it belongs in this PR. Operators can already get there today by setting baseline_client_scopes = scopes_supported, since the baseline is unioned in after validation on both paths. Mostly I'd like the residual gap named somewhere so it isn't rediscovered from a bug report.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named in 4f6f612: the fallback block in ValidateScopes now carries a "Known residual gap" comment spelling out the DefaultScopes-vs-allowedScopes limit, the PRM interaction that moves the failure to /oauth/authorize, and the baseline_client_scopes = scopes_supported opt-in; the PR body has the same under a dedicated section. Agreed that defaulting to allowedScopes is a policy change that should not ride along here.

Demote the per-registration dropped-defaults record to Debug and move
it after the baseline union: on the DCR path it rides the existing
'registered new DCR client' record keyed on client_id, on the CIMD
path it logs the final post-union scope set. The drop is a pure
function of startup configuration, so the operator-facing signal is
now a one-time startup log in Config.applyDefaults (Info when the
intersection narrows the default set, Warn when it is empty and
scope-omitting clients will be rejected), reusing ValidateScopes so
the startup message cannot drift from request-path behavior.

Name the residual gap in ValidateScopes: the omitted-scope fallback
draws from DefaultScopes only, so entries in scopes_supported outside
the default set remain unreachable on that path; the explicit opt-in
is baseline_client_scopes = scopes_supported.

Update the stale BaselineClientScopes doc comments that described the
pre-intersection fallback and regenerate docs/server.

Signed-off-by: Aleksandr Filippov <71711753+alex-feel@users.noreply.github.com>
@alex-feel

Copy link
Copy Markdown
Author

Thanks for the thorough pass — all points addressed in 4f6f612:

  • The per-request drop record is Debug and merged into the final registration record (DCR) / emitted post-union (CIMD), keyed on client_id, so scopes always shows the true final set.
  • The operator-facing signal is a one-time startup log in Config.applyDefaults, reusing ValidateScopes(nil, ...): Info when the intersection narrows the default set, Warn when it is empty.
  • The fetch-path log-volume point is left for a follow-up as suggested — I can pick up the negative cache after this lands.
  • The residual DefaultScopes-vs-allowedScopes gap is named in a code comment at the fallback site and in the PR body.

Also fixed the stale BaselineClientScopes doc comments (the RunConfig swagger source and the server config struct), regenerated docs/server/, and the PR body now records the 400→200 behavior change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants