diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go index a742f083d..1f42d5b05 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go @@ -11,6 +11,11 @@ const ( // ExternalAuthTypeHeaderInjection is the type for custom header injection ExternalAuthTypeHeaderInjection ExternalAuthType = "headerInjection" + + // ExternalAuthTypeUnauthenticated is the type for no authentication + // This should only be used for backends on trusted networks (e.g., localhost, VPC) + // or when authentication is handled by network-level security + ExternalAuthTypeUnauthenticated ExternalAuthType = "unauthenticated" ) // ExternalAuthType represents the type of external authentication @@ -21,7 +26,7 @@ type ExternalAuthType string // MCPServer resources in the same namespace. type MCPExternalAuthConfigSpec struct { // Type is the type of external authentication to configure - // +kubebuilder:validation:Enum=tokenExchange;headerInjection + // +kubebuilder:validation:Enum=tokenExchange;headerInjection;unauthenticated // +kubebuilder:validation:Required Type ExternalAuthType `json:"type"` diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook.go b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook.go new file mode 100644 index 000000000..ecae8b226 --- /dev/null +++ b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook.go @@ -0,0 +1,75 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// SetupWebhookWithManager sets up the webhook with the Manager +func (r *MCPExternalAuthConfig) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +//nolint:lll // kubebuilder webhook marker cannot be split +// +kubebuilder:webhook:path=/validate-toolhive-stacklok-com-v1alpha1-mcpexternalauthconfig,mutating=false,failurePolicy=fail,sideEffects=None,groups=toolhive.stacklok.com,resources=mcpexternalauthconfigs,verbs=create;update,versions=v1alpha1,name=vmcpexternalauthconfig.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomValidator = &MCPExternalAuthConfig{} + +// ValidateCreate implements webhook.CustomValidator +func (r *MCPExternalAuthConfig) ValidateCreate(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, r.validate() +} + +// ValidateUpdate implements webhook.CustomValidator +func (r *MCPExternalAuthConfig) ValidateUpdate( + _ context.Context, _ runtime.Object, _ runtime.Object, +) (admission.Warnings, error) { + return nil, r.validate() +} + +// ValidateDelete implements webhook.CustomValidator +func (*MCPExternalAuthConfig) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + // No validation needed for deletion + return nil, nil +} + +// validate performs validation on the MCPExternalAuthConfig spec +func (r *MCPExternalAuthConfig) validate() error { + switch r.Spec.Type { + case ExternalAuthTypeTokenExchange: + if r.Spec.TokenExchange == nil { + return fmt.Errorf("tokenExchange configuration is required when type is 'tokenExchange'") + } + if r.Spec.HeaderInjection != nil { + return fmt.Errorf("headerInjection must not be set when type is 'tokenExchange'") + } + + case ExternalAuthTypeHeaderInjection: + if r.Spec.HeaderInjection == nil { + return fmt.Errorf("headerInjection configuration is required when type is 'headerInjection'") + } + if r.Spec.TokenExchange != nil { + return fmt.Errorf("tokenExchange must not be set when type is 'headerInjection'") + } + + case ExternalAuthTypeUnauthenticated: + if r.Spec.TokenExchange != nil { + return fmt.Errorf("tokenExchange must not be set when type is 'unauthenticated'") + } + if r.Spec.HeaderInjection != nil { + return fmt.Errorf("headerInjection must not be set when type is 'unauthenticated'") + } + + default: + return fmt.Errorf("unsupported auth type: %s", r.Spec.Type) + } + + return nil +} diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook_test.go b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook_test.go new file mode 100644 index 000000000..d2837217d --- /dev/null +++ b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook_test.go @@ -0,0 +1,262 @@ +package v1alpha1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMCPExternalAuthConfig_ValidateCreate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *MCPExternalAuthConfig + expectError bool + errorMsg string + }{ + { + name: "valid unauthenticated", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unauthenticated", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + }, + }, + expectError: false, + }, + { + name: "unauthenticated with tokenExchange should fail", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + TokenExchange: &TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + }, + }, + }, + expectError: true, + errorMsg: "tokenExchange must not be set when type is 'unauthenticated'", + }, + { + name: "unauthenticated with headerInjection should fail", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + HeaderInjection: &HeaderInjectionConfig{ + HeaderName: "Authorization", + ValueSecretRef: &SecretKeyRef{ + Name: "secret", + Key: "key", + }, + }, + }, + }, + expectError: true, + errorMsg: "headerInjection must not be set when type is 'unauthenticated'", + }, + { + name: "valid tokenExchange", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-tokenexchange", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + Audience: "backend-service", + }, + }, + }, + expectError: false, + }, + { + name: "tokenExchange without config should fail", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeTokenExchange, + }, + }, + expectError: true, + errorMsg: "tokenExchange configuration is required when type is 'tokenExchange'", + }, + { + name: "tokenExchange with headerInjection should fail", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + Audience: "backend-service", + }, + HeaderInjection: &HeaderInjectionConfig{ + HeaderName: "Authorization", + ValueSecretRef: &SecretKeyRef{ + Name: "secret", + Key: "key", + }, + }, + }, + }, + expectError: true, + errorMsg: "headerInjection must not be set when type is 'tokenExchange'", + }, + { + name: "valid headerInjection", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-headerinjection", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeHeaderInjection, + HeaderInjection: &HeaderInjectionConfig{ + HeaderName: "X-API-Key", + ValueSecretRef: &SecretKeyRef{ + Name: "api-key-secret", + Key: "api-key", + }, + }, + }, + }, + expectError: false, + }, + { + name: "headerInjection without config should fail", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeHeaderInjection, + }, + }, + expectError: true, + errorMsg: "headerInjection configuration is required when type is 'headerInjection'", + }, + { + name: "headerInjection with tokenExchange should fail", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeHeaderInjection, + HeaderInjection: &HeaderInjectionConfig{ + HeaderName: "X-API-Key", + ValueSecretRef: &SecretKeyRef{ + Name: "secret", + Key: "key", + }, + }, + TokenExchange: &TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + }, + }, + }, + expectError: true, + errorMsg: "tokenExchange must not be set when type is 'headerInjection'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + warnings, err := tt.config.ValidateCreate(context.Background(), tt.config) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + + // Warnings should always be nil for now + assert.Nil(t, warnings) + }) + } +} + +func TestMCPExternalAuthConfig_ValidateUpdate(t *testing.T) { + t.Parallel() + + config := &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unauthenticated", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + }, + } + + // ValidateUpdate should use the same logic as ValidateCreate + warnings, err := config.ValidateUpdate(context.Background(), nil, config) + require.NoError(t, err) + assert.Nil(t, warnings) + + // Test invalid update + invalidConfig := &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + TokenExchange: &TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + }, + }, + } + + warnings, err = invalidConfig.ValidateUpdate(context.Background(), nil, invalidConfig) + require.Error(t, err) + assert.Contains(t, err.Error(), "tokenExchange must not be set when type is 'unauthenticated'") + assert.Nil(t, warnings) +} + +func TestMCPExternalAuthConfig_ValidateDelete(t *testing.T) { + t.Parallel() + + config := &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unauthenticated", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + }, + } + + // ValidateDelete should always succeed + warnings, err := config.ValidateDelete(context.Background(), config) + require.NoError(t, err) + assert.Nil(t, warnings) +} diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 6950242d6..6d230b6f4 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -8,8 +8,6 @@ import ( "fmt" "maps" "reflect" - "regexp" - "strings" "time" appsv1 "k8s.io/api/apps/v1" @@ -1271,37 +1269,17 @@ func (*VirtualMCPServerReconciler) convertExternalAuthConfigToStrategy( if strategy.TokenExchange != nil && externalAuthConfig.Spec.TokenExchange != nil && externalAuthConfig.Spec.TokenExchange.ClientSecretRef != nil { - strategy.TokenExchange.ClientSecretEnv = generateUniqueTokenExchangeEnvVarName(externalAuthConfig.Name) + strategy.TokenExchange.ClientSecretEnv = ctrlutil.GenerateUniqueTokenExchangeEnvVarName(externalAuthConfig.Name) } if strategy.HeaderInjection != nil && externalAuthConfig.Spec.HeaderInjection != nil && externalAuthConfig.Spec.HeaderInjection.ValueSecretRef != nil { - strategy.HeaderInjection.HeaderValueEnv = generateUniqueHeaderInjectionEnvVarName(externalAuthConfig.Name) + strategy.HeaderInjection.HeaderValueEnv = ctrlutil.GenerateUniqueHeaderInjectionEnvVarName(externalAuthConfig.Name) } return strategy, nil } -// generateUniqueTokenExchangeEnvVarName generates a unique environment variable name for token exchange -// client secrets, incorporating the ExternalAuthConfig name to ensure uniqueness. -func generateUniqueTokenExchangeEnvVarName(configName string) string { - // Sanitize config name for use in env var (uppercase, replace invalid chars with underscore) - sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) - // Remove any remaining invalid characters (keep only alphanumeric and underscore) - sanitized = regexp.MustCompile(`[^A-Z0-9_]`).ReplaceAllString(sanitized, "_") - return fmt.Sprintf("TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_%s", sanitized) -} - -// generateUniqueHeaderInjectionEnvVarName generates a unique environment variable name for header injection -// values, incorporating the ExternalAuthConfig name to ensure uniqueness. -func generateUniqueHeaderInjectionEnvVarName(configName string) string { - // Sanitize config name for use in env var (uppercase, replace invalid chars with underscore) - sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) - // Remove any remaining invalid characters (keep only alphanumeric and underscore) - sanitized = regexp.MustCompile(`[^A-Z0-9_]`).ReplaceAllString(sanitized, "_") - return fmt.Sprintf("TOOLHIVE_HEADER_INJECTION_VALUE_%s", sanitized) -} - // convertBackendAuthConfigToVMCP converts a BackendAuthConfig from CRD to vmcp config. func (r *VirtualMCPServerReconciler) convertBackendAuthConfigToVMCP( ctx context.Context, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index 9608e0d85..eadfe9de2 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -425,7 +425,7 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVar( if externalAuthConfig.Spec.TokenExchange.ClientSecretRef == nil { return nil, nil // No secret to mount } - envVarName = generateUniqueTokenExchangeEnvVarName(externalAuthConfigName) + envVarName = ctrlutil.GenerateUniqueTokenExchangeEnvVarName(externalAuthConfigName) secretRef = externalAuthConfig.Spec.TokenExchange.ClientSecretRef case mcpv1alpha1.ExternalAuthTypeHeaderInjection: @@ -435,9 +435,13 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVar( if externalAuthConfig.Spec.HeaderInjection.ValueSecretRef == nil { return nil, nil // No secret to mount } - envVarName = generateUniqueHeaderInjectionEnvVarName(externalAuthConfigName) + envVarName = ctrlutil.GenerateUniqueHeaderInjectionEnvVarName(externalAuthConfigName) secretRef = externalAuthConfig.Spec.HeaderInjection.ValueSecretRef + case mcpv1alpha1.ExternalAuthTypeUnauthenticated: + // No secrets to mount for unauthenticated + return nil, nil + default: return nil, nil // Not applicable } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index 1e13e3ec6..8b383564c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -950,7 +950,7 @@ func TestGenerateUniqueTokenExchangeEnvVarName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := generateUniqueTokenExchangeEnvVarName(tt.configName) + result := ctrlutil.GenerateUniqueTokenExchangeEnvVarName(tt.configName) assert.Contains(t, result, expectedPrefix) assert.Contains(t, result, tt.expectedSuffix) // Verify format: PREFIX_SUFFIX @@ -1007,7 +1007,7 @@ func TestGenerateUniqueHeaderInjectionEnvVarName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := generateUniqueHeaderInjectionEnvVarName(tt.configName) + result := ctrlutil.GenerateUniqueHeaderInjectionEnvVarName(tt.configName) assert.True(t, strings.HasPrefix(result, expectedPrefix+"_"), "Result should start with prefix") assert.True(t, strings.HasSuffix(result, tt.expectedSuffix), "Result should end with suffix") // Verify format: PREFIX_SUFFIX diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index a49dd870d..7f088186e 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -196,7 +196,8 @@ func TestConvertBackendAuthConfig(t *testing.T) { authConfig: &mcpv1alpha1.BackendAuthConfig{ Type: mcpv1alpha1.BackendAuthTypeDiscovered, }, - expectedType: mcpv1alpha1.BackendAuthTypeDiscovered, + // "discovered" type is converted to "unauthenticated" by the converter + expectedType: "unauthenticated", }, { name: "external auth config ref", @@ -206,7 +207,8 @@ func TestConvertBackendAuthConfig(t *testing.T) { Name: "auth-config", }, }, - expectedType: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, + // For external_auth_config_ref, the type comes from the referenced MCPExternalAuthConfig + expectedType: "unauthenticated", }, } @@ -216,6 +218,10 @@ func TestConvertBackendAuthConfig(t *testing.T) { t.Parallel() vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp", + Namespace: "default", + }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ GroupRef: mcpv1alpha1.GroupRef{ Name: "test-group", @@ -226,7 +232,34 @@ func TestConvertBackendAuthConfig(t *testing.T) { }, } - converter := newTestConverter(t, newNoOpMockResolver(t)) + // For external_auth_config_ref test, create the referenced MCPExternalAuthConfig + var converter *vmcpconfig.Converter + if tt.authConfig.Type == mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef { + // Create a fake MCPExternalAuthConfig + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + }, + } + + // Create converter with fake client that has the external auth config + scheme := runtime.NewScheme() + _ = mcpv1alpha1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(externalAuthConfig). + Build() + var err error + converter, err = vmcpconfig.NewConverter(newNoOpMockResolver(t), fakeClient) + require.NoError(t, err) + } else { + converter = newTestConverter(t, newNoOpMockResolver(t)) + } + config, err := converter.Convert(context.Background(), vmcpServer) require.NoError(t, err) diff --git a/cmd/thv-operator/main.go b/cmd/thv-operator/main.go index 831d64436..1f2ac566a 100644 --- a/cmd/thv-operator/main.go +++ b/cmd/thv-operator/main.go @@ -199,6 +199,11 @@ func setupControllersAndWebhooks(mgr ctrl.Manager) error { if err := (&mcpv1alpha1.VirtualMCPCompositeToolDefinition{}).SetupWebhookWithManager(mgr); err != nil { return fmt.Errorf("unable to create webhook VirtualMCPCompositeToolDefinition: %w", err) } + + // Set up MCPExternalAuthConfig webhook + if err := (&mcpv1alpha1.MCPExternalAuthConfig{}).SetupWebhookWithManager(mgr); err != nil { + return fmt.Errorf("unable to create webhook MCPExternalAuthConfig: %w", err) + } //+kubebuilder:scaffold:builder return nil diff --git a/cmd/thv-operator/pkg/controllerutil/externalauth.go b/cmd/thv-operator/pkg/controllerutil/externalauth.go new file mode 100644 index 000000000..01ceff543 --- /dev/null +++ b/cmd/thv-operator/pkg/controllerutil/externalauth.go @@ -0,0 +1,38 @@ +// Package controllerutil provides utility functions for Kubernetes controllers. +package controllerutil + +import ( + "fmt" + "regexp" + "strings" +) + +// GenerateUniqueTokenExchangeEnvVarName generates a unique environment variable name for token exchange +// client secrets, incorporating the ExternalAuthConfig name to ensure uniqueness. +// This function is used by both the converter and deployment controller to ensure consistent +// environment variable naming across the system. +// +// Example: For an ExternalAuthConfig named "my-auth-config", this returns: +// "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_MY_AUTH_CONFIG" +func GenerateUniqueTokenExchangeEnvVarName(configName string) string { + // Sanitize config name for use in env var (uppercase, replace invalid chars with underscore) + sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) + // Remove any remaining invalid characters (keep only alphanumeric and underscore) + sanitized = regexp.MustCompile(`[^A-Z0-9_]`).ReplaceAllString(sanitized, "_") + return fmt.Sprintf("TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_%s", sanitized) +} + +// GenerateUniqueHeaderInjectionEnvVarName generates a unique environment variable name for header injection +// values, incorporating the ExternalAuthConfig name to ensure uniqueness. +// This function is used by both the converter and deployment controller to ensure consistent +// environment variable naming across the system. +// +// Example: For an ExternalAuthConfig named "my-auth-config", this returns: +// "TOOLHIVE_HEADER_INJECTION_VALUE_MY_AUTH_CONFIG" +func GenerateUniqueHeaderInjectionEnvVarName(configName string) string { + // Sanitize config name for use in env var (uppercase, replace invalid chars with underscore) + sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) + // Remove any remaining invalid characters (keep only alphanumeric and underscore) + sanitized = regexp.MustCompile(`[^A-Z0-9_]`).ReplaceAllString(sanitized, "_") + return fmt.Sprintf("TOOLHIVE_HEADER_INJECTION_VALUE_%s", sanitized) +} diff --git a/cmd/thv-operator/pkg/controllerutil/externalauth_test.go b/cmd/thv-operator/pkg/controllerutil/externalauth_test.go new file mode 100644 index 000000000..2b8c7ea48 --- /dev/null +++ b/cmd/thv-operator/pkg/controllerutil/externalauth_test.go @@ -0,0 +1,102 @@ +package controllerutil + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestGenerateUniqueTokenExchangeEnvVarName tests the GenerateUniqueTokenExchangeEnvVarName function +func TestGenerateUniqueTokenExchangeEnvVarName(t *testing.T) { + t.Parallel() + + expectedPrefix := "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET" + tests := []struct { + name string + configName string + expectedSuffix string + }{ + { + name: "simple name", + configName: "test-config", + expectedSuffix: "TEST_CONFIG", + }, + { + name: "multiple hyphens", + configName: "my-test-config", + expectedSuffix: "MY_TEST_CONFIG", + }, + { + name: "with special characters", + configName: "test.config@123", + expectedSuffix: "TEST_CONFIG_123", + }, + { + name: "single character", + configName: "a", + expectedSuffix: "A", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := GenerateUniqueTokenExchangeEnvVarName(tt.configName) + assert.Contains(t, result, expectedPrefix) + assert.Contains(t, result, tt.expectedSuffix) + // Verify format: PREFIX_SUFFIX + assert.Contains(t, result, "_") + // Verify all characters are valid for env vars (uppercase, alphanumeric, underscore) + envVarPattern := regexp.MustCompile(`^[A-Z0-9_]+$`) + assert.Regexp(t, envVarPattern, result, "Result should be a valid environment variable name") + }) + } +} + +// TestGenerateUniqueHeaderInjectionEnvVarName tests the GenerateUniqueHeaderInjectionEnvVarName function +func TestGenerateUniqueHeaderInjectionEnvVarName(t *testing.T) { + t.Parallel() + + expectedPrefix := "TOOLHIVE_HEADER_INJECTION_VALUE" + tests := []struct { + name string + configName string + expectedSuffix string + }{ + { + name: "simple name", + configName: "test-config", + expectedSuffix: "TEST_CONFIG", + }, + { + name: "multiple hyphens", + configName: "my-test-config", + expectedSuffix: "MY_TEST_CONFIG", + }, + { + name: "with special characters", + configName: "test.config@123", + expectedSuffix: "TEST_CONFIG_123", + }, + { + name: "single character", + configName: "x", + expectedSuffix: "X", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := GenerateUniqueHeaderInjectionEnvVarName(tt.configName) + assert.True(t, regexp.MustCompile("^"+expectedPrefix+"_").MatchString(result), "Result should start with prefix") + assert.True(t, regexp.MustCompile(tt.expectedSuffix+"$").MatchString(result), "Result should end with suffix") + // Verify format: PREFIX_SUFFIX + assert.Contains(t, result, "_") + // Verify all characters are valid for env vars (uppercase, alphanumeric, underscore) + envVarPattern := regexp.MustCompile(`^[A-Z0-9_]+$`) + assert.Regexp(t, envVarPattern, result, "Result should be a valid environment variable name") + }) + } +} diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go index 7ae396da1..f64fe4d77 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go @@ -116,6 +116,9 @@ func AddExternalAuthConfigOptions( return addTokenExchangeConfig(ctx, c, namespace, externalAuthConfig, options) case mcpv1alpha1.ExternalAuthTypeHeaderInjection: return addHeaderInjectionConfig(ctx, c, namespace, externalAuthConfig, options) + case mcpv1alpha1.ExternalAuthTypeUnauthenticated: + // No config to add for unauthenticated + return nil default: return fmt.Errorf("unsupported external auth type: %s", externalAuthConfig.Spec.Type) } diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 438b59b4f..16320f875 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -14,6 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -39,7 +41,8 @@ type Converter struct { // NewConverter creates a new Converter instance. // oidcResolver is required and used to resolve OIDC configuration from various sources // (kubernetes, configMap, inline). Use a mock resolver in tests. -// k8sClient is required and used to fetch referenced VirtualMCPCompositeToolDefinition resources. +// k8sClient is required for resolving MCPToolConfig references and fetching referenced +// VirtualMCPCompositeToolDefinition resources. // Returns an error if oidcResolver or k8sClient is nil. func NewConverter(oidcResolver oidc.Resolver, k8sClient client.Client) (*Converter, error) { if oidcResolver == nil { @@ -75,7 +78,11 @@ func (c *Converter) Convert( // Convert OutgoingAuth - always set with defaults if not specified if vmcp.Spec.OutgoingAuth != nil { - config.OutgoingAuth = c.convertOutgoingAuth(ctx, vmcp) + outgoingAuth, err := c.convertOutgoingAuth(ctx, vmcp) + if err != nil { + return nil, fmt.Errorf("failed to convert outgoing auth: %w", err) + } + config.OutgoingAuth = outgoingAuth } else { // Provide default outgoing auth config config.OutgoingAuth = &vmcpconfig.OutgoingAuthConfig{ @@ -85,7 +92,11 @@ func (c *Converter) Convert( // Convert Aggregation - always set with defaults if not specified if vmcp.Spec.Aggregation != nil { - config.Aggregation = c.convertAggregation(ctx, vmcp) + agg, err := c.convertAggregation(ctx, vmcp) + if err != nil { + return nil, fmt.Errorf("failed to convert aggregation config: %w", err) + } + config.Aggregation = agg } else { // Provide default aggregation config with prefix conflict resolution config.Aggregation = &vmcpconfig.AggregationConfig{ @@ -218,9 +229,9 @@ func mapResolvedOIDCToVmcpConfig( // convertOutgoingAuth converts OutgoingAuthConfig from CRD to vmcp config func (c *Converter) convertOutgoingAuth( - _ context.Context, + ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, -) *vmcpconfig.OutgoingAuthConfig { +) (*vmcpconfig.OutgoingAuthConfig, error) { outgoing := &vmcpconfig.OutgoingAuthConfig{ Source: vmcp.Spec.OutgoingAuth.Source, Backends: make(map[string]*authtypes.BackendAuthStrategy), @@ -228,41 +239,136 @@ func (c *Converter) convertOutgoingAuth( // Convert Default if vmcp.Spec.OutgoingAuth.Default != nil { - outgoing.Default = c.convertBackendAuthConfig(vmcp.Spec.OutgoingAuth.Default) + defaultStrategy, err := c.convertBackendAuthConfig(ctx, vmcp, "default", vmcp.Spec.OutgoingAuth.Default) + if err != nil { + return nil, fmt.Errorf("failed to convert default backend auth: %w", err) + } + outgoing.Default = defaultStrategy } // Convert per-backend overrides for backendName, backendAuth := range vmcp.Spec.OutgoingAuth.Backends { - outgoing.Backends[backendName] = c.convertBackendAuthConfig(&backendAuth) + strategy, err := c.convertBackendAuthConfig(ctx, vmcp, backendName, &backendAuth) + if err != nil { + return nil, fmt.Errorf("failed to convert backend auth for %s: %w", backendName, err) + } + outgoing.Backends[backendName] = strategy } - return outgoing + return outgoing, nil } // convertBackendAuthConfig converts BackendAuthConfig from CRD to vmcp config -func (*Converter) convertBackendAuthConfig( +func (c *Converter) convertBackendAuthConfig( + ctx context.Context, + vmcp *mcpv1alpha1.VirtualMCPServer, + backendName string, crdConfig *mcpv1alpha1.BackendAuthConfig, -) *authtypes.BackendAuthStrategy { - strategy := &authtypes.BackendAuthStrategy{ - Type: crdConfig.Type, +) (*authtypes.BackendAuthStrategy, error) { + // If type is "discovered", return unauthenticated strategy + if crdConfig.Type == mcpv1alpha1.BackendAuthTypeDiscovered { + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeUnauthenticated, + }, nil + } + + // If type is "external_auth_config_ref", resolve the MCPExternalAuthConfig + if crdConfig.Type == mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef { + if crdConfig.ExternalAuthConfigRef == nil { + return nil, fmt.Errorf("backend %s: external_auth_config_ref type requires externalAuthConfigRef field", backendName) + } + + // Fetch the MCPExternalAuthConfig resource + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := c.k8sClient.Get(ctx, types.NamespacedName{ + Name: crdConfig.ExternalAuthConfigRef.Name, + Namespace: vmcp.Namespace, + }, externalAuthConfig) + if err != nil { + return nil, fmt.Errorf("failed to get MCPExternalAuthConfig %s/%s: %w", + vmcp.Namespace, crdConfig.ExternalAuthConfigRef.Name, err) + } + + // Convert the external auth config to backend auth strategy + return c.convertExternalAuthConfigToStrategy(ctx, externalAuthConfig) } - // Note: When Type is "external_auth_config_ref", the actual MCPExternalAuthConfig - // resource should be resolved at runtime and its configuration (TokenExchange or - // HeaderInjection) should be populated into the corresponding typed fields. - // This conversion happens during server initialization when the referenced - // MCPExternalAuthConfig can be looked up. + // Unknown type + return nil, fmt.Errorf("backend %s: unknown auth type %q", backendName, crdConfig.Type) +} + +// convertExternalAuthConfigToStrategy converts MCPExternalAuthConfig to BackendAuthStrategy +func (*Converter) convertExternalAuthConfigToStrategy( + _ context.Context, + externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, +) (*authtypes.BackendAuthStrategy, error) { + strategy := &authtypes.BackendAuthStrategy{} - return strategy + switch externalAuthConfig.Spec.Type { + case mcpv1alpha1.ExternalAuthTypeUnauthenticated: + strategy.Type = authtypes.StrategyTypeUnauthenticated + + case mcpv1alpha1.ExternalAuthTypeHeaderInjection: + if externalAuthConfig.Spec.HeaderInjection == nil { + return nil, fmt.Errorf("headerInjection config is required when type is headerInjection") + } + + strategy.Type = authtypes.StrategyTypeHeaderInjection + strategy.HeaderInjection = &authtypes.HeaderInjectionConfig{ + HeaderName: externalAuthConfig.Spec.HeaderInjection.HeaderName, + // The secret value will be mounted as an environment variable by the deployment controller + // Use the same env var naming convention as the deployment controller + HeaderValueEnv: controllerutil.GenerateUniqueHeaderInjectionEnvVarName(externalAuthConfig.Name), + } + + case mcpv1alpha1.ExternalAuthTypeTokenExchange: + if externalAuthConfig.Spec.TokenExchange == nil { + return nil, fmt.Errorf("tokenExchange config is required when type is tokenExchange") + } + + strategy.Type = authtypes.StrategyTypeTokenExchange + strategy.TokenExchange = &authtypes.TokenExchangeConfig{ + TokenURL: externalAuthConfig.Spec.TokenExchange.TokenURL, + ClientID: externalAuthConfig.Spec.TokenExchange.ClientID, + Audience: externalAuthConfig.Spec.TokenExchange.Audience, + Scopes: externalAuthConfig.Spec.TokenExchange.Scopes, + SubjectTokenType: externalAuthConfig.Spec.TokenExchange.SubjectTokenType, + } + + // If client secret ref is set, use an environment variable + if externalAuthConfig.Spec.TokenExchange.ClientSecretRef != nil { + // The secret value will be mounted as an environment variable by the deployment controller + // Use the same env var naming convention as the deployment controller + strategy.TokenExchange.ClientSecretEnv = controllerutil.GenerateUniqueTokenExchangeEnvVarName(externalAuthConfig.Name) + } + + default: + return nil, fmt.Errorf("unknown external auth type: %s", externalAuthConfig.Spec.Type) + } + + return strategy, nil } // convertAggregation converts AggregationConfig from CRD to vmcp config -func (*Converter) convertAggregation( - _ context.Context, +func (c *Converter) convertAggregation( + ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, -) *vmcpconfig.AggregationConfig { +) (*vmcpconfig.AggregationConfig, error) { agg := &vmcpconfig.AggregationConfig{} + c.convertConflictResolution(vmcp, agg) + if err := c.convertToolConfigs(ctx, vmcp, agg); err != nil { + return nil, err + } + + return agg, nil +} + +// convertConflictResolution converts conflict resolution strategy and config +func (*Converter) convertConflictResolution( + vmcp *mcpv1alpha1.VirtualMCPServer, + agg *vmcpconfig.AggregationConfig, +) { // Convert conflict resolution strategy switch vmcp.Spec.Aggregation.ConflictResolution { case mcpv1alpha1.ConflictResolutionPrefix: @@ -287,32 +393,137 @@ func (*Converter) convertAggregation( PrefixFormat: "{workload}_", } } +} - // Convert per-workload tool configs - if len(vmcp.Spec.Aggregation.Tools) > 0 { - agg.Tools = make([]*vmcpconfig.WorkloadToolConfig, 0, len(vmcp.Spec.Aggregation.Tools)) - for _, toolConfig := range vmcp.Spec.Aggregation.Tools { - wtc := &vmcpconfig.WorkloadToolConfig{ - Workload: toolConfig.Workload, - Filter: toolConfig.Filter, - } +// convertToolConfigs converts per-workload tool configurations +func (c *Converter) convertToolConfigs( + ctx context.Context, + vmcp *mcpv1alpha1.VirtualMCPServer, + agg *vmcpconfig.AggregationConfig, +) error { + if len(vmcp.Spec.Aggregation.Tools) == 0 { + return nil + } - // Convert overrides - if len(toolConfig.Overrides) > 0 { - wtc.Overrides = make(map[string]*vmcpconfig.ToolOverride) - for toolName, override := range toolConfig.Overrides { - wtc.Overrides[toolName] = &vmcpconfig.ToolOverride{ - Name: override.Name, - Description: override.Description, - } - } + ctxLogger := log.FromContext(ctx) + agg.Tools = make([]*vmcpconfig.WorkloadToolConfig, 0, len(vmcp.Spec.Aggregation.Tools)) + + for _, toolConfig := range vmcp.Spec.Aggregation.Tools { + wtc := &vmcpconfig.WorkloadToolConfig{ + Workload: toolConfig.Workload, + Filter: toolConfig.Filter, + } + + if err := c.applyToolConfigRef(ctx, ctxLogger, vmcp, toolConfig, wtc); err != nil { + return err + } + c.applyInlineOverrides(toolConfig, wtc) + + agg.Tools = append(agg.Tools, wtc) + } + return nil +} + +// applyToolConfigRef resolves and applies MCPToolConfig reference +func (c *Converter) applyToolConfigRef( + ctx context.Context, + ctxLogger logr.Logger, + vmcp *mcpv1alpha1.VirtualMCPServer, + toolConfig mcpv1alpha1.WorkloadToolConfig, + wtc *vmcpconfig.WorkloadToolConfig, +) error { + if toolConfig.ToolConfigRef == nil { + return nil + } + + resolvedConfig, err := c.resolveMCPToolConfig(ctx, vmcp.Namespace, toolConfig.ToolConfigRef.Name) + if err != nil { + ctxLogger.Error(err, "failed to resolve MCPToolConfig reference", + "workload", toolConfig.Workload, + "toolConfigRef", toolConfig.ToolConfigRef.Name) + // Fail closed: return error when MCPToolConfig is configured but resolution fails + // This prevents deploying without tool filtering when explicit configuration is requested + return fmt.Errorf("MCPToolConfig resolution failed for %q: %w", + toolConfig.ToolConfigRef.Name, err) + } + + // Note: resolveMCPToolConfig never returns (nil, nil) - it either succeeds with + // (toolConfig, nil) or fails with (nil, error), so no nil check needed here + + c.mergeToolConfigFilter(wtc, resolvedConfig) + c.mergeToolConfigOverrides(wtc, resolvedConfig) + return nil +} + +// mergeToolConfigFilter merges filter from MCPToolConfig +func (*Converter) mergeToolConfigFilter( + wtc *vmcpconfig.WorkloadToolConfig, + resolvedConfig *mcpv1alpha1.MCPToolConfig, +) { + if len(wtc.Filter) == 0 && len(resolvedConfig.Spec.ToolsFilter) > 0 { + wtc.Filter = resolvedConfig.Spec.ToolsFilter + } +} + +// mergeToolConfigOverrides merges overrides from MCPToolConfig +func (*Converter) mergeToolConfigOverrides( + wtc *vmcpconfig.WorkloadToolConfig, + resolvedConfig *mcpv1alpha1.MCPToolConfig, +) { + if len(resolvedConfig.Spec.ToolsOverride) == 0 { + return + } + + if wtc.Overrides == nil { + wtc.Overrides = make(map[string]*vmcpconfig.ToolOverride) + } + + for toolName, override := range resolvedConfig.Spec.ToolsOverride { + if _, exists := wtc.Overrides[toolName]; !exists { + wtc.Overrides[toolName] = &vmcpconfig.ToolOverride{ + Name: override.Name, + Description: override.Description, } + } + } +} - agg.Tools = append(agg.Tools, wtc) +// applyInlineOverrides applies inline tool overrides +func (*Converter) applyInlineOverrides( + toolConfig mcpv1alpha1.WorkloadToolConfig, + wtc *vmcpconfig.WorkloadToolConfig, +) { + if len(toolConfig.Overrides) == 0 { + return + } + + if wtc.Overrides == nil { + wtc.Overrides = make(map[string]*vmcpconfig.ToolOverride) + } + + for toolName, override := range toolConfig.Overrides { + wtc.Overrides[toolName] = &vmcpconfig.ToolOverride{ + Name: override.Name, + Description: override.Description, } } +} - return agg +// resolveMCPToolConfig fetches an MCPToolConfig resource by name and namespace +func (c *Converter) resolveMCPToolConfig( + ctx context.Context, + namespace string, + name string, +) (*mcpv1alpha1.MCPToolConfig, error) { + toolConfig := &mcpv1alpha1.MCPToolConfig{} + err := c.k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: namespace, + }, toolConfig) + if err != nil { + return nil, fmt.Errorf("failed to get MCPToolConfig %s/%s: %w", namespace, name, err) + } + return toolConfig, nil } // convertCompositeTools converts CompositeToolSpec from CRD to vmcp config diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 5e040977a..dce4598d8 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -4,6 +4,26 @@ kind: ValidatingWebhookConfiguration metadata: name: validating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-toolhive-stacklok-com-v1alpha1-mcpexternalauthconfig + failurePolicy: Fail + name: vmcpexternalauthconfig.kb.io + rules: + - apiGroups: + - toolhive.stacklok.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - mcpexternalauthconfigs + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index dc9f0bb91..c5f8fd6a5 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.0.74 +version: 0.0.75 appVersion: "0.0.1" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 3f42fd3b8..4fd2a51f4 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.0.74](https://img.shields.io/badge/Version-0.0.74-informational?style=flat-square) +![Version: 0.0.75](https://img.shields.io/badge/Version-0.0.75-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index d8f4ace8f..cecd3d6a3 100644 --- a/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -147,6 +147,7 @@ spec: enum: - tokenExchange - headerInjection + - unauthenticated type: string required: - type diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index abdea6210..e2e828577 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -360,6 +360,7 @@ _Appears in:_ | --- | --- | | `tokenExchange` | ExternalAuthTypeTokenExchange is the type for RFC-8693 token exchange
| | `headerInjection` | ExternalAuthTypeHeaderInjection is the type for custom header injection
| +| `unauthenticated` | ExternalAuthTypeUnauthenticated is the type for no authentication
This should only be used for backends on trusted networks (e.g., localhost, VPC)
or when authentication is handled by network-level security
| #### FailureHandlingConfig @@ -580,7 +581,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[ExternalAuthType](#externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection]
Required: \{\}
| +| `type` _[ExternalAuthType](#externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection unauthenticated]
Required: \{\}
| | `tokenExchange` _[TokenExchangeConfig](#tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange
Only used when Type is "tokenExchange" | | | | `headerInjection` _[HeaderInjectionConfig](#headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection
Only used when Type is "headerInjection" | | | diff --git a/pkg/vmcp/auth/converters/interface.go b/pkg/vmcp/auth/converters/interface.go index 8a911a19b..163b7b63e 100644 --- a/pkg/vmcp/auth/converters/interface.go +++ b/pkg/vmcp/auth/converters/interface.go @@ -69,6 +69,7 @@ func NewRegistry() *Registry { // Register built-in converters r.Register(mcpv1alpha1.ExternalAuthTypeTokenExchange, &TokenExchangeConverter{}) r.Register(mcpv1alpha1.ExternalAuthTypeHeaderInjection, &HeaderInjectionConverter{}) + r.Register(mcpv1alpha1.ExternalAuthTypeUnauthenticated, &UnauthenticatedConverter{}) return r } diff --git a/pkg/vmcp/auth/converters/unauthenticated.go b/pkg/vmcp/auth/converters/unauthenticated.go new file mode 100644 index 000000000..899947f57 --- /dev/null +++ b/pkg/vmcp/auth/converters/unauthenticated.go @@ -0,0 +1,42 @@ +package converters + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// UnauthenticatedConverter converts unauthenticated external auth configs to BackendAuthStrategy. +// This converter handles the case where no authentication is required for a backend. +type UnauthenticatedConverter struct{} + +// StrategyType returns the vMCP strategy type identifier for unauthenticated auth. +func (*UnauthenticatedConverter) StrategyType() string { + return authtypes.StrategyTypeUnauthenticated +} + +// ConvertToStrategy converts an MCPExternalAuthConfig with type "unauthenticated" to a BackendAuthStrategy. +// Since unauthenticated requires no configuration, this simply returns a strategy with the correct type. +func (*UnauthenticatedConverter) ConvertToStrategy( + _ *mcpv1alpha1.MCPExternalAuthConfig, +) (*authtypes.BackendAuthStrategy, error) { + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeUnauthenticated, + // No additional fields needed for unauthenticated + }, nil +} + +// ResolveSecrets is a no-op for unauthenticated strategy since there are no secrets to resolve. +func (*UnauthenticatedConverter) ResolveSecrets( + _ context.Context, + _ *mcpv1alpha1.MCPExternalAuthConfig, + _ client.Client, + _ string, + strategy *authtypes.BackendAuthStrategy, +) (*authtypes.BackendAuthStrategy, error) { + // No secrets to resolve for unauthenticated strategy + return strategy, nil +} diff --git a/pkg/vmcp/auth/converters/unauthenticated_test.go b/pkg/vmcp/auth/converters/unauthenticated_test.go new file mode 100644 index 000000000..dc0613e13 --- /dev/null +++ b/pkg/vmcp/auth/converters/unauthenticated_test.go @@ -0,0 +1,138 @@ +package converters + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +func TestUnauthenticatedConverter_StrategyType(t *testing.T) { + t.Parallel() + + converter := &UnauthenticatedConverter{} + assert.Equal(t, authtypes.StrategyTypeUnauthenticated, converter.StrategyType()) +} + +func TestUnauthenticatedConverter_ConvertToStrategy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalAuth *mcpv1alpha1.MCPExternalAuthConfig + expectedType string + expectedError bool + }{ + { + name: "valid unauthenticated config", + externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unauthenticated", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + }, + }, + expectedType: authtypes.StrategyTypeUnauthenticated, + expectedError: false, + }, + { + name: "unauthenticated with no extra fields", + externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unauthenticated-minimal", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + // No TokenExchange or HeaderInjection + }, + }, + expectedType: authtypes.StrategyTypeUnauthenticated, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + converter := &UnauthenticatedConverter{} + strategy, err := converter.ConvertToStrategy(tt.externalAuth) + + if tt.expectedError { + require.Error(t, err) + assert.Nil(t, strategy) + } else { + require.NoError(t, err) + require.NotNil(t, strategy) + assert.Equal(t, tt.expectedType, strategy.Type) + // Verify no auth-specific fields are set + assert.Nil(t, strategy.TokenExchange) + assert.Nil(t, strategy.HeaderInjection) + } + }) + } +} + +func TestUnauthenticatedConverter_ResolveSecrets(t *testing.T) { + t.Parallel() + + converter := &UnauthenticatedConverter{} + externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unauthenticated", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + }, + } + + strategy := &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeUnauthenticated, + } + + // ResolveSecrets should be a no-op for unauthenticated + resolvedStrategy, err := converter.ResolveSecrets(context.Background(), externalAuth, nil, "default", strategy) + + require.NoError(t, err) + require.NotNil(t, resolvedStrategy) + assert.Equal(t, strategy, resolvedStrategy, "Strategy should be unchanged") + assert.Equal(t, authtypes.StrategyTypeUnauthenticated, resolvedStrategy.Type) +} + +func TestUnauthenticatedConverter_Integration(t *testing.T) { + t.Parallel() + + // Test that unauthenticated converter is registered in default registry + registry := DefaultRegistry() + converter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeUnauthenticated) + require.NoError(t, err) + require.NotNil(t, converter) + assert.IsType(t, &UnauthenticatedConverter{}, converter) + + // Test end-to-end conversion using ConvertToStrategy convenience function + externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unauthenticated", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + }, + } + + strategy, err := ConvertToStrategy(externalAuth) + require.NoError(t, err) + require.NotNil(t, strategy) + assert.Equal(t, authtypes.StrategyTypeUnauthenticated, strategy.Type) + assert.Nil(t, strategy.TokenExchange) + assert.Nil(t, strategy.HeaderInjection) +} diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go new file mode 100644 index 000000000..d7d1ad8ac --- /dev/null +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go @@ -0,0 +1,816 @@ +package virtualmcp + +import ( + "context" + "fmt" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/test/e2e/images" +) + +var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func() { + var ( + testNamespace = "default" + mcpGroupName = "test-unauthenticated-auth-group" + vmcpServerName = "test-vmcp-unauthenticated" + backendName = "backend-fetch-unauthenticated" + externalAuthConfigName = "test-unauthenticated-auth-config" + timeout = 5 * time.Minute + pollingInterval = 5 * time.Second + vmcpNodePort int32 + ) + + BeforeAll(func() { + By("Creating MCPExternalAuthConfig with unauthenticated type") + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: externalAuthConfigName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + // No TokenExchange or HeaderInjection fields needed + }, + } + Expect(k8sClient.Create(ctx, externalAuthConfig)).To(Succeed()) + + By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, testNamespace, + "Test MCP Group for VirtualMCP unauthenticated auth", timeout, pollingInterval) + + By("Creating backend MCPServer without auth (localhost, trusted)") + backend := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: backendName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.GofetchServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + McpPort: 8080, + // Reference the unauthenticated external auth config + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: externalAuthConfigName, + }, + }, + } + Expect(k8sClient.Create(ctx, backend)).To(Succeed()) + + By("Waiting for backend MCPServer to be ready") + Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: backendName, + Namespace: testNamespace, + }, server) + if err != nil { + return fmt.Errorf("failed to get server: %w", err) + } + if server.Status.Phase == mcpv1alpha1.MCPServerPhaseRunning { + return nil + } + return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) + }, timeout, pollingInterval).Should(Succeed()) + + By("Creating VirtualMCPServer with discovered auth mode (should use unauthenticated)") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpServerName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: mcpv1alpha1.GroupRef{ + Name: mcpGroupName, + }, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Source: "discovered", // Will discover unauthenticated from backend + }, + ServiceType: "NodePort", + }, + } + Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed()) + + By("Waiting for VirtualMCPServer to be ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout) + + By("Getting NodePort for VirtualMCPServer") + vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval) + }) + + AfterAll(func() { + By("Cleaning up test resources") + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, + }) + }) + + Context("when using unauthenticated backend auth", func() { + It("should discover unauthenticated auth from backend MCPServer", func() { + By("Verifying VirtualMCPServer discovered unauthenticated auth") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: vmcpServerName, + Namespace: testNamespace, + }, vmcpServer) + Expect(err).ToNot(HaveOccurred()) + Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("discovered")) + + // Check that backend was discovered with auth config + Expect(vmcpServer.Status.DiscoveredBackends).ToNot(BeEmpty()) + found := false + for _, backend := range vmcpServer.Status.DiscoveredBackends { + if backend.Name == backendName { + found = true + Expect(backend.AuthConfigRef).To(Equal(externalAuthConfigName)) + break + } + } + Expect(found).To(BeTrue(), "Backend should be discovered with auth config reference") + }) + + It("should successfully connect and call tools with unauthenticated backend", func() { + By("Creating MCP client") + serverURL := fmt.Sprintf("http://localhost:%d/mcp", vmcpNodePort) + mcpClient, err := client.NewStreamableHttpClient(serverURL) + Expect(err).ToNot(HaveOccurred()) + defer mcpClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + By("Starting MCP client") + err = mcpClient.Start(ctx) + Expect(err).ToNot(HaveOccurred()) + + By("Initializing MCP session") + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "toolhive-e2e-test", + Version: "1.0.0", + } + _, err = mcpClient.Initialize(ctx, initRequest) + Expect(err).ToNot(HaveOccurred()) + + By("Listing tools from unauthenticated backend") + listRequest := mcp.ListToolsRequest{} + tools, err := mcpClient.ListTools(ctx, listRequest) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty()) + + By("Calling a tool from unauthenticated backend") + // Find the fetch tool + var fetchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == fetchToolName || tool.Name == "backend-fetch-unauthenticated_fetch" { + t := tool + fetchTool = &t + break + } + } + Expect(fetchTool).ToNot(BeNil(), "fetch tool should be available") + + // Call the fetch tool + callRequest := mcp.CallToolRequest{} + callRequest.Params.Name = fetchTool.Name + callRequest.Params.Arguments = map[string]interface{}{ + "url": "https://example.com", + } + + result, err := mcpClient.CallTool(ctx, callRequest) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Content).ToNot(BeEmpty()) + }) + + It("should validate MCPExternalAuthConfig with unauthenticated type", func() { + By("Verifying MCPExternalAuthConfig exists and is valid") + authConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: externalAuthConfigName, + Namespace: testNamespace, + }, authConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(authConfig.Spec.Type).To(Equal(mcpv1alpha1.ExternalAuthTypeUnauthenticated)) + Expect(authConfig.Spec.TokenExchange).To(BeNil()) + Expect(authConfig.Spec.HeaderInjection).To(BeNil()) + }) + }) +}) + +var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered, func() { + var ( + testNamespace = "default" + mcpGroupName = "test-inline-unauth-group" + vmcpServerName = "test-vmcp-inline-unauth" + backendName = "backend-inline-unauth" + externalAuthConfigName = "test-inline-unauth-config" + timeout = 5 * time.Minute + pollingInterval = 5 * time.Second + vmcpNodePort int32 + ) + + BeforeAll(func() { + By("Creating MCPExternalAuthConfig with unauthenticated type for inline mode") + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: externalAuthConfigName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + }, + } + Expect(k8sClient.Create(ctx, externalAuthConfig)).To(Succeed()) + + By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, testNamespace, + "Test MCP Group for inline unauthenticated auth", timeout, pollingInterval) + + By("Creating backend MCPServer") + backend := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: backendName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.GofetchServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + McpPort: 8080, + }, + } + Expect(k8sClient.Create(ctx, backend)).To(Succeed()) + + By("Waiting for backend MCPServer to be ready") + Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: backendName, + Namespace: testNamespace, + }, server) + if err != nil { + return fmt.Errorf("failed to get server: %w", err) + } + if server.Status.Phase == mcpv1alpha1.MCPServerPhaseRunning { + return nil + } + return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) + }, timeout, pollingInterval).Should(Succeed()) + + By("Creating VirtualMCPServer with inline unauthenticated auth") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpServerName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: mcpv1alpha1.GroupRef{ + Name: mcpGroupName, + }, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Source: "inline", + // Explicitly configure unauthenticated for specific backend + Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + backendName: { + Type: "external_auth_config_ref", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: externalAuthConfigName, + }, + }, + }, + }, + ServiceType: "NodePort", + }, + } + Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed()) + + By("Waiting for VirtualMCPServer to be ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout) + + By("Getting NodePort for VirtualMCPServer") + vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval) + }) + + AfterAll(func() { + By("Cleaning up test resources") + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, + }) + }) + + Context("when using inline unauthenticated backend auth", func() { + It("should configure inline unauthenticated auth for specific backend", func() { + By("Verifying VirtualMCPServer has inline auth configured") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: vmcpServerName, + Namespace: testNamespace, + }, vmcpServer) + Expect(err).ToNot(HaveOccurred()) + Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("inline")) + Expect(vmcpServer.Spec.OutgoingAuth.Backends).To(HaveKey(backendName)) + Expect(vmcpServer.Spec.OutgoingAuth.Backends[backendName].Type).To(Equal("external_auth_config_ref")) + Expect(vmcpServer.Spec.OutgoingAuth.Backends[backendName].ExternalAuthConfigRef.Name).To(Equal(externalAuthConfigName)) + }) + + It("should successfully proxy tool calls with inline unauthenticated auth", func() { + By("Creating MCP client") + serverURL := fmt.Sprintf("http://localhost:%d/mcp", vmcpNodePort) + mcpClient, err := client.NewStreamableHttpClient(serverURL) + Expect(err).ToNot(HaveOccurred()) + defer mcpClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + By("Starting and initializing MCP client") + err = mcpClient.Start(ctx) + Expect(err).ToNot(HaveOccurred()) + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "toolhive-e2e-test", + Version: "1.0.0", + } + _, err = mcpClient.Initialize(ctx, initRequest) + Expect(err).ToNot(HaveOccurred()) + + By("Listing and calling tools through inline unauthenticated proxy") + listRequest := mcp.ListToolsRequest{} + tools, err := mcpClient.ListTools(ctx, listRequest) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty()) + + // Verify tools are accessible + var fetchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == fetchToolName || tool.Name == "backend-inline-unauth_fetch" { + t := tool + fetchTool = &t + break + } + } + Expect(fetchTool).ToNot(BeNil(), "fetch tool should be available") + }) + }) +}) + +var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func() { + var ( + testNamespace = "default" + mcpGroupName = "test-headerinjection-auth-group" + vmcpServerName = "test-vmcp-headerinjection" + backendName = "backend-fetch-headerinjection" + externalAuthConfigName = "test-headerinjection-auth-config" + secretName = "test-headerinjection-secret" + timeout = 5 * time.Minute + pollingInterval = 5 * time.Second + vmcpNodePort int32 + ) + + BeforeAll(func() { + By("Creating Secret for header injection") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: testNamespace, + }, + StringData: map[string]string{ + "api-key": "test-api-key-value", + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + By("Creating MCPExternalAuthConfig with headerInjection type") + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: externalAuthConfigName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + HeaderName: "X-API-Key", + ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: secretName, + Key: "api-key", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, externalAuthConfig)).To(Succeed()) + + By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, testNamespace, + "Test MCP Group for VirtualMCP headerInjection auth", timeout, pollingInterval) + + By("Creating backend MCPServer with headerInjection auth") + backend := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: backendName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.GofetchServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + McpPort: 8080, + // Reference the headerInjection external auth config + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: externalAuthConfigName, + }, + }, + } + Expect(k8sClient.Create(ctx, backend)).To(Succeed()) + + By("Waiting for backend MCPServer to be ready") + Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: backendName, + Namespace: testNamespace, + }, server) + if err != nil { + return fmt.Errorf("failed to get server: %w", err) + } + if server.Status.Phase == mcpv1alpha1.MCPServerPhaseRunning { + return nil + } + return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) + }, timeout, pollingInterval).Should(Succeed()) + + By("Creating VirtualMCPServer with discovered auth mode (should use headerInjection)") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpServerName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: mcpv1alpha1.GroupRef{ + Name: mcpGroupName, + }, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Source: "discovered", // Will discover headerInjection from backend + }, + ServiceType: "NodePort", + }, + } + Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed()) + + By("Waiting for VirtualMCPServer to be ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout) + + By("Getting NodePort for VirtualMCPServer") + vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval) + }) + + AfterAll(func() { + By("Cleaning up test resources") + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: testNamespace}, + }) + }) + + Context("when using headerInjection backend auth", func() { + It("should discover headerInjection auth from backend MCPServer", func() { + By("Verifying VirtualMCPServer discovered headerInjection auth") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: vmcpServerName, + Namespace: testNamespace, + }, vmcpServer) + Expect(err).ToNot(HaveOccurred()) + Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("discovered")) + + // Check that backend was discovered with auth config + Expect(vmcpServer.Status.DiscoveredBackends).ToNot(BeEmpty()) + found := false + for _, backend := range vmcpServer.Status.DiscoveredBackends { + if backend.Name == backendName { + found = true + Expect(backend.AuthConfigRef).To(Equal(externalAuthConfigName)) + break + } + } + Expect(found).To(BeTrue(), "Backend should be discovered with auth config reference") + }) + + It("should successfully connect and call tools with headerInjection backend", func() { + By("Creating MCP client") + serverURL := fmt.Sprintf("http://localhost:%d/mcp", vmcpNodePort) + mcpClient, err := client.NewStreamableHttpClient(serverURL) + Expect(err).ToNot(HaveOccurred()) + defer mcpClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + By("Starting MCP client") + err = mcpClient.Start(ctx) + Expect(err).ToNot(HaveOccurred()) + + By("Initializing MCP session") + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "toolhive-e2e-test", + Version: "1.0.0", + } + _, err = mcpClient.Initialize(ctx, initRequest) + Expect(err).ToNot(HaveOccurred()) + + By("Listing tools from headerInjection backend") + listRequest := mcp.ListToolsRequest{} + tools, err := mcpClient.ListTools(ctx, listRequest) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty()) + + By("Calling a tool from headerInjection backend") + // Find the fetch tool + var fetchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == fetchToolName || tool.Name == "backend-fetch-headerinjection_fetch" { + t := tool + fetchTool = &t + break + } + } + Expect(fetchTool).ToNot(BeNil(), "fetch tool should be available") + + // Call the fetch tool + callRequest := mcp.CallToolRequest{} + callRequest.Params.Name = fetchTool.Name + callRequest.Params.Arguments = map[string]interface{}{ + "url": "https://example.com", + } + + result, err := mcpClient.CallTool(ctx, callRequest) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Content).ToNot(BeEmpty()) + }) + + It("should validate MCPExternalAuthConfig with headerInjection type", func() { + By("Verifying MCPExternalAuthConfig exists and is valid") + authConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: externalAuthConfigName, + Namespace: testNamespace, + }, authConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(authConfig.Spec.Type).To(Equal(mcpv1alpha1.ExternalAuthTypeHeaderInjection)) + Expect(authConfig.Spec.TokenExchange).To(BeNil()) + Expect(authConfig.Spec.HeaderInjection).ToNot(BeNil()) + Expect(authConfig.Spec.HeaderInjection.HeaderName).To(Equal("X-API-Key")) + Expect(authConfig.Spec.HeaderInjection.ValueSecretRef.Name).To(Equal(secretName)) + Expect(authConfig.Spec.HeaderInjection.ValueSecretRef.Key).To(Equal("api-key")) + }) + }) +}) + +var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered, func() { + var ( + testNamespace = "default" + mcpGroupName = "test-inline-headerinjection-group" + vmcpServerName = "test-vmcp-inline-headerinjection" + backendName = "backend-inline-headerinjection" + externalAuthConfigName = "test-inline-headerinjection-config" + secretName = "test-inline-headerinjection-secret" + timeout = 5 * time.Minute + pollingInterval = 5 * time.Second + vmcpNodePort int32 + ) + + BeforeAll(func() { + By("Creating Secret for inline header injection") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: testNamespace, + }, + StringData: map[string]string{ + "api-key": "test-inline-api-key-value", + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + By("Creating MCPExternalAuthConfig with headerInjection type for inline mode") + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: externalAuthConfigName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + HeaderName: "X-Custom-Auth", + ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: secretName, + Key: "api-key", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, externalAuthConfig)).To(Succeed()) + + By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, testNamespace, + "Test MCP Group for inline headerInjection auth", timeout, pollingInterval) + + By("Creating backend MCPServer") + backend := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: backendName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.GofetchServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + McpPort: 8080, + }, + } + Expect(k8sClient.Create(ctx, backend)).To(Succeed()) + + By("Waiting for backend MCPServer to be ready") + Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: backendName, + Namespace: testNamespace, + }, server) + if err != nil { + return fmt.Errorf("failed to get server: %w", err) + } + if server.Status.Phase == mcpv1alpha1.MCPServerPhaseRunning { + return nil + } + return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) + }, timeout, pollingInterval).Should(Succeed()) + + By("Creating VirtualMCPServer with inline headerInjection auth") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpServerName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: mcpv1alpha1.GroupRef{ + Name: mcpGroupName, + }, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Source: "inline", + // Explicitly configure headerInjection for specific backend + Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + backendName: { + Type: "external_auth_config_ref", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: externalAuthConfigName, + }, + }, + }, + }, + ServiceType: "NodePort", + }, + } + Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed()) + + By("Waiting for VirtualMCPServer to be ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout) + + By("Getting NodePort for VirtualMCPServer") + vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval) + }) + + AfterAll(func() { + By("Cleaning up test resources") + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, + }) + _ = k8sClient.Delete(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: testNamespace}, + }) + }) + + Context("when using inline headerInjection backend auth", func() { + It("should configure inline headerInjection auth for specific backend", func() { + By("Verifying VirtualMCPServer has inline auth configured") + vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: vmcpServerName, + Namespace: testNamespace, + }, vmcpServer) + Expect(err).ToNot(HaveOccurred()) + Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("inline")) + Expect(vmcpServer.Spec.OutgoingAuth.Backends).To(HaveKey(backendName)) + Expect(vmcpServer.Spec.OutgoingAuth.Backends[backendName].Type).To(Equal("external_auth_config_ref")) + Expect(vmcpServer.Spec.OutgoingAuth.Backends[backendName].ExternalAuthConfigRef.Name).To(Equal(externalAuthConfigName)) + }) + + It("should successfully proxy tool calls with inline headerInjection auth", func() { + By("Creating MCP client") + serverURL := fmt.Sprintf("http://localhost:%d/mcp", vmcpNodePort) + mcpClient, err := client.NewStreamableHttpClient(serverURL) + Expect(err).ToNot(HaveOccurred()) + defer mcpClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + By("Starting and initializing MCP client") + err = mcpClient.Start(ctx) + Expect(err).ToNot(HaveOccurred()) + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "toolhive-e2e-test", + Version: "1.0.0", + } + _, err = mcpClient.Initialize(ctx, initRequest) + Expect(err).ToNot(HaveOccurred()) + + By("Listing and calling tools through inline headerInjection proxy") + listRequest := mcp.ListToolsRequest{} + tools, err := mcpClient.ListTools(ctx, listRequest) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty()) + + // Verify tools are accessible + var fetchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == fetchToolName || tool.Name == "backend-inline-headerinjection_fetch" { + t := tool + fetchTool = &t + break + } + } + Expect(fetchTool).ToNot(BeNil(), "fetch tool should be available") + }) + }) +})