Skip to content

Commit 7c14ee2

Browse files
amirejazclaude
andcommitted
Address tgrunnagle review feedback on CIMD validation
F1 Move TestUnionScopes to registration package where UnionScopes lives; delete now-empty handlers/scopes.go and handlers/scopes_test.go F2 Add assert.ErrorIs(ErrInvalidClient)/NotErrorIs(ErrNotFound) to all CIMD policy rejection tests to pin the error type change F4 Replace 6 positional NewCIMDStorageDecorator args with CIMDDecoratorConfig struct — prevents silent swap of adjacent []string F5 Omitted-scope now calls ValidateScopes(nil, scopesSupported) matching DCR: returns DefaultScopes when DefaultScopes ⊆ ScopesSupported, error otherwise (document must declare scope explicitly) F6 Fix dcrErr.Error → dcrErr.ErrorDescription in scope validation hint so the human-readable description reaches the fosite hint field F7 slices.Clone scope slices in CIMDDecoratorConfig constructor F8 Fix buildFositeClient doc: "when empty" not "when nil" F9 Export ValidatePublicGrantTypes/ValidatePublicResponseTypes from registration package; replace hand-rolled loops in cimd_decorator.go with calls to these shared validators — grant_type/response_type validation is now identical on both DCR and CIMD paths F10 Rename AcceptsSupportedGrantTypes→AcceptsOmittedGrantTypes and RejectsRefreshTokenOnly→RejectsGrantTypesMissingAuthorizationCode F11 Remove redundant TestBuildFositeClient_ScopeDefaultsToScopesSupported (real decision lives in fetch(), not buildFositeClient) F12 Update slog.Warn message to name the security consequence when CIMD+BaselineClientScopes are both configured Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 25b296d commit 7c14ee2

8 files changed

Lines changed: 279 additions & 375 deletions

File tree

pkg/authserver/server/handlers/authorize.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ func (h *Handler) AuthorizeHandler(w http.ResponseWriter, req *http.Request) {
6666
return
6767
}
6868

69-
slog.Debug("parsed client-requested scopes", //nolint:gosec // G706: scope count is an integer
69+
slog.Debug("authorize request received",
70+
"client_id", clientID,
71+
"redirect_uri", redirectURI,
7072
"scope_count", len(scopes),
7173
)
7274

pkg/authserver/server/handlers/scopes.go

Lines changed: 0 additions & 4 deletions
This file was deleted.

pkg/authserver/server/handlers/scopes_test.go

Lines changed: 0 additions & 117 deletions
This file was deleted.

pkg/authserver/server/registration/dcr.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,19 @@ func UnionScopes(requested, baseline []string) []string {
355355
}
356356
return out
357357
}
358+
359+
// ValidatePublicGrantTypes validates the grant_types for a public OAuth client,
360+
// applying the same rules as DCR: authorization_code must be present, and all
361+
// declared values must be in the allowed set. Returns the validated slice (with
362+
// defaults applied when nil/empty) or a *DCRError on violation.
363+
func ValidatePublicGrantTypes(grantTypes []string) ([]string, *DCRError) {
364+
return validateGrantTypes(grantTypes)
365+
}
366+
367+
// ValidatePublicResponseTypes validates the response_types for a public OAuth
368+
// client, applying the same rules as DCR: code must be present and all declared
369+
// values must be in the allowed set. Returns the validated slice (with defaults
370+
// applied when nil/empty) or a *DCRError on violation.
371+
func ValidatePublicResponseTypes(responseTypes []string) ([]string, *DCRError) {
372+
return validateResponseTypes(responseTypes)
373+
}

