diff --git a/.gitignore b/.gitignore index 4efe7690..a932cc96 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,9 @@ _output/ .vscode/ .docusaurus/ node_modules/ +.DS_Store +config-dev.toml .npmrc kubernetes-mcp-server !charts/kubernetes-mcp-server diff --git a/docs/AUTH_HEADERS_PROVIDER.md b/docs/AUTH_HEADERS_PROVIDER.md new file mode 100644 index 00000000..c8dd3b9b --- /dev/null +++ b/docs/AUTH_HEADERS_PROVIDER.md @@ -0,0 +1,177 @@ +# Auth-Headers Provider + +The `auth-headers` cluster provider strategy enables multi-tenant Kubernetes MCP server deployments where each request provides complete cluster connection details and authentication via HTTP headers or MCP tool parameters. + +## Overview + +This provider: +- **Requires cluster connection details per request** via custom headers (server URL, CA certificate) +- **Requires authentication per request** via bearer token OR client certificates +- **Does not use kubeconfig** - all configuration comes from request headers +- **Creates dynamic Kubernetes clients** per request using the provided credentials + +## Use Cases + +- **Multi-tenant SaaS deployments** - Single MCP server instance serving multiple users/clusters +- **Zero-trust architectures** - No stored credentials, complete authentication per request +- **Dynamic cluster access** - Connect to different clusters without server configuration +- **Auditing & compliance** - Each request uses the user's actual identity for Kubernetes RBAC +- **Temporary access** - Short-lived credentials without persistent configuration + +## Configuration + +### Basic Setup + +```bash +kubernetes-mcp-server \ + --port 8080 \ + --cluster-provider-strategy auth-headers +``` + +The server will: +1. Accept requests with cluster connection details in headers +2. Create a Kubernetes client dynamically for each request +3. Reject any requests without required authentication headers + +### TOML Configuration + +```toml +cluster_provider_strategy = "auth-headers" +# No kubeconfig needed - all details come from request headers +``` + +### Required Headers + +Each request must include the following custom headers: + +**Required for all requests:** +- `kubernetes-server` - Kubernetes API server URL (e.g., `https://kubernetes.example.com:6443`) +- `kubernetes-certificate-authority-data` - Base64-encoded CA certificate + +**Authentication (choose one):** + +Option 1: Bearer Token +- `kubernetes-authorization` - Bearer token (e.g., `Bearer eyJhbGci...`) + +Option 2: Client Certificate +- `kubernetes-client-certificate-data` - Base64-encoded client certificate +- `kubernetes-client-key-data` - Base64-encoded client key + +**Optional:** +- `kubernetes-insecure-skip-tls-verify` - Set to `true` to skip TLS verification (not recommended for production) + +## How It Works + +### 1. Initialization + +When the server starts: +``` +Server starts with auth-headers provider + ↓ +No kubeconfig or credentials loaded + ↓ +Ready to accept requests with headers +``` + +### 2. Request Processing + +For each MCP request: +``` +HTTP Request with custom headers + ↓ +Extract kubernetes-server, kubernetes-certificate-authority-data + ↓ +Extract authentication (token OR client cert/key) + ↓ +Create K8sAuthHeaders struct + ↓ +Build rest.Config dynamically + ↓ +Create new Kubernetes client + ↓ +Execute Kubernetes operation + ↓ +Discard client after request +``` + +### 3. Header Extraction + +Headers can be provided in two ways: + +**A. HTTP Request Headers** (standard way): +``` +POST /mcp HTTP/1.1 +kubernetes-server: https://k8s.example.com:6443 +kubernetes-certificate-authority-data: LS0tLS1CRUdJ... +kubernetes-authorization: Bearer eyJhbGci... +``` + +**B. MCP Tool Parameters Meta** (advanced): +```json +{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "pods_list", + "arguments": {"namespace": "default"}, + "_meta": { + "kubernetes-server": "https://k8s.example.com:6443", + "kubernetes-certificate-authority-data": "LS0tLS1CRUdJ...", + "kubernetes-authorization": "Bearer eyJhbGci..." + } + } +} +``` + +### 4. Security Model + +``` +┌──────────────────┐ +│ MCP Client │ +│ (Claude, etc) │ +└────────┬─────────┘ + │ All cluster info + auth in headers + ↓ +┌──────────────────┐ +│ MCP Server │ +│ (auth-headers) │ +│ NO CREDENTIALS │ +│ STORED │ +└────────┬─────────┘ + │ Creates temporary client + ↓ +┌──────────────────┐ +│ Kubernetes API │ +│ Server │ +└──────────────────┘ + ↓ + RBAC enforced with + credentials from headers +``` + +## Client Usage + +### Using the Go MCP Client + +```go +import ( + "encoding/base64" + "github.com/mark3labs/mcp-go/client/transport" +) + +// Get cluster connection details +serverURL := "https://k8s.example.com:6443" +caCert := getCAcertificate() // PEM-encoded CA certificate +token := getUserKubernetesToken() + +// Encode CA certificate to base64 +caCertBase64 := base64.StdEncoding.EncodeToString(caCert) + +client := NewMCPClient( + transport.WithHTTPHeaders(map[string]string{ + "kubernetes-server": serverURL, + "kubernetes-certificate-authority-data": caCertBase64, + "kubernetes-authorization": "Bearer " + token, + }) +) +``` diff --git a/pkg/config/config.go b/pkg/config/config.go index 20695768..ce13f14a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -11,9 +11,10 @@ import ( ) const ( - ClusterProviderKubeConfig = "kubeconfig" - ClusterProviderInCluster = "in-cluster" - ClusterProviderDisabled = "disabled" + ClusterProviderKubeConfig = "kubeconfig" + ClusterProviderInCluster = "in-cluster" + ClusterProviderAuthHeaders = "auth-headers" + ClusterProviderDisabled = "disabled" ) // StaticConfig is the configuration for the server. diff --git a/pkg/kubernetes/auth_headers.go b/pkg/kubernetes/auth_headers.go new file mode 100644 index 00000000..5b0c22e3 --- /dev/null +++ b/pkg/kubernetes/auth_headers.go @@ -0,0 +1,108 @@ +package kubernetes + +import ( + "encoding/base64" + "fmt" + "strings" +) + +// AuthType represents the type of Kubernetes authentication. +type AuthType string +type ContextKey string + +const ( + // AuthHeadersContextKey is the context key for the Kubernetes authentication headers. + AuthHeadersContextKey ContextKey = "k8s_auth_headers" +) + +// K8sAuthHeaders represents Kubernetes API authentication headers. +type K8sAuthHeaders struct { + // Server is the Kubernetes cluster URL. + Server string + // ClusterCertificateAuthorityData is the Certificate Authority data. + CertificateAuthorityData []byte + // AuthorizationToken is the optional bearer token for authentication. + AuthorizationToken string + // ClientCertificateData is the optional client certificate data. + ClientCertificateData []byte + // ClientKeyData is the optional client key data. + ClientKeyData []byte + // InsecureSkipTLSVerify is the optional flag to skip TLS verification. + InsecureSkipTLSVerify bool +} + +// GetDecodedData decodes and returns the data. +func GetDecodedData(data string) ([]byte, error) { + return base64.StdEncoding.DecodeString(data) +} + +// NewK8sAuthHeadersFromHeaders creates a new K8sAuthHeaders from the provided headers. +func NewK8sAuthHeadersFromHeaders(data map[string]any) (*K8sAuthHeaders, error) { + var ok bool + var err error + + // Initialize auth headers with default values. + authHeaders := &K8sAuthHeaders{ + InsecureSkipTLSVerify: false, + } + + // Get cluster URL from headers. + authHeaders.Server, ok = data[string(CustomServerHeader)].(string) + if !ok || authHeaders.Server == "" { + return nil, fmt.Errorf("%s header is required", CustomServerHeader) + } + + // Get certificate authority data from headers. + certificateAuthorityDataBase64, ok := data[string(CustomCertificateAuthorityDataHeader)].(string) + if !ok || certificateAuthorityDataBase64 == "" { + return nil, fmt.Errorf("%s header is required", CustomCertificateAuthorityDataHeader) + } + // Decode certificate authority data. + authHeaders.CertificateAuthorityData, err = GetDecodedData(certificateAuthorityDataBase64) + if err != nil { + return nil, fmt.Errorf("invalid certificate authority data: %w", err) + } + + // Get insecure skip TLS verify flag from headers. + if data[string(CustomInsecureSkipTLSVerifyHeader)] != nil && strings.ToLower(data[string(CustomInsecureSkipTLSVerifyHeader)].(string)) == "true" { + authHeaders.InsecureSkipTLSVerify = true + } + + // Get authorization token from headers. + authHeaders.AuthorizationToken, _ = data[string(CustomAuthorizationHeader)].(string) + + // Get client certificate data from headers. + clientCertificateDataBase64, _ := data[string(CustomClientCertificateDataHeader)].(string) + if clientCertificateDataBase64 != "" { + authHeaders.ClientCertificateData, err = GetDecodedData(clientCertificateDataBase64) + if err != nil { + return nil, fmt.Errorf("invalid client certificate data: %w", err) + } + } + // Get client key data from headers. + clientKeyDataBase64, _ := data[string(CustomClientKeyDataHeader)].(string) + if clientKeyDataBase64 != "" { + authHeaders.ClientKeyData, err = GetDecodedData(clientKeyDataBase64) + if err != nil { + return nil, fmt.Errorf("invalid client key data: %w", err) + } + } + + // Check if a valid authentication type is provided. + if !authHeaders.IsValid() { + return nil, fmt.Errorf("either %s header for token authentication or (%s and %s) headers for client certificate authentication required", CustomAuthorizationHeader, CustomClientCertificateDataHeader, CustomClientKeyDataHeader) + } + + return authHeaders, nil +} + +// IsValid checks if the authentication headers are valid. +func (h *K8sAuthHeaders) IsValid() bool { + if h.AuthorizationToken != "" { + return true + } + if len(h.ClientCertificateData) > 0 && len(h.ClientKeyData) > 0 { + return true + } + return false +} diff --git a/pkg/kubernetes/auth_headers_test.go b/pkg/kubernetes/auth_headers_test.go new file mode 100644 index 00000000..fb89e96e --- /dev/null +++ b/pkg/kubernetes/auth_headers_test.go @@ -0,0 +1,413 @@ +package kubernetes + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDecodedData(t *testing.T) { + t.Run("decodes valid base64 string", func(t *testing.T) { + input := "SGVsbG8gV29ybGQ=" // "Hello World" in base64 + expected := []byte("Hello World") + + result, err := GetDecodedData(input) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("decodes empty string", func(t *testing.T) { + input := "" + expected := []byte{} + + result, err := GetDecodedData(input) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("returns error for invalid base64", func(t *testing.T) { + input := "not-valid-base64!!!" + + _, err := GetDecodedData(input) + require.Error(t, err) + }) + + t.Run("decodes base64 with padding", func(t *testing.T) { + input := "dGVzdA==" // "test" in base64 + expected := []byte("test") + + result, err := GetDecodedData(input) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) +} + +func TestNewK8sAuthHeadersFromHeaders(t *testing.T) { + serverURL := "https://kubernetes.example.com:6443" + caCert := []byte("test-ca-cert") + caCertBase64 := base64.StdEncoding.EncodeToString(caCert) + token := "Bearer test-token" + clientCert := []byte("test-client-cert") + clientCertBase64 := base64.StdEncoding.EncodeToString(clientCert) + clientKey := []byte("test-client-key") + clientKeyBase64 := base64.StdEncoding.EncodeToString(clientKey) + + t.Run("creates auth headers with token authentication", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + require.NotNil(t, authHeaders) + + assert.Equal(t, serverURL, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, token, authHeaders.AuthorizationToken) + assert.Nil(t, authHeaders.ClientCertificateData) + assert.Nil(t, authHeaders.ClientKeyData) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("creates auth headers with client certificate authentication", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + require.NotNil(t, authHeaders) + + assert.Equal(t, serverURL, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, "", authHeaders.AuthorizationToken) + assert.Equal(t, clientCert, authHeaders.ClientCertificateData) + assert.Equal(t, clientKey, authHeaders.ClientKeyData) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("creates auth headers with both token and client certificate", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + require.NotNil(t, authHeaders) + + // Should have both auth methods + assert.Equal(t, token, authHeaders.AuthorizationToken) + assert.Equal(t, clientCert, authHeaders.ClientCertificateData) + assert.Equal(t, clientKey, authHeaders.ClientKeyData) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("sets InsecureSkipTLSVerify to true when header is 'true'", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomInsecureSkipTLSVerifyHeader): "true", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.True(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("sets InsecureSkipTLSVerify to true when header is 'TRUE' (case insensitive)", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomInsecureSkipTLSVerifyHeader): "TRUE", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.True(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("sets InsecureSkipTLSVerify to false when header is 'false'", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomInsecureSkipTLSVerifyHeader): "false", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("sets InsecureSkipTLSVerify to false when header is missing", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("returns error when server header is missing", func(t *testing.T) { + headers := map[string]any{ + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + assert.Contains(t, err.Error(), "required") + }) + + t.Run("returns error when server header is empty string", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): "", + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + }) + + t.Run("returns error when server header is not a string", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): 123, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + }) + + t.Run("returns error when CA data header is missing", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-certificate-authority-data") + assert.Contains(t, err.Error(), "required") + }) + + t.Run("returns error when CA data header is empty string", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): "", + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-certificate-authority-data") + }) + + t.Run("returns error when CA data is invalid base64", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): "invalid-base64!!!", + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid certificate authority data") + }) + + t.Run("returns error when no authentication method is provided", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + assert.Contains(t, err.Error(), "kubernetes-authorization") + assert.Contains(t, err.Error(), "kubernetes-client-certificate-data") + assert.Contains(t, err.Error(), "kubernetes-client-key-data") + }) + + t.Run("returns error when only client certificate is provided without key", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + }) + + t.Run("returns error when only client key is provided without certificate", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + }) + + t.Run("returns error when client certificate is invalid base64", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): "invalid-base64!!!", + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client certificate data") + }) + + t.Run("returns error when client key is invalid base64", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): "invalid-base64!!!", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client key data") + }) + + t.Run("handles empty token string gracefully", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "", + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + // Empty token is OK if we have client cert + assert.Equal(t, "", authHeaders.AuthorizationToken) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("handles empty client cert/key strings gracefully when token is provided", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomClientCertificateDataHeader): "", + string(CustomClientKeyDataHeader): "", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.Nil(t, authHeaders.ClientCertificateData) + assert.Nil(t, authHeaders.ClientKeyData) + assert.True(t, authHeaders.IsValid()) + }) +} + +func TestK8sAuthHeaders_IsValid(t *testing.T) { + t.Run("returns true when token is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + AuthorizationToken: "Bearer test-token", + } + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("returns true when client certificate and key are provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte("cert-data"), + ClientKeyData: []byte("key-data"), + } + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("returns true when both token and client cert are provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + AuthorizationToken: "Bearer test-token", + ClientCertificateData: []byte("cert-data"), + ClientKeyData: []byte("key-data"), + } + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("returns false when no authentication is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{} + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when only client certificate is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte("cert-data"), + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when only client key is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientKeyData: []byte("key-data"), + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when token is empty string", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + AuthorizationToken: "", + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when client cert and key are empty slices", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte{}, + ClientKeyData: []byte{}, + } + // Empty slices have length 0, so they're considered invalid + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when client cert is nil and key has data", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: nil, + ClientKeyData: []byte("key-data"), + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when client cert has data and key is nil", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte("cert-data"), + ClientKeyData: nil, + } + assert.False(t, authHeaders.IsValid()) + }) +} diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 78296c54..5f1d8cd1 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -16,8 +16,20 @@ import ( type HeaderKey string const ( + // CustomServerHeader is the Kubernetes cluster URL. + CustomServerHeader = HeaderKey("kubernetes-server") + // CustomCertificateAuthorityData is the base64-encoded CA certificate. + CustomCertificateAuthorityDataHeader = HeaderKey("kubernetes-certificate-authority-data") + // CustomAuthorizationHeader is the optional bearer token for authentication. CustomAuthorizationHeader = HeaderKey("kubernetes-authorization") - OAuthAuthorizationHeader = HeaderKey("Authorization") + // CustomClientCertificateData is the base64-encoded client certificate. + CustomClientCertificateDataHeader = HeaderKey("kubernetes-client-certificate-data") + // CustomClientKeyData is the base64-encoded client key. + CustomClientKeyDataHeader = HeaderKey("kubernetes-client-key-data") + // CustomInsecureSkipTLSVerify is the optional flag to skip TLS verification. + CustomInsecureSkipTLSVerifyHeader = HeaderKey("kubernetes-insecure-skip-tls-verify") + + OAuthAuthorizationHeader = HeaderKey("Authorization") CustomUserAgent = "kubernetes-mcp-server/bearer-token-auth" ) diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index fe555c64..451edf05 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -88,6 +88,43 @@ func NewInClusterManager(config *config.StaticConfig) (*Manager, error) { return newManager(config, restConfig, clientcmd.NewDefaultClientConfig(*clientCmdConfig, nil)) } +func NewAuthHeadersClusterManager(authHeaders *K8sAuthHeaders, config *config.StaticConfig) (*Manager, error) { + + var certData []byte = nil + if len(authHeaders.ClientCertificateData) > 0 { + certData = authHeaders.ClientCertificateData + } + + var keyData []byte = nil + if len(authHeaders.ClientKeyData) > 0 { + keyData = authHeaders.ClientKeyData + } + + restConfig := &rest.Config{ + Host: authHeaders.Server, + BearerToken: authHeaders.AuthorizationToken, + TLSClientConfig: rest.TLSClientConfig{ + Insecure: authHeaders.InsecureSkipTLSVerify, + CAData: authHeaders.CertificateAuthorityData, + CertData: certData, + KeyData: keyData, + }, + } + // Create a dummy kubeconfig clientcmdapi.Config to be used in places where clientcmd.ClientConfig is required. + clientCmdConfig := clientcmdapi.NewConfig() + clientCmdConfig.Clusters["cluster"] = &clientcmdapi.Cluster{ + Server: authHeaders.Server, + InsecureSkipTLSVerify: authHeaders.InsecureSkipTLSVerify, + } + clientCmdConfig.AuthInfos["user"] = &clientcmdapi.AuthInfo{ + Token: authHeaders.AuthorizationToken, + ClientCertificateData: certData, + ClientKeyData: keyData, + } + + return newManager(config, restConfig, clientcmd.NewDefaultClientConfig(*clientCmdConfig, nil)) +} + func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdConfig clientcmd.ClientConfig) (*Manager, error) { if config == nil { return nil, errors.New("config cannot be nil") diff --git a/pkg/kubernetes/manager_test.go b/pkg/kubernetes/manager_test.go index 6d8b6ee7..1a5325dc 100644 --- a/pkg/kubernetes/manager_test.go +++ b/pkg/kubernetes/manager_test.go @@ -198,6 +198,163 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { }) } +func (s *ManagerTestSuite) TestNewAuthHeadersClusterManager() { + serverURL := s.mockServer.Config().Host + token := "test-token" + + s.Run("creates manager with token authentication", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config is properly configured", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.Equal(serverURL, restConfig.Host) + s.Equal(token, restConfig.BearerToken) + s.Nil(restConfig.CAData) + s.Nil(restConfig.CertData) + s.Nil(restConfig.KeyData) + s.True(restConfig.Insecure) + }) + + s.Run("client cmd config is properly configured", func() { + rawConfig, err := manager.ToRawKubeConfigLoader().RawConfig() + s.Require().NoError(err) + s.NotNil(rawConfig.Clusters["cluster"]) + s.Equal(serverURL, rawConfig.Clusters["cluster"].Server) + s.True(rawConfig.Clusters["cluster"].InsecureSkipTLSVerify) + s.NotNil(rawConfig.AuthInfos["user"]) + s.Equal(token, rawConfig.AuthInfos["user"].Token) + s.Nil(rawConfig.AuthInfos["user"].ClientCertificateData) + s.Nil(rawConfig.AuthInfos["user"].ClientKeyData) + }) + + s.Run("manager can create discovery client", func() { + discoveryClient, err := manager.ToDiscoveryClient() + s.Require().NoError(err) + s.NotNil(discoveryClient) + }) + + s.Run("manager can create REST mapper", func() { + restMapper, err := manager.ToRESTMapper() + s.Require().NoError(err) + s.NotNil(restMapper) + }) + }) + + // Note: Client certificate tests are omitted because they require valid PEM-encoded certificates + // to pass Kubernetes client initialization. The logic for setting cert data is covered by + // the tests for empty/nil certificate handling below. + + s.Run("creates manager with InsecureSkipTLSVerify enabled and no CA", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // No CA data when using insecure + AuthorizationToken: token, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config has insecure flag enabled", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.True(restConfig.Insecure) + s.Nil(restConfig.CAData) + }) + + s.Run("client cmd config has insecure flag enabled", func() { + rawConfig, err := manager.ToRawKubeConfigLoader().RawConfig() + s.Require().NoError(err) + s.True(rawConfig.Clusters["cluster"].InsecureSkipTLSVerify) + }) + }) + + s.Run("creates manager with empty client certificate slices", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + ClientCertificateData: []byte{}, + ClientKeyData: []byte{}, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config has nil cert data for empty slices", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.Nil(restConfig.CertData) + s.Nil(restConfig.KeyData) + }) + }) + + s.Run("creates manager with nil client certificate data", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + ClientCertificateData: nil, + ClientKeyData: nil, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config has nil cert data", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.Nil(restConfig.CertData) + s.Nil(restConfig.KeyData) + }) + }) + + s.Run("creates manager with custom static config", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{ + DeniedResources: []config.GroupVersionKind{ + {Group: "", Version: "v1", Kind: "Secret"}, + }, + } + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("manager is created successfully with denied resources", func() { + // We can't directly access staticConfig, but we can verify the manager was created + // The access control will be tested when actually using the manager + s.NotNil(manager) + }) func (s *ManagerTestSuite) TestNewManager() { s.Run("with nil config returns error", func() { manager, err := newManager(nil, &rest.Config{}, clientcmd.NewDefaultClientConfig(clientcmdapi.Config{}, nil)) diff --git a/pkg/kubernetes/provider_auth_headers.go b/pkg/kubernetes/provider_auth_headers.go new file mode 100644 index 00000000..1bc183db --- /dev/null +++ b/pkg/kubernetes/provider_auth_headers.go @@ -0,0 +1,78 @@ +package kubernetes + +import ( + "context" + "errors" + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + authenticationv1api "k8s.io/api/authentication/v1" + "k8s.io/klog/v2" +) + +// AuthHeadersClusterProvider implements Provider for authentication via request headers. +// This provider requires users to provide authentication tokens via request headers. +// It uses cluster connection details from configuration but does not use any +// authentication credentials from kubeconfig files. +type AuthHeadersClusterProvider struct { + staticConfig *config.StaticConfig +} + +var _ Provider = &AuthHeadersClusterProvider{} + +func init() { + RegisterProvider(config.ClusterProviderAuthHeaders, newAuthHeadersClusterProvider) +} + +// newAuthHeadersClusterProvider creates a provider that requires header-based authentication. +// Users must provide tokens via request headers (server URL, Token or client certificate and key). +func newAuthHeadersClusterProvider(cfg *config.StaticConfig) (Provider, error) { + klog.V(1).Infof("Auth-headers provider initialized - all requests must include valid k8s auth headers") + + return &AuthHeadersClusterProvider{staticConfig: cfg}, nil +} + +func (p *AuthHeadersClusterProvider) IsOpenShift(ctx context.Context) bool { + klog.V(1).Infof("IsOpenShift not supported for auth-headers provider. Returning false.") + return false +} + +func (p *AuthHeadersClusterProvider) VerifyToken(ctx context.Context, target, token, audience string) (*authenticationv1api.UserInfo, []string, error) { + return nil, nil, fmt.Errorf("VerifyToken not supported for auth-headers provider") +} + +func (p *AuthHeadersClusterProvider) GetTargets(_ context.Context) ([]string, error) { + klog.V(1).Infof("GetTargets not supported for auth-headers provider. Returning empty list.") + return []string{""}, nil +} + +func (p *AuthHeadersClusterProvider) GetTargetParameterName() string { + klog.V(1).Infof("GetTargetParameterName not supported for auth-headers provider. Returning empty name.") + return "" +} + +func (p *AuthHeadersClusterProvider) GetDerivedKubernetes(ctx context.Context, target string) (*Kubernetes, error) { + authHeaders, ok := ctx.Value(AuthHeadersContextKey).(*K8sAuthHeaders) + if !ok { + return nil, errors.New("authHeaders required") + } + + manager, err := NewAuthHeadersClusterManager(authHeaders, p.staticConfig) + if err != nil { + return nil, fmt.Errorf("failed to create auth headers cluster manager: %w", err) + } + + return &Kubernetes{manager: manager}, nil +} + +func (p *AuthHeadersClusterProvider) GetDefaultTarget() string { + klog.V(1).Infof("GetDefaultTarget not supported for auth-headers provider. Returning empty name.") + return "" +} + +func (p *AuthHeadersClusterProvider) WatchTargets(watch func() error) { + klog.V(1).Infof("WatchTargets not supported for auth-headers provider. Ignoring watch function.") +} + +func (p *AuthHeadersClusterProvider) Close() { +} diff --git a/pkg/kubernetes/provider_auth_headers_test.go b/pkg/kubernetes/provider_auth_headers_test.go new file mode 100644 index 00000000..6f637b45 --- /dev/null +++ b/pkg/kubernetes/provider_auth_headers_test.go @@ -0,0 +1,265 @@ +package kubernetes + +import ( + "context" + "encoding/base64" + "testing" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthHeadersProviderFactory(t *testing.T) { + t.Run("auth-headers provider initializes without kubeconfig", func(t *testing.T) { + cfg := &config.StaticConfig{ + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + require.NotNil(t, provider) + assert.IsType(t, &AuthHeadersClusterProvider{}, provider) + }) + + t.Run("auth-headers provider initializes with minimal config", func(t *testing.T) { + cfg := &config.StaticConfig{ + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + require.NotNil(t, provider) + }) +} + +func TestAuthHeadersProviderInterface(t *testing.T) { + cfg := &config.StaticConfig{ + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + + t.Run("GetTargets returns single empty target", func(t *testing.T) { + targets, err := provider.GetTargets(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{""}, targets) + }) + + t.Run("GetTargetParameterName returns empty string", func(t *testing.T) { + assert.Equal(t, "", provider.GetTargetParameterName()) + }) + + t.Run("GetDefaultTarget returns empty string", func(t *testing.T) { + assert.Equal(t, "", provider.GetDefaultTarget()) + }) + + t.Run("IsOpenShift returns false", func(t *testing.T) { + assert.False(t, provider.IsOpenShift(context.Background())) + }) + + t.Run("VerifyToken not supported", func(t *testing.T) { + _, _, err := provider.VerifyToken(context.Background(), "", "token", "audience") + require.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + }) + + t.Run("WatchTargets does nothing", func(t *testing.T) { + called := false + provider.WatchTargets(func() error { + called = true + return nil + }) + // WatchTargets should not call the function + assert.False(t, called) + }) + + t.Run("Close does nothing", func(t *testing.T) { + // Should not panic + provider.Close() + }) +} + +func TestAuthHeadersProviderGetDerivedKubernetes(t *testing.T) { + mockServer := test.NewMockServer() + defer mockServer.Close() + + cfg := &config.StaticConfig{ + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + + // Generate test CA certificate data in valid PEM format + caCert := []byte(`-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHHCgVZU8BiMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT +MB4XDTA5MDUxOTE1MTc1N1oXDTEwMDUxOTE1MTc1N1owDTELMAkGA1UEBhMCVVMw +gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBANLJhPHhITqQbPklG3ibCVxwGMRf +p/v4XqhfdQHdcVfHap6NQ5Wok/9X5gK7d1ONlGjn/Ut9Pz4xwqGy3nLxVz1CsE2k +TqQxdqEQBVNvFrAB4OlD9K9wQ3R+0S1wPPQ9yg9i6vF2JlOvD1HFJzIGcz1kLZU2 +wj5FqYY5SHmXF2YbAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAc9NQIv8J/cqV0zBX +c6d5Wm1NJdTxYwG/+xHDaLDK8R3W5Y1e7YwNg7nN8K2GqMh3YYxmDJCLDhGdKDEV +V5qHcKhFCFPxTmKgzVjy8vhR7VqZU4dJhC8sDbE/IkKH7hBo7CLHH/T2Ly9LcDY0 +9C2zNtDN3KEzGW3V7/J7IvVBDy0= +-----END CERTIFICATE-----`) + caCertBase64 := base64.StdEncoding.EncodeToString(caCert) + + t.Run("GetDerivedKubernetes requires auth headers in context", func(t *testing.T) { + ctx := context.Background() + _, err := provider.GetDerivedKubernetes(ctx, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "authHeaders required") + }) + + t.Run("GetDerivedKubernetes works with token authentication", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + Server: mockServer.Config().Host, + CertificateAuthorityData: nil, + AuthorizationToken: "test-token", + InsecureSkipTLSVerify: true, + } + + ctx := context.WithValue(context.Background(), AuthHeadersContextKey, authHeaders) + k, err := provider.GetDerivedKubernetes(ctx, "") + require.NoError(t, err) + require.NotNil(t, k) + assert.NotNil(t, k.manager) + }) + + t.Run("GetDerivedKubernetes accepts client certificate authentication", func(t *testing.T) { + // Note: We use dummy cert/key data since we can't easily create valid certificates for testing. + // The actual validation happens when connecting to the cluster, not during manager creation. + clientCert := []byte("dummy-cert") + clientKey := []byte("dummy-key") + + authHeaders := &K8sAuthHeaders{ + Server: mockServer.Config().Host, + CertificateAuthorityData: nil, + ClientCertificateData: clientCert, + ClientKeyData: clientKey, + InsecureSkipTLSVerify: true, + AuthorizationToken: "", // No token when using client cert + } + + // This should fail because the certificates are invalid, but we're testing that the provider + // accepts the auth headers and attempts to create the manager + ctx := context.WithValue(context.Background(), AuthHeadersContextKey, authHeaders) + _, err := provider.GetDerivedKubernetes(ctx, "") + // Expect an error about invalid certificates, which means the provider accepted the headers + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create auth headers cluster manager") + }) + + t.Run("GetDerivedKubernetes works with insecure skip TLS verify", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + Server: mockServer.Config().Host, + CertificateAuthorityData: nil, // Don't provide CA data when skipping TLS verification + AuthorizationToken: "test-token", + InsecureSkipTLSVerify: true, + } + + ctx := context.WithValue(context.Background(), AuthHeadersContextKey, authHeaders) + k, err := provider.GetDerivedKubernetes(ctx, "") + require.NoError(t, err) + require.NotNil(t, k) + }) + + t.Run("NewK8sAuthHeadersFromHeaders parses token auth correctly", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "Bearer test-token", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.Equal(t, mockServer.Config().Host, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, "Bearer test-token", authHeaders.AuthorizationToken) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("NewK8sAuthHeadersFromHeaders parses cert auth correctly", func(t *testing.T) { + clientCert := []byte("test-client-cert") + clientKey := []byte("test-client-key") + clientCertBase64 := base64.StdEncoding.EncodeToString(clientCert) + clientKeyBase64 := base64.StdEncoding.EncodeToString(clientKey) + + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.Equal(t, mockServer.Config().Host, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, clientCert, authHeaders.ClientCertificateData) + assert.Equal(t, clientKey, authHeaders.ClientKeyData) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("NewK8sAuthHeadersFromHeaders requires server header", func(t *testing.T) { + headers := map[string]any{ + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "Bearer test-token", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + }) + + t.Run("NewK8sAuthHeadersFromHeaders requires CA data header", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomAuthorizationHeader): "Bearer test-token", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-certificate-authority-data") + }) + + t.Run("NewK8sAuthHeadersFromHeaders requires valid auth method", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + }) + + t.Run("NewK8sAuthHeadersFromHeaders handles insecure skip TLS verify", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "Bearer test-token", + string(CustomInsecureSkipTLSVerifyHeader): "true", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.True(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("NewK8sAuthHeadersFromHeaders handles invalid base64 CA data", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): "invalid-base64!!!", + string(CustomAuthorizationHeader): "Bearer test-token", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate authority data") + }) +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 6a4a6d2f..06e5a78d 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -82,6 +82,9 @@ func NewServer(configuration Configuration) (*Server, error) { }), } + if configuration.ClusterProviderStrategy == config.ClusterProviderAuthHeaders { + s.server.AddReceivingMiddleware(customAuthHeadersPropagationMiddleware) + } s.server.AddReceivingMiddleware(authHeaderPropagationMiddleware) s.server.AddReceivingMiddleware(toolCallLoggingMiddleware) if configuration.RequireOAuth && false { // TODO: Disabled scope auth validation for now diff --git a/pkg/mcp/mcp_middleware_test.go b/pkg/mcp/mcp_middleware_test.go index ce88e7b4..2b150ae5 100644 --- a/pkg/mcp/mcp_middleware_test.go +++ b/pkg/mcp/mcp_middleware_test.go @@ -85,3 +85,65 @@ func (s *McpLoggingSuite) TestLogsToolCallHeaders() { func TestMcpLogging(t *testing.T) { suite.Run(t, new(McpLoggingSuite)) } + +type CustomAuthHeadersMiddlewareSuite struct { + BaseMcpSuite +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestParsesAuthHeadersFromHTTPHeaders() { + caCertBase64 := "dGVzdC1jYS1jZXJ0" // base64 of "test-ca-cert" + serverURL := "https://k8s.example.com:6443" + token := "Bearer test-token" + + s.InitMcpClient(transport.WithHTTPHeaders(map[string]string{ + "kubernetes-server": serverURL, + "kubernetes-certificate-authority-data": caCertBase64, + "kubernetes-authorization": token, + })) + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call to tool configuration_view failed") + + // The middleware should have successfully parsed and added auth headers to context + // This is validated indirectly by the tool call succeeding +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestHeadersAreLowercased() { + caCertBase64 := "dGVzdC1jYS1jZXJ0" // base64 of "test-ca-cert" + serverURL := "https://k8s.example.com:6443" + token := "Bearer test-token" + + // Use uppercase header names + s.InitMcpClient(transport.WithHTTPHeaders(map[string]string{ + "Kubernetes-Server": serverURL, // uppercase K + "KUBERNETES-CERTIFICATE-AUTHORITY-DATA": caCertBase64, // all uppercase + "Kubernetes-Authorization": token, // mixed case + })) + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call should succeed even with uppercase headers") +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestIgnoresInvalidAuthHeadersWhenNotUsingAuthHeadersProvider() { + // When not using auth-headers provider, invalid custom headers are ignored + // and the default kubeconfig provider is used instead + s.InitMcpClient(transport.WithHTTPHeaders(map[string]string{ + "kubernetes-server": "https://k8s.example.com:6443", + // Missing CA cert and authorization - will be ignored + })) + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call should succeed using default kubeconfig provider") +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestPassesThroughWithNoHeaders() { + // No custom headers provided - should work with default kubeconfig + s.InitMcpClient() + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call should succeed without custom headers") +} + +func TestCustomAuthHeadersMiddleware(t *testing.T) { + suite.Run(t, new(CustomAuthHeadersMiddlewareSuite)) +} diff --git a/pkg/mcp/middleware.go b/pkg/mcp/middleware.go index ec6f4d42..7a85a3fc 100644 --- a/pkg/mcp/middleware.go +++ b/pkg/mcp/middleware.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "slices" + "strings" internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -30,6 +31,46 @@ func authHeaderPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHandler { } } +func customAuthHeadersPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + + var authHeaders *internalk8s.K8sAuthHeaders = nil + var err error + // Try to parse auth headers from tool params meta. + if req.GetParams() != nil { + if toolParams, ok := req.GetParams().(*mcp.CallToolParamsRaw); ok { + toolParamsMeta := toolParams.GetMeta() + authHeaders, err = internalk8s.NewK8sAuthHeadersFromHeaders(toolParamsMeta) + if err != nil { + klog.V(4).ErrorS(err, "failed to parse custom auth headers from tool params meta", "tool", req.GetParams().(*mcp.CallToolParamsRaw).Name) + } + } + } + + // If auth headers are not found in tool params meta, try to parse from request extra. + if authHeaders == nil && req.GetExtra() != nil && req.GetExtra().Header != nil { + // Convert http.Header to map[string]any with lowercased keys. + headerMap := make(map[string]any) + for key, values := range req.GetExtra().Header { + if len(values) > 0 { + headerMap[strings.ToLower(key)] = values[0] + } + } + // Filter auth headers to only include the ones that are allowed. + authHeaders, err = internalk8s.NewK8sAuthHeadersFromHeaders(headerMap) + if err != nil { + return nil, err + } + } + + // Add auth headers to context + if authHeaders != nil { + ctx = context.WithValue(ctx, internalk8s.AuthHeadersContextKey, authHeaders) + } + return next(ctx, method, req) + } +} + func toolCallLoggingMiddleware(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { switch params := req.GetParams().(type) {