@@ -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.
216211var 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.
225215var 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).
242229func buildFositeClient (doc * cimd.ClientMetadataDocument , resolvedScopes []string ) fosite.Client {
243230 grantTypes := doc .GrantTypes
244231 if len (grantTypes ) == 0 {
0 commit comments