pkg/authserver/server/registration/dcr_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,3 +694,31 @@ func TestValidateScopeSubset(t *testing.T) {
694694
})
695695
}
696696
}
697+
698+
func TestUnionScopes(t *testing.T) {
699+
t.Parallel()
700+
701+
tests := []struct {
702+
name string
703+
req []string
704+
baseline []string
705+
want []string
706+
}{
707+
{name: "both nil returns nil", req: nil, baseline: nil, want: nil},
708+
{name: "both empty returns nil", req: []string{}, baseline: []string{}, want: nil},
709+
{name: "requested only preserved unchanged", req: []string{"openid", "profile"}, baseline: nil, want: []string{"openid", "profile"}},
710+
{name: "baseline only returned when no requested", req: nil, baseline: []string{"openid", "email"}, want: []string{"openid", "email"}},
711+
{name: "requested subset of baseline expands correctly", req: []string{"openid"}, baseline: []string{"openid", "profile", "email"}, want: []string{"openid", "profile", "email"}},
712+
{name: "disjoint sets: requested first then baseline", req: []string{"openid", "profile"}, baseline: []string{"email", "offline_access"}, want: []string{"openid", "profile", "email", "offline_access"}},
713+
{name: "exact match produces no duplicates", req: []string{"openid", "profile"}, baseline: []string{"openid", "profile"}, want: []string{"openid", "profile"}},
714+
{name: "duplicates in requested are deduplicated", req: []string{"openid", "openid", "profile"}, baseline: nil, want: []string{"openid", "profile"}},
715+
}
716+
717+
for _, tt := range tests {
718+
t.Run(tt.name, func(t *testing.T) {
719+
t.Parallel()
720+
got := UnionScopes(tt.req, tt.baseline)
721+
assert.Equal(t, tt.want, got)
722+
})
723+
}
724+
}

