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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ type EmbeddedAuthServerConfig struct {

// UpstreamProviders configures connections to upstream Identity Providers.
// The embedded auth server delegates authentication to these providers.
// Currently only a single upstream provider is supported (validated at runtime).
// MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
UpstreamProviders []UpstreamProviderConfig `json:"upstreamProviders"`
Expand Down Expand Up @@ -238,8 +238,11 @@ const (
type UpstreamProviderConfig struct {
// Name uniquely identifies this upstream provider.
// Used for routing decisions and session binding in multi-upstream scenarios.
// Must be lowercase alphanumeric with hyphens (DNS-label-like).
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Name string `json:"name"`

// Type specifies the provider type: "oidc" or "oauth2"
Expand Down Expand Up @@ -783,12 +786,17 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error {
if len(cfg.UpstreamProviders) == 0 {
return fmt.Errorf("at least one upstream provider is required")
}
// Note: we add runtime validation for 'max items = 1' here since multi-provider support is not yet implemented.
if len(cfg.UpstreamProviders) > 1 {
return fmt.Errorf("currently only one upstream provider is supported (found %d)", len(cfg.UpstreamProviders))
}
// Note: multi-upstream is accepted at the CRD level. Consumer controllers
// (MCPServer, MCPRemoteProxy) enforce single-upstream restrictions;
// VirtualMCPServer allows multiple upstreams.

seen := make(map[string]bool, len(cfg.UpstreamProviders))
for i, provider := range cfg.UpstreamProviders {
if seen[provider.Name] {
return fmt.Errorf("upstreamProviders[%d]: duplicate name %q", i, provider.Name)
}
seen[provider.Name] = true

if err := r.validateUpstreamProvider(i, &provider); err != nil {
return err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) {
expectErr: false,
},
{
name: "invalid embeddedAuthServer with multiple providers",
name: "embeddedAuthServer with multiple providers - valid at CRD level",
config: &MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test-embedded-multi",
Expand All @@ -152,8 +152,7 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) {
},
},
},
expectErr: true,
errMsg: "currently only one upstream provider is supported (found 2)",
expectErr: false,
},
{
name: "invalid embeddedAuthServer with no providers",
Expand Down Expand Up @@ -312,7 +311,7 @@ func TestMCPExternalAuthConfig_validateEmbeddedAuthServer(t *testing.T) {
expectErr: false,
},
{
name: "multiple providers - invalid",
name: "multiple providers - valid at CRD level",
config: &MCPExternalAuthConfig{
Spec: MCPExternalAuthConfigSpec{
Type: ExternalAuthTypeEmbeddedAuthServer,
Expand Down Expand Up @@ -343,8 +342,7 @@ func TestMCPExternalAuthConfig_validateEmbeddedAuthServer(t *testing.T) {
},
},
},
expectErr: true,
errMsg: "currently only one upstream provider is supported (found 3)",
expectErr: false,
},
{
name: "empty providers array - invalid",
Expand Down
4 changes: 4 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ const (
// ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError indicates an error occurred fetching the MCPExternalAuthConfig
ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError = "ExternalAuthConfigFetchError"

// ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream indicates multi-upstream is not supported
// for MCPRemoteProxy (use VirtualMCPServer for multi-upstream).
ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream = "MultiUpstreamNotSupported"

// ConditionReasonConfigurationValid indicates all configuration validations passed
ConditionReasonConfigurationValid = "ConfigurationValid"

Expand Down
11 changes: 11 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ const (
ConditionReasonCABundleRefInvalid = "CABundleRefInvalid"
)

const (
// ConditionTypeExternalAuthConfigValidated indicates whether the ExternalAuthConfig is valid
ConditionTypeExternalAuthConfigValidated = "ExternalAuthConfigValidated"
)

const (
// ConditionReasonExternalAuthConfigMultiUpstream indicates the ExternalAuthConfig has multiple upstreams,
// which is not supported for MCPServer (use VirtualMCPServer for multi-upstream).
ConditionReasonExternalAuthConfigMultiUpstream = "MultiUpstreamNotSupported"
)

// MCPServerSpec defines the desired state of MCPServer
type MCPServerSpec struct {
// Image is the container image for the MCP server
Expand Down
17 changes: 17 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,23 @@ func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context,
return fmt.Errorf("failed to fetch MCPExternalAuthConfig: %w", err)
}

// MCPRemoteProxy supports only single-upstream embedded auth server configs.
// Multi-upstream requires VirtualMCPServer.
if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 {
meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated,
Status: metav1.ConditionFalse,
Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream,
Message: fmt.Sprintf(
"MCPRemoteProxy supports only one upstream provider (found %d); "+
"use VirtualMCPServer for multi-upstream",
len(embeddedCfg.UpstreamProviders)),
ObservedGeneration: proxy.Generation,
})
return fmt.Errorf("MCPRemoteProxy %s/%s: embedded auth server has %d upstream providers, but only 1 is supported",
proxy.Namespace, proxy.Name, len(embeddedCfg.UpstreamProviders))
}

// ExternalAuthConfig found and valid
meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated,
Expand Down
37 changes: 37 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,43 @@ func TestHandleExternalAuthConfig(t *testing.T) {
expectedCondStatus: metav1.ConditionFalse,
expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError,
},
{
name: "embedded auth server with multiple upstreams rejected",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "multi-upstream-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://mcp.example.com",
ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{
Name: "multi-upstream-config",
},
},
},
externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "multi-upstream-config",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{
Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer,
EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{
Issuer: "https://auth.example.com",
UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{
{Name: "github", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id1"}},
{Name: "google", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://accounts.google.com", ClientID: "id2"}},
},
},
},
Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"},
},
expectError: true,
errContains: "only 1 is supported",
expectCondition: true,
expectedCondStatus: metav1.ConditionFalse,
expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream,
},
}

for _, tt := range tests {
Expand Down
19 changes: 19 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,25 @@ func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *m
return fmt.Errorf("MCPExternalAuthConfig %s not found", m.Spec.ExternalAuthConfigRef.Name)
}

// MCPServer supports only single-upstream embedded auth server configs.
// Multi-upstream requires VirtualMCPServer.
if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 {
meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeExternalAuthConfigValidated,
Status: metav1.ConditionFalse,
Reason: mcpv1alpha1.ConditionReasonExternalAuthConfigMultiUpstream,
Message: fmt.Sprintf(
"MCPServer supports only one upstream provider (found %d); "+
"use VirtualMCPServer for multi-upstream",
len(embeddedCfg.UpstreamProviders)),
ObservedGeneration: m.Generation,
})
return fmt.Errorf(
"MCPServer %s/%s: embedded auth server has %d upstream providers, "+
"but only 1 is supported; use VirtualMCPServer",
m.Namespace, m.Name, len(embeddedCfg.UpstreamProviders))
}

