diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go new file mode 100644 index 000000000..349404c79 --- /dev/null +++ b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go @@ -0,0 +1,117 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// External auth configuration types +const ( + // ExternalAuthTypeTokenExchange is the type for RFC-8693 token exchange + ExternalAuthTypeTokenExchange = "tokenExchange" +) + +// MCPExternalAuthConfigSpec defines the desired state of MCPExternalAuthConfig. +// MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by +// MCPServer resources in the same namespace. +type MCPExternalAuthConfigSpec struct { + // Type is the type of external authentication to configure + // +kubebuilder:validation:Enum=tokenExchange + // +kubebuilder:validation:Required + Type string `json:"type"` + + // TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange + // Only used when Type is "tokenExchange" + // +optional + TokenExchange *TokenExchangeConfig `json:"tokenExchange,omitempty"` +} + +// TokenExchangeConfig holds configuration for RFC-8693 OAuth 2.0 Token Exchange. +// This configuration is used to exchange incoming authentication tokens for tokens +// that can be used with external services. +// The structure matches the tokenexchange.Config from pkg/auth/tokenexchange/middleware.go +type TokenExchangeConfig struct { + // TokenURL is the OAuth 2.0 token endpoint URL for token exchange + // +kubebuilder:validation:Required + TokenURL string `json:"token_url"` + + // ClientID is the OAuth 2.0 client identifier + // +kubebuilder:validation:Required + ClientID string `json:"client_id"` + + // ClientSecretRef is a reference to a secret containing the OAuth 2.0 client secret + // +kubebuilder:validation:Required + ClientSecretRef SecretKeyRef `json:"client_secret_ref"` + + // Audience is the target audience for the exchanged token + // +kubebuilder:validation:Required + Audience string `json:"audience"` + + // Scope is the scope to request for the exchanged token (space-separated string) + // +optional + Scope string `json:"scope,omitempty"` + + // ExternalTokenHeaderName is the name of the custom header to use for the exchanged token. + // If set, the exchanged token will be added to this custom header (e.g., "X-Upstream-Token"). + // If empty or not set, the exchanged token will replace the Authorization header (default behavior). + // +optional + ExternalTokenHeaderName string `json:"external_token_header_name,omitempty"` +} + +// SecretKeyRef is a reference to a key within a Secret +type SecretKeyRef struct { + // Name is the name of the secret + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Key is the key within the secret + // +kubebuilder:validation:Required + Key string `json:"key"` +} + +// MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig +type MCPExternalAuthConfigStatus struct { + // ObservedGeneration is the most recent generation observed for this MCPExternalAuthConfig. + // It corresponds to the MCPExternalAuthConfig's generation, which is updated on mutation by the API Server. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // ConfigHash is a hash of the current configuration for change detection + // +optional + ConfigHash string `json:"configHash,omitempty"` + + // ReferencingServers is a list of MCPServer resources that reference this MCPExternalAuthConfig + // This helps track which servers need to be reconciled when this config changes + // +optional + ReferencingServers []string `json:"referencingServers,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=extauth;mcpextauth +// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MCPExternalAuthConfig is the Schema for the mcpexternalauthconfigs API. +// MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by +// MCPServer resources within the same namespace. Cross-namespace references +// are not supported for security and isolation reasons. +type MCPExternalAuthConfig struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec MCPExternalAuthConfigSpec `json:"spec,omitempty"` + Status MCPExternalAuthConfigStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// MCPExternalAuthConfigList contains a list of MCPExternalAuthConfig +type MCPExternalAuthConfigList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPExternalAuthConfig `json:"items"` +} + +func init() { + SchemeBuilder.Register(&MCPExternalAuthConfig{}, &MCPExternalAuthConfigList{}) +} diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go index b54fea6af..4decfcd72 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go @@ -116,6 +116,11 @@ type MCPServerSpec struct { // +optional ToolConfigRef *ToolConfigRef `json:"toolConfigRef,omitempty"` + // ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication. + // The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. + // +optional + ExternalAuthConfigRef *ExternalAuthConfigRef `json:"externalAuthConfigRef,omitempty"` + // Telemetry defines observability configuration for the MCP server // +optional Telemetry *TelemetryConfig `json:"telemetry,omitempty"` @@ -480,6 +485,14 @@ type ToolConfigRef struct { Name string `json:"name"` } +// ExternalAuthConfigRef defines a reference to a MCPExternalAuthConfig resource. +// The referenced MCPExternalAuthConfig must be in the same namespace as the MCPServer. +type ExternalAuthConfigRef struct { + // Name is the name of the MCPExternalAuthConfig resource + // +kubebuilder:validation:Required + Name string `json:"name"` +} + // InlineAuthzConfig contains direct authorization configuration type InlineAuthzConfig struct { // Policies is a list of Cedar policy strings @@ -587,6 +600,10 @@ type MCPServerStatus struct { // +optional ToolConfigHash string `json:"toolConfigHash,omitempty"` + // ExternalAuthConfigHash is the hash of the referenced MCPExternalAuthConfig spec + // +optional + ExternalAuthConfigHash string `json:"externalAuthConfigHash,omitempty"` + // URL is the URL where the MCP server can be accessed // +optional URL string `json:"url,omitempty"` diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index 4f9a24f21..e1dda0ee1 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -145,6 +145,21 @@ func (in *EnvVar) DeepCopy() *EnvVar { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalAuthConfigRef) DeepCopyInto(out *ExternalAuthConfigRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalAuthConfigRef. +func (in *ExternalAuthConfigRef) DeepCopy() *ExternalAuthConfigRef { + if in == nil { + return nil + } + out := new(ExternalAuthConfigRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitSource) DeepCopyInto(out *GitSource) { *out = *in @@ -215,6 +230,105 @@ func (in *KubernetesOIDCConfig) DeepCopy() *KubernetesOIDCConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfig. +func (in *MCPExternalAuthConfig) DeepCopy() *MCPExternalAuthConfig { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPExternalAuthConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfigList) DeepCopyInto(out *MCPExternalAuthConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPExternalAuthConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigList. +func (in *MCPExternalAuthConfigList) DeepCopy() *MCPExternalAuthConfigList { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPExternalAuthConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfigSpec) DeepCopyInto(out *MCPExternalAuthConfigSpec) { + *out = *in + if in.TokenExchange != nil { + in, out := &in.TokenExchange, &out.TokenExchange + *out = new(TokenExchangeConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigSpec. +func (in *MCPExternalAuthConfigSpec) DeepCopy() *MCPExternalAuthConfigSpec { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfigStatus) DeepCopyInto(out *MCPExternalAuthConfigStatus) { + *out = *in + if in.ReferencingServers != nil { + in, out := &in.ReferencingServers, &out.ReferencingServers + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigStatus. +func (in *MCPExternalAuthConfigStatus) DeepCopy() *MCPExternalAuthConfigStatus { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfigStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPRegistry) DeepCopyInto(out *MCPRegistry) { *out = *in @@ -490,6 +604,11 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = new(ToolConfigRef) **out = **in } + if in.ExternalAuthConfigRef != nil { + in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef + *out = new(ExternalAuthConfigRef) + **out = **in + } if in.Telemetry != nil { in, out := &in.Telemetry, &out.Telemetry *out = new(TelemetryConfig) @@ -992,6 +1111,21 @@ func (in *ResourceRequirements) DeepCopy() *ResourceRequirements { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. +func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { + if in == nil { + return nil + } + out := new(SecretKeyRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretRef) DeepCopyInto(out *SecretRef) { *out = *in @@ -1115,6 +1249,22 @@ func (in *TelemetryConfig) DeepCopy() *TelemetryConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TokenExchangeConfig) DeepCopyInto(out *TokenExchangeConfig) { + *out = *in + out.ClientSecretRef = in.ClientSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenExchangeConfig. +func (in *TokenExchangeConfig) DeepCopy() *TokenExchangeConfig { + if in == nil { + return nil + } + out := new(TokenExchangeConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ToolConfigRef) DeepCopyInto(out *ToolConfigRef) { *out = *in diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go new file mode 100644 index 000000000..771695b7a --- /dev/null +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go @@ -0,0 +1,285 @@ +package controllers + +import ( + "context" + "fmt" + "hash/fnv" + "time" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/dump" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" +) + +const ( + // ExternalAuthConfigFinalizerName is the name of the finalizer for MCPExternalAuthConfig + ExternalAuthConfigFinalizerName = "mcpexternalauthconfig.toolhive.stacklok.dev/finalizer" + + // externalAuthConfigRequeueDelay is the delay before requeuing after adding a finalizer + externalAuthConfigRequeueDelay = 500 * time.Millisecond +) + +// MCPExternalAuthConfigReconciler reconciles a MCPExternalAuthConfig object +type MCPExternalAuthConfigReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs/finalizers,verbs=update +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpservers,verbs=get;list;watch;update;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Fetch the MCPExternalAuthConfig instance + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := r.Get(ctx, req.NamespacedName, externalAuthConfig) + if err != nil { + if errors.IsNotFound(err) { + // Object not found, could have been deleted after reconcile request. + // Return and don't requeue + logger.Info("MCPExternalAuthConfig resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + logger.Error(err, "Failed to get MCPExternalAuthConfig") + return ctrl.Result{}, err + } + + // Check if the MCPExternalAuthConfig is being deleted + if !externalAuthConfig.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, externalAuthConfig) + } + + // Add finalizer if it doesn't exist + if !controllerutil.ContainsFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName) { + controllerutil.AddFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName) + if err := r.Update(ctx, externalAuthConfig); err != nil { + logger.Error(err, "Failed to add finalizer") + return ctrl.Result{}, err + } + // Requeue to continue processing after finalizer is added + return ctrl.Result{RequeueAfter: externalAuthConfigRequeueDelay}, nil + } + + // Calculate the hash of the current configuration + configHash := r.calculateConfigHash(externalAuthConfig.Spec) + + // Check if the hash has changed + if externalAuthConfig.Status.ConfigHash != configHash { + logger.Info("MCPExternalAuthConfig configuration changed", + "oldHash", externalAuthConfig.Status.ConfigHash, + "newHash", configHash) + + // Update the status with the new hash + externalAuthConfig.Status.ConfigHash = configHash + externalAuthConfig.Status.ObservedGeneration = externalAuthConfig.Generation + + // Find all MCPServers that reference this MCPExternalAuthConfig + referencingServers, err := r.findReferencingMCPServers(ctx, externalAuthConfig) + if err != nil { + logger.Error(err, "Failed to find referencing MCPServers") + return ctrl.Result{}, fmt.Errorf("failed to find referencing MCPServers: %w", err) + } + + // Update the status with the list of referencing servers + serverNames := make([]string, 0, len(referencingServers)) + for _, server := range referencingServers { + serverNames = append(serverNames, server.Name) + } + externalAuthConfig.Status.ReferencingServers = serverNames + + // Update the MCPExternalAuthConfig status + if err := r.Status().Update(ctx, externalAuthConfig); err != nil { + logger.Error(err, "Failed to update MCPExternalAuthConfig status") + return ctrl.Result{}, err + } + + // Trigger reconciliation of all referencing MCPServers + for _, server := range referencingServers { + logger.Info("Triggering reconciliation of MCPServer due to MCPExternalAuthConfig change", + "mcpserver", server.Name, "externalAuthConfig", externalAuthConfig.Name) + + // Add an annotation to the MCPServer to trigger reconciliation + if server.Annotations == nil { + server.Annotations = make(map[string]string) + } + server.Annotations["toolhive.stacklok.dev/externalauthconfig-hash"] = configHash + + if err := r.Update(ctx, &server); err != nil { + logger.Error(err, "Failed to update MCPServer annotation", "mcpserver", server.Name) + // Continue with other servers even if one fails + } + } + } + + return ctrl.Result{}, nil +} + +// calculateConfigHash calculates a hash of the MCPExternalAuthConfig spec using Kubernetes utilities +func (*MCPExternalAuthConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPExternalAuthConfigSpec) string { + // Use k8s.io/apimachinery/pkg/util/dump.ForHash which is designed for + // generating consistent string representations for hashing in Kubernetes + hashString := dump.ForHash(spec) + + // Use FNV-1a hash which is commonly used in Kubernetes for fast hashing + // See: https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/controller_utils.go + hasher := fnv.New32a() + // Write returns an error only if the underlying writer returns an error, + // which never happens for hash.Hash implementations + //nolint:errcheck + _, _ = hasher.Write([]byte(hashString)) + return fmt.Sprintf("%x", hasher.Sum32()) +} + +// handleDeletion handles the deletion of a MCPExternalAuthConfig +func (r *MCPExternalAuthConfigReconciler) handleDeletion( + ctx context.Context, + externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + if controllerutil.ContainsFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName) { + // Check if any MCPServers are still referencing this MCPExternalAuthConfig + referencingServers, err := r.findReferencingMCPServers(ctx, externalAuthConfig) + if err != nil { + logger.Error(err, "Failed to find referencing MCPServers during deletion") + return ctrl.Result{}, err + } + + if len(referencingServers) > 0 { + // Cannot delete - still referenced + serverNames := make([]string, 0, len(referencingServers)) + for _, server := range referencingServers { + serverNames = append(serverNames, server.Name) + } + logger.Info("Cannot delete MCPExternalAuthConfig - still referenced by MCPServers", + "externalAuthConfig", externalAuthConfig.Name, "referencingServers", serverNames) + + // Update status to show it's still referenced + externalAuthConfig.Status.ReferencingServers = serverNames + if err := r.Status().Update(ctx, externalAuthConfig); err != nil { + logger.Error(err, "Failed to update MCPExternalAuthConfig status during deletion") + } + + // Return an error to prevent deletion + return ctrl.Result{}, fmt.Errorf("MCPExternalAuthConfig %s is still referenced by MCPServers: %v", + externalAuthConfig.Name, serverNames) + } + + // No references, safe to remove finalizer and allow deletion + controllerutil.RemoveFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName) + if err := r.Update(ctx, externalAuthConfig); err != nil { + logger.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err + } + logger.Info("Removed finalizer from MCPExternalAuthConfig", "externalAuthConfig", externalAuthConfig.Name) + } + + return ctrl.Result{}, nil +} + +// findReferencingMCPServers finds all MCPServers that reference the given MCPExternalAuthConfig +func (r *MCPExternalAuthConfigReconciler) findReferencingMCPServers( + ctx context.Context, + externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, +) ([]mcpv1alpha1.MCPServer, error) { + // List all MCPServers in the same namespace + mcpServerList := &mcpv1alpha1.MCPServerList{} + if err := r.List(ctx, mcpServerList, client.InNamespace(externalAuthConfig.Namespace)); err != nil { + return nil, fmt.Errorf("failed to list MCPServers: %w", err) + } + + // Filter MCPServers that reference this MCPExternalAuthConfig + var referencingServers []mcpv1alpha1.MCPServer + for _, server := range mcpServerList.Items { + if server.Spec.ExternalAuthConfigRef != nil && + server.Spec.ExternalAuthConfigRef.Name == externalAuthConfig.Name { + referencingServers = append(referencingServers, server) + } + } + + return referencingServers, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *MCPExternalAuthConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Create a handler that maps MCPExternalAuthConfig changes to MCPServer reconciliation requests + externalAuthConfigHandler := handler.EnqueueRequestsFromMapFunc( + func(ctx context.Context, obj client.Object) []reconcile.Request { + externalAuthConfig, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig) + if !ok { + return nil + } + + // Find all MCPServers that reference this MCPExternalAuthConfig + mcpServers, err := r.findReferencingMCPServers(ctx, externalAuthConfig) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to find referencing MCPServers") + return nil + } + + // Create reconcile requests for each referencing MCPServer + requests := make([]reconcile.Request, 0, len(mcpServers)) + for _, server := range mcpServers { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: server.Name, + Namespace: server.Namespace, + }, + }) + } + + return requests + }, + ) + + return ctrl.NewControllerManagedBy(mgr). + For(&mcpv1alpha1.MCPExternalAuthConfig{}). + // Watch for MCPServers and reconcile the MCPExternalAuthConfig when they change + Watches(&mcpv1alpha1.MCPServer{}, externalAuthConfigHandler). + Complete(r) +} + +// GetExternalAuthConfigForMCPServer retrieves the MCPExternalAuthConfig referenced by an MCPServer. +// This function is exported for use by the MCPServer controller (Phase 5 integration). +func GetExternalAuthConfigForMCPServer( + ctx context.Context, + c client.Client, + mcpServer *mcpv1alpha1.MCPServer, +) (*mcpv1alpha1.MCPExternalAuthConfig, error) { + if mcpServer.Spec.ExternalAuthConfigRef == nil { + // We throw an error because in this case you assume there is a ExternalAuthConfig + // but there isn't one referenced. + return nil, fmt.Errorf("MCPServer %s does not reference a MCPExternalAuthConfig", mcpServer.Name) + } + + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := c.Get(ctx, types.NamespacedName{ + Name: mcpServer.Spec.ExternalAuthConfigRef.Name, + Namespace: mcpServer.Namespace, // Same namespace as MCPServer + }, externalAuthConfig) + + if err != nil { + if errors.IsNotFound(err) { + return nil, fmt.Errorf("MCPExternalAuthConfig %s not found in namespace %s", + mcpServer.Spec.ExternalAuthConfigRef.Name, mcpServer.Namespace) + } + return nil, fmt.Errorf("failed to get MCPExternalAuthConfig: %w", err) + } + + return externalAuthConfig, nil +} diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go new file mode 100644 index 000000000..7c494d3d9 --- /dev/null +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go @@ -0,0 +1,691 @@ +package controllers + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" +) + +func TestMCPExternalAuthConfigReconciler_calculateConfigHash(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec mcpv1alpha1.MCPExternalAuthConfigSpec + }{ + { + name: "empty spec", + spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + }, + }, + { + name: "with token exchange config", + spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client-id", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + Scope: "read write", + }, + }, + }, + { + name: "with custom header", + spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client-id", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + ExternalTokenHeaderName: "X-Upstream-Token", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := &MCPExternalAuthConfigReconciler{} + + hash1 := r.calculateConfigHash(tt.spec) + hash2 := r.calculateConfigHash(tt.spec) + + // Same spec should produce same hash + assert.Equal(t, hash1, hash2, "Hash should be consistent for same spec") + assert.NotEmpty(t, hash1, "Hash should not be empty") + }) + } + + // Different specs should produce different hashes + t.Run("different specs produce different hashes", func(t *testing.T) { + t.Parallel() + r := &MCPExternalAuthConfigReconciler{} + spec1 := mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client1", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "secret1", + Key: "key1", + }, + Audience: "audience1", + }, + } + spec2 := mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client2", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "secret2", + Key: "key2", + }, + Audience: "audience2", + }, + } + + hash1 := r.calculateConfigHash(spec1) + hash2 := r.calculateConfigHash(spec2) + + assert.NotEqual(t, hash1, hash2, "Different specs should produce different hashes") + }) +} + +func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig + existingMCPServer *mcpv1alpha1.MCPServer + expectFinalizer bool + expectHash bool + }{ + { + name: "new external auth config without references", + externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + }, + expectFinalizer: true, + expectHash: true, + }, + { + name: "external auth config with referencing mcpserver", + externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + Scope: "read write", + }, + }, + }, + existingMCPServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + }, + expectFinalizer: true, + expectHash: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + // Create fake client with objects + objs := []client.Object{tt.externalAuthConfig} + if tt.existingMCPServer != nil { + objs = append(objs, tt.existingMCPServer) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&mcpv1alpha1.MCPExternalAuthConfig{}). + Build() + + r := &MCPExternalAuthConfigReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Reconcile + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: tt.externalAuthConfig.Name, + Namespace: tt.externalAuthConfig.Namespace, + }, + } + + // First reconciliation adds the finalizer and returns Requeue: true + result, err := r.Reconcile(ctx, req) + require.NoError(t, err) + + // If it's a new object, it will requeue to add finalizer + if result.RequeueAfter > 0 { + // Second reconciliation processes the actual logic + result, err = r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Equal(t, time.Duration(0), result.RequeueAfter) + } + + // Check the updated MCPExternalAuthConfig + var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) + require.NoError(t, err) + + // Check finalizer + if tt.expectFinalizer { + assert.Contains(t, updatedConfig.Finalizers, ExternalAuthConfigFinalizerName, + "MCPExternalAuthConfig should have finalizer") + } + + // Check hash in status + if tt.expectHash { + assert.NotEmpty(t, updatedConfig.Status.ConfigHash, + "MCPExternalAuthConfig status should have config hash") + } + }) + } +} + +func TestMCPExternalAuthConfigReconciler_findReferencingMCPServers(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + } + + mcpServer1 := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "server1", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + } + + mcpServer2 := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "server2", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + } + + mcpServer3 := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "server3", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + // No ExternalAuthConfigRef + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(externalAuthConfig, mcpServer1, mcpServer2, mcpServer3). + Build() + + r := &MCPExternalAuthConfigReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + ctx := t.Context() + servers, err := r.findReferencingMCPServers(ctx, externalAuthConfig) + require.NoError(t, err) + + assert.Len(t, servers, 2, "Should find 2 referencing MCPServers") + + serverNames := make([]string, len(servers)) + for i, s := range servers { + serverNames[i] = s.Name + } + assert.Contains(t, serverNames, "server1") + assert.Contains(t, serverNames, "server2") + assert.NotContains(t, serverNames, "server3") +} + +func TestGetExternalAuthConfigForMCPServer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mcpServer *mcpv1alpha1.MCPServer + existingConfig *mcpv1alpha1.MCPExternalAuthConfig + expectConfig bool + expectError bool + }{ + { + name: "mcpserver without external auth config ref", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + }, + }, + expectConfig: false, + expectError: true, // Expect an error when no ExternalAuthConfigRef is present + }, + { + name: "mcpserver with existing external auth config", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + }, + existingConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + }, + expectConfig: true, + expectError: false, + }, + { + name: "mcpserver with non-existent external auth config", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "non-existent", + }, + }, + }, + expectConfig: false, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + objs := []client.Object{} + if tt.existingConfig != nil { + objs = append(objs, tt.existingConfig) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() + + config, err := GetExternalAuthConfigForMCPServer(ctx, fakeClient, tt.mcpServer) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, config) + } else { + assert.NoError(t, err) + if tt.expectConfig { + assert.NotNil(t, config) + assert.Equal(t, tt.existingConfig.Name, config.Name) + } else { + assert.Nil(t, config) + } + } + }) + } +} + +func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig + referencingServers []*mcpv1alpha1.MCPServer + expectError bool + expectFinalizerRemoved bool + }{ + { + name: "delete config without references", + externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + Finalizers: []string{ExternalAuthConfigFinalizerName}, + DeletionTimestamp: &metav1.Time{ + Time: time.Now(), + }, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + }, + expectError: false, + expectFinalizerRemoved: true, + }, + { + name: "delete config with references", + externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + Finalizers: []string{ExternalAuthConfigFinalizerName}, + DeletionTimestamp: &metav1.Time{ + Time: time.Now(), + }, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + }, + referencingServers: []*mcpv1alpha1.MCPServer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "server1", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + }, + }, + expectError: true, + expectFinalizerRemoved: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + // Build objects list + objs := []client.Object{tt.externalAuthConfig} + for _, server := range tt.referencingServers { + objs = append(objs, server) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() + + r := &MCPExternalAuthConfigReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Call handleDeletion directly + result, err := r.handleDeletion(ctx, tt.externalAuthConfig) + + if tt.expectError { + assert.Error(t, err) + // When there's an error, finalizer should still be present + assert.Contains(t, tt.externalAuthConfig.Finalizers, ExternalAuthConfigFinalizerName, + "Finalizer should still be present after error") + } else { + assert.NoError(t, err) + assert.Equal(t, time.Duration(0), result.RequeueAfter) + + // Check if finalizer was removed from the object in memory + // Note: We check the original object because after finalizer removal, + // Kubernetes will delete the object and it won't be in the fake client store + if tt.expectFinalizerRemoved { + assert.NotContains(t, tt.externalAuthConfig.Finalizers, ExternalAuthConfigFinalizerName, + "Finalizer should be removed") + } + } + }) + } +} + +func TestMCPExternalAuthConfigReconciler_ConfigChangeTriggersReconciliation(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + Generation: 1, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + } + + mcpServer := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(externalAuthConfig, mcpServer). + WithStatusSubresource(&mcpv1alpha1.MCPExternalAuthConfig{}). + Build() + + r := &MCPExternalAuthConfigReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: externalAuthConfig.Name, + Namespace: externalAuthConfig.Namespace, + }, + } + + // First reconciliation - add finalizer + result, err := r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Greater(t, result.RequeueAfter, time.Duration(0), "Should requeue after adding finalizer") + + // Second reconciliation - calculate hash + result, err = r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Equal(t, time.Duration(0), result.RequeueAfter) + + // Get updated config and check hash was set + var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) + require.NoError(t, err) + assert.NotEmpty(t, updatedConfig.Status.ConfigHash, "Config hash should be set") + firstHash := updatedConfig.Status.ConfigHash + + // Update the config spec (simulate a change) + updatedConfig.Spec.TokenExchange.Audience = "new-audience" + updatedConfig.Generation = 2 + err = fakeClient.Update(ctx, &updatedConfig) + require.NoError(t, err) + + // Third reconciliation - should detect change and update hash + result, err = r.Reconcile(ctx, req) + require.NoError(t, err) + + // Get final config and verify hash changed + var finalConfig mcpv1alpha1.MCPExternalAuthConfig + err = fakeClient.Get(ctx, req.NamespacedName, &finalConfig) + require.NoError(t, err) + assert.NotEmpty(t, finalConfig.Status.ConfigHash, "Config hash should still be set") + assert.NotEqual(t, firstHash, finalConfig.Status.ConfigHash, "Hash should change when spec changes") + assert.Equal(t, int64(2), finalConfig.Status.ObservedGeneration, "ObservedGeneration should be updated") + + // Verify MCPServer has annotation with new hash + var updatedServer mcpv1alpha1.MCPServer + err = fakeClient.Get(ctx, types.NamespacedName{ + Name: mcpServer.Name, + Namespace: mcpServer.Namespace, + }, &updatedServer) + require.NoError(t, err) + assert.Equal(t, finalConfig.Status.ConfigHash, + updatedServer.Annotations["toolhive.stacklok.dev/externalauthconfig-hash"], + "MCPServer should have annotation with new config hash") +} diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index 3f80de163..470879573 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -29,7 +29,9 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" @@ -142,6 +144,7 @@ func (r *MCPServerReconciler) detectPlatform(ctx context.Context) (kubernetes.Pl // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpservers/status,verbs=get;update;patch // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpservers/finalizers,verbs=update // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptoolconfigs,verbs=get;list;watch +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=configmaps,verbs=create;delete;get;list;patch;update;watch // +kubebuilder:rbac:groups="",resources=services,verbs=create;delete;get;list;patch;update;watch;apply // +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=roles,verbs=create;delete;get;list;patch;update;watch @@ -197,6 +200,17 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, err } + // Check if MCPExternalAuthConfig is referenced and handle it + if err := r.handleExternalAuthConfig(ctx, mcpServer); err != nil { + ctxLogger.Error(err, "Failed to handle MCPExternalAuthConfig") + // Update status to reflect the error + mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPServer status after MCPExternalAuthConfig error") + } + return ctrl.Result{}, err + } + // Validate MCPServer image against enforcing registries imageValidator := validation.NewImageValidator(r.Client, mcpServer.Namespace, r.ImageValidation) err = imageValidator.ValidateImage(ctx, mcpServer.Spec.Image, mcpServer.ObjectMeta) @@ -669,6 +683,53 @@ func (r *MCPServerReconciler) handleToolConfig(ctx context.Context, m *mcpv1alph return nil } + +// handleExternalAuthConfig validates and tracks the hash of the referenced MCPExternalAuthConfig. +// It updates the MCPServer status when the external auth configuration changes. +func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *mcpv1alpha1.MCPServer) error { + ctxLogger := log.FromContext(ctx) + if m.Spec.ExternalAuthConfigRef == nil { + // No MCPExternalAuthConfig referenced, clear any stored hash + if m.Status.ExternalAuthConfigHash != "" { + m.Status.ExternalAuthConfigHash = "" + if err := r.Status().Update(ctx, m); err != nil { + return fmt.Errorf("failed to clear MCPExternalAuthConfig hash from status: %w", err) + } + } + return nil + } + + // Get the referenced MCPExternalAuthConfig + externalAuthConfig, err := GetExternalAuthConfigForMCPServer(ctx, r.Client, m) + if err != nil { + return err + } + + if externalAuthConfig == nil { + return fmt.Errorf("MCPExternalAuthConfig %s not found", m.Spec.ExternalAuthConfigRef.Name) + } + + // Check if the MCPExternalAuthConfig hash has changed + if m.Status.ExternalAuthConfigHash != externalAuthConfig.Status.ConfigHash { + ctxLogger.Info("MCPExternalAuthConfig has changed, updating MCPServer", + "mcpserver", m.Name, + "externalAuthConfig", externalAuthConfig.Name, + "oldHash", m.Status.ExternalAuthConfigHash, + "newHash", externalAuthConfig.Status.ConfigHash) + + // Update the stored hash + m.Status.ExternalAuthConfigHash = externalAuthConfig.Status.ConfigHash + if err := r.Status().Update(ctx, m); err != nil { + return fmt.Errorf("failed to update MCPExternalAuthConfig hash in status: %w", err) + } + + // The change in hash will trigger a reconciliation of the RunConfig + // which will pick up the new external auth configuration + } + + return nil +} + func (r *MCPServerReconciler) ensureRBACResources(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) error { proxyRunnerNameForRBAC := proxyRunnerServiceAccountName(mcpServer.Name) @@ -928,6 +989,17 @@ func (r *MCPServerReconciler) deploymentForMCPServer(ctx context.Context, m *mcp env = append(env, otelEnvVars...) } + // Add token exchange environment variables + if m.Spec.ExternalAuthConfigRef != nil { + tokenExchangeEnvVars, err := r.generateTokenExchangeEnvVars(ctx, m) + if err != nil { + ctxLogger := log.FromContext(ctx) + ctxLogger.Error(err, "Failed to generate token exchange environment variables") + } else { + env = append(env, tokenExchangeEnvVars...) + } + } + // Add user-specified proxy environment variables from ResourceOverrides if m.Spec.ResourceOverrides != nil && m.Spec.ResourceOverrides.ProxyDeployment != nil { for _, envVar := range m.Spec.ResourceOverrides.ProxyDeployment.Env { @@ -1450,6 +1522,17 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( expectedProxyEnv = append(expectedProxyEnv, otelEnvVars...) } + // Add token exchange environment variables + if mcpServer.Spec.ExternalAuthConfigRef != nil { + tokenExchangeEnvVars, err := r.generateTokenExchangeEnvVars(ctx, mcpServer) + if err != nil { + // If we can't generate env vars, consider the deployment needs update + // The actual error will be caught during reconciliation + return true + } + expectedProxyEnv = append(expectedProxyEnv, tokenExchangeEnvVars...) + } + // Add user-specified environment variables if mcpServer.Spec.ResourceOverrides != nil && mcpServer.Spec.ResourceOverrides.ProxyDeployment != nil { for _, envVar := range mcpServer.Spec.ResourceOverrides.ProxyDeployment.Env { @@ -2106,6 +2189,49 @@ func (*MCPServerReconciler) generateOpenTelemetryEnvVars(m *mcpv1alpha1.MCPServe return envVars } +// generateTokenExchangeEnvVars generates environment variables for token exchange authentication +func (r *MCPServerReconciler) generateTokenExchangeEnvVars( + ctx context.Context, + m *mcpv1alpha1.MCPServer, +) ([]corev1.EnvVar, error) { + var envVars []corev1.EnvVar + + if m.Spec.ExternalAuthConfigRef == nil { + return envVars, nil + } + + // Fetch the MCPExternalAuthConfig + externalAuthConfig, err := GetExternalAuthConfigForMCPServer(ctx, r.Client, m) + if err != nil { + return nil, fmt.Errorf("failed to get MCPExternalAuthConfig: %w", err) + } + + // Only token exchange type is supported currently + if externalAuthConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeTokenExchange { + return envVars, nil + } + + tokenExchangeSpec := externalAuthConfig.Spec.TokenExchange + if tokenExchangeSpec == nil { + return envVars, nil + } + + // Add environment variable that references the Kubernetes Secret + envVars = append(envVars, corev1.EnvVar{ + Name: "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: tokenExchangeSpec.ClientSecretRef.Name, + }, + Key: tokenExchangeSpec.ClientSecretRef.Key, + }, + }, + }) + + return envVars, nil +} + // ensureAuthzConfigMap ensures the authorization ConfigMap exists for inline configuration func (r *MCPServerReconciler) ensureAuthzConfigMap(ctx context.Context, m *mcpv1alpha1.MCPServer) error { ctxLogger := log.FromContext(ctx) @@ -2188,10 +2314,44 @@ func int32Ptr(i int32) *int32 { // SetupWithManager sets up the controller with the Manager. func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Create a handler that maps MCPExternalAuthConfig changes to MCPServer reconciliation requests + externalAuthConfigHandler := handler.EnqueueRequestsFromMapFunc( + func(ctx context.Context, obj client.Object) []reconcile.Request { + externalAuthConfig, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig) + if !ok { + return nil + } + + // List all MCPServers in the same namespace + mcpServerList := &mcpv1alpha1.MCPServerList{} + if err := r.List(ctx, mcpServerList, client.InNamespace(externalAuthConfig.Namespace)); err != nil { + log.FromContext(ctx).Error(err, "Failed to list MCPServers for MCPExternalAuthConfig watch") + return nil + } + + // Find MCPServers that reference this MCPExternalAuthConfig + var requests []reconcile.Request + for _, server := range mcpServerList.Items { + if server.Spec.ExternalAuthConfigRef != nil && + server.Spec.ExternalAuthConfigRef.Name == externalAuthConfig.Name { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: server.Name, + Namespace: server.Namespace, + }, + }) + } + } + + return requests + }, + ) + return ctrl.NewControllerManagedBy(mgr). For(&mcpv1alpha1.MCPServer{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). + Watches(&mcpv1alpha1.MCPExternalAuthConfig{}, externalAuthConfigHandler). Complete(r) } diff --git a/cmd/thv-operator/controllers/mcpserver_externalauth_test.go b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go new file mode 100644 index 000000000..9b12f1877 --- /dev/null +++ b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go @@ -0,0 +1,442 @@ +// Copyright 2025 Stacklok, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" +) + +func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mcpServer *mcpv1alpha1.MCPServer + externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig + expectError bool + expectHash string + expectHashCleared bool + }{ + { + name: "no external auth config reference", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + // No ExternalAuthConfigRef + }, + Status: mcpv1alpha1.MCPServerStatus{}, + }, + expectError: false, + expectHash: "", + expectHashCleared: false, + }, + { + name: "external auth config reference exists", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + Status: mcpv1alpha1.MCPServerStatus{}, + }, + externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + ConfigHash: "test-hash-123", + }, + }, + expectError: false, + expectHash: "test-hash-123", + }, + { + name: "external auth config not found", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "non-existent-config", + }, + }, + Status: mcpv1alpha1.MCPServerStatus{}, + }, + expectError: true, + }, + { + name: "external auth config hash changed", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + Status: mcpv1alpha1.MCPServerStatus{ + ExternalAuthConfigHash: "old-hash", + }, + }, + externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "new-audience", // Changed config + }, + }, + Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + ConfigHash: "new-hash-456", + }, + }, + expectError: false, + expectHash: "new-hash-456", + }, + { + name: "clear hash when reference is removed", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + // No ExternalAuthConfigRef (was removed) + }, + Status: mcpv1alpha1.MCPServerStatus{ + ExternalAuthConfigHash: "old-hash-to-clear", + }, + }, + expectError: false, + expectHash: "", + expectHashCleared: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + // Build objects for fake client + objs := []runtime.Object{tt.mcpServer} + if tt.externalAuthConfig != nil { + objs = append(objs, tt.externalAuthConfig) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(objs...). + WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + Build() + + reconciler := &MCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Execute + err := reconciler.handleExternalAuthConfig(ctx, tt.mcpServer) + + // Assert + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + if tt.expectHash != "" { + assert.Equal(t, tt.expectHash, tt.mcpServer.Status.ExternalAuthConfigHash, + "Hash should be updated in status") + } + + if tt.expectHashCleared { + assert.Empty(t, tt.mcpServer.Status.ExternalAuthConfigHash, + "Hash should be cleared from status") + } + } + }) + } +} + +func TestMCPServerReconciler_handleExternalAuthConfig_SameNamespace(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + // External auth config in a different namespace + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "other-namespace", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + ConfigHash: "test-hash-123", + }, + } + + // MCPServer in different namespace + mcpServer := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", // References config in same namespace (default) + }, + }, + Status: mcpv1alpha1.MCPServerStatus{}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(externalAuthConfig, mcpServer). + WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + Build() + + reconciler := &MCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Execute - should fail because config is in different namespace + err := reconciler.handleExternalAuthConfig(ctx, mcpServer) + + // Assert - should get an error because config is not in same namespace + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestMCPServerReconciler_handleExternalAuthConfig_HashUpdateTrigger(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + ConfigHash: "initial-hash", + }, + } + + mcpServer := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + Status: mcpv1alpha1.MCPServerStatus{ + ExternalAuthConfigHash: "initial-hash", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(externalAuthConfig, mcpServer). + WithStatusSubresource(&mcpv1alpha1.MCPServer{}, &mcpv1alpha1.MCPExternalAuthConfig{}). + Build() + + reconciler := &MCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // First call - hash is the same, no update needed + err := reconciler.handleExternalAuthConfig(ctx, mcpServer) + assert.NoError(t, err) + assert.Equal(t, "initial-hash", mcpServer.Status.ExternalAuthConfigHash) + + // Simulate external auth config change - need to get the object first + var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + err = fakeClient.Get(ctx, client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) + require.NoError(t, err) + + updatedConfig.Status.ConfigHash = "updated-hash" + err = fakeClient.Status().Update(ctx, &updatedConfig) + require.NoError(t, err) + + // Second call - hash changed, should update + err = reconciler.handleExternalAuthConfig(ctx, mcpServer) + assert.NoError(t, err) + assert.Equal(t, "updated-hash", mcpServer.Status.ExternalAuthConfigHash, + "Hash should be updated to new value") +} + +func TestMCPServerReconciler_handleExternalAuthConfig_NoHashInConfig(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + // External auth config without hash in status + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client", + ClientSecretRef: mcpv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "client-secret", + }, + Audience: "backend-service", + }, + }, + Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + // ConfigHash is empty + }, + } + + mcpServer := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "test-config", + }, + }, + Status: mcpv1alpha1.MCPServerStatus{}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(externalAuthConfig, mcpServer). + WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + Build() + + reconciler := &MCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Execute + err := reconciler.handleExternalAuthConfig(ctx, mcpServer) + + // Assert - should succeed, but hash will be empty + assert.NoError(t, err) + assert.Empty(t, mcpServer.Status.ExternalAuthConfigHash, + "Hash should be empty when external auth config has no hash") +} diff --git a/cmd/thv-operator/controllers/mcpserver_runconfig.go b/cmd/thv-operator/controllers/mcpserver_runconfig.go index b33ac325f..fb5eb2101 100644 --- a/cmd/thv-operator/controllers/mcpserver_runconfig.go +++ b/cmd/thv-operator/controllers/mcpserver_runconfig.go @@ -309,6 +309,11 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer // Add OIDC authentication configuration if specified addOIDCConfigOptions(&options, m.Spec.OIDCConfig) + // Add external auth configuration if specified + if err := r.addExternalAuthConfigOptions(ctx, m, &options); err != nil { + return nil, fmt.Errorf("failed to process ExternalAuthConfig: %w", err) + } + // Add audit configuration if specified addAuditConfigOptions(&options, m.Spec.Audit) @@ -774,3 +779,101 @@ func addAuditConfigOptions( // Add audit config to options with default config (no custom config path for now) *options = append(*options, runner.WithAuditEnabled(auditConfig.Enabled, "")) } + +// addExternalAuthConfigOptions adds external authentication configuration options to the builder options +// This creates middleware configuration for token exchange +func (r *MCPServerReconciler) addExternalAuthConfigOptions( + ctx context.Context, + m *mcpv1alpha1.MCPServer, + options *[]runner.RunConfigBuilderOption, +) error { + if m.Spec.ExternalAuthConfigRef == nil { + return nil + } + + // Fetch the MCPExternalAuthConfig + externalAuthConfig, err := GetExternalAuthConfigForMCPServer(ctx, r.Client, m) + if err != nil { + return fmt.Errorf("failed to get MCPExternalAuthConfig: %w", err) + } + + // Only token exchange type is supported currently + if externalAuthConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeTokenExchange { + return fmt.Errorf("unsupported external auth type: %s", externalAuthConfig.Spec.Type) + } + + tokenExchangeSpec := externalAuthConfig.Spec.TokenExchange + if tokenExchangeSpec == nil { + return fmt.Errorf("token exchange configuration is nil for type tokenExchange") + } + + // Validate that the referenced Kubernetes secret exists + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{ + Namespace: m.Namespace, + Name: tokenExchangeSpec.ClientSecretRef.Name, + }, &secret); err != nil { + return fmt.Errorf("failed to get client secret %s/%s: %w", + m.Namespace, tokenExchangeSpec.ClientSecretRef.Name, err) + } + + if _, ok := secret.Data[tokenExchangeSpec.ClientSecretRef.Key]; !ok { + return fmt.Errorf("client secret %s/%s is missing key %q", + m.Namespace, tokenExchangeSpec.ClientSecretRef.Name, tokenExchangeSpec.ClientSecretRef.Key) + } + + // Convert scope string to slice + var scopes []string + if tokenExchangeSpec.Scope != "" { + scopes = strings.Fields(tokenExchangeSpec.Scope) + } + + // Determine header strategy based on ExternalTokenHeaderName + headerStrategy := "replace" // Default strategy + if tokenExchangeSpec.ExternalTokenHeaderName != "" { + headerStrategy = "custom" + } + + // Build token exchange middleware configuration + // Client secret is provided via TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable + // to avoid embedding plaintext secrets in the ConfigMap + tokenExchangeConfig := map[string]interface{}{ + "token_url": tokenExchangeSpec.TokenURL, + "client_id": tokenExchangeSpec.ClientID, + "audience": tokenExchangeSpec.Audience, + } + + if len(scopes) > 0 { + tokenExchangeConfig["scopes"] = scopes + } + + if headerStrategy != "" { + tokenExchangeConfig["header_strategy"] = headerStrategy + } + + if tokenExchangeSpec.ExternalTokenHeaderName != "" { + tokenExchangeConfig["external_token_header_name"] = tokenExchangeSpec.ExternalTokenHeaderName + } + + // Create middleware parameters + middlewareParams := map[string]interface{}{ + "token_exchange_config": tokenExchangeConfig, + } + + // Marshal parameters to JSON + paramsJSON, err := json.Marshal(middlewareParams) + if err != nil { + return fmt.Errorf("failed to marshal token exchange middleware parameters: %w", err) + } + + // Create middleware config + middlewareConfig := transporttypes.MiddlewareConfig{ + Type: "tokenexchange", + Parameters: json.RawMessage(paramsJSON), + } + + // Add to options using the WithMiddlewareConfig builder option + *options = append(*options, runner.WithMiddlewareConfig([]transporttypes.MiddlewareConfig{middlewareConfig})) + + return nil +} diff --git a/cmd/thv-operator/main.go b/cmd/thv-operator/main.go index 7a15e63ae..a2fb94cd9 100644 --- a/cmd/thv-operator/main.go +++ b/cmd/thv-operator/main.go @@ -86,6 +86,24 @@ func main() { os.Exit(1) } + // Register MCPToolConfig controller + if err = (&controllers.ToolConfigReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "MCPToolConfig") + os.Exit(1) + } + + // Register MCPExternalAuthConfig controller + if err = (&controllers.MCPExternalAuthConfigReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "MCPExternalAuthConfig") + os.Exit(1) + } + // Only register MCPRegistry controller if feature flag is enabled rec.ImageValidation = validation.ImageValidationRegistryEnforcing diff --git a/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml new file mode 100644 index 000000000..f6a1d5737 --- /dev/null +++ b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -0,0 +1,139 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: mcpexternalauthconfigs.toolhive.stacklok.dev +spec: + group: toolhive.stacklok.dev + names: + kind: MCPExternalAuthConfig + listKind: MCPExternalAuthConfigList + plural: mcpexternalauthconfigs + shortNames: + - extauth + - mcpextauth + singular: mcpexternalauthconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + MCPExternalAuthConfig is the Schema for the mcpexternalauthconfigs API. + MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by + MCPServer resources within the same namespace. Cross-namespace references + are not supported for security and isolation reasons. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPExternalAuthConfigSpec defines the desired state of MCPExternalAuthConfig. + MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by + MCPServer resources in the same namespace. + properties: + tokenExchange: + description: |- + TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange + Only used when Type is "tokenExchange" + properties: + audience: + description: Audience is the target audience for the exchanged + token + type: string + client_id: + description: ClientID is the OAuth 2.0 client identifier + type: string + client_secret_ref: + description: ClientSecretRef is a reference to a secret containing + the OAuth 2.0 client secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + external_token_header_name: + description: |- + ExternalTokenHeaderName is the name of the custom header to use for the exchanged token. + If set, the exchanged token will be added to this custom header (e.g., "X-Upstream-Token"). + If empty or not set, the exchanged token will replace the Authorization header (default behavior). + type: string + scope: + description: Scope is the scope to request for the exchanged token + (space-separated string) + type: string + token_url: + description: TokenURL is the OAuth 2.0 token endpoint URL for + token exchange + type: string + required: + - audience + - client_id + - client_secret_ref + - token_url + type: object + type: + description: Type is the type of external authentication to configure + enum: + - tokenExchange + type: string + required: + - type + type: object + status: + description: MCPExternalAuthConfigStatus defines the observed state of + MCPExternalAuthConfig + properties: + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this MCPExternalAuthConfig. + It corresponds to the MCPExternalAuthConfig's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + referencingServers: + description: |- + ReferencingServers is a list of MCPServer resources that reference this MCPExternalAuthConfig + This helps track which servers need to be reconciled when this config changes + items: + type: string + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpservers.yaml index b4d7824d8..44808fcab 100644 --- a/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -131,6 +131,17 @@ spec: - value type: object type: array + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication. + The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. + properties: + name: + description: Name is the name of the MCPExternalAuthConfig resource + type: string + required: + - name + type: object image: description: Image is the container image for the MCP server type: string @@ -9039,6 +9050,10 @@ spec: - type type: object type: array + externalAuthConfigHash: + description: ExternalAuthConfigHash is the hash of the referenced + MCPExternalAuthConfig spec + type: string message: description: Message provides additional information about the current phase diff --git a/deploy/charts/operator/templates/clusterrole/role.yaml b/deploy/charts/operator/templates/clusterrole/role.yaml index 75c2e08e5..5685e52d1 100644 --- a/deploy/charts/operator/templates/clusterrole/role.yaml +++ b/deploy/charts/operator/templates/clusterrole/role.yaml @@ -100,6 +100,7 @@ rules: - apiGroups: - toolhive.stacklok.dev resources: + - mcpexternalauthconfigs - mcpregistries - mcpservers - mcptoolconfigs @@ -114,6 +115,7 @@ rules: - apiGroups: - toolhive.stacklok.dev resources: + - mcpexternalauthconfigs/finalizers - mcpregistries/finalizers - mcpservers/finalizers - mcptoolconfigs/finalizers @@ -122,6 +124,7 @@ rules: - apiGroups: - toolhive.stacklok.dev resources: + - mcpexternalauthconfigs/status - mcpregistries/status - mcpservers/status - mcptoolconfigs/status diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 483f6a7b1..e0314a89f 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -9,6 +9,8 @@ Package v1alpha1 contains API Schema definitions for the toolhive v1alpha1 API group ### Resource Types +- [MCPExternalAuthConfig](#mcpexternalauthconfig) +- [MCPExternalAuthConfigList](#mcpexternalauthconfiglist) - [MCPRegistry](#mcpregistry) - [MCPRegistryList](#mcpregistrylist) - [MCPServer](#mcpserver) @@ -161,6 +163,23 @@ _Appears in:_ | `value` _string_ | Value of the environment variable | | Required: \{\}
| +#### ExternalAuthConfigRef + + + +ExternalAuthConfigRef defines a reference to a MCPExternalAuthConfig resource. +The referenced MCPExternalAuthConfig must be in the same namespace as the MCPServer. + + + +_Appears in:_ +- [MCPServerSpec](#mcpserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the MCPExternalAuthConfig resource | | Required: \{\}
| + + #### GitSource @@ -244,6 +263,88 @@ _Appears in:_ | `useClusterAuth` _boolean_ | UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token
When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification
and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication
Defaults to true if not specified | | | +#### MCPExternalAuthConfig + + + +MCPExternalAuthConfig is the Schema for the mcpexternalauthconfigs API. +MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by +MCPServer resources within the same namespace. Cross-namespace references +are not supported for security and isolation reasons. + + + +_Appears in:_ +- [MCPExternalAuthConfigList](#mcpexternalauthconfiglist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPExternalAuthConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[MCPExternalAuthConfigSpec](#mcpexternalauthconfigspec)_ | | | | +| `status` _[MCPExternalAuthConfigStatus](#mcpexternalauthconfigstatus)_ | | | | + + +#### MCPExternalAuthConfigList + + + +MCPExternalAuthConfigList contains a list of MCPExternalAuthConfig + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPExternalAuthConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[MCPExternalAuthConfig](#mcpexternalauthconfig) array_ | | | | + + +#### MCPExternalAuthConfigSpec + + + +MCPExternalAuthConfigSpec defines the desired state of MCPExternalAuthConfig. +MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by +MCPServer resources in the same namespace. + + + +_Appears in:_ +- [MCPExternalAuthConfig](#mcpexternalauthconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type is the type of external authentication to configure | | Enum: [tokenExchange]
Required: \{\}
| +| `tokenExchange` _[TokenExchangeConfig](#tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange
Only used when Type is "tokenExchange" | | | + + +#### MCPExternalAuthConfigStatus + + + +MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig + + + +_Appears in:_ +- [MCPExternalAuthConfig](#mcpexternalauthconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPExternalAuthConfig.
It corresponds to the MCPExternalAuthConfig's generation, which is updated on mutation by the API Server. | | | +| `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | | +| `referencingServers` _string array_ | ReferencingServers is a list of MCPServer resources that reference this MCPExternalAuthConfig
This helps track which servers need to be reconciled when this config changes | | | + + #### MCPRegistry @@ -463,6 +564,7 @@ _Appears in:_ | `audit` _[AuditConfig](#auditconfig)_ | Audit defines audit logging configuration for the MCP server | | | | `tools` _string array_ | ToolsFilter is the filter on tools applied to the MCP server
Deprecated: Use ToolConfigRef instead | | | | `toolConfigRef` _[ToolConfigRef](#toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.
The referenced MCPToolConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons.
If specified, this takes precedence over the inline ToolsFilter field. | | | +| `externalAuthConfigRef` _[ExternalAuthConfigRef](#externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication.
The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. | | | | `telemetry` _[TelemetryConfig](#telemetryconfig)_ | Telemetry defines observability configuration for the MCP server | | | | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | | @@ -482,6 +584,7 @@ _Appears in:_ | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPServer's state | | | | `toolConfigHash` _string_ | ToolConfigHash stores the hash of the referenced ToolConfig for change detection | | | +| `externalAuthConfigHash` _string_ | ExternalAuthConfigHash is the hash of the referenced MCPExternalAuthConfig spec | | | | `url` _string_ | URL is the URL where the MCP server can be accessed | | | | `phase` _[MCPServerPhase](#mcpserverphase)_ | Phase is the current phase of the MCPServer | | Enum: [Pending Running Failed Terminating]
| | `message` _string_ | Message provides additional information about the current phase | | | @@ -836,6 +939,23 @@ _Appears in:_ | `requests` _[ResourceList](#resourcelist)_ | Requests describes the minimum amount of compute resources required | | | +#### SecretKeyRef + + + +SecretKeyRef is a reference to a key within a Secret + + + +_Appears in:_ +- [TokenExchangeConfig](#tokenexchangeconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the secret | | Required: \{\}
| +| `key` _string_ | Key is the key within the secret | | Required: \{\}
| + + #### SecretRef @@ -965,6 +1085,30 @@ _Appears in:_ | `prometheus` _[PrometheusConfig](#prometheusconfig)_ | Prometheus defines Prometheus-specific configuration | | | +#### TokenExchangeConfig + + + +TokenExchangeConfig holds configuration for RFC-8693 OAuth 2.0 Token Exchange. +This configuration is used to exchange incoming authentication tokens for tokens +that can be used with external services. +The structure matches the tokenexchange.Config from pkg/auth/tokenexchange/middleware.go + + + +_Appears in:_ +- [MCPExternalAuthConfigSpec](#mcpexternalauthconfigspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `token_url` _string_ | TokenURL is the OAuth 2.0 token endpoint URL for token exchange | | Required: \{\}
| +| `client_id` _string_ | ClientID is the OAuth 2.0 client identifier | | Required: \{\}
| +| `client_secret_ref` _[SecretKeyRef](#secretkeyref)_ | ClientSecretRef is a reference to a secret containing the OAuth 2.0 client secret | | Required: \{\}
| +| `audience` _string_ | Audience is the target audience for the exchanged token | | Required: \{\}
| +| `scope` _string_ | Scope is the scope to request for the exchanged token (space-separated string) | | | +| `external_token_header_name` _string_ | ExternalTokenHeaderName is the name of the custom header to use for the exchanged token.
If set, the exchanged token will be added to this custom header (e.g., "X-Upstream-Token").
If empty or not set, the exchanged token will replace the Authorization header (default behavior). | | | + + #### ToolConfigRef diff --git a/examples/operator/external-auth/complete_example.yaml b/examples/operator/external-auth/complete_example.yaml new file mode 100644 index 000000000..7393e2265 --- /dev/null +++ b/examples/operator/external-auth/complete_example.yaml @@ -0,0 +1,73 @@ +# Complete external authentication example +# This file contains all resources needed for external authentication: +# 1. Secret containing OAuth client credentials +# 2. MCPExternalAuthConfig for token exchange configuration +# 3. MCPServer that uses the external auth configuration + +--- +# Secret containing OAuth2 client credentials +# Note: In production, manage secrets using a secret management solution +apiVersion: v1 +kind: Secret +metadata: + name: oauth-client-secret + namespace: default +type: Opaque +stringData: + # OAuth2 client secret (replace with your actual secret) + client-secret: "your-client-secret-here" + +--- +# External authentication configuration +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPExternalAuthConfig +metadata: + name: keycloak-token-exchange + namespace: default +spec: + type: tokenExchange + tokenExchange: + # Keycloak token endpoint + token_url: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token + + # OAuth2 client credentials + client_id: toolhive-client + client_secret_ref: + name: oauth-client-secret + key: client-secret + + # Target audience for the exchanged token + audience: mcp-backend + + # OAuth2 scopes + scope: "openid profile" + + # Extract external token from custom header + external_token_header_name: "X-Upstream-Authorization" + +--- +# MCP Server with external authentication +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: authenticated-fetch + namespace: default +spec: + image: ghcr.io/stackloklabs/gofetch/server + transport: streamable-http + tools: + - fetch + port: 8080 + targetPort: 8080 + + # Reference to external auth configuration + externalAuthConfigRef: + name: keycloak-token-exchange + + resources: + limits: + cpu: "200m" + memory: "256Mi" + requests: + cpu: "100m" + memory: "128Mi" diff --git a/examples/operator/external-auth/mcpexternalauthconfig_basic.yaml b/examples/operator/external-auth/mcpexternalauthconfig_basic.yaml new file mode 100644 index 000000000..2df3b35ff --- /dev/null +++ b/examples/operator/external-auth/mcpexternalauthconfig_basic.yaml @@ -0,0 +1,33 @@ +# Basic MCPExternalAuthConfig example with token exchange +# This configures external authentication using OAuth2 token exchange +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPExternalAuthConfig +metadata: + name: oauth-token-exchange + namespace: default +spec: + # Type of external authentication (currently only "tokenExchange" is supported) + type: tokenExchange + + # Token exchange configuration for OAuth2 token exchange flow + tokenExchange: + # OAuth2 token endpoint URL + token_url: https://oauth.example.com/token + + # OAuth2 client ID + client_id: my-client-id + + # Reference to Kubernetes Secret containing the client secret + client_secret_ref: + name: oauth-client-secret + key: client-secret + + # Target audience for the exchanged token + audience: backend-service + + # Optional: OAuth2 scopes to request + scope: "read write" + + # Optional: Custom header name for extracting external token from incoming requests + # If not specified, defaults to "Authorization" header + # external_token_header_name: "X-Upstream-Token" diff --git a/examples/operator/external-auth/mcpexternalauthconfig_minimal.yaml b/examples/operator/external-auth/mcpexternalauthconfig_minimal.yaml new file mode 100644 index 000000000..5609abe48 --- /dev/null +++ b/examples/operator/external-auth/mcpexternalauthconfig_minimal.yaml @@ -0,0 +1,16 @@ +# Minimal MCPExternalAuthConfig example +# This shows the minimum required fields for token exchange configuration +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPExternalAuthConfig +metadata: + name: minimal-oauth + namespace: default +spec: + type: tokenExchange + tokenExchange: + token_url: https://oauth.example.com/token + client_id: my-client + client_secret_ref: + name: oauth-secret + key: client-secret + audience: my-audience diff --git a/examples/operator/external-auth/mcpserver_with_external_auth.yaml b/examples/operator/external-auth/mcpserver_with_external_auth.yaml new file mode 100644 index 000000000..6c3ccac88 --- /dev/null +++ b/examples/operator/external-auth/mcpserver_with_external_auth.yaml @@ -0,0 +1,35 @@ +# MCPServer with external authentication configuration +# This example shows how to configure an MCP server to use external authentication +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: fetch-with-auth + namespace: default +spec: + # Container image for the MCP server + image: ghcr.io/stackloklabs/gofetch/server + + # Transport protocol (streamable-http, stdio, or sse) + transport: streamable-http + + # Tools exposed by this server + tools: + - fetch + + # Port configuration + port: 8080 + targetPort: 8080 + + # Reference to external authentication configuration + # The MCPExternalAuthConfig must be in the same namespace + externalAuthConfigRef: + name: oauth-token-exchange + + # Resource limits and requests + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-pod-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-pod-running.yaml new file mode 100644 index 000000000..2016eaad9 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-pod-running.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Pod +metadata: + namespace: toolhive-system + labels: + app.kubernetes.io/instance: external-auth-test +status: + phase: Running diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-running.yaml new file mode 100644 index 000000000..6d5a39d74 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-running.yaml @@ -0,0 +1,7 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: external-auth-test + namespace: toolhive-system +status: + phase: Running diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/chainsaw-test.yaml new file mode 100644 index 000000000..5ff8457a9 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/chainsaw-test.yaml @@ -0,0 +1,277 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: external-auth-configmap-test +spec: + description: Test that external authentication (token exchange) configuration is correctly generated in ConfigMap + steps: + - name: enable-configmap-mode + try: + - script: + content: | + echo "Setting TOOLHIVE_USE_CONFIGMAP=true on operator deployment..." + + # Use strategic merge patch to add the environment variable to existing env array + kubectl patch deployment toolhive-operator -n toolhive-system --type='strategic' -p='{"spec":{"template":{"spec":{"containers":[{"name":"manager","env":[{"name":"TOOLHIVE_USE_CONFIGMAP","value":"true"}]}]}}}}' + + # Wait for rollout to complete + kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s + + # Verify the environment variable was set + echo "Verifying TOOLHIVE_USE_CONFIGMAP environment variable is set..." + ENV_VAR=$(kubectl get deployment toolhive-operator -n toolhive-system -o jsonpath='{.spec.template.spec.containers[?(@.name=="manager")].env[?(@.name=="TOOLHIVE_USE_CONFIGMAP")].value}') + if [ "$ENV_VAR" = "true" ]; then + echo "✓ TOOLHIVE_USE_CONFIGMAP=true verified on operator deployment" + else + echo "✗ Failed to set TOOLHIVE_USE_CONFIGMAP environment variable" + exit 1 + fi + timeout: 120s + + - name: create-oauth-secret + try: + - apply: + file: oauth-secret.yaml + - assert: + resource: + apiVersion: v1 + kind: Secret + metadata: + name: oauth-test-secret + namespace: toolhive-system + + - name: create-external-auth-config + try: + - apply: + file: mcpexternalauthconfig.yaml + - assert: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPExternalAuthConfig + metadata: + name: test-external-auth + namespace: toolhive-system + + - name: verify-external-auth-config-status + try: + - script: + content: | + echo "Verifying MCPExternalAuthConfig status..." + + # Wait for status to be updated with hash + for i in $(seq 1 10); do + HASH=$(kubectl get mcpexternalauthconfig test-external-auth -n toolhive-system -o jsonpath='{.status.configHash}' 2>/dev/null || echo "") + if [ -n "$HASH" ]; then + echo "✓ MCPExternalAuthConfig hash is set: $HASH" + break + fi + echo " Waiting for MCPExternalAuthConfig hash... (attempt $i/10)" + sleep 2 + done + + if [ -z "$HASH" ]; then + echo "✗ MCPExternalAuthConfig hash was not set" + kubectl get mcpexternalauthconfig test-external-auth -n toolhive-system -o yaml + exit 1 + fi + + echo "✓ MCPExternalAuthConfig status verified" + timeout: 120s + + - name: create-mcpserver-with-external-auth + try: + - apply: + file: mcpserver-with-external-auth.yaml + - assert: + file: assert-mcpserver-running.yaml + timeout: 120s + + - name: verify-pod-running + try: + - assert: + file: assert-mcpserver-pod-running.yaml + timeout: 120s + + - name: verify-mcpserver-external-auth-hash + try: + - script: + content: | + echo "Verifying MCPServer has external auth config hash..." + + # Get hash from MCPExternalAuthConfig + EXTERNAL_AUTH_HASH=$(kubectl get mcpexternalauthconfig test-external-auth -n toolhive-system -o jsonpath='{.status.configHash}') + echo "MCPExternalAuthConfig hash: $EXTERNAL_AUTH_HASH" + + # Get hash from MCPServer status + MCPSERVER_HASH=$(kubectl get mcpserver external-auth-test -n toolhive-system -o jsonpath='{.status.externalAuthConfigHash}') + echo "MCPServer externalAuthConfigHash: $MCPSERVER_HASH" + + if [ "$EXTERNAL_AUTH_HASH" != "$MCPSERVER_HASH" ]; then + echo "✗ Hash mismatch between MCPExternalAuthConfig and MCPServer" + exit 1 + fi + + echo "✓ MCPServer external auth hash verified" + timeout: 120s + + - name: verify-configmap-external-auth-config + try: + - script: + content: | + echo "Verifying ConfigMap external auth configuration..." + + # Wait for ConfigMap to be created + for i in $(seq 1 10); do + if kubectl get configmap -n toolhive-system -l toolhive.stacklok.io/mcp-server=external-auth-test >/dev/null 2>&1; then + echo "✓ ConfigMap exists" + break + fi + echo " Waiting for ConfigMap... (attempt $i/10)" + sleep 2 + done + + # Get the ConfigMap and extract the runconfig.json + CONFIGMAP_JSON=$(kubectl get configmap -n toolhive-system -l toolhive.stacklok.io/mcp-server=external-auth-test -o jsonpath='{.items[0].data.runconfig\.json}' 2>/dev/null || echo "") + + if [ -z "$CONFIGMAP_JSON" ]; then + echo "✗ ConfigMap does not contain runconfig.json data" + kubectl get configmap -n toolhive-system -l toolhive.stacklok.io/mcp-server=external-auth-test -o yaml + exit 1 + fi + + echo "$CONFIGMAP_JSON" > /tmp/runconfig.json + + # Debug: Show the full ConfigMap content + echo "=== DEBUG: Full runconfig.json content ===" + echo "$CONFIGMAP_JSON" | jq . || echo "$CONFIGMAP_JSON" + echo "=== END DEBUG ===" + + # Verify middleware_config section is present in runconfig.json + if ! echo "$CONFIGMAP_JSON" | jq -e '.middleware_config' > /dev/null 2>&1; then + echo "✗ middleware_config section not found in runconfig.json" + exit 1 + fi + echo "✓ middleware_config section found" + + # Verify token_exchange section exists within middleware_config + if ! echo "$CONFIGMAP_JSON" | jq -e '.middleware_config.token_exchange' > /dev/null 2>&1; then + echo "✗ token_exchange section not found in middleware_config" + exit 1 + fi + echo "✓ token_exchange section found" + + # Verify token URL + TOKEN_URL=$(echo "$CONFIGMAP_JSON" | jq -r '.middleware_config.token_exchange.token_url // empty') + if [ "$TOKEN_URL" != "https://oauth.example.com/token" ]; then + echo "✗ Token URL mismatch. Expected: 'https://oauth.example.com/token', Got: '$TOKEN_URL'" + exit 1 + fi + echo "✓ Token URL verified" + + # Verify client ID + CLIENT_ID=$(echo "$CONFIGMAP_JSON" | jq -r '.middleware_config.token_exchange.client_id // empty') + if [ "$CLIENT_ID" != "test-client-id" ]; then + echo "✗ Client ID mismatch. Expected: 'test-client-id', Got: '$CLIENT_ID'" + exit 1 + fi + echo "✓ Client ID verified" + + # Verify audience + AUDIENCE=$(echo "$CONFIGMAP_JSON" | jq -r '.middleware_config.token_exchange.audience // empty') + if [ "$AUDIENCE" != "mcp-backend" ]; then + echo "✗ Audience mismatch. Expected: 'mcp-backend', Got: '$AUDIENCE'" + exit 1 + fi + echo "✓ Audience verified" + + # Verify scope + SCOPE=$(echo "$CONFIGMAP_JSON" | jq -r '.middleware_config.token_exchange.scope // empty') + if [ "$SCOPE" != "read write" ]; then + echo "✗ Scope mismatch. Expected: 'read write', Got: '$SCOPE'" + exit 1 + fi + echo "✓ Scope verified" + + # Verify external token header name + EXT_HEADER=$(echo "$CONFIGMAP_JSON" | jq -r '.middleware_config.token_exchange.external_token_header_name // empty') + if [ "$EXT_HEADER" != "X-Upstream-Token" ]; then + echo "✗ External token header name mismatch. Expected: 'X-Upstream-Token', Got: '$EXT_HEADER'" + exit 1 + fi + echo "✓ External token header name verified" + + # Verify header strategy is "extract" (inferred from external_token_header_name being present) + HEADER_STRATEGY=$(echo "$CONFIGMAP_JSON" | jq -r '.middleware_config.token_exchange.header_strategy // empty') + if [ "$HEADER_STRATEGY" != "extract" ]; then + echo "✗ Header strategy mismatch. Expected: 'extract', Got: '$HEADER_STRATEGY'" + exit 1 + fi + echo "✓ Header strategy verified" + + # Verify client secret is NOT in ConfigMap (should be env var) + CLIENT_SECRET=$(echo "$CONFIGMAP_JSON" | jq -r '.middleware_config.token_exchange.client_secret // empty') + if [ -n "$CLIENT_SECRET" ]; then + echo "✗ Client secret should not be in ConfigMap (should be environment variable)" + exit 1 + fi + echo "✓ Client secret not in ConfigMap (as expected)" + + echo "✓ All external auth configuration validations passed!" + timeout: 120s + + - name: verify-deployment-env-var + try: + - script: + content: | + echo "Verifying TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable..." + + # Get deployment by name (deployment name matches MCPServer name) + DEPLOYMENT_NAME="external-auth-test" + + # Verify deployment exists + if ! kubectl get deployment "$DEPLOYMENT_NAME" -n toolhive-system >/dev/null 2>&1; then + echo "✗ Deployment not found: $DEPLOYMENT_NAME" + exit 1 + fi + echo "Found deployment: $DEPLOYMENT_NAME" + + # Check for TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET env var + ENV_VAR=$(kubectl get deployment "$DEPLOYMENT_NAME" -n toolhive-system -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET")]}') + + if [ -z "$ENV_VAR" ]; then + echo "✗ TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable not found" + kubectl get deployment "$DEPLOYMENT_NAME" -n toolhive-system -o yaml + exit 1 + fi + + # Verify it's sourced from secret + SECRET_REF=$(echo "$ENV_VAR" | jq -r '.valueFrom.secretKeyRef.name // empty') + if [ "$SECRET_REF" != "oauth-test-secret" ]; then + echo "✗ Environment variable not sourced from correct secret. Expected: 'oauth-test-secret', Got: '$SECRET_REF'" + exit 1 + fi + + SECRET_KEY=$(echo "$ENV_VAR" | jq -r '.valueFrom.secretKeyRef.key // empty') + if [ "$SECRET_KEY" != "client-secret" ]; then + echo "✗ Environment variable not sourced from correct key. Expected: 'client-secret', Got: '$SECRET_KEY'" + exit 1 + fi + + echo "✓ TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable verified" + timeout: 120s + + - name: cleanup-configmap-mode + try: + - script: + content: | + echo "Cleaning up ConfigMap mode..." + + # Wait for ConfigMap to be deleted + kubectl wait --for=delete configmap -l toolhive.stacklok.io/mcp-server=external-auth-test -n toolhive-system --timeout=60s || true + + # Disable ConfigMap mode to avoid affecting subsequent tests + echo "Disabling ConfigMap mode..." + kubectl patch deployment toolhive-operator -n toolhive-system --type='strategic' -p='{"spec":{"template":{"spec":{"containers":[{"name":"manager","env":[{"name":"TOOLHIVE_USE_CONFIGMAP","value":"false"}]}]}}}}' + kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s + echo "✓ ConfigMap mode cleanup completed" + timeout: 120s diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpexternalauthconfig.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpexternalauthconfig.yaml new file mode 100644 index 000000000..cc80f4f62 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpexternalauthconfig.yaml @@ -0,0 +1,16 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPExternalAuthConfig +metadata: + name: test-external-auth + namespace: toolhive-system +spec: + type: token-exchange + tokenExchange: + token_url: https://oauth.example.com/token + client_id: test-client-id + client_secret_ref: + name: oauth-test-secret + key: client-secret + audience: mcp-backend + scope: "read write" + external_token_header_name: "X-Upstream-Token" diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpserver-with-external-auth.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpserver-with-external-auth.yaml new file mode 100644 index 000000000..809d7bb71 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpserver-with-external-auth.yaml @@ -0,0 +1,24 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: external-auth-test + namespace: toolhive-system +spec: + image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 + transport: stdio + port: 8080 + + # Reference to external authentication configuration + externalAuthConfigRef: + name: test-external-auth + + permissionProfile: + type: builtin + name: network + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/oauth-secret.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/oauth-secret.yaml new file mode 100644 index 000000000..1442e25d9 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/oauth-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: oauth-test-secret + namespace: toolhive-system +type: Opaque +stringData: + client-secret: "test-secret-value"