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
49 changes: 49 additions & 0 deletions pkg/authserver/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ type RunConfig struct {
// Storage configures the storage backend for the auth server.
// If nil, defaults to in-memory storage.
Storage *storage.RunConfig `json:"storage,omitempty" yaml:"storage,omitempty"`

// CIMD controls client_id metadata document support. When enabled, the
// embedded authorization server accepts HTTPS URLs as client_id values
// and resolves them via the CIMD protocol instead of requiring DCR.
CIMD *CIMDRunConfig `json:"cimd,omitempty" yaml:"cimd,omitempty"`
}

// Validate checks that the on-disk RunConfig is internally consistent. Called
Expand Down Expand Up @@ -118,6 +123,26 @@ func (c *RunConfig) validateBaselineClientScopes() error {
return registration.ValidateScopeSubset(c.BaselineClientScopes, effective, "baseline_client_scopes")
}

// CIMDRunConfig controls client_id metadata document (CIMD) support.
//
// TODO(cimd): expose these fields in the MCPExternalAuthConfig CRD so Kubernetes
// operators can configure CIMD through the normal CRD workflow instead of
// writing RunConfig YAML directly.
type CIMDRunConfig struct {
// Enabled activates CIMD client lookup when true.
Enabled bool `json:"enabled" yaml:"enabled"`

// CacheMaxSize is the maximum number of CIMD documents held in the LRU cache.
// Defaults to 256 when Enabled is true and this field is zero.
CacheMaxSize int `json:"cache_max_size,omitempty" yaml:"cache_max_size,omitempty"`

// CacheFallbackTTL is the fixed TTL applied to every cached CIMD document.
// Cache-Control header parsing is not yet implemented; all entries use this value.
// Defaults to 5 minutes when Enabled is true and this field is zero.
//nolint:lll // field tags require full JSON+YAML names
CacheFallbackTTL time.Duration `json:"cache_fallback_ttl,omitempty" yaml:"cache_fallback_ttl,omitempty" swaggertype:"string" example:"5m"`
}

// SigningKeyRunConfig configures where to load signing keys from.
// Keys are loaded from PEM-encoded files on disk (typically mounted from secrets).
type SigningKeyRunConfig struct {
Expand Down Expand Up @@ -537,6 +562,20 @@ type Config struct {
// When empty, any request with a "resource" parameter will be rejected with
// "invalid_target". Configure this for proper MCP specification compliance.
AllowedAudiences []string

// CIMDEnabled enables the CIMD storage decorator so the authorization server
// accepts HTTPS URLs as client_id values without prior DCR registration.
CIMDEnabled bool

// CIMDCacheMaxSize is the maximum number of CIMD documents held in the LRU
// cache. Zero is replaced by a default (256) in applyDefaults when CIMDEnabled
// is true.
CIMDCacheMaxSize int

// CIMDCacheFallbackTTL is the TTL applied to cached CIMD documents that carry
// no Cache-Control header. Zero is replaced by a default (5 minutes) in
// applyDefaults when CIMDEnabled is true.
CIMDCacheFallbackTTL time.Duration
}

// Validate checks that the Config is valid.
Expand Down Expand Up @@ -589,6 +628,10 @@ func (c *Config) Validate() error {
}
}

if c.CIMDEnabled && c.CIMDCacheMaxSize < 1 {
return fmt.Errorf("cimd.cache_max_size must be >= 1 when CIMD is enabled")
}

slog.Debug("authserver config validation passed",
"issuer", c.Issuer,
"upstream_count", len(c.Upstreams),
Expand Down Expand Up @@ -819,6 +862,12 @@ func (c *Config) applyDefaults() error {
c.ScopesSupported = registration.DefaultScopes
slog.Debug("applied default scopes_supported", "scopes", c.ScopesSupported)
}
if c.CIMDEnabled && c.CIMDCacheMaxSize == 0 {
c.CIMDCacheMaxSize = 256
}
if c.CIMDEnabled && c.CIMDCacheFallbackTTL == 0 {
c.CIMDCacheFallbackTTL = 5 * time.Minute
}
return nil
}

Expand Down
46 changes: 46 additions & 0 deletions pkg/authserver/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,3 +589,49 @@ func TestConfigApplyDefaults_BaselineClientScopes(t *testing.T) {
})
}
}

