Skip to content
Open
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
10 changes: 10 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,16 @@ type EmbeddedAuthServerConfig struct {
// +optional
Storage *AuthServerStorageConfig `json:"storage,omitempty"`

// DisableUpstreamTokenInjection prevents the embedded auth server from injecting
// upstream IdP tokens into requests forwarded to the backend MCP server.
// When true, the embedded auth server still handles OAuth flows for clients
// but does not swap ToolHive JWTs for upstream tokens on outgoing requests.
// This is useful when the backend MCP server does not require authentication
// (e.g., public documentation servers) but you still want client authentication.
// +kubebuilder:default=false
// +optional
DisableUpstreamTokenInjection bool `json:"disableUpstreamTokenInjection,omitempty"`

// AllowedAudiences is the list of valid resource URIs that tokens can be issued for.
// For an embedded auth server, this can be determined by the servers (MCP or vMCP) it serves.

Expand Down
16 changes: 14 additions & 2 deletions cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,20 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) {
expectError: true,
errContains: "remote URL must not be empty",
},
// Note: "missing OIDC config" test removed - OIDCConfig is now a required value type
// with kubebuilder:validation:Required, so the API server prevents resources without it
{
name: "valid spec without OIDC config (anonymous access)",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "anon-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://docs.example.com/mcp",
ProxyPort: 8080,
},
},
expectError: false,
},
{
name: "with valid external auth config",
proxy: &mcpv1alpha1.MCPRemoteProxy{
Expand Down
69 changes: 36 additions & 33 deletions cmd/thv-operator/controllers/mcpremoteproxy_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,39 +179,8 @@ func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy(
env = append(env, otelEnvVars...)
}

// Add token exchange environment variables
// Note: Embedded auth server env vars are added separately in deploymentForMCPRemoteProxy
// to avoid duplicate API calls.
if proxy.Spec.ExternalAuthConfigRef != nil {
tokenExchangeEnvVars, err := ctrlutil.GenerateTokenExchangeEnvVars(
ctx,
r.Client,
proxy.Namespace,
proxy.Spec.ExternalAuthConfigRef,
ctrlutil.GetExternalAuthConfigByName,
)
if err != nil {
ctxLogger := log.FromContext(ctx)
ctxLogger.Error(err, "Failed to generate token exchange environment variables")
} 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 token exchange and bearer token environment variables
env = append(env, r.buildExternalAuthEnvVars(ctx, proxy)...)

// Add OIDC client secret environment variable if using inline config with secretRef
env = append(env, r.buildOIDCClientSecretEnvVars(ctx, proxy)...)
Expand Down Expand Up @@ -265,6 +234,40 @@ func (r *MCPRemoteProxyReconciler) buildOIDCClientSecretEnvVars(
return []corev1.EnvVar{*oidcClientSecretEnvVar}
}

// buildExternalAuthEnvVars builds environment variables for external auth (token exchange and bearer token).
// Note: Embedded auth server env vars are added separately in deploymentForMCPRemoteProxy
// to avoid duplicate API calls.
func (r *MCPRemoteProxyReconciler) buildExternalAuthEnvVars(
ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy,
) []corev1.EnvVar {
if proxy.Spec.ExternalAuthConfigRef == nil {
return nil
}

ctxLogger := log.FromContext(ctx)
var env []corev1.EnvVar

tokenExchangeEnvVars, err := ctrlutil.GenerateTokenExchangeEnvVars(
ctx, r.Client, proxy.Namespace, proxy.Spec.ExternalAuthConfigRef, ctrlutil.GetExternalAuthConfigByName,
)
if err != nil {
ctxLogger.Error(err, "Failed to generate token exchange environment variables")
} else {
env = append(env, tokenExchangeEnvVars...)
}

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

return env
}

// buildHeaderForwardSecretEnvVars builds environment variables for header forward secrets.
// Each secret is mounted as an env var using Kubernetes SecretKeyRef, with a name following
// the TOOLHIVE_SECRET_<identifier> pattern expected by the secrets.EnvironmentProvider.
Expand Down
44 changes: 44 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,50 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) {
secret *corev1.Secret
validateResult func(*testing.T, *mcpv1alpha1.MCPRemoteProxy, client.Client)
}{
{
name: "anonymous proxy without OIDC config",
proxy: &mcpv1alpha1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "anon-proxy",
Namespace: "default",
},
Spec: mcpv1alpha1.MCPRemoteProxySpec{
RemoteURL: "https://docs.example.com/mcp",
ProxyPort: 8080,
Transport: "streamable-http",
},
},
validateResult: func(t *testing.T, proxy *mcpv1alpha1.MCPRemoteProxy, c client.Client) {
t.Helper()

// Verify RunConfig ConfigMap created with no OIDC config
cm := &corev1.ConfigMap{}
err := c.Get(context.TODO(), types.NamespacedName{
Name: fmt.Sprintf("%s-runconfig", proxy.Name),
Namespace: proxy.Namespace,
}, cm)
assert.NoError(t, err, "RunConfig ConfigMap should be created")
assert.Contains(t, cm.Data, "runconfig.json")
// OIDC config should not be present in the RunConfig
assert.NotContains(t, cm.Data["runconfig.json"], "oidc_issuer")

// Verify Deployment created
dep := &appsv1.Deployment{}
err = c.Get(context.TODO(), types.NamespacedName{
Name: proxy.Name,
Namespace: proxy.Namespace,
}, dep)
assert.NoError(t, err, "Deployment should be created")

// Verify Service created
svc := &corev1.Service{}
err = c.Get(context.TODO(), types.NamespacedName{
Name: createProxyServiceName(proxy.Name),
Namespace: proxy.Namespace,
}, svc)
assert.NoError(t, err, "Service should be created")
},
},
{
name: "basic proxy with inline OIDC",
proxy: &mcpv1alpha1.MCPRemoteProxy{
Expand Down
14 changes: 10 additions & 4 deletions cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,15 @@ func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy(
return nil, fmt.Errorf("failed to process AuthzConfig: %w", err)
}

// Add OIDC configuration (required for proxy mode)
// Supports both legacy inline OIDCConfig and new MCPOIDCConfigRef paths
// Add OIDC configuration
// Supports both legacy inline OIDCConfig and new MCPOIDCConfigRef paths.
// Returns nil when neither is set (anonymous access).
resolvedOIDCConfig, err := r.resolveAndAddOIDCConfig(apiCtx, proxy, &options)
if err != nil {
return nil, err
}

// Add external auth configuration if specified (updated call)
// Will fail if embedded auth server is used without OIDC config or resourceUrl
// Add external auth configuration if specified
if err := ctrlutil.AddExternalAuthConfigOptions(
apiCtx, r.Client, proxy.Namespace, proxy.Name, proxy.Spec.ExternalAuthConfigRef,
resolvedOIDCConfig, &options,
Expand Down Expand Up @@ -179,11 +179,17 @@ func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy(

// resolveAndAddOIDCConfig resolves OIDC configuration from either the shared MCPOIDCConfigRef
// or the legacy inline OIDCConfig, adds the appropriate runner options, and returns the resolved config.
// Returns nil when neither OIDCConfig nor OIDCConfigRef is set (anonymous access).
func (r *MCPRemoteProxyReconciler) resolveAndAddOIDCConfig(
ctx context.Context,
proxy *mcpv1alpha1.MCPRemoteProxy,
options *[]runner.RunConfigBuilderOption,
) (*oidc.OIDCConfig, error) {
// No OIDC config = anonymous access (no auth middleware)
if proxy.Spec.OIDCConfig == nil && proxy.Spec.OIDCConfigRef == nil {
return nil, nil
}

resolver := oidc.NewResolver(r.Client)

if proxy.Spec.OIDCConfigRef != nil {
Expand Down
3 changes: 3 additions & 0 deletions cmd/thv-operator/pkg/controllerutil/authserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,9 @@ func BuildAuthServerRunConfig(
}
config.Storage = storageCfg

// Wire through upstream token injection flag
config.DisableUpstreamTokenInjection = authConfig.DisableUpstreamTokenInjection

return config, nil
}

Expand Down
39 changes: 39 additions & 0 deletions cmd/thv-operator/pkg/controllerutil/authserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,45 @@ func TestBuildAuthServerRunConfig(t *testing.T) {
assert.Equal(t, UpstreamClientSecretEnvVar+"_GITHUB", github.OAuth2Config.ClientSecretEnvVar)
},
},
{
name: "DisableUpstreamTokenInjection is wired through",
authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{
Issuer: "https://auth.example.com",
SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{
{Name: "signing-key", Key: "private.pem"},
},
HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{
{Name: "hmac-secret", Key: "hmac"},
},
DisableUpstreamTokenInjection: true,
},
allowedAudiences: defaultAudiences,
scopesSupported: defaultScopes,
checkFunc: func(t *testing.T, config *authserver.RunConfig) {
t.Helper()
assert.True(t, config.DisableUpstreamTokenInjection,
"DisableUpstreamTokenInjection should be wired from CRD to RunConfig")
},
},
{
name: "DisableUpstreamTokenInjection defaults to false",
authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{
Issuer: "https://auth.example.com",
SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{
{Name: "signing-key", Key: "private.pem"},
},
HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{
{Name: "hmac-secret", Key: "hmac"},
},
},
allowedAudiences: defaultAudiences,
scopesSupported: defaultScopes,
checkFunc: func(t *testing.T, config *authserver.RunConfig) {
t.Helper()
assert.False(t, config.DisableUpstreamTokenInjection,
"DisableUpstreamTokenInjection should default to false")
},
},
}

for _, tt := range tests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash.
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
disableUpstreamTokenInjection:
default: false
description: |-
DisableUpstreamTokenInjection prevents the embedded auth server from injecting
upstream IdP tokens into requests forwarded to the backend MCP server.
When true, the embedded auth server still handles OAuth flows for clients
but does not swap ToolHive JWTs for upstream tokens on outgoing requests.
This is useful when the backend MCP server does not require authentication
(e.g., public documentation servers) but you still want client authentication.
type: boolean
hmacSecretRefs:
description: |-
HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash.
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
disableUpstreamTokenInjection:
default: false
description: |-
DisableUpstreamTokenInjection prevents the embedded auth server from injecting
upstream IdP tokens into requests forwarded to the backend MCP server.
When true, the embedded auth server still handles OAuth flows for clients
but does not swap ToolHive JWTs for upstream tokens on outgoing requests.
This is useful when the backend MCP server does not require authentication
(e.g., public documentation servers) but you still want client authentication.
type: boolean
hmacSecretRefs:
description: |-
HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash.
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
disableUpstreamTokenInjection:
default: false
description: |-
DisableUpstreamTokenInjection prevents the embedded auth server from injecting
upstream IdP tokens into requests forwarded to the backend MCP server.
When true, the embedded auth server still handles OAuth flows for clients
but does not swap ToolHive JWTs for upstream tokens on outgoing requests.
This is useful when the backend MCP server does not require authentication
(e.g., public documentation servers) but you still want client authentication.
type: boolean
hmacSecretRefs:
description: |-
HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash.
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
disableUpstreamTokenInjection:
default: false
description: |-
DisableUpstreamTokenInjection prevents the embedded auth server from injecting
upstream IdP tokens into requests forwarded to the backend MCP server.
When true, the embedded auth server still handles OAuth flows for clients
but does not swap ToolHive JWTs for upstream tokens on outgoing requests.
This is useful when the backend MCP server does not require authentication
(e.g., public documentation servers) but you still want client authentication.
type: boolean
hmacSecretRefs:
description: |-
HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
Expand Down
1 change: 1 addition & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading