Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`

Expand Down
87 changes: 87 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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) {
var warnings admission.Warnings
if r.Spec.Type == ExternalAuthTypeUnauthenticated {
warnings = append(warnings,
"'unauthenticated' type disables authentication to the backend. "+
"Only use for backends on trusted networks or when authentication is handled by network-level security.")
}
return warnings, r.validate()
}

// ValidateUpdate implements webhook.CustomValidator
func (r *MCPExternalAuthConfig) ValidateUpdate(
_ context.Context, _ runtime.Object, _ runtime.Object,
) (admission.Warnings, error) {
var warnings admission.Warnings
if r.Spec.Type == ExternalAuthTypeUnauthenticated {
warnings = append(warnings,
"'unauthenticated' type disables authentication to the backend. "+
"Only use for backends on trusted networks or when authentication is handled by network-level security.")
}
return warnings, 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
}
279 changes: 279 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
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
expectWarning bool
warningMsg string
}{
{
name: "valid unauthenticated",
config: &MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test-unauthenticated",
Namespace: "default",
},
Spec: MCPExternalAuthConfigSpec{
Type: ExternalAuthTypeUnauthenticated,
},
},
expectError: false,
expectWarning: true,
warningMsg: "'unauthenticated' type disables authentication to the backend. Only use for backends on trusted networks or when authentication is handled by network-level security.",
},
{
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'",
expectWarning: true,
warningMsg: "'unauthenticated' type disables authentication to the backend. Only use for backends on trusted networks or when authentication is handled by network-level security.",
},
{
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'",
expectWarning: true,
warningMsg: "'unauthenticated' type disables authentication to the backend. Only use for backends on trusted networks or when authentication is handled by network-level security.",
},
{
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)
}

// Check warnings
if tt.expectWarning {
require.Len(t, warnings, 1, "expected exactly one warning")
assert.Equal(t, tt.warningMsg, string(warnings[0]))
} else {
assert.Nil(t, warnings, "expected no 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)
// Should have warning for unauthenticated type
require.Len(t, warnings, 1, "expected exactly one warning")
assert.Equal(t, "'unauthenticated' type disables authentication to the backend. Only use for backends on trusted networks or when authentication is handled by network-level security.", string(warnings[0]))

// 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'")
// Should still have warning for unauthenticated type even when validation fails
require.Len(t, warnings, 1, "expected exactly one warning")
assert.Equal(t, "'unauthenticated' type disables authentication to the backend. Only use for backends on trusted networks or when authentication is handled by network-level security.", string(warnings[0]))
}

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)
}
Loading
Loading