func TestConfigApplyDefaults_CIMD(t *testing.T) {
t.Parallel()

tests := []struct {
name string
cfg Config
wantMaxSize int
wantFallbackTTL time.Duration
}{
{
name: "CIMD enabled with zero fields applies defaults",
cfg: Config{Issuer: "https://example.com", CIMDEnabled: true},
wantMaxSize: 256,
wantFallbackTTL: 5 * time.Minute,
},
{
name: "CIMD enabled preserves non-zero values",
cfg: Config{
Issuer: "https://example.com",
CIMDEnabled: true,
CIMDCacheMaxSize: 128,
CIMDCacheFallbackTTL: 10 * time.Minute,
},
wantMaxSize: 128,
wantFallbackTTL: 10 * time.Minute,
},
{
name: "CIMD disabled leaves zero fields unchanged",
cfg: Config{Issuer: "https://example.com", CIMDEnabled: false},
wantMaxSize: 0,
wantFallbackTTL: 0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := tt.cfg
err := cfg.applyDefaults()
require.NoError(t, err)
require.Equal(t, tt.wantMaxSize, cfg.CIMDCacheMaxSize)
require.Equal(t, tt.wantFallbackTTL, cfg.CIMDCacheFallbackTTL)
})
}
}
14 changes: 14 additions & 0 deletions pkg/authserver/runner/embeddedauthserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ func newEmbeddedAuthServerWithStorage(
// here once at the boundary lets all downstream stages share by reference
// safely. Cost is negligible — each slice is bounded by validation (≤10
// for BaselineClientScopes, low cardinality in practice for the others).
cimdEnabled, cimdCacheMaxSize, cimdCacheFallbackTTL := resolveCIMDConfig(cfg.CIMD)

resolvedCfg := authserver.Config{
Issuer: cfg.Issuer,
AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL,
Expand All @@ -188,6 +190,9 @@ func newEmbeddedAuthServerWithStorage(
ScopesSupported: slices.Clone(cfg.ScopesSupported),
BaselineClientScopes: slices.Clone(cfg.BaselineClientScopes),
AllowedAudiences: slices.Clone(cfg.AllowedAudiences),
CIMDEnabled: cimdEnabled,
CIMDCacheMaxSize: cimdCacheMaxSize,
CIMDCacheFallbackTTL: cimdCacheFallbackTTL,
}

// 7. Create the auth server. authserver.New also asserts the DCR
Expand Down Expand Up @@ -782,6 +787,15 @@ func convertRedisTLSRunConfig(rc *storage.RedisTLSRunConfig) (*tcredis.TLSConfig
return cfg, nil
}

// resolveCIMDConfig extracts CIMD settings from a CIMDRunConfig.
// Returns zero values when cfg is nil (CIMD disabled).
func resolveCIMDConfig(cfg *authserver.CIMDRunConfig) (enabled bool, cacheMaxSize int, cacheFallbackTTL time.Duration) {
if cfg == nil {
return false, 0, 0
}
return cfg.Enabled, cfg.CacheMaxSize, cfg.CacheFallbackTTL
}

// resolveEnvVar reads a value from the named environment variable.
func resolveEnvVar(envVar string) (string, error) {
if envVar == "" {
Expand Down
25 changes: 25 additions & 0 deletions pkg/authserver/runner/embeddedauthserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2035,3 +2035,28 @@ func TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog(t *testing.T) {
assert.Contains(t, logged, "redis.example.com",
"closeErr host must remain in the Warn record after sanitisation")
}

func TestResolveCIMDConfig(t *testing.T) {
t.Parallel()

t.Run("nil input returns zero values", func(t *testing.T) {
t.Parallel()
enabled, size, ttl := resolveCIMDConfig(nil)
assert.False(t, enabled)
assert.Zero(t, size)
assert.Zero(t, ttl)
})

t.Run("non-nil input passes values through", func(t *testing.T) {
t.Parallel()
cfg := &authserver.CIMDRunConfig{
Enabled: true,
CacheMaxSize: 128,
CacheFallbackTTL: 10 * time.Minute,
}
enabled, size, ttl := resolveCIMDConfig(cfg)
assert.True(t, enabled)
assert.Equal(t, 128, size)
assert.Equal(t, 10*time.Minute, ttl)
})
}
2 changes: 2 additions & 0 deletions pkg/authserver/server/handlers/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ func (h *Handler) buildOAuthMetadata() sharedobauth.AuthorizationServerMetadata
},
CodeChallengeMethodsSupported: []string{crypto.PKCEChallengeMethodS256},
TokenEndpointAuthMethodsSupported: []string{sharedobauth.TokenEndpointAuthMethodNone},

ClientIDMetadataDocumentSupported: h.config.CIMDEnabled,
}
}

Expand Down
56 changes: 56 additions & 0 deletions pkg/authserver/server/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
// testSetupOptions allows customizing the test handler setup.
type testSetupOptions struct {
AuthorizationEndpointBaseURL string
CIMDEnabled bool
}

// testSetup creates a Handler with all dependencies for testing.
Expand Down Expand Up @@ -66,6 +67,7 @@ func testSetupWithOptions(t *testing.T, opts testSetupOptions) *Handler {
cfg := &server.AuthorizationServerParams{
Issuer: "https://auth.example.com",
AuthorizationEndpointBaseURL: opts.AuthorizationEndpointBaseURL,
CIMDEnabled: opts.CIMDEnabled,
AccessTokenLifespan: time.Hour,
RefreshTokenLifespan: time.Hour * 24,
AuthCodeLifespan: time.Minute * 10,
Expand Down Expand Up @@ -340,3 +342,57 @@ func TestWellKnownRoutes(t *testing.T) {
}

// TODO: Add TestOAuthRoutes once OAuth handlers are implemented

func TestDiscoveryHandlers_CIMDEnabled_AdvertisesSupport(t *testing.T) {
t.Parallel()

handler := testSetupWithOptions(t, testSetupOptions{CIMDEnabled: true})

for _, tc := range []struct {
name string
fn func(http.ResponseWriter, *http.Request)
}{
{"OAuth AS metadata", handler.OAuthDiscoveryHandler},
{"OIDC discovery", handler.OIDCDiscoveryHandler},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
tc.fn(rec, req)
require.Equal(t, http.StatusOK, rec.Code)

var meta sharedobauth.AuthorizationServerMetadata
require.NoError(t, json.NewDecoder(rec.Body).Decode(&meta))
assert.True(t, meta.ClientIDMetadataDocumentSupported,
"client_id_metadata_document_supported must be true when CIMD is enabled")
})
}
}

func TestDiscoveryHandlers_CIMDDisabled_OmitsFlag(t *testing.T) {
t.Parallel()

handler := testSetupWithOptions(t, testSetupOptions{CIMDEnabled: false})

for _, tc := range []struct {
name string
fn func(http.ResponseWriter, *http.Request)
}{
{"OAuth AS metadata", handler.OAuthDiscoveryHandler},
{"OIDC discovery", handler.OIDCDiscoveryHandler},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
tc.fn(rec, req)
require.Equal(t, http.StatusOK, rec.Code)

var meta sharedobauth.AuthorizationServerMetadata
require.NoError(t, json.NewDecoder(rec.Body).Decode(&meta))
assert.False(t, meta.ClientIDMetadataDocumentSupported,
"client_id_metadata_document_supported must be absent when CIMD is disabled")
})
}
}
7 changes: 7 additions & 0 deletions pkg/authserver/server/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ type AuthorizationServerConfig struct {
// AuthorizationEndpointBaseURL overrides the base URL for the authorization_endpoint
// in the discovery document. When empty, defaults to the issuer (AccessTokenIssuer).
AuthorizationEndpointBaseURL string
// CIMDEnabled indicates that the CIMD storage decorator is active. When true,
// the discovery document advertises client_id_metadata_document_supported.
CIMDEnabled bool
}

