From 36b5e97e3f11a5acacad7d5a164c5bd914fb9142 Mon Sep 17 00:00:00 2001 From: amirejaz Date: Wed, 27 May 2026 02:01:51 +0500 Subject: [PATCH] Validate CIMD scope, grant_types and response_types against AS policy C3 - Thread ScopesSupported into NewCIMDStorageDecorator so CIMD scope handling is consistent with DCR. Uses registration.ValidateScopes (same function as the DCR handler) to validate declared scopes against the AS allowlist and compute the effective scope list. When ScopesSupported is unset, the document's declared scopes are used directly; omitted scopes default to DefaultScopes. C4 - Reject CIMD documents that declare grant_types or response_types the embedded AS does not support for public clients (authorization_code + refresh_token; code). Consistent with DCR which returns invalid_client_metadata for the same cases. buildFositeClient now receives pre-computed scopes from fetch() rather than re-parsing doc.Scope, matching the DCR handler pattern where scope computation and validation happen before client construction. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- pkg/authserver/server/handlers/dcr.go | 2 +- pkg/authserver/server/handlers/scopes.go | 34 --- pkg/authserver/server/handlers/scopes_test.go | 4 +- pkg/authserver/server/registration/dcr.go | 28 +++ pkg/authserver/server_impl.go | 9 +- pkg/authserver/storage/cimd_decorator.go | 121 ++++++++-- pkg/authserver/storage/cimd_decorator_test.go | 228 +++++++++++++++++- 7 files changed, 358 insertions(+), 68 deletions(-) diff --git a/pkg/authserver/server/handlers/dcr.go b/pkg/authserver/server/handlers/dcr.go index bd9f8ef4df..f7dabed355 100644 --- a/pkg/authserver/server/handlers/dcr.go +++ b/pkg/authserver/server/handlers/dcr.go @@ -77,7 +77,7 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request // offline_access) — every DCR-registered client gains the ability to request // these scopes at /oauth/authorize regardless of what they registered with. if len(h.config.BaselineClientScopes) > 0 { - effective := unionScopes(scopes, h.config.BaselineClientScopes) + effective := registration.UnionScopes(scopes, h.config.BaselineClientScopes) if !slices.Equal(effective, scopes) { // Baseline-driven expansion is the intended behavior whenever // baseline_client_scopes is configured, so per-registration diff --git a/pkg/authserver/server/handlers/scopes.go b/pkg/authserver/server/handlers/scopes.go index 6f4705c25b..11949be943 100644 --- a/pkg/authserver/server/handlers/scopes.go +++ b/pkg/authserver/server/handlers/scopes.go @@ -2,37 +2,3 @@ // SPDX-License-Identifier: Apache-2.0 package handlers - -// unionScopes returns the union of requested and baseline scopes, preserving -// the order of requested first, then appending any baseline scopes not already -// present. Duplicates are removed. Returns nil when the result is empty. -// -// This is used by the DCR registration handler to inject an -// operator-configured scope baseline into the registered client's scope set: -// if a client narrows the scope field at /oauth/register, the baseline scopes -// are still part of the client's registered set so that the client can -// request them later at /oauth/authorize without invalid_scope rejection. -// -// Both inputs must already be validated by the caller (e.g. via ValidateScopes -// for client-supplied scopes). unionScopes does not filter empty strings or -// validate scope syntax — it only deduplicates and merges in stable order. -func unionScopes(requested, baseline []string) []string { - seen := make(map[string]bool, len(requested)+len(baseline)) - out := make([]string, 0, len(requested)+len(baseline)) - for _, s := range requested { - if !seen[s] { - seen[s] = true - out = append(out, s) - } - } - for _, s := range baseline { - if !seen[s] { - seen[s] = true - out = append(out, s) - } - } - if len(out) == 0 { - return nil - } - return out -} diff --git a/pkg/authserver/server/handlers/scopes_test.go b/pkg/authserver/server/handlers/scopes_test.go index 93ebf32dce..0b9b25a929 100644 --- a/pkg/authserver/server/handlers/scopes_test.go +++ b/pkg/authserver/server/handlers/scopes_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" ) func TestUnionScopes(t *testing.T) { @@ -108,7 +110,7 @@ func TestUnionScopes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := unionScopes(tt.req, tt.baseline) + got := registration.UnionScopes(tt.req, tt.baseline) assert.Equal(t, tt.want, got) }) } diff --git a/pkg/authserver/server/registration/dcr.go b/pkg/authserver/server/registration/dcr.go index 3197c1e807..dbea3f830b 100644 --- a/pkg/authserver/server/registration/dcr.go +++ b/pkg/authserver/server/registration/dcr.go @@ -327,3 +327,31 @@ func ValidateScopes(requestedScopes, allowedScopes []string) ([]string, *DCRErro return scopes, nil } + +// UnionScopes returns the union of requested and baseline scopes, preserving +// the order of requested first, then appending any baseline scopes not already +// present. Duplicates are removed. Returns nil when the result is empty. +// +// Both inputs must already be validated by the caller. UnionScopes does not +// filter empty strings or validate scope syntax — it only deduplicates and +// merges in stable order. +func UnionScopes(requested, baseline []string) []string { + seen := make(map[string]bool, len(requested)+len(baseline)) + out := make([]string, 0, len(requested)+len(baseline)) + for _, s := range requested { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + for _, s := range baseline { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index 39a22468ca..4e7948d926 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -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; "+ + "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) } diff --git a/pkg/authserver/storage/cimd_decorator.go b/pkg/authserver/storage/cimd_decorator.go index fbead1ef50..a553c9a68a 100644 --- a/pkg/authserver/storage/cimd_decorator.go +++ b/pkg/authserver/storage/cimd_decorator.go @@ -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 +// 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, ) (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, + 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) + } + } + + // 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) + } + resolvedScopes = computed + } else { + resolvedScopes = slices.Clone(d.scopesSupported) + } + } 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{ diff --git a/pkg/authserver/storage/cimd_decorator_test.go b/pkg/authserver/storage/cimd_decorator_test.go index 99c3d66dc8..787e7d9bb1 100644 --- a/pkg/authserver/storage/cimd_decorator_test.go +++ b/pkg/authserver/storage/cimd_decorator_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "sync" "sync/atomic" "testing" @@ -62,7 +63,7 @@ func newTestBase(t *testing.T) *MemoryStorage { // newEnabledDecorator creates a CIMDStorageDecorator wrapping base. func newEnabledDecorator(t *testing.T, base *MemoryStorage, maxSize int, ttl time.Duration) *CIMDStorageDecorator { t.Helper() - got, err := NewCIMDStorageDecorator(base, true, maxSize, ttl) + got, err := NewCIMDStorageDecorator(base, true, maxSize, ttl, nil, nil) require.NoError(t, err) return got.(*CIMDStorageDecorator) } @@ -77,7 +78,7 @@ func cimdURL(srv *httptest.Server, path string) string { func TestNewCIMDStorageDecorator_DisabledReturnsBase(t *testing.T) { t.Parallel() base := newTestBase(t) - got, err := NewCIMDStorageDecorator(base, false, 10, time.Minute) + got, err := NewCIMDStorageDecorator(base, false, 10, time.Minute, nil, nil) require.NoError(t, err) assert.Same(t, base, got, "disabled decorator must return base unchanged") } @@ -85,21 +86,21 @@ func TestNewCIMDStorageDecorator_DisabledReturnsBase(t *testing.T) { func TestNewCIMDStorageDecorator_ZeroCacheSizeReturnsError(t *testing.T) { t.Parallel() base := newTestBase(t) - _, err := NewCIMDStorageDecorator(base, true, 0, time.Minute) + _, err := NewCIMDStorageDecorator(base, true, 0, time.Minute, nil, nil) require.Error(t, err) } func TestNewCIMDStorageDecorator_NegativeCacheSizeReturnsError(t *testing.T) { t.Parallel() base := newTestBase(t) - _, err := NewCIMDStorageDecorator(base, true, -1, time.Minute) + _, err := NewCIMDStorageDecorator(base, true, -1, time.Minute, nil, nil) require.Error(t, err) } func TestNewCIMDStorageDecorator_EnabledReturnsCIMDDecorator(t *testing.T) { t.Parallel() base := newTestBase(t) - got, err := NewCIMDStorageDecorator(base, true, 10, time.Minute) + got, err := NewCIMDStorageDecorator(base, true, 10, time.Minute, nil, nil) require.NoError(t, err) require.NotNil(t, got) _, isCIMD := got.(*CIMDStorageDecorator) @@ -336,7 +337,7 @@ func TestBuildFositeClient_Defaults(t *testing.T) { RedirectURIs: []string{"https://example.com/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) assert.Equal(t, "https://example.com/meta.json", got.GetID()) assert.True(t, got.IsPublic()) assert.ElementsMatch(t, []string{"authorization_code", "refresh_token"}, []string(got.GetGrantTypes())) @@ -355,7 +356,7 @@ func TestBuildFositeClient_ExplicitGrantTypes(t *testing.T) { GrantTypes: []string{"authorization_code"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) assert.ElementsMatch(t, []string{"authorization_code"}, []string(got.GetGrantTypes())) } @@ -368,7 +369,9 @@ func TestBuildFositeClient_ScopeParsing(t *testing.T) { Scope: "openid profile email", } - got := buildFositeClient(doc) + // Scope parsing is now done by fetch() before calling buildFositeClient. + resolvedScopes := strings.Fields(doc.Scope) + got := buildFositeClient(doc, resolvedScopes) assert.ElementsMatch(t, []string{"openid", "profile", "email"}, []string(got.GetScopes())) } @@ -380,7 +383,7 @@ func TestBuildFositeClient_LoopbackRedirectWrapsInLoopbackClient(t *testing.T) { RedirectURIs: []string{"http://localhost/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) // LoopbackClient adds MatchRedirectURI — check the distinctive method is present. type loopbackMatcher interface { MatchRedirectURI(string) bool @@ -403,7 +406,7 @@ func TestBuildFositeClient_NonLoopbackRedirectReturnsOpenIDConnectClient(t *test RedirectURIs: []string{"https://example.com/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) _, ok := got.(*fosite.DefaultOpenIDConnectClient) assert.True(t, ok, "non-loopback redirect URI must produce a DefaultOpenIDConnectClient") } @@ -416,7 +419,7 @@ func TestBuildFositeClient_TokenEndpointAuthMethodDefault(t *testing.T) { RedirectURIs: []string{"https://example.com/callback"}, } - got := buildFositeClient(doc) + got := buildFositeClient(doc, nil) if oidc, ok := got.(fosite.OpenIDConnectClient); ok { assert.Equal(t, "none", oidc.GetTokenEndpointAuthMethod()) } @@ -442,3 +445,206 @@ func TestFetch_RejectsUnsupportedTokenEndpointAuthMethod(t *testing.T) { _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") require.Error(t, err, "fetch must fail when token_endpoint_auth_method is not \"none\"") } + +// --- grant_types / response_types validation --- + +func TestFetch_RejectsUnsupportedGrantType(t *testing.T) { + t.Parallel() + for _, unsupported := range []string{"client_credentials", "implicit", "urn:ietf:params:oauth:grant-type:device_code"} { + t.Run(unsupported, func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{unsupported}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "unsupported grant_type %q must be rejected", unsupported) + }) + } +} + +func TestFetch_AcceptsSupportedGrantTypes(t *testing.T) { + t.Parallel() + srv := serveCIMDDoc(t, "/meta.json", nil) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + // Default grant_types (omitted in document) must succeed + _, err := dec.fetchOrCached(context.Background(), cimdURL(srv, "/meta.json")) + require.NoError(t, err) +} + +func TestFetch_RejectsRefreshTokenOnlyGrantTypes(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{"refresh_token"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "grant_types=[refresh_token] without authorization_code must be rejected") +} + +func TestFetch_AcceptsExplicitSupportedGrantTypes(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), cimdURL(srv, "/meta.json")) + require.NoError(t, err, "explicit [authorization_code, refresh_token] must be accepted") +} + +func TestFetch_RejectsUnsupportedResponseType(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + ResponseTypes: []string{"token"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "unsupported response_type \"token\" must be rejected") +} + +// --- scope validation against ScopesSupported --- + +func TestBuildFositeClient_ScopeDefaultsToScopesSupported(t *testing.T) { + t.Parallel() + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + // Scope deliberately omitted + } + scopesSupported := []string{"openid", "profile"} + got := buildFositeClient(doc, scopesSupported) + assert.ElementsMatch(t, scopesSupported, []string(got.GetScopes()), + "omitted scope should default to ScopesSupported, not DefaultScopes") +} + +func TestBuildFositeClient_ScopeDefaultsToDefaultScopesWhenNoScopesSupported(t *testing.T) { + t.Parallel() + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + } + got := buildFositeClient(doc, nil) + assert.ElementsMatch(t, registration.DefaultScopes, []string(got.GetScopes()), + "omitted scope with no ScopesSupported should default to registration.DefaultScopes") +} + +func TestFetch_RejectsScopeOutsideScopesSupported(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + Scope: "openid profile email", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + // Decorator configured with scopesSupported=["openid"] only + got, err := NewCIMDStorageDecorator(newTestBase(t), true, 10, time.Minute, []string{"openid"}, nil) + require.NoError(t, err) + dec := got.(*CIMDStorageDecorator) + + _, err = dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "scope outside ScopesSupported must be rejected") +} + +func TestFetch_AcceptsScopeWithinScopesSupported(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + Scope: "openid", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + got, err := NewCIMDStorageDecorator(newTestBase(t), true, 10, time.Minute, []string{"openid", "profile"}, nil) + require.NoError(t, err) + dec := got.(*CIMDStorageDecorator) + + client, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"openid"}, []string(client.GetScopes())) +} + +func TestFetch_EmptyScopeDefaultsToScopesSupported(t *testing.T) { + t.Parallel() + // When the document omits scope and ScopesSupported is restricted, the client + // should receive ScopesSupported, not DefaultScopes (which may exceed it). + srv := serveCIMDDoc(t, "/meta.json", nil) // document has no Scope field + got, err := NewCIMDStorageDecorator(newTestBase(t), true, 10, time.Minute, []string{"openid"}, nil) + require.NoError(t, err) + dec := got.(*CIMDStorageDecorator) + + client, err := dec.fetchOrCached(context.Background(), cimdURL(srv, "/meta.json")) + require.NoError(t, err, "omitted scope with ScopesSupported set must not error") + assert.ElementsMatch(t, []string{"openid"}, []string(client.GetScopes()), + "omitted scope should default to ScopesSupported, not DefaultScopes") +} + +func TestFetch_BaselineClientScopesUnionedIn(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + Scope: "openid", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + got, err := NewCIMDStorageDecorator( + newTestBase(t), true, 10, time.Minute, + []string{"openid", "offline_access"}, + []string{"offline_access"}, // baseline + ) + require.NoError(t, err) + dec := got.(*CIMDStorageDecorator) + + client, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"openid", "offline_access"}, []string(client.GetScopes()), + "baseline scopes must be unioned into the client's scope set") +}