// Check if the MCPExternalAuthConfig hash has changed
if m.Status.ExternalAuthConfigHash != externalAuthConfig.Status.ConfigHash {
ctxLogger.Info("MCPExternalAuthConfig has changed, updating MCPServer",
Expand Down
34 changes: 34 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_externalauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,40 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) {
expectHash: "",
expectHashCleared: true,
},
{
name: "embedded auth server with multiple upstreams rejected",
mcpServer: &mcpv1alpha1.MCPServer{
ObjectMeta: metav1.ObjectMeta{
Name: "test-server",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPServerSpec{
Image: "test-image",
ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{
Name: "multi-upstream-config",
},
},
Status: mcpv1alpha1.MCPServerStatus{},
},
externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "multi-upstream-config",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{
Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer,
EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{
Issuer: "https://auth.example.com",
UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{
{Name: "github", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id1"}},
{Name: "google", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://accounts.google.com", ClientID: "id2"}},
},
},
},
Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"},
},
expectError: true,
},
}

for _, tt := range tests {
Expand Down
77 changes: 53 additions & 24 deletions cmd/thv-operator/pkg/controllerutil/authserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package controllerutil
import (
"context"
"fmt"
"strings"

corev1 "k8s.io/api/core/v1"
k8sptr "k8s.io/utils/ptr"
Expand Down Expand Up @@ -51,14 +52,43 @@ const (
// AuthServerHMACFilePattern is the pattern for HMAC secret filenames
AuthServerHMACFilePattern = "hmac-%d"

// UpstreamClientSecretEnvVar is the environment variable name for the upstream client secret
// UpstreamClientSecretEnvVar is the prefix for upstream client secret environment variables.
// Actual names are TOOLHIVE_UPSTREAM_CLIENT_SECRET_<PROVIDER> where PROVIDER is the
// upstream name uppercased with hyphens replaced by underscores.
// #nosec G101 -- This is an environment variable name, not a hardcoded credential
UpstreamClientSecretEnvVar = "TOOLHIVE_UPSTREAM_CLIENT_SECRET"

// DefaultSentinelPort is the default Redis Sentinel port
DefaultSentinelPort = 26379
)

// upstreamSecretBinding binds an upstream provider to its env var name for the
// client secret. Both GenerateAuthServerEnvVars (Pod env) and
// buildUpstreamRunConfig (runtime config) MUST use these bindings so the
// env var names stay consistent.
type upstreamSecretBinding struct {
Provider *mcpv1alpha1.UpstreamProviderConfig
EnvVarName string
}

// buildUpstreamSecretBindings computes the canonical env var name for each
// upstream provider's client secret. The env var name is derived from the
// provider's Name field (uppercased, hyphens replaced with underscores) to
// keep bindings stable across provider reordering in the CRD.
func buildUpstreamSecretBindings(
providers []mcpv1alpha1.UpstreamProviderConfig,
) []upstreamSecretBinding {
bindings := make([]upstreamSecretBinding, len(providers))
for i := range providers {
suffix := strings.ToUpper(strings.ReplaceAll(providers[i].Name, "-", "_"))
bindings[i] = upstreamSecretBinding{
Provider: &providers[i],
EnvVarName: fmt.Sprintf("%s_%s", UpstreamClientSecretEnvVar, suffix),
}
}
return bindings
}

// GenerateAuthServerConfig generates volumes, volume mounts, and environment variables
// for the embedded auth server if the external auth config is of type embeddedAuthServer.
//
Expand Down Expand Up @@ -228,13 +258,11 @@ func GenerateAuthServerVolumes(
}

// GenerateAuthServerEnvVars creates environment variables for embedded auth server.
// Currently generates TOOLHIVE_UPSTREAM_CLIENT_SECRET from the upstream provider's
// client secret reference.
// Generates TOOLHIVE_UPSTREAM_CLIENT_SECRET_<PROVIDER> env vars for each upstream
// provider that has a client secret reference configured, where PROVIDER is the
// provider name uppercased with hyphens replaced by underscores.
//
// The function looks at the first upstream provider (currently only one is supported)
// and generates an environment variable for its client secret if one is configured.
//
// Returns nil slice if authConfig is nil or if no client secret is configured.
// Returns nil slice if authConfig is nil or if no client secrets are configured.
func GenerateAuthServerEnvVars(
authConfig *mcpv1alpha1.EmbeddedAuthServerConfig,
) []corev1.EnvVar {
Expand All @@ -244,27 +272,25 @@ func GenerateAuthServerEnvVars(

var envVars []corev1.EnvVar

// Generate env var for upstream client secret if provided
if len(authConfig.UpstreamProviders) > 0 {
provider := authConfig.UpstreamProviders[0]

// Generate env vars for upstream client secrets using shared bindings
for _, b := range buildUpstreamSecretBindings(authConfig.UpstreamProviders) {
// Extract client secret reference based on provider type
var clientSecretRef *mcpv1alpha1.SecretKeyRef

switch provider.Type {
switch b.Provider.Type {
case mcpv1alpha1.UpstreamProviderTypeOIDC:
if provider.OIDCConfig != nil {
clientSecretRef = provider.OIDCConfig.ClientSecretRef
if b.Provider.OIDCConfig != nil {
clientSecretRef = b.Provider.OIDCConfig.ClientSecretRef
}
case mcpv1alpha1.UpstreamProviderTypeOAuth2:
if provider.OAuth2Config != nil {
clientSecretRef = provider.OAuth2Config.ClientSecretRef
if b.Provider.OAuth2Config != nil {
clientSecretRef = b.Provider.OAuth2Config.ClientSecretRef
}
}

if clientSecretRef != nil {
envVars = append(envVars, corev1.EnvVar{
Name: UpstreamClientSecretEnvVar,
Name: b.EnvVarName,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Expand Down Expand Up @@ -431,10 +457,11 @@ func buildEmbeddedAuthServerRunnerConfig(
}
}

// Build upstream provider config (currently only one supported)
if len(authConfig.UpstreamProviders) > 0 {
provider := authConfig.UpstreamProviders[0]
config.Upstreams = []authserver.UpstreamRunConfig{*buildUpstreamRunConfig(&provider)}
// Build upstream provider configs using shared bindings
bindings := buildUpstreamSecretBindings(authConfig.UpstreamProviders)
config.Upstreams = make([]authserver.UpstreamRunConfig, 0, len(bindings))
for _, b := range bindings {
config.Upstreams = append(config.Upstreams, *buildUpstreamRunConfig(b.Provider, b.EnvVarName))
}

// Build storage configuration
Expand Down Expand Up @@ -561,9 +588,11 @@ func resolveSentinelAddrs(
}

// buildUpstreamRunConfig converts CRD UpstreamProviderConfig to authserver.UpstreamRunConfig.
// Client secrets are passed via environment variable reference (UpstreamClientSecretEnvVar).
// The envVarName is computed by buildUpstreamSecretBindings to keep Pod env
// and runtime config in sync.
func buildUpstreamRunConfig(
provider *mcpv1alpha1.UpstreamProviderConfig,
envVarName string,
) *authserver.UpstreamRunConfig {
config := &authserver.UpstreamRunConfig{
Name: provider.Name,
Expand All @@ -581,7 +610,7 @@ func buildUpstreamRunConfig(
}
// If client secret is configured, reference it via env var
if provider.OIDCConfig.ClientSecretRef != nil {
config.OIDCConfig.ClientSecretEnvVar = UpstreamClientSecretEnvVar
config.OIDCConfig.ClientSecretEnvVar = envVarName
}
if provider.OIDCConfig.UserInfoOverride != nil {
config.OIDCConfig.UserInfoOverride = buildUserInfoRunConfig(provider.OIDCConfig.UserInfoOverride)
Expand All @@ -598,7 +627,7 @@ func buildUpstreamRunConfig(
}
// If client secret is configured, reference it via env var
if provider.OAuth2Config.ClientSecretRef != nil {
config.OAuth2Config.ClientSecretEnvVar = UpstreamClientSecretEnvVar
config.OAuth2Config.ClientSecretEnvVar = envVarName
}
if provider.OAuth2Config.UserInfo != nil {
config.OAuth2Config.UserInfo = buildUserInfoRunConfig(provider.OAuth2Config.UserInfo)
Expand Down
Loading
Loading