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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 154 additions & 5 deletions cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ package controllers

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
Expand Down Expand Up @@ -40,6 +43,7 @@ type MCPExternalAuthConfigReconciler struct {
// +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
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
Expand Down Expand Up @@ -77,8 +81,12 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr
return ctrl.Result{RequeueAfter: externalAuthConfigRequeueDelay}, nil
}

// Calculate the hash of the current configuration
configHash := r.calculateConfigHash(externalAuthConfig.Spec)
// Calculate the hash of the current configuration (including referenced Secret values)
configHash, err := r.calculateConfigHash(ctx, externalAuthConfig)
if err != nil {
logger.Error(err, "Failed to calculate config hash")
return ctrl.Result{}, err
}

// Check if the hash has changed
if externalAuthConfig.Status.ConfigHash != configHash {
Expand Down Expand Up @@ -131,9 +139,71 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr
return ctrl.Result{}, nil
}

// calculateConfigHash calculates a hash of the MCPExternalAuthConfig spec using Kubernetes utilities
func (*MCPExternalAuthConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPExternalAuthConfigSpec) string {
return ctrlutil.CalculateConfigHash(spec)
// calculateConfigHash calculates a hash of the MCPExternalAuthConfig spec including referenced Secret values
// This ensures that changes to Secret values trigger reconciliation
func (r *MCPExternalAuthConfigReconciler) calculateConfigHash(
ctx context.Context,
externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig,
) (string, error) {
// Start with the base spec hash
hashString := ctrlutil.CalculateConfigHash(externalAuthConfig.Spec)

// Include referenced Secret values in the hash for bearer token configs
if externalAuthConfig.Spec.Type == mcpv1alpha1.ExternalAuthTypeBearerToken &&
externalAuthConfig.Spec.BearerToken != nil &&
externalAuthConfig.Spec.BearerToken.TokenSecretRef != nil {
var secret corev1.Secret
if err := r.Get(ctx, types.NamespacedName{
Namespace: externalAuthConfig.Namespace,
Name: externalAuthConfig.Spec.BearerToken.TokenSecretRef.Name,
}, &secret); err != nil {
if errors.IsNotFound(err) {
// Secret doesn't exist yet, include that in hash
hashString += ":secret-not-found"
} else {
return "", fmt.Errorf("failed to get bearer token secret: %w", err)
}
} else {
// Include the secret value in the hash
if tokenValue, ok := secret.Data[externalAuthConfig.Spec.BearerToken.TokenSecretRef.Key]; ok {
hasher := sha256.New()
hasher.Write(tokenValue)
hashString += ":" + hex.EncodeToString(hasher.Sum(nil))[:16]
} else {
hashString += ":key-not-found"
}
}
}

// Also include token exchange client secret if present
if externalAuthConfig.Spec.Type == mcpv1alpha1.ExternalAuthTypeTokenExchange &&
externalAuthConfig.Spec.TokenExchange != nil &&
externalAuthConfig.Spec.TokenExchange.ClientSecretRef != nil {
var secret corev1.Secret
if err := r.Get(ctx, types.NamespacedName{
Namespace: externalAuthConfig.Namespace,
Name: externalAuthConfig.Spec.TokenExchange.ClientSecretRef.Name,
}, &secret); err != nil {
if errors.IsNotFound(err) {
hashString += ":client-secret-not-found"
} else {
return "", fmt.Errorf("failed to get client secret: %w", err)
}
} else {
if secretValue, ok := secret.Data[externalAuthConfig.Spec.TokenExchange.ClientSecretRef.Key]; ok {
hasher := sha256.New()
hasher.Write(secretValue)
hashString += ":" + hex.EncodeToString(hasher.Sum(nil))[:16]
} else {
hashString += ":client-secret-key-not-found"
}
}
}

// Hash the final combined string
hasher := sha256.New()
hasher.Write([]byte(hashString))
return hex.EncodeToString(hasher.Sum(nil))[:16], nil
}

// handleDeletion handles the deletion of a MCPExternalAuthConfig
Expand Down Expand Up @@ -197,6 +267,54 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingMCPServers(
})
}

// findMCPExternalAuthConfigsReferencingSecret finds all MCPExternalAuthConfigs that reference the given Secret.
// This includes configs that reference the Secret for bearer tokens or token exchange client secrets.
func (r *MCPExternalAuthConfigReconciler) findMCPExternalAuthConfigsReferencingSecret(
ctx context.Context,
secret *corev1.Secret,
) ([]mcpv1alpha1.MCPExternalAuthConfig, error) {
// List all MCPExternalAuthConfigs in the same namespace as the Secret
externalAuthConfigs := &mcpv1alpha1.MCPExternalAuthConfigList{}
if err := r.List(ctx, externalAuthConfigs, client.InNamespace(secret.Namespace)); err != nil {
return nil, fmt.Errorf("failed to list MCPExternalAuthConfigs: %w", err)
}

// Filter configs that reference this Secret
referencingConfigs := make([]mcpv1alpha1.MCPExternalAuthConfig, 0)
for _, config := range externalAuthConfigs.Items {
if configReferencesSecret(&config, secret.Name) {
referencingConfigs = append(referencingConfigs, config)
}
}

return referencingConfigs, nil
}

// configReferencesSecret checks if an MCPExternalAuthConfig references a Secret by name.
// This checks both bearer token and token exchange configurations.
func configReferencesSecret(
config *mcpv1alpha1.MCPExternalAuthConfig,
secretName string,
) bool {
// Check bearer token config
if config.Spec.Type == mcpv1alpha1.ExternalAuthTypeBearerToken &&
config.Spec.BearerToken != nil &&
config.Spec.BearerToken.TokenSecretRef != nil &&
config.Spec.BearerToken.TokenSecretRef.Name == secretName {
return true
}

// Check token exchange config
if config.Spec.Type == mcpv1alpha1.ExternalAuthTypeTokenExchange &&
config.Spec.TokenExchange != nil &&
config.Spec.TokenExchange.ClientSecretRef != nil &&
config.Spec.TokenExchange.ClientSecretRef.Name == secretName {
return true
}

return false
}

// 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
Expand Down Expand Up @@ -229,10 +347,41 @@ func (r *MCPExternalAuthConfigReconciler) SetupWithManager(mgr ctrl.Manager) err
},
)

// Create a handler that maps Secret changes to MCPExternalAuthConfig reconciliation requests
secretHandler := handler.EnqueueRequestsFromMapFunc(
func(ctx context.Context, obj client.Object) []reconcile.Request {
secret, ok := obj.(*corev1.Secret)
if !ok {
return nil
}

// Find all MCPExternalAuthConfigs that reference this Secret
configs, err := r.findMCPExternalAuthConfigsReferencingSecret(ctx, secret)
if err != nil {
log.FromContext(ctx).Error(err, "Failed to find MCPExternalAuthConfigs referencing Secret")
return nil
}

requests := make([]reconcile.Request, 0, len(configs))
for _, config := range configs {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: config.Name,
Namespace: config.Namespace,
},
})
}

return requests
},
)

return ctrl.NewControllerManagedBy(mgr).
For(&mcpv1alpha1.MCPExternalAuthConfig{}).
// Watch for MCPServers and reconcile the MCPExternalAuthConfig when they change
Watches(&mcpv1alpha1.MCPServer{}, externalAuthConfigHandler).
// Watch for Secrets and reconcile MCPExternalAuthConfigs that reference them
Watches(&corev1.Secret{}, secretHandler).
Complete(r)
}

Expand Down
Loading
Loading