Skip to content
15 changes: 15 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy(
} else {
env = append(env, tokenExchangeEnvVars...)
}

// Add bearer token environment variables
bearerTokenEnvVars, err := ctrlutil.GenerateBearerTokenEnvVar(
ctx,
r.Client,
proxy.Namespace,
proxy.Spec.ExternalAuthConfigRef,
ctrlutil.GetExternalAuthConfigByName,
)
if err != nil {
ctxLogger := log.FromContext(ctx)
ctxLogger.Error(err, "Failed to generate bearer token environment variables")
} else {
env = append(env, bearerTokenEnvVars...)
}
}

// Add OIDC client secret environment variable if using inline config with secretRef
Expand Down
61 changes: 61 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,67 @@ func TestBuildEnvVarsForProxy(t *testing.T) {
assert.True(t, authFound, "Authorization header secret should be referenced")
},
},
{
name: "with bearer token",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "bearer-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://mcp.example.com",
OIDCConfig: mcpv1alpha1.OIDCConfigRef{
Type: mcpv1alpha1.OIDCConfigTypeInline,
Inline: &mcpv1alpha1.InlineOIDCConfig{
Issuer: "https://auth.example.com",
Audience: "mcp-proxy",
},
},
ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{
Name: "bearer-config",
},
},
},
externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "bearer-config",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{
Type: mcpv1alpha1.ExternalAuthTypeBearerToken,
BearerToken: &mcpv1alpha1.BearerTokenConfig{
TokenSecretRef: &mcpv1alpha1.SecretKeyRef{
Name: "bearer-secret",
Key: "token",
},
},
},
},
clientSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "bearer-secret",
Namespace: "default",
},
Data: map[string][]byte{
"token": []byte("my-bearer-token"),
},
},
validate: func(t *testing.T, envVars []corev1.EnvVar) {
t.Helper()
found := false
for _, env := range envVars {
if env.Name == "TOOLHIVE_SECRET_bearer-secret" {
require.NotNil(t, env.ValueFrom)
require.NotNil(t, env.ValueFrom.SecretKeyRef)
assert.Equal(t, "bearer-secret", env.ValueFrom.SecretKeyRef.Name)
assert.Equal(t, "token", env.ValueFrom.SecretKeyRef.Key)
found = true
break
}
}
assert.True(t, found, "Bearer token secret should be referenced as TOOLHIVE_SECRET_bearer-secret")
},
},
}

for _, tt := range tests {
Expand Down
233 changes: 233 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,239 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithTokenExchange(t *testing.T) {
}
}

// TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken tests RunConfig generation with bearer token
func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) {
t.Parallel()

tests := []struct {
name string
proxy *mcpv1alpha1.MCPRemoteProxy
externalAuth *mcpv1alpha1.MCPExternalAuthConfig
bearerSecret *corev1.Secret
expectError bool
validate func(*testing.T, *runner.RunConfig)
}{
{
name: "with bearer token",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "bearer-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://mcp.example.com/api",
Port: 8080,
OIDCConfig: mcpv1alpha1.OIDCConfigRef{
Type: mcpv1alpha1.OIDCConfigTypeInline,
Inline: &mcpv1alpha1.InlineOIDCConfig{
Issuer: "https://auth.example.com",
Audience: "mcp-proxy",
},
},
ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{
Name: "api-bearer-auth",
},
},
},
externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "api-bearer-auth",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{
Type: mcpv1alpha1.ExternalAuthTypeBearerToken,
BearerToken: &mcpv1alpha1.BearerTokenConfig{
TokenSecretRef: &mcpv1alpha1.SecretKeyRef{
Name: "api-bearer-token",
Key: "token",
},
},
},
},
bearerSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "api-bearer-token",
Namespace: "default",
},
Data: map[string][]byte{
"token": []byte("my-bearer-token-123"),
},
},
expectError: false,
validate: func(t *testing.T, config *runner.RunConfig) {
t.Helper()
assert.Equal(t, "bearer-proxy", config.Name)
assert.Equal(t, "https://mcp.example.com/api", config.RemoteURL)

// Verify RemoteAuthConfig has bearer token in CLI format
require.NotNil(t, config.RemoteAuthConfig)
assert.Equal(t, "api-bearer-token,target=bearer_token", config.RemoteAuthConfig.BearerToken)
},
},
{
name: "missing TokenSecretRef",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "broken-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://mcp.example.com",
Port: 8080,
OIDCConfig: mcpv1alpha1.OIDCConfigRef{
Type: mcpv1alpha1.OIDCConfigTypeInline,
Inline: &mcpv1alpha1.InlineOIDCConfig{
Issuer: "https://auth.example.com",
Audience: "mcp-proxy",
},
},
ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{
Name: "broken-bearer",
},
},
},
externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "broken-bearer",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{
Type: mcpv1alpha1.ExternalAuthTypeBearerToken,
BearerToken: &mcpv1alpha1.BearerTokenConfig{
TokenSecretRef: nil, // Missing TokenSecretRef
},
},
},
expectError: true,
},
{
name: "secret not found",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "missing-secret-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://mcp.example.com",
Port: 8080,
OIDCConfig: mcpv1alpha1.OIDCConfigRef{
Type: mcpv1alpha1.OIDCConfigTypeInline,
Inline: &mcpv1alpha1.InlineOIDCConfig{
Issuer: "https://auth.example.com",
Audience: "mcp-proxy",
},
},
ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{
Name: "missing-secret-bearer",
},
},
},
externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "missing-secret-bearer",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{
Type: mcpv1alpha1.ExternalAuthTypeBearerToken,
BearerToken: &mcpv1alpha1.BearerTokenConfig{
TokenSecretRef: &mcpv1alpha1.SecretKeyRef{
Name: "non-existent-secret",
Key: "token",
},
},
},
},
expectError: true,
},
{
name: "secret missing key",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "missing-key-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://mcp.example.com",
Port: 8080,
OIDCConfig: mcpv1alpha1.OIDCConfigRef{
Type: mcpv1alpha1.OIDCConfigTypeInline,
Inline: &mcpv1alpha1.InlineOIDCConfig{
Issuer: "https://auth.example.com",
Audience: "mcp-proxy",
},
},
ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{
Name: "missing-key-bearer",
},
},
},
externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "missing-key-bearer",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{
Type: mcpv1alpha1.ExternalAuthTypeBearerToken,
BearerToken: &mcpv1alpha1.BearerTokenConfig{
TokenSecretRef: &mcpv1alpha1.SecretKeyRef{
Name: "incomplete-secret",
Key: "token",
},
},
},
},
bearerSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "incomplete-secret",
Namespace: "default",
},
Data: map[string][]byte{
"other-key": []byte("value"),
// Missing "token" key
},
},
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

