diff --git a/internal/config/config.go b/internal/config/config.go index 50a5905..c967b5f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,6 +93,7 @@ type Config struct { Filter *FilterConfig `yaml:"filter,omitempty"` Database *DatabaseConfig `yaml:"database,omitempty"` FileStorage *FileStorageConfig `yaml:"fileStorage,omitempty"` + Auth *AuthConfig `yaml:"auth,omitempty"` } // SourceConfig defines the data source configuration @@ -163,6 +164,155 @@ type TagFilterConfig struct { Exclude []string `yaml:"exclude,omitempty"` } +// AuthMode represents the authentication mode +type AuthMode string + +const ( + // AuthModeAnonymous allows unauthenticated access + AuthModeAnonymous AuthMode = "anonymous" + + // AuthModeOAuth requires OAuth/OIDC authentication + AuthModeOAuth AuthMode = "oauth" +) + +// AuthConfig defines authentication configuration for the registry server +type AuthConfig struct { + // Mode specifies the authentication mode (anonymous or oauth) + // Defaults to "anonymous" if not specified + Mode AuthMode `yaml:"mode,omitempty"` + + // PublicPaths defines additional paths that bypass authentication + // These extend the default public paths (health, docs, swagger, well-known) + // Example: ["/api/v0/public", "/custom/public"] + PublicPaths []string `yaml:"publicPaths,omitempty"` + + // OAuth contains OAuth/OIDC specific configuration + // Required when Mode is "oauth" + OAuth *OAuthConfig `yaml:"oauth,omitempty"` +} + +// OAuthConfig defines OAuth/OIDC specific authentication settings +type OAuthConfig struct { + // ResourceURL is the URL identifying this protected resource (RFC 9728) + // Used in the /.well-known/oauth-protected-resource endpoint + ResourceURL string `yaml:"resourceUrl,omitempty"` + + // Providers defines the OAuth/OIDC providers for authentication + // Multiple providers can be configured (e.g., Kubernetes + external IDP) + Providers []OAuthProviderConfig `yaml:"providers,omitempty"` + + // ScopesSupported defines the OAuth scopes supported by this resource (RFC 9728) + // Defaults to ["mcp-registry:read", "mcp-registry:write"] if not specified + ScopesSupported []string `yaml:"scopesSupported,omitempty"` + + // Realm is the protection space identifier for WWW-Authenticate header (RFC 7235) + // Defaults to "mcp-registry" if not specified + Realm string `yaml:"realm,omitempty"` +} + +// OAuthProviderConfig defines configuration for an OAuth/OIDC provider +type OAuthProviderConfig struct { + // Name is a unique identifier for this provider (e.g., "kubernetes", "keycloak") + Name string `yaml:"name"` + + // IssuerURL is the OIDC issuer URL (e.g., https://accounts.google.com) + // The JWKS URL will be discovered automatically from .well-known/openid-configuration + IssuerURL string `yaml:"issuerUrl"` + + // Audience is the expected audience claim in the token (REQUIRED) + // Per RFC 6749 Section 4.1.3, tokens must be validated against expected audience + // For Kubernetes, this is typically the API server URL + Audience string `yaml:"audience"` + + // ClientID is the OAuth client ID for token introspection (optional) + ClientID string `yaml:"clientId,omitempty"` + + // ClientSecretFile is the path to a file containing the client secret + // The file should contain only the secret with optional trailing whitespace + ClientSecretFile string `yaml:"clientSecretFile,omitempty"` + + // CACertPath is the path to a CA certificate bundle for verifying the provider's TLS certificate + // Required for Kubernetes in-cluster authentication or self-signed certificates + // TODO: Add GetCACert() method with path validation when implementing auth middleware + CACertPath string `yaml:"caCertPath,omitempty"` +} + +// GetClientSecret returns the client secret by reading from the file specified in ClientSecretFile. +// Returns empty string if ClientSecretFile is not configured. +// Returns an error if the file cannot be read. +func (p *OAuthProviderConfig) GetClientSecret() (string, error) { + secret, err := readSecretFromFile(p.ClientSecretFile) + if err != nil { + return "", fmt.Errorf("failed to read client secret: %w", err) + } + return secret, nil +} + +// validateProvider validates a single OAuth provider configuration. +// index is used for error message formatting to identify which provider failed validation. +func (p *OAuthProviderConfig) validateProvider(index int) error { + if p.Name == "" { + return fmt.Errorf("auth.oauth.providers[%d].name is required", index) + } + if p.IssuerURL == "" { + return fmt.Errorf("auth.oauth.providers[%d].issuerUrl is required", index) + } + + // Validate IssuerURL format + issuerURL, err := url.Parse(p.IssuerURL) + if err != nil { + return fmt.Errorf("auth.oauth.providers[%d].issuerUrl is invalid: %w", index, err) + } + + if !issuerURL.IsAbs() || issuerURL.Host == "" { + return fmt.Errorf("auth.oauth.providers[%d].issuerUrl must be an absolute URL with host", index) + } + + // Enforce HTTPS unless THV_REGISTRY_INSECURE_URL=true or localhost + if issuerURL.Scheme != "https" && os.Getenv("THV_REGISTRY_INSECURE_URL") != "true" { + host := issuerURL.Hostname() + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + const msg = "must use HTTPS (set THV_REGISTRY_INSECURE_URL=true to allow HTTP)" + return fmt.Errorf("auth.oauth.providers[%d].issuerUrl %s", index, msg) + } + } + + if p.Audience == "" { + return fmt.Errorf("auth.oauth.providers[%d].audience is required", index) + } + + return nil +} + +// Validate performs validation on the auth configuration +func (a *AuthConfig) Validate() error { + // Validate mode - empty defaults to anonymous + switch a.Mode { + case AuthModeAnonymous, "": + // Anonymous mode doesn't require OAuth config + return nil + case AuthModeOAuth: + // OAuth mode requires OAuth config + if a.OAuth == nil { + return fmt.Errorf("auth.oauth is required when mode is oauth") + } + if len(a.OAuth.Providers) == 0 { + return fmt.Errorf("auth.oauth.providers is required when mode is oauth") + } + + // Validate each provider + for i, provider := range a.OAuth.Providers { + if err := provider.validateProvider(i); err != nil { + return err + } + } + + return nil + default: + return fmt.Errorf("invalid auth.mode: %s (must be 'anonymous' or 'oauth')", a.Mode) + } +} + // DatabaseConfig defines database connection settings type DatabaseConfig struct { // Host is the database server hostname or IP address @@ -323,7 +473,15 @@ func (c *Config) validate() error { return fmt.Errorf("config cannot be nil") } - // Validate source configuration + if err := c.validateSource(); err != nil { + return err + } + + return c.validateAuth() +} + +// validateSource validates the source configuration +func (c *Config) validateSource() error { if c.Source.Type == "" { return fmt.Errorf("source.type is required") } @@ -412,3 +570,40 @@ func (c *Config) validateStorageConfig() error { return nil } + +// validateAuth validates the auth configuration if present +func (c *Config) validateAuth() error { + if c.Auth != nil { + if err := c.Auth.Validate(); err != nil { + return err + } + } + + return nil +} + +// readSecretFromFile reads a secret from a file with proper path validation. +// It resolves symlinks, requires absolute paths, and trims whitespace from content. +func readSecretFromFile(filePath string) (string, error) { + if filePath == "" { + return "", nil + } + + // Resolve symlinks to get real path + realPath, err := filepath.EvalSymlinks(filePath) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + + // Require absolute paths for security + if !filepath.IsAbs(realPath) { + return "", fmt.Errorf("path must be absolute: %s", filePath) + } + + data, err := os.ReadFile(realPath) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + + return strings.TrimSpace(string(data)), nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b540bef..1fd20e9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,10 +12,11 @@ import ( func TestLoadConfig(t *testing.T) { t.Parallel() tests := []struct { - name string - yamlContent string - wantConfig *Config - wantErr bool + name string + yamlContent string + skipFileCreation bool + wantConfig *Config + wantErr bool }{ { name: "valid_config_matching_spec", @@ -146,10 +147,11 @@ filter: wantErr: true, }, { - name: "file_not_found", - yamlContent: "", - wantConfig: nil, - wantErr: true, + name: "file_not_found", + yamlContent: "", + skipFileCreation: true, + wantConfig: nil, + wantErr: true, }, } @@ -158,20 +160,17 @@ filter: t.Parallel() // Create a temporary directory for test files tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") - // TODO: this is typical Claude Code pattern, we must fix this - if tt.name == "file_not_found" { + if tt.skipFileCreation { // Test with non-existent file - _, err := LoadConfig(WithConfigPath(filepath.Join(tmpDir, "non-existent.yaml"))) - require.Error(t, err) - return + configPath = filepath.Join(tmpDir, "non-existent.yaml") + } else { + // Create test config file + err := os.WriteFile(configPath, []byte(tt.yamlContent), 0600) + require.NoError(t, err) } - // Create test config file - configPath := filepath.Join(tmpDir, "config.yaml") - err := os.WriteFile(configPath, []byte(tt.yamlContent), 0600) - require.NoError(t, err) - // Load the config config, err := LoadConfig(WithConfigPath(configPath)) @@ -363,20 +362,6 @@ func TestConfigValidate(t *testing.T) { wantErr: true, errMsg: "source.file is required", }, - { - name: "missing_file_path", - config: &Config{ - Source: SourceConfig{ - Type: "file", - File: &FileConfig{}, - }, - SyncPolicy: &SyncPolicyConfig{ - Interval: "30m", - }, - }, - wantErr: true, - errMsg: "source.file.path is required", - }, { name: "invalid_format_when_type_is_api", config: &Config{ @@ -1147,3 +1132,253 @@ syncPolicy: }) } } + +func TestAuthConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantErr bool + errMsg string + check func(t *testing.T, cfg *Config) + }{ + { + name: "auth defaults to anonymous", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m"`, + wantErr: false, + check: func(t *testing.T, cfg *Config) { + t.Helper() + // Source should be parsed correctly + assert.Equal(t, "file", cfg.Source.Type) + assert.Equal(t, "/data/registry.json", cfg.Source.File.Path) + // Auth should be nil (defaults to anonymous behavior) + assert.Nil(t, cfg.Auth) + }, + }, + { + name: "oauth with k8s and okta providers", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: oauth + oauth: + providers: + - name: kubernetes + issuerUrl: https://kubernetes.default.svc.cluster.local + audience: https://kubernetes.default.svc.cluster.local + - name: okta + issuerUrl: https://dev-12345.okta.com + audience: api://mcp-registry`, + wantErr: false, + check: func(t *testing.T, cfg *Config) { + t.Helper() + require.NotNil(t, cfg.Auth) + assert.Equal(t, AuthModeOAuth, cfg.Auth.Mode) + require.NotNil(t, cfg.Auth.OAuth) + assert.Len(t, cfg.Auth.OAuth.Providers, 2) + assert.Equal(t, "https://kubernetes.default.svc.cluster.local", cfg.Auth.OAuth.Providers[0].IssuerURL) + assert.Equal(t, "https://dev-12345.okta.com", cfg.Auth.OAuth.Providers[1].IssuerURL) + }, + }, + { + name: "explicit anonymous mode", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: anonymous`, + wantErr: false, + check: func(t *testing.T, cfg *Config) { + t.Helper() + require.NotNil(t, cfg.Auth) + assert.Equal(t, AuthModeAnonymous, cfg.Auth.Mode) + }, + }, + { + name: "oauth requires oauth config", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: oauth`, + wantErr: true, + errMsg: "auth.oauth is required", + }, + { + name: "oauth requires providers", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: oauth + oauth: + providers: []`, + wantErr: true, + errMsg: "providers", + }, + { + name: "provider requires issuerUrl", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: oauth + oauth: + providers: + - name: kubernetes`, + wantErr: true, + errMsg: "issuerUrl", + }, + { + name: "provider requires audience", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: oauth + oauth: + providers: + - name: kubernetes + issuerUrl: https://kubernetes.default.svc.cluster.local`, + wantErr: true, + errMsg: "audience is required", + }, + { + name: "issuerUrl must be HTTPS", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: oauth + oauth: + providers: + - name: test + issuerUrl: http://example.com + audience: api://test`, + wantErr: true, + errMsg: "must use HTTPS", + }, + { + name: "issuerUrl must be valid URL", + yaml: `source: + type: file + file: + path: /data/registry.json +syncPolicy: + interval: "30m" +auth: + mode: oauth + oauth: + providers: + - name: test + issuerUrl: "not-a-valid-url" + audience: api://test`, + wantErr: true, + errMsg: "must be an absolute URL with host", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + err := os.WriteFile(configPath, []byte(tt.yaml), 0600) + require.NoError(t, err) + + cfg, err := LoadConfig(WithConfigPath(configPath)) + + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + return + } + + require.NoError(t, err) + if tt.check != nil { + tt.check(t, cfg) + } + }) + } +} + +func TestGetClientSecret(t *testing.T) { + t.Parallel() + + t.Run("reads secret from file", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + secretFile := filepath.Join(tmpDir, "secret.txt") + err := os.WriteFile(secretFile, []byte(" my-secret-value\n"), 0600) + require.NoError(t, err) + + provider := &OAuthProviderConfig{ + Name: "test", + IssuerURL: "https://example.com", + ClientSecretFile: secretFile, + } + + secret, err := provider.GetClientSecret() + require.NoError(t, err) + assert.Equal(t, "my-secret-value", secret) + }) + + t.Run("returns empty for no file configured", func(t *testing.T) { + t.Parallel() + + provider := &OAuthProviderConfig{ + Name: "test", + IssuerURL: "https://example.com", + } + + secret, err := provider.GetClientSecret() + require.NoError(t, err) + assert.Equal(t, "", secret) + }) + + t.Run("returns error for missing file", func(t *testing.T) { + t.Parallel() + + provider := &OAuthProviderConfig{ + Name: "test", + IssuerURL: "https://example.com", + ClientSecretFile: "/nonexistent/secret.txt", + } + + _, err := provider.GetClientSecret() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read client secret") + }) +}