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"