-
Notifications
You must be signed in to change notification settings - Fork 221
Validate CIMD scope, grant_types and response_types against AS policy #5385
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -177,7 +177,14 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se | |
| // so that GetClient calls for HTTPS client_id values are intercepted at the | ||
| // fosite level (not just the handler level). | ||
| if cfg.CIMDEnabled { | ||
| stor, err = storage.NewCIMDStorageDecorator(stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL) | ||
| if len(cfg.BaselineClientScopes) > 0 { | ||
| slog.Warn("CIMD is enabled with baseline_client_scopes configured; "+ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F12 (LOW) — The warning fires every server start when both
Pick one:
The current shape splits the difference. |
||
| "all dynamically resolved CIMD clients will receive the baseline scopes", | ||
| "baseline_client_scopes", cfg.BaselineClientScopes) | ||
| } | ||
| stor, err = storage.NewCIMDStorageDecorator( | ||
| stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL, | ||
| cfg.ScopesSupported, cfg.BaselineClientScopes) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to initialize CIMD storage decorator: %w", err) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -28,10 +28,12 @@ import ( | |||||||||||
| // Only GetClient is overridden. DCR clients (opaque IDs) continue to work | ||||||||||||
| // exactly as before. | ||||||||||||
| type CIMDStorageDecorator struct { | ||||||||||||
| Storage // embed full interface — all methods delegate | ||||||||||||
| sf singleflight.Group // deduplicates concurrent fetches for the same URL | ||||||||||||
| cache *lru.Cache[string, *cimdCacheEntry] | ||||||||||||
| ttl time.Duration | ||||||||||||
| Storage // embed full interface — all methods delegate | ||||||||||||
| sf singleflight.Group // deduplicates concurrent fetches for the same URL | ||||||||||||
| cache *lru.Cache[string, *cimdCacheEntry] | ||||||||||||
| ttl time.Duration | ||||||||||||
| scopesSupported []string // AS-configured scopes; nil means accept any | ||||||||||||
| baselineClientScopes []string // unioned into every client's scope set, same as DCR | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| type cimdCacheEntry struct { | ||||||||||||
|
|
@@ -43,11 +45,20 @@ type cimdCacheEntry struct { | |||||||||||
| // it returns base unchanged (no allocation). cacheMaxSize must be >= 1; | ||||||||||||
| // fallbackTTL is the fixed TTL applied to every cache entry (Cache-Control | ||||||||||||
| // header parsing is not yet implemented; all entries use this value). | ||||||||||||
| // scopesSupported is the AS-configured scope allowlist; documents that declare | ||||||||||||
| // scopes outside this set are rejected at fetch time. In production this is | ||||||||||||
| // always non-nil because applyDefaults populates ScopesSupported before the | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F8 (LOW) — Doc comments document cross-file invariants not enforced in code. Two related drift hazards:
Suggestions:
|
||||||||||||
| // decorator is constructed. Pass nil only in tests that need unconstrained scope | ||||||||||||
| // passthrough. | ||||||||||||
| // baselineClientScopes mirrors the AS-level baseline: it is unioned into every | ||||||||||||
| // CIMD client's scope set after validation, matching DCR handler behaviour. | ||||||||||||
| func NewCIMDStorageDecorator( | ||||||||||||
| base Storage, | ||||||||||||
| enabled bool, | ||||||||||||
| cacheMaxSize int, | ||||||||||||
| fallbackTTL time.Duration, | ||||||||||||
| scopesSupported []string, | ||||||||||||
| baselineClientScopes []string, | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F4 (MEDIUM) — The constructor signature is A future addition (e.g., Cache-Control TTL parsing, per-document size cap) would push the signature further past readability limits. Suggested refactor — introduce a config struct: type CIMDDecoratorConfig struct {
Enabled bool
CacheMaxSize int
FallbackTTL time.Duration
ScopesSupported []string
BaselineClientScopes []string
}
func NewCIMDStorageDecorator(base Storage, cfg CIMDDecoratorConfig) (Storage, error)The single production caller in |
||||||||||||
| ) (Storage, error) { | ||||||||||||
| if !enabled { | ||||||||||||
| return base, nil | ||||||||||||
|
|
@@ -63,9 +74,11 @@ func NewCIMDStorageDecorator( | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| return &CIMDStorageDecorator{ | ||||||||||||
| Storage: base, | ||||||||||||
| cache: c, | ||||||||||||
| ttl: fallbackTTL, | ||||||||||||
| Storage: base, | ||||||||||||
| cache: c, | ||||||||||||
| ttl: fallbackTTL, | ||||||||||||
| scopesSupported: scopesSupported, | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F7 (LOW) — Constructor stores caller-supplied scope slices without defensive
return &CIMDStorageDecorator{
Storage: base,
cache: c,
ttl: fallbackTTL,
scopesSupported: slices.Clone(scopesSupported),
baselineClientScopes: slices.Clone(baselineClientScopes),
}, nilIf a future config-reload path mutates these slices in place (e.g., appends a new scope), the decorator's view will not silently drift. |
||||||||||||
| baselineClientScopes: baselineClientScopes, | ||||||||||||
| }, nil | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
|
|
@@ -119,17 +132,75 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Reject documents that declare an auth method this AS does not support. | ||||||||||||
| // The embedded AS only advertises "none"; accepting a doc that says | ||||||||||||
| // "private_key_jwt" and then silently treating the client as public would | ||||||||||||
| // mislead operators and break clients that actually try to use JWT assertions. | ||||||||||||
| // ErrInvalidClient: the document was fetched successfully but its declared | ||||||||||||
| // metadata violates AS policy (distinct from ErrNotFound which means the | ||||||||||||
| // document could not be fetched at all). | ||||||||||||
| if m := doc.TokenEndpointAuthMethod; m != "" && m != defaultCIMDTokenEndpointAuthMethod { | ||||||||||||
| return nil, fmt.Errorf("%w: CIMD document at %s claims token_endpoint_auth_method %q "+ | ||||||||||||
| "but this server only supports %q", | ||||||||||||
| fosite.ErrNotFound.WithHint("unsupported token_endpoint_auth_method"), | ||||||||||||
| fosite.ErrInvalidClient.WithHint("unsupported token_endpoint_auth_method"), | ||||||||||||
| id, m, defaultCIMDTokenEndpointAuthMethod) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| client := buildFositeClient(doc) | ||||||||||||
| // Reject documents that declare grant_types the embedded AS does not support. | ||||||||||||
| // Mirrors DCR's validateGrantTypes which restricts public clients to | ||||||||||||
| // authorization_code + refresh_token and requires authorization_code to be present. | ||||||||||||
| for _, gt := range doc.GrantTypes { | ||||||||||||
| if !allowedCIMDGrantTypes[gt] { | ||||||||||||
| return nil, fmt.Errorf("%w: CIMD document at %s claims grant_type %q "+ | ||||||||||||
| "but this server only supports %v for public clients", | ||||||||||||
| fosite.ErrInvalidClient.WithHint("unsupported grant_type"), | ||||||||||||
| id, gt, defaultCIMDGrantTypes) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| if len(doc.GrantTypes) > 0 && !slices.Contains(doc.GrantTypes, "authorization_code") { | ||||||||||||
| return nil, fmt.Errorf("%w: CIMD document at %s grant_types must include %q", | ||||||||||||
| fosite.ErrInvalidClient.WithHint("grant_types must include authorization_code"), | ||||||||||||
| id, "authorization_code") | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Reject documents that declare response_types the embedded AS does not support. | ||||||||||||
| for _, rt := range doc.ResponseTypes { | ||||||||||||
| if !allowedCIMDResponseTypes[rt] { | ||||||||||||
| return nil, fmt.Errorf("%w: CIMD document at %s claims response_type %q "+ | ||||||||||||
| "but this server only supports %v", | ||||||||||||
| fosite.ErrInvalidClient.WithHint("unsupported response_type"), | ||||||||||||
| id, rt, defaultCIMDResponseTypes) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F9 (LOW) — DCR-parity gaps around Two related findings around the parity claim:
Cleanest fix: extract |
||||||||||||
|
|
||||||||||||
| // Compute and validate the client scope list consistent with DCR. | ||||||||||||
| // When ScopesSupported is configured: | ||||||||||||
| // - Declared scopes are validated via registration.ValidateScopes (same | ||||||||||||
| // function as the DCR handler). | ||||||||||||
| // - When the document omits scope, the client receives ScopesSupported | ||||||||||||
| // rather than DefaultScopes — a CIMD document that doesn't declare scope | ||||||||||||
| // means "whatever the AS supports", not "give me the full default set" | ||||||||||||
| // (which may exceed ScopesSupported). | ||||||||||||
| // When ScopesSupported is not configured: no AS-level validation; declared | ||||||||||||
| // scopes are used directly, or nil to let buildFositeClient apply DefaultScopes. | ||||||||||||
| // In both cases BaselineClientScopes is unioned in after validation, | ||||||||||||
| // matching the DCR handler's behaviour. | ||||||||||||
| var resolvedScopes []string | ||||||||||||
| if len(d.scopesSupported) > 0 { | ||||||||||||
| if doc.Scope != "" { | ||||||||||||
| computed, dcrErr := registration.ValidateScopes(strings.Fields(doc.Scope), d.scopesSupported) | ||||||||||||
| if dcrErr != nil { | ||||||||||||
| return nil, fmt.Errorf("%w: CIMD document at %s: %s", | ||||||||||||
| fosite.ErrInvalidClient.WithHint(dcrErr.Error), id, dcrErr.ErrorDescription) | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F6 (LOW) —
The current code passes the error code as the fosite hint. The descriptive text ends up in the wrapped error chain via The other rejection branches in this file (lines 141, 152, 158, 167) pass static descriptive hints like
Suggested change
(Or drop the redundant |
||||||||||||
| } | ||||||||||||
| resolvedScopes = computed | ||||||||||||
| } else { | ||||||||||||
| resolvedScopes = slices.Clone(d.scopesSupported) | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F5 (MEDIUM) — Omitted-scope branch silently diverges from DCR when When In the default config these are equal because In the worst case the extra scopes include elevated permissions ( Suggested fix — call the same validator DCR uses, which returns
Suggested change
(The error return can't trigger here in production because If the current behavior is intentional, remove the "same scope policy as DCR" claim from the PR description and add a note to the doc comment explaining the intentional divergence. |
||||||||||||
| } | ||||||||||||
| } else if doc.Scope != "" { | ||||||||||||
| resolvedScopes = strings.Fields(doc.Scope) | ||||||||||||
| } | ||||||||||||
| if len(d.baselineClientScopes) > 0 { | ||||||||||||
| resolvedScopes = registration.UnionScopes(resolvedScopes, d.baselineClientScopes) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| client := buildFositeClient(doc, resolvedScopes) | ||||||||||||
|
|
||||||||||||
| d.cache.Add(id, &cimdCacheEntry{ | ||||||||||||
| client: client, | ||||||||||||
|
|
@@ -144,10 +215,19 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli | |||||||||||
| // that use the authorization code flow with refresh token rotation. | ||||||||||||
| var defaultCIMDGrantTypes = []string{"authorization_code", "refresh_token"} | ||||||||||||
|
|
||||||||||||
| // allowedCIMDGrantTypes is the set of grant_type values a CIMD document may | ||||||||||||
| // declare. Values outside this set are rejected at fetch time, consistent with | ||||||||||||
| // DCR which restricts public clients to authorization_code + refresh_token. | ||||||||||||
| var allowedCIMDGrantTypes = map[string]bool{"authorization_code": true, "refresh_token": true} | ||||||||||||
|
|
||||||||||||
| // defaultCIMDResponseTypes are the OAuth 2.0 response types applied when the | ||||||||||||
| // CIMD document omits response_types. | ||||||||||||
| var defaultCIMDResponseTypes = []string{"code"} | ||||||||||||
|
|
||||||||||||
| // allowedCIMDResponseTypes is the set of response_type values a CIMD document | ||||||||||||
| // may declare. Values outside this set are rejected at fetch time. | ||||||||||||
| var allowedCIMDResponseTypes = map[string]bool{"code": true} | ||||||||||||
|
|
||||||||||||
| // defaultCIMDTokenEndpointAuthMethod is the token endpoint authentication | ||||||||||||
| // method applied when the CIMD document omits token_endpoint_auth_method. | ||||||||||||
| // Documents that declare any other value are rejected by fetch() before | ||||||||||||
|
|
@@ -157,7 +237,9 @@ const defaultCIMDTokenEndpointAuthMethod = "none" | |||||||||||
| // buildFositeClient converts a ClientMetadataDocument into a fosite.Client. | ||||||||||||
| // Redirect URIs containing http://localhost are wrapped in a LoopbackClient | ||||||||||||
| // so that RFC 8252 §7.3 dynamic port matching applies. | ||||||||||||
| func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client { | ||||||||||||
| // resolvedScopes is the already-validated scope list computed by fetch() via | ||||||||||||
| // registration.ValidateScopes; when nil, DefaultScopes is used (unconstrained AS). | ||||||||||||
| func buildFositeClient(doc *cimd.ClientMetadataDocument, resolvedScopes []string) fosite.Client { | ||||||||||||
| grantTypes := doc.GrantTypes | ||||||||||||
| if len(grantTypes) == 0 { | ||||||||||||
| grantTypes = defaultCIMDGrantTypes | ||||||||||||
|
|
@@ -173,13 +255,12 @@ func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client { | |||||||||||
| tokenEndpointAuthMethod = defaultCIMDTokenEndpointAuthMethod | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // When the document omits the scope field, apply the same defaults as DCR | ||||||||||||
| // registration so CIMD clients can request openid/profile/email/offline_access | ||||||||||||
| // without needing to enumerate them explicitly in the metadata document. | ||||||||||||
| // Clone to avoid aliasing the package-level DefaultScopes slice. | ||||||||||||
| scopes := slices.Clone(registration.DefaultScopes) | ||||||||||||
| if doc.Scope != "" { | ||||||||||||
| scopes = strings.Fields(doc.Scope) | ||||||||||||
| // Scopes were computed and validated by fetch() via registration.ValidateScopes, | ||||||||||||
| // consistent with the DCR handler. Fall back to DefaultScopes only when the | ||||||||||||
| // decorator has no ScopesSupported restriction (unconstrained AS). | ||||||||||||
| scopes := resolvedScopes | ||||||||||||
| if len(scopes) == 0 { | ||||||||||||
| scopes = slices.Clone(registration.DefaultScopes) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| defaultClient := &fosite.DefaultClient{ | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
F1 (MEDIUM) —
TestUnionScopeslives in the wrong package after the move.This test now calls
registration.UnionScopesacross the package boundary, but the file itself stayed inpackage handlers. Meanwhilehandlers/scopes.gowas reduced to just the SPDX header andpackage handlers(no contents). This violates.claude/rules/testing.md("tests must only test code in the package under test") and leaves an orphaned empty source-file pair in the wrong location.Anyone running
go test ./pkg/authserver/server/registration/...after editingUnionScopeswill not exercise these table-driven cases.Fix: move this test (and the file, or fold into
registration/dcr_test.go) topkg/authserver/server/registration/. Delete the now-emptypkg/authserver/server/handlers/scopes.goand this file. Raised by 3 of 4 specialist agents (gocorr, tests, general).