Intersect default scopes with scopes_supported when scope is omitted - #6283
Intersect default scopes with scopes_supported when scope is omitted#6283alex-feel wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
| // 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", |
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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.
| // 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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
Thanks for the thorough pass — all points addressed in 4f6f612:
Also fixed the stale |
Closes #6186
Problem
When a registration omits
scope,registration.ValidateScopesrequired every entry ofDefaultScopesto be present in the server'sscopes_supportedand rejected the client otherwise. Any server whosescopes_supportedlacks 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 genericinvalid_clientwith no server-side log line at any level (see #6186 for the analysis and repro).Change
ValidateScopesempty-input fallback now returns the intersection ofDefaultScopeswithallowedScopes, 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.dropped_defaultsattribute of the existingregistered new DCR clientrecord (after the baseline union and theclient_idmint, soscopesis the final set and the record correlates byclient_id); on the CIMD path as a post-union Debug record keyed on theclient_idURL. The operator-facing signal is a one-time startup log inConfig.applyDefaultsreusingValidateScopes(nil, ...): Info naming the intersection and the dropped defaults whenscopes_supporteddoes not cover the default set, Warn when the intersection is empty (scope-omitting clients will be rejected).fetch()(fetch failure,token_endpoint_auth_method,grant_types,response_types, scope) WARN-logs theclient_idand the reason, since fosite's production error rendering drops the hint and these failures previously left no server-side trace.BaselineClientScopesdoc comments (theRunConfigswagger source and the server config struct) now describe the intersection fallback;docs/server/regenerated.Behavior
scopes_supportedcarries all defaultsscopes_supportedlacks some defaultsinvalid_client)scopes_supporteddisjoint from defaultsbaseline_client_scopesunionUser-facing change
A registration that omits
scopeagainst a server whosescopes_supportedlacks part of the default set previously failed with HTTP 400 (invalid_client_metadataon DCR,invalid_clienton CIMD); it now succeeds and registers the intersection. The DCR response'sscopefield reflects the granted set.Known residual gap (deliberately out of scope)
The omitted-scope fallback draws from
DefaultScopesonly, never from the rest ofscopes_supported. A scope-omitting client against a server advertising non-default entries (e.g.mcp:tools) registers without them — and since protected-resource metadata advertisesscopes_supportedand MCP guidance tells clients to request all of it absent a narrower hint, such a client can still fail at/oauth/authorizewithinvalid_scope. Defaulting toallowedScopesitself 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
ValidateScopestable extended: intersection with one dropped default (the exact CIMD clients that omitscopeare silently rejected as 'Client does not exist' when scopes_supported does not include all DefaultScopes (breaks ChatGPT connector) #6186 shape), single-scope intersection, disjoint-set rejection.TestFetch_ScopeResolution: the previous "restrictive ScopesSupported requires explicit scope" case now pins the intersection grant; a disjoint case pins the remaining rejection.scopes_supportedlackingprofilereturns 201 with the intersection in the response.As proposed in #6186 (comment). Happy to split the diagnosability WARNs into a separate PR if preferred.