Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/authserver/server_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ 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)
stor, err = storage.NewCIMDStorageDecorator(stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL, cfg.ScopesSupported)
if err != nil {
return nil, fmt.Errorf("failed to initialize CIMD storage decorator: %w", err)
}
Expand Down
81 changes: 65 additions & 16 deletions pkg/authserver/storage/cimd_decorator.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ 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
}

type cimdCacheEntry struct {
Expand All @@ -43,11 +44,15 @@ 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. Pass nil to skip scope
// validation (e.g. when ScopesSupported is unset and DefaultScopes applies).
func NewCIMDStorageDecorator(
base Storage,
enabled bool,
cacheMaxSize int,
fallbackTTL time.Duration,
scopesSupported []string,
) (Storage, error) {
if !enabled {
return base, nil
Expand All @@ -63,9 +68,10 @@ func NewCIMDStorageDecorator(
}

return &CIMDStorageDecorator{
Storage: base,
cache: c,
ttl: fallbackTTL,
Storage: base,
cache: c,
ttl: fallbackTTL,
scopesSupported: scopesSupported,
}, nil
}

Expand Down Expand Up @@ -129,7 +135,49 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
id, m, defaultCIMDTokenEndpointAuthMethod)
}

client := buildFositeClient(doc)
// Reject documents that declare grant_types the embedded AS does not support.
// Consistent with DCR which restricts public clients to authorization_code + refresh_token.
allowedGrantTypes := map[string]bool{"authorization_code": true, "refresh_token": true}
for _, gt := range doc.GrantTypes {
if !allowedGrantTypes[gt] {
return nil, fmt.Errorf("%w: CIMD document at %s claims grant_type %q "+
"but this server only supports %v for public clients",
fosite.ErrNotFound.WithHint("unsupported grant_type"),
id, gt, defaultCIMDGrantTypes)
}
}

// Reject documents that declare response_types the embedded AS does not support.
allowedResponseTypes := map[string]bool{"code": true}
for _, rt := range doc.ResponseTypes {
if !allowedResponseTypes[rt] {
return nil, fmt.Errorf("%w: CIMD document at %s claims response_type %q "+
"but this server only supports %v",
fosite.ErrNotFound.WithHint("unsupported response_type"),
id, rt, defaultCIMDResponseTypes)
}
}

// Compute and validate the client scope list consistent with DCR.
// When ScopesSupported is configured: use registration.ValidateScopes which
// validates each declared scope against the allowlist and falls back to
// DefaultScopes (also validated) when the document omits the field — the
// same logic the DCR handler applies.
// When ScopesSupported is not configured: no AS-level validation; use the
// declared scopes directly, or nil to let buildFositeClient apply DefaultScopes.
var resolvedScopes []string
if len(d.scopesSupported) > 0 {
computed, dcrErr := registration.ValidateScopes(strings.Fields(doc.Scope), d.scopesSupported)
if dcrErr != nil {
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
fosite.ErrNotFound.WithHint(string(dcrErr.Error)), id, dcrErr.ErrorDescription)
}
resolvedScopes = computed
} else if doc.Scope != "" {
resolvedScopes = strings.Fields(doc.Scope)
}

client := buildFositeClient(doc, resolvedScopes)

d.cache.Add(id, &cimdCacheEntry{
client: client,
Expand Down Expand Up @@ -157,7 +205,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
Expand All @@ -173,13 +223,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{
Expand Down
149 changes: 138 additions & 11 deletions pkg/authserver/storage/cimd_decorator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -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)
require.NoError(t, err)
return got.(*CIMDStorageDecorator)
}
Expand All @@ -77,29 +78,29 @@ 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)
require.NoError(t, err)
assert.Same(t, base, got, "disabled decorator must return base unchanged")
}

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)
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)
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)
require.NoError(t, err)
require.NotNil(t, got)
_, isCIMD := got.(*CIMDStorageDecorator)
Expand Down Expand Up @@ -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()))
Expand All @@ -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()))
}

Expand All @@ -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()))
}

Expand All @@ -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
Expand All @@ -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")
}
Expand All @@ -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())
}
Expand All @@ -442,3 +445,127 @@ 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\"")
}

// --- C4: 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_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")
}

// --- C3: 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"})
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"})
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()))
}
Loading
Loading