Skip to content

Commit 9bec199

Browse files
committed
Refactor Converter to require OIDC resolver for all OIDC types
Previously, the Converter only handled inline OIDC configuration directly and had a TODO for kubernetes and configMap types. This change: - Makes oidcResolver a required parameter for NewConverter (returns error if nil) - Uses the resolver for all OIDC types (kubernetes, configMap, inline) - Adds mapResolvedOIDCToVmcpConfig() to map resolver output to vmcp config - Handles ClientSecretEnv for all OIDC types that may have client secrets - Updates controller to pass OIDC resolver to converter - Adds comprehensive tests using gomock for OIDC resolution - Adds compile-time interface assertion for VirtualMCPServer This ensures fail-closed behavior: if OIDC is configured but resolution fails, the converter returns an error rather than silently deploying without authentication.
1 parent ea48e84 commit 9bec199

File tree

4 files changed

+273
-54
lines changed

4 files changed

+273
-54
lines changed

cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"sigs.k8s.io/controller-runtime/pkg/log"
1414

1515
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
16+
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc"
1617
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum"
1718
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpconfig"
1819
"github.com/stacklok/toolhive/pkg/vmcp/workloads"
@@ -25,8 +26,14 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap(
2526
) error {
2627
ctxLogger := log.FromContext(ctx)
2728

28-
// Convert CRD to vmcp config using converter
29-
converter := vmcpconfig.NewConverter()
29+
// Create OIDC resolver to handle all OIDC types (kubernetes, configMap, inline)
30+
oidcResolver := oidc.NewResolver(r.Client)
31+
32+
// Convert CRD to vmcp config using converter with OIDC resolver
33+
converter, err := vmcpconfig.NewConverter(oidcResolver)
34+
if err != nil {
35+
return fmt.Errorf("failed to create vmcp converter: %w", err)
36+
}
3037
config, err := converter.Convert(ctx, vmcp)
3138
if err != nil {
3239
return fmt.Errorf("failed to create vmcp Config from VirtualMCPServer: %w", err)

cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020

2121
"github.com/stretchr/testify/assert"
2222
"github.com/stretchr/testify/require"
23+
"go.uber.org/mock/gomock"
2324
"gopkg.in/yaml.v3"
2425
corev1 "k8s.io/api/core/v1"
2526
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -28,10 +29,29 @@ import (
2829
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2930

3031
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
32+
oidcmocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc/mocks"
3133
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpconfig"
3234
"github.com/stacklok/toolhive/pkg/vmcp"
3335
)
3436

37+
// newNoOpMockResolver creates a mock resolver that returns (nil, nil) for all calls.
38+
// Use this in tests that don't care about OIDC configuration.
39+
func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver {
40+
t.Helper()
41+
ctrl := gomock.NewController(t)
42+
mockResolver := oidcmocks.NewMockResolver(ctrl)
43+
mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
44+
return mockResolver
45+
}
46+
47+
// newTestConverter creates a Converter with the given resolver, failing the test if creation fails.
48+
func newTestConverter(t *testing.T, resolver *oidcmocks.MockResolver) *vmcpconfig.Converter {
49+
t.Helper()
50+
converter, err := vmcpconfig.NewConverter(resolver)
51+
require.NoError(t, err)
52+
return converter
53+
}
54+
3555
// TestCreateVmcpConfigFromVirtualMCPServer tests vmcp config generation
3656
func TestCreateVmcpConfigFromVirtualMCPServer(t *testing.T) {
3757
t.Parallel()
@@ -65,7 +85,7 @@ func TestCreateVmcpConfigFromVirtualMCPServer(t *testing.T) {
6585
t.Run(tt.name, func(t *testing.T) {
6686
t.Parallel()
6787

68-
converter := vmcpconfig.NewConverter()
88+
converter := newTestConverter(t, newNoOpMockResolver(t))
6989
config, err := converter.Convert(context.Background(), tt.vmcp)
7090

7191
require.NoError(t, err)
@@ -138,7 +158,7 @@ func TestConvertOutgoingAuth(t *testing.T) {
138158
},
139159
}
140160

141-
converter := vmcpconfig.NewConverter()
161+
converter := newTestConverter(t, newNoOpMockResolver(t))
142162
config, err := converter.Convert(context.Background(), vmcpServer)
143163
require.NoError(t, err)
144164

@@ -198,7 +218,7 @@ func TestConvertBackendAuthConfig(t *testing.T) {
198218
},
199219
}
200220

201-
converter := vmcpconfig.NewConverter()
221+
converter := newTestConverter(t, newNoOpMockResolver(t))
202222
config, err := converter.Convert(context.Background(), vmcpServer)
203223
require.NoError(t, err)
204224

@@ -292,7 +312,7 @@ func TestConvertAggregation(t *testing.T) {
292312
},
293313
}
294314

295-
converter := vmcpconfig.NewConverter()
315+
converter := newTestConverter(t, newNoOpMockResolver(t))
296316
config, err := converter.Convert(context.Background(), vmcpServer)
297317
require.NoError(t, err)
298318

@@ -385,7 +405,7 @@ func TestConvertCompositeTools(t *testing.T) {
385405
},
386406
}
387407

388-
converter := vmcpconfig.NewConverter()
408+
converter := newTestConverter(t, newNoOpMockResolver(t))
389409
config, err := converter.Convert(context.Background(), vmcpServer)
390410
require.NoError(t, err)
391411

@@ -564,7 +584,7 @@ func TestYAMLMarshalingDeterminism(t *testing.T) {
564584
},
565585
}
566586

567-
converter := vmcpconfig.NewConverter()
587+
converter := newTestConverter(t, newNoOpMockResolver(t))
568588

569589
// Marshal the config 10 times to ensure deterministic output
570590
const iterations = 10

cmd/thv-operator/pkg/vmcpconfig/converter.go

Lines changed: 93 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ package vmcpconfig
44
import (
55
"context"
66
"encoding/json"
7+
"fmt"
78
"time"
89

910
"sigs.k8s.io/controller-runtime/pkg/log"
1011

1112
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
13+
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc"
1214
authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
1315
vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
1416
)
@@ -18,19 +20,31 @@ const (
1820
authzLabelValueInline = "inline"
1921
// conflictResolutionPrefix is the string value for prefix conflict resolution strategy
2022
conflictResolutionPrefix = "prefix"
23+
// vmcpOIDCClientSecretEnvVar is the environment variable name for the OIDC client secret.
24+
// The deployment controller mounts secrets as environment variables with this name.
25+
//nolint:gosec // This is an environment variable name, not a credential
26+
vmcpOIDCClientSecretEnvVar = "VMCP_OIDC_CLIENT_SECRET"
2127
)
2228

2329
// Converter converts VirtualMCPServer CRD specs to vmcp Config
24-
type Converter struct{}
30+
type Converter struct {
31+
oidcResolver oidc.Resolver
32+
}
2533

26-
// NewConverter creates a new Converter instance
27-
func NewConverter() *Converter {
28-
return &Converter{}
34+
// NewConverter creates a new Converter instance.
35+
// oidcResolver is required and used to resolve OIDC configuration from various sources
36+
// (kubernetes, configMap, inline). Use a mock resolver in tests.
37+
// Returns an error if oidcResolver is nil.
38+
func NewConverter(oidcResolver oidc.Resolver) (*Converter, error) {
39+
if oidcResolver == nil {
40+
return nil, fmt.Errorf("oidcResolver is required")
41+
}
42+
return &Converter{
43+
oidcResolver: oidcResolver,
44+
}, nil
2945
}
3046

3147
// Convert converts VirtualMCPServer CRD spec to vmcp Config
32-
//
33-
//nolint:unparam // error return reserved for future reference resolution
3448
func (c *Converter) Convert(
3549
ctx context.Context,
3650
vmcp *mcpv1alpha1.VirtualMCPServer,
@@ -42,7 +56,11 @@ func (c *Converter) Convert(
4256

4357
// Convert IncomingAuth
4458
if vmcp.Spec.IncomingAuth != nil {
45-
config.IncomingAuth = c.convertIncomingAuth(ctx, vmcp)
59+
incomingAuth, err := c.convertIncomingAuth(ctx, vmcp)
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to convert incoming auth: %w", err)
62+
}
63+
config.IncomingAuth = incomingAuth
4664
}
4765

4866
// Convert OutgoingAuth - always set with defaults if not specified
@@ -84,45 +102,34 @@ func (c *Converter) Convert(
84102
return config, nil
85103
}
86104

87-
// convertIncomingAuth converts IncomingAuthConfig from CRD to vmcp config
88-
func (*Converter) convertIncomingAuth(
89-
_ context.Context,
105+
// convertIncomingAuth converts IncomingAuthConfig from CRD to vmcp config.
106+
func (c *Converter) convertIncomingAuth(
107+
ctx context.Context,
90108
vmcp *mcpv1alpha1.VirtualMCPServer,
91-
) *vmcpconfig.IncomingAuthConfig {
109+
) (*vmcpconfig.IncomingAuthConfig, error) {
110+
ctxLogger := log.FromContext(ctx)
111+
92112
incoming := &vmcpconfig.IncomingAuthConfig{
93113
Type: vmcp.Spec.IncomingAuth.Type,
94114
}
95115

96116
// Convert OIDC configuration if present
97117
if vmcp.Spec.IncomingAuth.OIDCConfig != nil {
98-
// Handle inline OIDC configuration
99-
if vmcp.Spec.IncomingAuth.OIDCConfig.Type == authzLabelValueInline && vmcp.Spec.IncomingAuth.OIDCConfig.Inline != nil {
100-
inline := vmcp.Spec.IncomingAuth.OIDCConfig.Inline
101-
oidcConfig := &vmcpconfig.OIDCConfig{
102-
Issuer: inline.Issuer,
103-
ClientID: inline.ClientID, // Note: API uses clientId (camelCase) but config uses ClientID
104-
Audience: inline.Audience,
105-
Resource: vmcp.Spec.IncomingAuth.OIDCConfig.ResourceURL,
106-
Scopes: nil, // TODO: Add scopes if needed
107-
ProtectedResourceAllowPrivateIP: inline.ProtectedResourceAllowPrivateIP,
108-
InsecureAllowHTTP: inline.InsecureAllowHTTP,
109-
}
110-
111-
// Handle client secret - always use environment variable reference for security
112-
// Both ClientSecretRef (reference to existing secret) and ClientSecret (literal value)
113-
// are mounted as environment variables by the deployment controller
114-
if inline.ClientSecretRef != nil || inline.ClientSecret != "" {
115-
// Generate environment variable name that will be mounted in the deployment
116-
// The deployment controller will mount the secret (either from ClientSecretRef or
117-
// from a generated secret for ClientSecret literal values)
118-
oidcConfig.ClientSecretEnv = "VMCP_OIDC_CLIENT_SECRET"
119-
}
120-
121-
incoming.OIDC = oidcConfig
122-
} else {
123-
// TODO: Handle configMap and kubernetes types
124-
// For now, create empty config to avoid nil pointer
125-
incoming.OIDC = &vmcpconfig.OIDCConfig{}
118+
// Use the OIDC resolver to handle all OIDC types (kubernetes, configMap, inline)
119+
// VirtualMCPServer implements OIDCConfigurable, so the resolver can work with it directly
120+
resolvedConfig, err := c.oidcResolver.Resolve(ctx, vmcp)
121+
if err != nil {
122+
ctxLogger.Error(err, "failed to resolve OIDC config",
123+
"vmcp", vmcp.Name,
124+
"namespace", vmcp.Namespace,
125+
"oidcType", vmcp.Spec.IncomingAuth.OIDCConfig.Type)
126+
// Fail closed: return error when OIDC is configured but resolution fails
127+
// This prevents deploying without authentication when OIDC is explicitly requested
128+
return nil, fmt.Errorf("OIDC resolution failed for type %q: %w",
129+
vmcp.Spec.IncomingAuth.OIDCConfig.Type, err)
130+
}
131+
if resolvedConfig != nil {
132+
incoming.OIDC = mapResolvedOIDCToVmcpConfig(resolvedConfig, vmcp.Spec.IncomingAuth.OIDCConfig)
126133
}
127134
}
128135

@@ -146,7 +153,53 @@ func (*Converter) convertIncomingAuth(
146153
// TODO: Load policies from ConfigMap if Type is "configMap"
147154
}
148155

149-
return incoming
156+
return incoming, nil
157+
}
158+
159+
// mapResolvedOIDCToVmcpConfig maps from oidc.OIDCConfig (resolved by the OIDC resolver)
160+
// to vmcpconfig.OIDCConfig (used by the vmcp runtime).
161+
// This keeps the vmcp config types separate from the operator's OIDC resolver types,
162+
// maintaining clean architectural boundaries while enabling unified OIDC resolution.
163+
func mapResolvedOIDCToVmcpConfig(
164+
resolved *oidc.OIDCConfig,
165+
oidcConfigRef *mcpv1alpha1.OIDCConfigRef,
166+
) *vmcpconfig.OIDCConfig {
167+
if resolved == nil {
168+
return nil
169+
}
170+
171+
config := &vmcpconfig.OIDCConfig{
172+
Issuer: resolved.Issuer,
173+
ClientID: resolved.ClientID,
174+
Audience: resolved.Audience,
175+
Resource: resolved.ResourceURL,
176+
ProtectedResourceAllowPrivateIP: resolved.JWKSAllowPrivateIP,
177+
InsecureAllowHTTP: resolved.InsecureAllowHTTP,
178+
// Scopes are not currently in oidc.OIDCConfig - should be added later
179+
}
180+
181+
// Handle client secret - the deployment controller mounts secrets as environment variables
182+
// We need to set ClientSecretEnv for all OIDC config types that may have a client secret
183+
if oidcConfigRef != nil {
184+
switch oidcConfigRef.Type {
185+
case mcpv1alpha1.OIDCConfigTypeInline:
186+
// Inline config: check if ClientSecretRef or ClientSecret is set
187+
if oidcConfigRef.Inline != nil {
188+
if oidcConfigRef.Inline.ClientSecretRef != nil || oidcConfigRef.Inline.ClientSecret != "" {
189+
config.ClientSecretEnv = vmcpOIDCClientSecretEnvVar
190+
}
191+
}
192+
case mcpv1alpha1.OIDCConfigTypeConfigMap:
193+
// ConfigMap config: check if the resolved config has a client secret
194+
// Note: Storing secrets in ConfigMaps is not recommended; use inline with SecretRef instead
195+
if resolved.ClientSecret != "" {
196+
config.ClientSecretEnv = vmcpOIDCClientSecretEnvVar
197+
}
198+
// OIDCConfigTypeKubernetes does not use client secrets (uses service account tokens)
199+
}
200+
}
201+
202+
return config
150203
}
151204

152205
// convertOutgoingAuth converts OutgoingAuthConfig from CRD to vmcp config

0 commit comments

Comments
 (0)