Skip to content
Merged
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
197 changes: 196 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
}
Loading
Loading