Skip to content
Open
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
75 changes: 75 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_webhook.go
Original file line number Diff line number Diff line change
@@ -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
}
262 changes: 262 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,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)
}
Loading
Loading