@@ -28,10 +28,12 @@ import (
2828// Only GetClient is overridden. DCR clients (opaque IDs) continue to work
2929// exactly as before.
3030type CIMDStorageDecorator struct {
31- Storage // embed full interface — all methods delegate
32- sf singleflight.Group // deduplicates concurrent fetches for the same URL
33- cache * lru.Cache [string , * cimdCacheEntry ]
34- ttl time.Duration
31+ Storage // embed full interface — all methods delegate
32+ sf singleflight.Group // deduplicates concurrent fetches for the same URL
33+ cache * lru.Cache [string , * cimdCacheEntry ]
34+ ttl time.Duration
35+ scopesSupported []string // AS-configured scopes; nil means accept any
36+ baselineClientScopes []string // unioned into every client's scope set, same as DCR
3537}
3638
3739type cimdCacheEntry struct {
@@ -43,11 +45,20 @@ type cimdCacheEntry struct {
4345// it returns base unchanged (no allocation). cacheMaxSize must be >= 1;
4446// fallbackTTL is the fixed TTL applied to every cache entry (Cache-Control
4547// 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.
4655func NewCIMDStorageDecorator (
4756 base Storage ,
4857 enabled bool ,
4958 cacheMaxSize int ,
5059 fallbackTTL time.Duration ,
60+ scopesSupported []string ,
61+ baselineClientScopes []string ,
5162) (Storage , error ) {
5263 if ! enabled {
5364 return base , nil
@@ -63,9 +74,11 @@ func NewCIMDStorageDecorator(
6374 }
6475
6576 return & CIMDStorageDecorator {
66- Storage : base ,
67- cache : c ,
68- ttl : fallbackTTL ,
77+ Storage : base ,
78+ cache : c ,
79+ ttl : fallbackTTL ,
80+ scopesSupported : scopesSupported ,
81+ baselineClientScopes : baselineClientScopes ,
6982 }, nil
7083}
7184
@@ -119,17 +132,75 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
119132 }
120133
121134 // Reject documents that declare an auth method this AS does not support.
122- // The embedded AS only advertises "none"; accepting a doc that says
123- // "private_key_jwt" and then silently treating the client as public would
124- // mislead operators and break clients that actually try to use JWT assertions .
135+ // ErrInvalidClient: the document was fetched successfully but its declared
136+ // metadata violates AS policy (distinct from ErrNotFound which means the
137+ // document could not be fetched at all) .
125138 if m := doc .TokenEndpointAuthMethod ; m != "" && m != defaultCIMDTokenEndpointAuthMethod {
126139 return nil , fmt .Errorf ("%w: CIMD document at %s claims token_endpoint_auth_method %q " +
127140 "but this server only supports %q" ,
128- fosite .ErrNotFound .WithHint ("unsupported token_endpoint_auth_method" ),
141+ fosite .ErrInvalidClient .WithHint ("unsupported token_endpoint_auth_method" ),
129142 id , m , defaultCIMDTokenEndpointAuthMethod )
130143 }
131144
132- client := buildFositeClient (doc )
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+ }
155+ }
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+ }
170+ }
171+
172+ // Compute and validate the client scope list consistent with DCR.
173+ // When ScopesSupported is configured:
174+ // - Declared scopes are validated via registration.ValidateScopes (same
175+ // 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).
180+ // When ScopesSupported is not configured: no AS-level validation; declared
181+ // scopes are used directly, or nil to let buildFositeClient apply DefaultScopes.
182+ // In both cases BaselineClientScopes is unioned in after validation,
183+ // matching the DCR handler's behaviour.
184+ var resolvedScopes []string
185+ if len (d .scopesSupported ) > 0 {
186+ if doc .Scope != "" {
187+ computed , dcrErr := registration .ValidateScopes (strings .Fields (doc .Scope ), d .scopesSupported )
188+ if dcrErr != nil {
189+ return nil , fmt .Errorf ("%w: CIMD document at %s: %s" ,
190+ fosite .ErrInvalidClient .WithHint (dcrErr .Error ), id , dcrErr .ErrorDescription )
191+ }
192+ resolvedScopes = computed
193+ } else {
194+ resolvedScopes = slices .Clone (d .scopesSupported )
195+ }
196+ } else if doc .Scope != "" {
197+ resolvedScopes = strings .Fields (doc .Scope )
198+ }
199+ if len (d .baselineClientScopes ) > 0 {
200+ resolvedScopes = registration .UnionScopes (resolvedScopes , d .baselineClientScopes )
201+ }
202+
203+ client := buildFositeClient (doc , resolvedScopes )
133204
134205 d .cache .Add (id , & cimdCacheEntry {
135206 client : client ,
@@ -144,10 +215,19 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
144215// that use the authorization code flow with refresh token rotation.
145216var defaultCIMDGrantTypes = []string {"authorization_code" , "refresh_token" }
146217
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+
147223// defaultCIMDResponseTypes are the OAuth 2.0 response types applied when the
148224// CIMD document omits response_types.
149225var defaultCIMDResponseTypes = []string {"code" }
150226
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+
151231// defaultCIMDTokenEndpointAuthMethod is the token endpoint authentication
152232// method applied when the CIMD document omits token_endpoint_auth_method.
153233// Documents that declare any other value are rejected by fetch() before
@@ -157,7 +237,9 @@ const defaultCIMDTokenEndpointAuthMethod = "none"
157237// buildFositeClient converts a ClientMetadataDocument into a fosite.Client.
158238// Redirect URIs containing http://localhost are wrapped in a LoopbackClient
159239// so that RFC 8252 §7.3 dynamic port matching applies.
160- func buildFositeClient (doc * cimd.ClientMetadataDocument ) fosite.Client {
240+ // resolvedScopes is the already-validated scope list computed by fetch() via
241+ // registration.ValidateScopes; when nil, DefaultScopes is used (unconstrained AS).
242+ func buildFositeClient (doc * cimd.ClientMetadataDocument , resolvedScopes []string ) fosite.Client {
161243 grantTypes := doc .GrantTypes
162244 if len (grantTypes ) == 0 {
163245 grantTypes = defaultCIMDGrantTypes
@@ -173,13 +255,12 @@ func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client {
173255 tokenEndpointAuthMethod = defaultCIMDTokenEndpointAuthMethod
174256 }
175257
176- // When the document omits the scope field, apply the same defaults as DCR
177- // registration so CIMD clients can request openid/profile/email/offline_access
178- // without needing to enumerate them explicitly in the metadata document.
179- // Clone to avoid aliasing the package-level DefaultScopes slice.
180- scopes := slices .Clone (registration .DefaultScopes )
181- if doc .Scope != "" {
182- scopes = strings .Fields (doc .Scope )
258+ // Scopes were computed and validated by fetch() via registration.ValidateScopes,
259+ // consistent with the DCR handler. Fall back to DefaultScopes only when the
260+ // decorator has no ScopesSupported restriction (unconstrained AS).
261+ scopes := resolvedScopes
262+ if len (scopes ) == 0 {
263+ scopes = slices .Clone (registration .DefaultScopes )
183264 }
184265
185266 defaultClient := & fosite.DefaultClient {
0 commit comments