pkg/authserver/server_impl.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,17 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
179179
if cfg.CIMDEnabled {
180180
if len(cfg.BaselineClientScopes) > 0 {
181181
slog.Warn("CIMD is enabled with baseline_client_scopes configured; "+
182-
"all dynamically resolved CIMD clients will receive the baseline scopes",
182+
"any third-party client resolved via CIMD will also receive these scopes — "+
183+
"ensure they are scopes you would grant by default to any unknown client",
183184
"baseline_client_scopes", cfg.BaselineClientScopes)
184185
}
185-
stor, err = storage.NewCIMDStorageDecorator(
186-
stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL,
187-
cfg.ScopesSupported, cfg.BaselineClientScopes)
186+
stor, err = storage.NewCIMDStorageDecorator(stor, storage.CIMDDecoratorConfig{
187+
Enabled: true,
188+
CacheMaxSize: cfg.CIMDCacheMaxSize,
189+
FallbackTTL: cfg.CIMDCacheFallbackTTL,
190+
ScopesSupported: cfg.ScopesSupported,
191+
BaselineClientScopes: cfg.BaselineClientScopes,
192+
})
188193
if err != nil {
189194
return nil, fmt.Errorf("failed to initialize CIMD storage decorator: %w", err)
190195
}

pkg/authserver/storage/cimd_decorator.go

Lines changed: 53 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -41,44 +41,45 @@ type cimdCacheEntry struct {
4141
expires time.Time
4242
}
4343

44-
// NewCIMDStorageDecorator wraps base with CIMD client lookup. When enabled=false
45-
// it returns base unchanged (no allocation). cacheMaxSize must be >= 1;
46-
// fallbackTTL is the fixed TTL applied to every cache entry (Cache-Control
47-
// header parsing is not yet implemented; all entries use this value).
48-
// scopesSupported is the AS-configured scope allowlist; documents that declare
49-
// scopes outside this set are rejected at fetch time. In production this is
50-
// always non-nil because applyDefaults populates ScopesSupported before the
51-
// decorator is constructed. Pass nil only in tests that need unconstrained scope
52-
// passthrough.
53-
// baselineClientScopes mirrors the AS-level baseline: it is unioned into every
54-
// CIMD client's scope set after validation, matching DCR handler behaviour.
55-
func NewCIMDStorageDecorator(
56-
base Storage,
57-
enabled bool,
58-
cacheMaxSize int,
59-
fallbackTTL time.Duration,
60-
scopesSupported []string,
61-
baselineClientScopes []string,
62-
) (Storage, error) {
63-
if !enabled {
44+
// CIMDDecoratorConfig holds the configuration for NewCIMDStorageDecorator.
45+
// Using a struct prevents silent swaps of the two adjacent []string fields.
46+
type CIMDDecoratorConfig struct {
47+
// Enabled returns base unchanged when false, avoiding an allocation.
48+
Enabled bool
49+
// CacheMaxSize is the maximum number of documents in the LRU cache (must be >= 1).
50+
CacheMaxSize int
51+
// FallbackTTL is the fixed TTL applied to every cache entry.
52+
FallbackTTL time.Duration
53+
// ScopesSupported is the AS scope allowlist; see pkg/authserver/config.go
54+
// applyDefaults for production guarantees. Pass nil in tests only.
55+
ScopesSupported []string
56+
// BaselineClientScopes is unioned into every CIMD client's scope set,
57+
// matching DCR handler behaviour.
58+
BaselineClientScopes []string
59+
}
60+
61+
// NewCIMDStorageDecorator wraps base with CIMD client lookup.
62+
// When cfg.Enabled is false it returns base unchanged (no allocation).
63+
func NewCIMDStorageDecorator(base Storage, cfg CIMDDecoratorConfig) (Storage, error) {
64+
if !cfg.Enabled {
6465
return base, nil
6566
}
6667

67-
if cacheMaxSize < 1 {
68-
return nil, fmt.Errorf("CIMD storage decorator cacheMaxSize must be >= 1, got %d", cacheMaxSize)
68+
if cfg.CacheMaxSize < 1 {
69+
return nil, fmt.Errorf("CIMD storage decorator cacheMaxSize must be >= 1, got %d", cfg.CacheMaxSize)
6970
}
7071

71-
c, err := lru.New[string, *cimdCacheEntry](cacheMaxSize)
72+
c, err := lru.New[string, *cimdCacheEntry](cfg.CacheMaxSize)
7273
if err != nil {
7374
return nil, fmt.Errorf("failed to create CIMD LRU cache: %w", err)
7475
}
7576

7677
return &CIMDStorageDecorator{
7778
Storage: base,
7879
cache: c,
79-
ttl: fallbackTTL,
80-
scopesSupported: scopesSupported,
81-
baselineClientScopes: baselineClientScopes,
80+
ttl: cfg.FallbackTTL,
81+
scopesSupported: slices.Clone(cfg.ScopesSupported),
82+
baselineClientScopes: slices.Clone(cfg.BaselineClientScopes),
8283
}, nil
8384
}
8485

@@ -142,41 +143,26 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
142143
id, m, defaultCIMDTokenEndpointAuthMethod)
143144
}
144145

145-
// Reject documents that declare grant_types the embedded AS does not support.
146-
// Mirrors DCR's validateGrantTypes which restricts public clients to
147-
// authorization_code + refresh_token and requires authorization_code to be present.
148-
for _, gt := range doc.GrantTypes {
149-
if !allowedCIMDGrantTypes[gt] {
150-
return nil, fmt.Errorf("%w: CIMD document at %s claims grant_type %q "+
151-
"but this server only supports %v for public clients",
152-
fosite.ErrInvalidClient.WithHint("unsupported grant_type"),
153-
id, gt, defaultCIMDGrantTypes)
154-
}
146+
// Reject documents that declare grant_types or response_types the embedded AS
147+
// does not support for public clients. Uses the same validators as DCR so the
148+
// error messages and allowed sets are identical on both registration paths.
149+
if _, dcrErr := registration.ValidatePublicGrantTypes(doc.GrantTypes); dcrErr != nil {
150+
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
151+
fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription)
155152
}
156-
if len(doc.GrantTypes) > 0 && !slices.Contains(doc.GrantTypes, "authorization_code") {
157-
return nil, fmt.Errorf("%w: CIMD document at %s grant_types must include %q",
158-
fosite.ErrInvalidClient.WithHint("grant_types must include authorization_code"),
159-
id, "authorization_code")
160-
}
161-
162-
// Reject documents that declare response_types the embedded AS does not support.
163-
for _, rt := range doc.ResponseTypes {
164-
if !allowedCIMDResponseTypes[rt] {
165-
return nil, fmt.Errorf("%w: CIMD document at %s claims response_type %q "+
166-
"but this server only supports %v",
167-
fosite.ErrInvalidClient.WithHint("unsupported response_type"),
168-
id, rt, defaultCIMDResponseTypes)
169-
}
153+
if _, dcrErr := registration.ValidatePublicResponseTypes(doc.ResponseTypes); dcrErr != nil {
154+
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
155+
fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription)
170156
}
171157

172158
// Compute and validate the client scope list consistent with DCR.
173159
// When ScopesSupported is configured:
174160
// - Declared scopes are validated via registration.ValidateScopes (same
175161
// function as the DCR handler).
176-
// - When the document omits scope, the client receives ScopesSupported
177-
// rather than DefaultScopes — a CIMD document that doesn't declare scope
178-
// means "whatever the AS supports", not "give me the full default set"
179-
// (which may exceed ScopesSupported).
162+
// - Omitted scope uses ValidateScopes(nil, scopesSupported) which returns
163+
// DefaultScopes when DefaultScopes ⊆ ScopesSupported, matching DCR.
164+
// If DefaultScopes ⊄ ScopesSupported the document must declare scope
165+
// explicitly to avoid ambiguous privilege grant.
180166
// When ScopesSupported is not configured: no AS-level validation; declared
181167
// scopes are used directly, or nil to let buildFositeClient apply DefaultScopes.
182168
// In both cases BaselineClientScopes is unioned in after validation,
@@ -187,11 +173,20 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
187173
computed, dcrErr := registration.ValidateScopes(strings.Fields(doc.Scope), d.scopesSupported)
188174
if dcrErr != nil {
189175
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
190-
fosite.ErrInvalidClient.WithHint(dcrErr.Error), id, dcrErr.ErrorDescription)
176+
fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription)
191177
}
192178
resolvedScopes = computed
193179
} else {
194-
resolvedScopes = slices.Clone(d.scopesSupported)
180+
// Omitted scope: match DCR — give DefaultScopes when they fit, else require explicit scope.
181+
computed, dcrErr := registration.ValidateScopes(nil, d.scopesSupported)
182+
if dcrErr != nil {
183+
return nil, fmt.Errorf("%w: CIMD document at %s omits scope but "+
184+
"DefaultScopes are not a subset of this server's scopes_supported — "+
185+
"the document must explicitly declare its required scopes",
186+
fosite.ErrInvalidClient.WithHint("scope field required"),
187+
id)
188+
}
189+
resolvedScopes = computed
195190
}
196191
} else if doc.Scope != "" {
197192
resolvedScopes = strings.Fields(doc.Scope)
@@ -215,19 +210,10 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
215210
// that use the authorization code flow with refresh token rotation.
216211
var defaultCIMDGrantTypes = []string{"authorization_code", "refresh_token"}
217212

218-
// allowedCIMDGrantTypes is the set of grant_type values a CIMD document may
219-
// declare. Values outside this set are rejected at fetch time, consistent with
220-
// DCR which restricts public clients to authorization_code + refresh_token.
221-
var allowedCIMDGrantTypes = map[string]bool{"authorization_code": true, "refresh_token": true}
222-
223213
// defaultCIMDResponseTypes are the OAuth 2.0 response types applied when the
224214
// CIMD document omits response_types.
225215
var defaultCIMDResponseTypes = []string{"code"}
226216

227-
// allowedCIMDResponseTypes is the set of response_type values a CIMD document
228-
// may declare. Values outside this set are rejected at fetch time.
229-
var allowedCIMDResponseTypes = map[string]bool{"code": true}
230-
231217
// defaultCIMDTokenEndpointAuthMethod is the token endpoint authentication
232218
// method applied when the CIMD document omits token_endpoint_auth_method.
233219
// Documents that declare any other value are rejected by fetch() before
@@ -238,7 +224,8 @@ const defaultCIMDTokenEndpointAuthMethod = "none"
238224
// Redirect URIs containing http://localhost are wrapped in a LoopbackClient
239225
// so that RFC 8252 §7.3 dynamic port matching applies.
240226
// resolvedScopes is the already-validated scope list computed by fetch() via
241-
// registration.ValidateScopes; when nil, DefaultScopes is used (unconstrained AS).
227+
// registration.ValidateScopes; when empty, DefaultScopes is used — this occurs when
228+
// the decorator has no ScopesSupported restriction (unconstrained AS).
242229
func buildFositeClient(doc *cimd.ClientMetadataDocument, resolvedScopes []string) fosite.Client {
243230
grantTypes := doc.GrantTypes
244231
if len(grantTypes) == 0 {

0 commit comments

Comments
 (0)