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") +}