Skip to content

Commit 919322d

Browse files
committed
fixes from review
1 parent d51f7ae commit 919322d

File tree

13 files changed

+262
-51
lines changed

13 files changed

+262
-51
lines changed

cmd/thv-operator/controllers/virtualmcpserver_deployment.go

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"strings"
78
"time"
89

910
appsv1 "k8s.io/api/apps/v1"
@@ -201,10 +202,61 @@ func (*VirtualMCPServerReconciler) buildEnvVarsForVmcp(
201202
// Log level env var will be added here
202203
}
203204

204-
// Note: Secrets (OIDC client secrets, Redis passwords, service account credentials) are NOT added
205-
// as environment variables here. They are validated by validateSecretReferences() during reconciliation
206-
// and configured through the vmcp config file or volume mounts for better security.
207-
// Environment variables are reserved for non-sensitive configuration only.
205+
// Mount OIDC client secret as environment variable
206+
// The vmcp config file will reference this via client_secret_env: "VMCP_OIDC_CLIENT_SECRET"
207+
//
208+
// Two approaches are supported:
209+
// 1. ClientSecretRef: References an existing Kubernetes Secret (recommended)
210+
// 2. ClientSecret: Literal value that will be stored in a generated Secret
211+
//
212+
// Both cases result in the secret being mounted as an environment variable for security.
213+
if vmcp.Spec.IncomingAuth != nil &&
214+
vmcp.Spec.IncomingAuth.OIDCConfig != nil &&
215+
vmcp.Spec.IncomingAuth.OIDCConfig.Inline != nil {
216+
inline := vmcp.Spec.IncomingAuth.OIDCConfig.Inline
217+
218+
// For testing: Skip OIDC discovery for example/test issuers
219+
// This allows tests to run without requiring a real OIDC provider
220+
if inline.Issuer != "" && (strings.Contains(inline.Issuer, "example.com") || strings.Contains(inline.Issuer, "test")) {
221+
env = append(env, corev1.EnvVar{
222+
Name: "VMCP_SKIP_OIDC_DISCOVERY",
223+
Value: "true",
224+
})
225+
}
226+
227+
if inline.ClientSecretRef != nil {
228+
// Approach 1: Mount from existing Secret reference
229+
env = append(env, corev1.EnvVar{
230+
Name: "VMCP_OIDC_CLIENT_SECRET",
231+
ValueFrom: &corev1.EnvVarSource{
232+
SecretKeyRef: &corev1.SecretKeySelector{
233+
LocalObjectReference: corev1.LocalObjectReference{
234+
Name: inline.ClientSecretRef.Name,
235+
},
236+
Key: inline.ClientSecretRef.Key,
237+
},
238+
},
239+
})
240+
} else if inline.ClientSecret != "" {
241+
// Approach 2: Mount from generated Secret containing literal value
242+
// The generated secret is created by ensureOIDCClientSecret()
243+
generatedSecretName := fmt.Sprintf("%s-oidc-client-secret", vmcp.Name)
244+
env = append(env, corev1.EnvVar{
245+
Name: "VMCP_OIDC_CLIENT_SECRET",
246+
ValueFrom: &corev1.EnvVarSource{
247+
SecretKeyRef: &corev1.SecretKeySelector{
248+
LocalObjectReference: corev1.LocalObjectReference{
249+
Name: generatedSecretName,
250+
},
251+
Key: "clientSecret",
252+
},
253+
},
254+
})
255+
}
256+
}
257+
258+
// Note: Other secrets (Redis passwords, service account credentials) may be added here in the future
259+
// following the same pattern of mounting from Kubernetes Secrets as environment variables.
208260