// Factory is a constructor which is used to create an OAuth2 endpoint handler.
Expand Down Expand Up @@ -102,6 +105,9 @@ type AuthorizationServerParams struct {
// AuthorizationEndpointBaseURL overrides the base URL for the authorization_endpoint
// in the discovery document. When empty, defaults to Issuer.
AuthorizationEndpointBaseURL string
// CIMDEnabled indicates that the CIMD storage decorator is active. When true,
// the discovery document advertises client_id_metadata_document_supported.
CIMDEnabled bool
}

// validateIssuerURL validates that the issuer is a valid URL with http or https scheme
Expand Down Expand Up @@ -256,6 +262,7 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio
ScopesSupported: cfg.ScopesSupported,
BaselineClientScopes: cfg.BaselineClientScopes,
AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL,
CIMDEnabled: cfg.CIMDEnabled,
}, nil
}

Expand Down
19 changes: 15 additions & 4 deletions pkg/authserver/server_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
BaselineClientScopes: cfg.BaselineClientScopes,
AllowedAudiences: cfg.AllowedAudiences,
AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL,
CIMDEnabled: cfg.CIMDEnabled,
}
authServerConfig, err := oauthserver.NewAuthorizationServerConfig(oauthParams)
if err != nil {
Expand All @@ -147,10 +148,6 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
"auth_code_lifespan", cfg.AuthCodeLifespan,
)

// Create fosite provider
slog.Debug("creating fosite OAuth2 provider")
fositeProvider := createProvider(authServerConfig, stor)

// Build ordered upstream provider list from all configured upstreams.
upstreams := make([]handlers.NamedUpstream, 0, len(cfg.Upstreams))
for i := range cfg.Upstreams {
Expand All @@ -173,6 +170,20 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
return nil, err
}

// Wrap storage with the CIMD decorator before constructing the fosite provider
// 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 err != nil {
return nil, fmt.Errorf("failed to initialize CIMD storage decorator: %w", err)
}
}

// Create fosite provider with the (possibly decorated) storage.
slog.Debug("creating fosite OAuth2 provider")
fositeProvider := createProvider(authServerConfig, stor)

handlerInstance, err := handlers.NewHandler(fositeProvider, authServerConfig, stor, upstreams)
if err != nil {
return nil, fmt.Errorf("failed to create handler: %w", err)
Expand Down
Loading