scheme := createRunConfigTestScheme()
objects := []runtime.Object{tt.proxy}
if tt.externalAuth != nil {
objects = append(objects, tt.externalAuth)
}
if tt.bearerSecret != nil {
objects = append(objects, tt.bearerSecret)
}

fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithRuntimeObjects(objects...).
Build()

reconciler := &MCPRemoteProxyReconciler{
Client: fakeClient,
Scheme: scheme,
}

runConfig, err := reconciler.createRunConfigFromMCPRemoteProxy(tt.proxy)

if tt.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.NotNil(t, runConfig)
if tt.validate != nil {
tt.validate(t, runConfig)
}
}
})
}
}

// TestValidateRunConfigForRemoteProxy tests the validation logic for remote proxy RunConfigs
func TestValidateRunConfigForRemoteProxy(t *testing.T) {
t.Parallel()
Expand Down
22 changes: 22 additions & 0 deletions cmd/thv-operator/pkg/controllerutil/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ package controllerutil
import (
"context"
"fmt"
"strings"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/log"

mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
"github.com/stacklok/toolhive/pkg/secrets"
)

// BuildResourceRequirements builds Kubernetes resource requirements from CRD spec
Expand Down Expand Up @@ -70,6 +72,7 @@ func EnsureRequiredEnvVars(ctx context.Context, env []corev1.EnvVar) []corev1.En
homeFound := false
toolhiveRuntimeFound := false
unstructuredLogsFound := false
hasSecrets := false

for _, envVar := range env {
switch envVar.Name {
Expand All @@ -82,6 +85,10 @@ func EnsureRequiredEnvVars(ctx context.Context, env []corev1.EnvVar) []corev1.En
case "UNSTRUCTURED_LOGS":
unstructuredLogsFound = true
}
// Check if this is a TOOLHIVE_SECRET_* env var (but not TOOLHIVE_SECRETS_PROVIDER itself)
if strings.HasPrefix(envVar.Name, secrets.EnvVarPrefix) && envVar.Name != secrets.ProviderEnvVar {
hasSecrets = true
}
}

if !xdgConfigHomeFound {
Expand Down Expand Up @@ -117,6 +124,21 @@ func EnsureRequiredEnvVars(ctx context.Context, env []corev1.EnvVar) []corev1.En
})
}

// Set secrets provider to environment if secrets are being used via TOOLHIVE_SECRET_* env vars
// This is needed to resolve CLI format secrets (e.g., "secret-name,target=bearer_token")
// The environment provider reads from TOOLHIVE_SECRET_* env vars to resolve CLI format secrets
//
// If TOOLHIVE_SECRETS_PROVIDER is already set to something other than "environment",
// we override it because TOOLHIVE_SECRET_* env vars REQUIRE the environment provider.
// Other providers (encrypted, 1password) cannot read from TOOLHIVE_SECRET_* env vars.
if hasSecrets {
ctxLogger.V(1).Info("TOOLHIVE_SECRET_* env vars found, setting TOOLHIVE_SECRETS_PROVIDER to environment")
env = append(env, corev1.EnvVar{
Name: secrets.ProviderEnvVar,
Value: string(secrets.EnvironmentType),
})
}

return env
}

Expand Down
Loading
Loading