209261
return ctrlutil.EnsureRequiredEnvVars(ctx, env)
210262
}

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,25 @@ func (*Converter) convertIncomingAuth(
8383
// Handle inline OIDC configuration
8484
if vmcp.Spec.IncomingAuth.OIDCConfig.Type == authzLabelValueInline && vmcp.Spec.IncomingAuth.OIDCConfig.Inline != nil {
8585
inline := vmcp.Spec.IncomingAuth.OIDCConfig.Inline
86-
incoming.OIDC = &vmcpconfig.OIDCConfig{
87-
Issuer: inline.Issuer,
88-
ClientID: inline.ClientID, // Note: API uses clientId (camelCase) but config uses ClientID
89-
ClientSecret: inline.ClientSecret,
90-
Audience: inline.Audience,
91-
Resource: vmcp.Spec.IncomingAuth.OIDCConfig.ResourceURL,
92-
Scopes: nil, // TODO: Add scopes if needed
86+
oidcConfig := &vmcpconfig.OIDCConfig{
87+
Issuer: inline.Issuer,
88+
ClientID: inline.ClientID, // Note: API uses clientId (camelCase) but config uses ClientID
89+
Audience: inline.Audience,
90+
Resource: vmcp.Spec.IncomingAuth.OIDCConfig.ResourceURL,
91+
Scopes: nil, // TODO: Add scopes if needed
9392
}
93+
94+
// Handle client secret - always use environment variable reference for security
95+
// Both ClientSecretRef (reference to existing secret) and ClientSecret (literal value)
96+
// are mounted as environment variables by the deployment controller
97+
if inline.ClientSecretRef != nil || inline.ClientSecret != "" {
98+
// Generate environment variable name that will be mounted in the deployment
99+
// The deployment controller will mount the secret (either from ClientSecretRef or
100+
// from a generated secret for ClientSecret literal values)
101+
oidcConfig.ClientSecretEnv = "VMCP_OIDC_CLIENT_SECRET"
102+
}
103+
104+
incoming.OIDC = oidcConfig
94105
} else {
95106
// TODO: Handle configMap and kubernetes types
96107
// For now, create empty config to avoid nil pointer

docs/operator/crd-api.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/auth/token.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,15 @@ func NewTokenValidator(ctx context.Context, config TokenValidatorConfig) (*Token
537537
jwksURL := config.JWKSURL
538538

539539
// If JWKS URL is not provided but issuer is, try to discover it
540-
if jwksURL == "" && config.Issuer != "" {
540+
// Skip discovery if VMCP_SKIP_OIDC_DISCOVERY is set (for testing only)
541+
skipDiscovery := os.Getenv("VMCP_SKIP_OIDC_DISCOVERY") == "true"
542+
if skipDiscovery {
543+
logger.Warnf("VMCP_SKIP_OIDC_DISCOVERY is enabled - OIDC discovery skipped (testing only!)")
544+
// Use a dummy JWKS URL when discovery is skipped
545+
if jwksURL == "" && config.Issuer != "" {
546+
jwksURL = config.Issuer + "/.well-known/jwks.json"
547+
}
548+
} else if jwksURL == "" && config.Issuer != "" {
541549
doc, err := discoverOIDCConfiguration(
542550
ctx, config.Issuer, config.CACertPath, config.AuthTokenFile,
543551
config.AllowPrivateIP, config.InsecureAllowHTTP,

pkg/vmcp/config/config.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,11 @@ type OIDCConfig struct {
119119
// ClientID is the OAuth client ID.
120120
ClientID string `json:"client_id" yaml:"client_id"`
121121

122-
// ClientSecret is the OAuth client secret (or secret reference).
123-
ClientSecret string `json:"client_secret" yaml:"client_secret"`
122+
// ClientSecretEnv is the name of the environment variable containing the client secret.
123+
// This is the secure way to reference secrets - the actual secret value is never stored
124+
// in configuration files, only the environment variable name.
125+
// The secret value will be resolved from this environment variable at runtime.
126+
ClientSecretEnv string `json:"client_secret_env,omitempty" yaml:"client_secret_env,omitempty"`
124127

125128
// Audience is the required token audience.
126129
Audience string `json:"audience" yaml:"audience"`

pkg/vmcp/config/validator.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ func (v *DefaultValidator) validateIncomingAuth(auth *IncomingAuthConfig) error
106106
return fmt.Errorf("incoming_auth.oidc.audience is required")
107107
}
108108

109-
// Client secret should be set (either directly or via env var reference)
110-
if auth.OIDC.ClientSecret == "" {
111-
return fmt.Errorf("incoming_auth.oidc.client_secret is required")
109+
// Client secret env var should be set (references a Kubernetes Secret mounted as env var)
110+
if auth.OIDC.ClientSecretEnv == "" {
111+
return fmt.Errorf("incoming_auth.oidc.client_secret_env is required")
112112
}
113113
}
114114

pkg/vmcp/config/validator_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,11 @@ func TestValidator_ValidateIncomingAuth(t *testing.T) {
118118
auth: &IncomingAuthConfig{
119119
Type: "oidc",
120120
OIDC: &OIDCConfig{
121-
Issuer: "https://example.com",
122-
ClientID: "test-client",
123-
ClientSecret: "test-secret",
124-
Audience: "vmcp",
125-
Scopes: []string{"openid"},
121+
Issuer: "https://example.com",
122+
ClientID: "test-client",
123+
ClientSecretEnv: "OIDC_CLIENT_SECRET",
124+
Audience: "vmcp",
125+
Scopes: []string{"openid"},
126126
},
127127
},
128128
wantErr: false,
@@ -148,9 +148,9 @@ func TestValidator_ValidateIncomingAuth(t *testing.T) {
148148
auth: &IncomingAuthConfig{
149149
Type: "oidc",
150150
OIDC: &OIDCConfig{
151-
ClientID: "test-client",
152-
ClientSecret: "test-secret",
153-
Audience: "vmcp",
151+
ClientID: "test-client",
152+
ClientSecretEnv: "OIDC_CLIENT_SECRET",
153+
Audience: "vmcp",
154154
},
155155
},
156156
wantErr: true,

pkg/vmcp/config/yaml_loader.go

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ type rawIncomingAuth struct {
6060
OIDC *struct {
6161
Issuer string `yaml:"issuer"`
6262
ClientID string `yaml:"client_id"`
63-
ClientSecretEnv string `yaml:"client_secret_env"` // CLI YAML format (env var name)
64-
ClientSecret string `yaml:"client_secret"` // Kubernetes JSON format (literal value)
63+
ClientSecretEnv string `yaml:"client_secret_env"` // Environment variable name containing the client secret
6564
Audience string `yaml:"audience"`
6665
Resource string `yaml:"resource"`
6766
Scopes []string `yaml:"scopes"`
@@ -247,32 +246,20 @@ func (l *YAMLLoader) transformToConfig(raw *rawConfig) (*Config, error) {
247246
return cfg, nil
248247
}
249248

249+
//nolint:unparam // error return reserved for future validation logic
250250
func (*YAMLLoader) transformIncomingAuth(raw *rawIncomingAuth) (*IncomingAuthConfig, error) {
251251
cfg := &IncomingAuthConfig{
252252
Type: raw.Type,
253253
}
254254

255255
if raw.OIDC != nil {
256-
// Support both CLI YAML format (client_secret_env) and Kubernetes JSON format (client_secret)
257-
var clientSecret string
258-
if raw.OIDC.ClientSecret != "" {
259-
// Kubernetes JSON format: literal client secret value
260-
clientSecret = raw.OIDC.ClientSecret
261-
} else if raw.OIDC.ClientSecretEnv != "" {
262-
// CLI YAML format: resolve environment variable for client secret
263-
clientSecret = os.Getenv(raw.OIDC.ClientSecretEnv)
264-
if clientSecret == "" {
265-
return nil, fmt.Errorf("environment variable %s not set for client_secret", raw.OIDC.ClientSecretEnv)
266-
}
267-
}
268-
269256
cfg.OIDC = &OIDCConfig{
270-
Issuer: raw.OIDC.Issuer,
271-
ClientID: raw.OIDC.ClientID,
272-
ClientSecret: clientSecret,
273-
Audience: raw.OIDC.Audience,
274-
Resource: raw.OIDC.Resource,
275-
Scopes: raw.OIDC.Scopes,
257+
Issuer: raw.OIDC.Issuer,
258+
ClientID: raw.OIDC.ClientID,
259+
ClientSecretEnv: raw.OIDC.ClientSecretEnv,
260+
Audience: raw.OIDC.Audience,
261+
Resource: raw.OIDC.Resource,
262+
Scopes: raw.OIDC.Scopes,
276263
}
277264
}
278265

pkg/vmcp/config/yaml_loader_test.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ aggregation:
100100
if cfg.IncomingAuth.OIDC.Issuer != "https://auth.example.com" {
101101
t.Errorf("OIDC.Issuer = %v, want https://auth.example.com", cfg.IncomingAuth.OIDC.Issuer)
102102
}
103-
if cfg.IncomingAuth.OIDC.ClientSecret != "my-secret-value" {
104-
t.Errorf("OIDC.ClientSecret = %v, want my-secret-value", cfg.IncomingAuth.OIDC.ClientSecret)
103+
if cfg.IncomingAuth.OIDC.ClientSecretEnv != "TEST_SECRET" {
104+
t.Errorf("OIDC.ClientSecretEnv = %v, want TEST_SECRET", cfg.IncomingAuth.OIDC.ClientSecretEnv)
105105
}
106106
},
107107
wantErr: false,
@@ -221,7 +221,7 @@ incoming_auth
221221
errMsg: "failed to parse YAML",
222222
},
223223
{
224-
name: "missing environment variable",
224+
name: "OIDC with unset environment variable is allowed (validation happens at runtime)",
225225
yaml: `
226226
name: test-vmcp
227227
group: test-group
@@ -244,8 +244,17 @@ aggregation:
244244
conflict_resolution_config:
245245
prefix_format: "{workload}_"
246246
`,
247-
wantErr: true,
248-
errMsg: "environment variable MISSING_VAR not set",
247+
want: func(t *testing.T, cfg *Config) {
248+
t.Helper()
249+
if cfg.IncomingAuth.OIDC == nil {
250+
t.Fatal("IncomingAuth.OIDC is nil")
251+
}
252+
// Verify the env var name is stored (not resolved)
253+
if cfg.IncomingAuth.OIDC.ClientSecretEnv != "MISSING_VAR" {
254+
t.Errorf("OIDC.ClientSecretEnv = %v, want MISSING_VAR", cfg.IncomingAuth.OIDC.ClientSecretEnv)
255+
}
256+
},
257+
wantErr: false,
249258
},
250259
{
251260
name: "invalid duration format",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: test-vmcp-oidc
5+
namespace: toolhive-system
6+
spec:
7+
template:
8+
spec:
9+
containers:
10+
- name: vmcp
11+
env:
12+
# Verify that the OIDC client secret is mounted as an environment variable from a Kubernetes Secret
13+
- name: VMCP_OIDC_CLIENT_SECRET
14+
valueFrom:
15+
secretKeyRef:
16+
name: test-oidc-client-secret
17+
key: clientSecret

0 commit comments

Comments
 (0)