diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
index 5716fe83b1..fe2ac6bcec 100644
--- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
+++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
@@ -230,6 +230,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.
diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go
index 177875da16..eee68565a9 100644
--- a/cmd/thv-operator/pkg/controllerutil/authserver.go
+++ b/cmd/thv-operator/pkg/controllerutil/authserver.go
@@ -581,6 +581,9 @@ func BuildAuthServerRunConfig(
}
config.Storage = storageCfg
+ // Wire through upstream token injection flag
+ config.DisableUpstreamTokenInjection = authConfig.DisableUpstreamTokenInjection
+
return config, nil
}
diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go
index c8cda64f38..bcd8f72eda 100644
--- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go
+++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go
@@ -1423,6 +1423,45 @@ func TestBuildAuthServerRunConfig(t *testing.T) {
"DCRConfig should remain nil when only ClientID is set")
},
},
+ {
+ name: "DisableUpstreamTokenInjection is wired through",
+ authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://auth.example.com",
+ SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{
+ {Name: "signing-key", Key: "private.pem"},
+ },
+ HMACSecretRefs: []mcpv1beta1.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: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://auth.example.com",
+ SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{
+ {Name: "signing-key", Key: "private.pem"},
+ },
+ HMACSecretRefs: []mcpv1beta1.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 {
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index 7988038f77..491f26eef4 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -214,6 +214,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
@@ -1385,6 +1395,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
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
index b00683e8d3..50ce0a95e3 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -87,6 +87,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
@@ -2723,6 +2733,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
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index fb4b731da4..6a973c0ea1 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -217,6 +217,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
@@ -1388,6 +1398,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
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
index bc484f596b..3a25ccec97 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -90,6 +90,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
@@ -2726,6 +2736,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
diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md
index 44e36106de..1ecd2b7d54 100644
--- a/docs/operator/crd-api.md
+++ b/docs/operator/crd-api.md
@@ -1135,6 +1135,7 @@ _Appears in:_
| `tokenLifespans` _[api.v1beta1.TokenLifespanConfig](#apiv1beta1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.
If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\}
|
| `upstreamProviders` _[api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) array_ | UpstreamProviders configures connections to upstream Identity Providers.
The embedded auth server delegates authentication to these providers.
MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. | | MinItems: 1
Required: \{\}
|
| `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.
If not specified, defaults to in-memory storage. | | Optional: \{\}
|
+| `disableUpstreamTokenInjection` _boolean_ | 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. | false | Optional: \{\}
|
#### api.v1beta1.EmbeddingResourceOverrides
diff --git a/docs/server/docs.go b/docs/server/docs.go
index 1cd3a245da..3f328b38a3 100644
--- a/docs/server/docs.go
+++ b/docs/server/docs.go
@@ -558,6 +558,10 @@ const docTemplate = `{
"description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n` + "`" + `{authorization_endpoint_base_url}/oauth/authorize` + "`" + ` instead of ` + "`" + `{issuer}/oauth/authorize` + "`" + `.\nAll other endpoints remain derived from the issuer.",
"type": "string"
},
+ "disable_upstream_token_injection": {
+ "description": "DisableUpstreamTokenInjection prevents the upstream swap middleware from being added.\nWhen true, the embedded auth server handles OAuth flows for clients but does not\ninject upstream IdP tokens into requests forwarded to the backend MCP server.",
+ "type": "boolean"
+ },
"hmac_secret_files": {
"description": "HMACSecretFiles contains file paths to HMAC secrets for signing authorization codes\nand refresh tokens (opaque tokens).\nFirst file is the current secret (must be at least 32 bytes), subsequent files\nare for rotation/verification of existing tokens.\nIf empty, an ephemeral secret will be auto-generated (development only).",
"items": {
diff --git a/docs/server/swagger.json b/docs/server/swagger.json
index 2fa20abe2d..8e37238e98 100644
--- a/docs/server/swagger.json
+++ b/docs/server/swagger.json
@@ -551,6 +551,10 @@
"description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n`{authorization_endpoint_base_url}/oauth/authorize` instead of `{issuer}/oauth/authorize`.\nAll other endpoints remain derived from the issuer.",
"type": "string"
},
+ "disable_upstream_token_injection": {
+ "description": "DisableUpstreamTokenInjection prevents the upstream swap middleware from being added.\nWhen true, the embedded auth server handles OAuth flows for clients but does not\ninject upstream IdP tokens into requests forwarded to the backend MCP server.",
+ "type": "boolean"
+ },
"hmac_secret_files": {
"description": "HMACSecretFiles contains file paths to HMAC secrets for signing authorization codes\nand refresh tokens (opaque tokens).\nFirst file is the current secret (must be at least 32 bytes), subsequent files\nare for rotation/verification of existing tokens.\nIf empty, an ephemeral secret will be auto-generated (development only).",
"items": {
diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml
index 46fa4b74a4..087db49b12 100644
--- a/docs/server/swagger.yaml
+++ b/docs/server/swagger.yaml
@@ -604,6 +604,12 @@ components:
`{authorization_endpoint_base_url}/oauth/authorize` instead of `{issuer}/oauth/authorize`.
All other endpoints remain derived from the issuer.
type: string
+ disable_upstream_token_injection:
+ description: |-
+ DisableUpstreamTokenInjection prevents the upstream swap middleware from being added.
+ When true, the embedded auth server handles OAuth flows for clients but does not
+ inject upstream IdP tokens into requests forwarded to the backend MCP server.
+ type: boolean
hmac_secret_files:
description: |-
HMACSecretFiles contains file paths to HMAC secrets for signing authorization codes
diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go
index e82f4bdab8..852deacdeb 100644
--- a/pkg/authserver/config.go
+++ b/pkg/authserver/config.go
@@ -79,6 +79,12 @@ type RunConfig struct {
// Storage configures the storage backend for the auth server.
// If nil, defaults to in-memory storage.
Storage *storage.RunConfig `json:"storage,omitempty" yaml:"storage,omitempty"`
+
+ // DisableUpstreamTokenInjection prevents the upstream swap middleware from being added.
+ // When true, the embedded auth server handles OAuth flows for clients but does not
+ // inject upstream IdP tokens into requests forwarded to the backend MCP server.
+ //nolint:lll // field tags require full JSON+YAML names
+ DisableUpstreamTokenInjection bool `json:"disable_upstream_token_injection,omitempty" yaml:"disable_upstream_token_injection,omitempty"`
}
// SigningKeyRunConfig configures where to load signing keys from.
diff --git a/pkg/runner/middleware.go b/pkg/runner/middleware.go
index be9dd33506..81c4a438c9 100644
--- a/pkg/runner/middleware.go
+++ b/pkg/runner/middleware.go
@@ -5,6 +5,7 @@ package runner
import (
"fmt"
+ "net/http"
"github.com/stacklok/toolhive/pkg/audit"
"github.com/stacklok/toolhive/pkg/auth"
@@ -45,6 +46,7 @@ func GetSupportedMiddlewareFactories() map[string]types.MiddlewareFactory {
headerfwd.HeaderForwardMiddlewareName: headerfwd.CreateMiddleware,
validating.MiddlewareType: validating.CreateMiddleware,
mutating.MiddlewareType: mutating.CreateMiddleware,
+ stripAuthMiddlewareType: createStripAuthMiddleware,
}
}
@@ -330,8 +332,9 @@ func addUsageMetricsMiddleware(middlewares []types.MiddlewareConfig, configDisab
// addUpstreamSwapMiddleware adds upstream swap middleware if the embedded auth server is configured.
// This middleware exchanges ToolHive JWTs for upstream IdP tokens.
-// The middleware is only added when EmbeddedAuthServerConfig is set; if UpstreamSwapConfig
-// is nil, default configuration values are used.
+// The middleware is only added when EmbeddedAuthServerConfig is set and
+// DisableUpstreamTokenInjection is false. If UpstreamSwapConfig is nil,
+// default configuration values are used.
func addUpstreamSwapMiddleware(
middlewares []types.MiddlewareConfig,
config *RunConfig,
@@ -341,6 +344,12 @@ func addUpstreamSwapMiddleware(
return middlewares, nil
}
+ // When upstream token injection is disabled, strip the Authorization header
+ // so the client's ToolHive JWT doesn't leak to the upstream server.
+ if config.EmbeddedAuthServerConfig.DisableUpstreamTokenInjection {
+ return addAuthHeaderStripMiddleware(middlewares)
+ }
+
// Use provided config or defaults
upstreamSwapConfig := config.UpstreamSwapConfig
if upstreamSwapConfig == nil {
@@ -396,6 +405,45 @@ func injectUpstreamProviderIfNeeded(
return cedar.InjectUpstreamProvider(authzCfg, providerName)
}
+// stripAuthMiddlewareType is the type identifier for the auth header stripping middleware.
+const stripAuthMiddlewareType = "strip-auth"
+
+// addAuthHeaderStripMiddleware adds a middleware that removes the Authorization header
+// before forwarding to the upstream. This prevents the client's ToolHive JWT from
+// leaking to upstream servers that don't expect it.
+func addAuthHeaderStripMiddleware(
+ middlewares []types.MiddlewareConfig,
+) ([]types.MiddlewareConfig, error) {
+ mwConfig, err := types.NewMiddlewareConfig(stripAuthMiddlewareType, struct{}{})
+ if err != nil {
+ return nil, fmt.Errorf("failed to create strip-auth middleware config: %w", err)
+ }
+ return append(middlewares, *mwConfig), nil
+}
+
+// createStripAuthMiddleware is the factory function for the auth header stripping middleware.
+func createStripAuthMiddleware(_ *types.MiddlewareConfig, runner types.MiddlewareRunner) error {
+ mw := &stripAuthMiddleware{}
+ runner.AddMiddleware(stripAuthMiddlewareType, mw)
+ return nil
+}
+
+// stripAuthMiddleware removes the Authorization header from requests.
+type stripAuthMiddleware struct{}
+
+// Handler returns the middleware function.
+func (*stripAuthMiddleware) Handler() types.MiddlewareFunction {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.Header.Del("Authorization")
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// Close cleans up resources.
+func (*stripAuthMiddleware) Close() error { return nil }
+
// addAWSStsMiddleware adds AWS STS middleware if configured.
// Returns an error if AWSStsConfig is set but RemoteURL is empty, because
// SigV4 signing is only meaningful for remote MCP servers.
diff --git a/pkg/runner/middleware_test.go b/pkg/runner/middleware_test.go
index ee890dc52a..f2aced92ea 100644
--- a/pkg/runner/middleware_test.go
+++ b/pkg/runner/middleware_test.go
@@ -271,6 +271,7 @@ func TestAddUpstreamSwapMiddleware(t *testing.T) {
name string
config *RunConfig
wantAppended bool
+ wantType string // expected middleware type when appended
}{
{
name: "nil EmbeddedAuthServerConfig returns input unchanged",
@@ -284,6 +285,17 @@ func TestAddUpstreamSwapMiddleware(t *testing.T) {
UpstreamSwapConfig: nil,
},
wantAppended: true,
+ wantType: upstreamswap.MiddlewareType,
+ },
+ {
+ name: "DisableUpstreamTokenInjection adds strip-auth middleware instead",
+ config: func() *RunConfig {
+ cfg := createMinimalAuthServerConfig()
+ cfg.DisableUpstreamTokenInjection = true
+ return &RunConfig{EmbeddedAuthServerConfig: cfg}
+ }(),
+ wantAppended: true,
+ wantType: stripAuthMiddlewareType,
},
{
name: "EmbeddedAuthServerConfig set with explicit UpstreamSwapConfig uses provided config",
@@ -294,6 +306,7 @@ func TestAddUpstreamSwapMiddleware(t *testing.T) {
},
},
wantAppended: true,
+ wantType: upstreamswap.MiddlewareType,
},
{
name: "EmbeddedAuthServerConfig with custom header strategy config",
@@ -305,6 +318,7 @@ func TestAddUpstreamSwapMiddleware(t *testing.T) {
},
},
wantAppended: true,
+ wantType: upstreamswap.MiddlewareType,
},
}
@@ -324,20 +338,20 @@ func TestAddUpstreamSwapMiddleware(t *testing.T) {
// Should have one additional entry.
require.Len(t, got, len(initial)+1)
added := got[len(got)-1]
- assert.Equal(t, upstreamswap.MiddlewareType, added.Type)
-
- // Verify serialized params contain the expected config.
- var params upstreamswap.MiddlewareParams
- require.NoError(t, json.Unmarshal(added.Parameters, ¶ms))
+ assert.Equal(t, tt.wantType, added.Type)
- if tt.config.UpstreamSwapConfig != nil {
- // Should use the provided config
- require.NotNil(t, params.Config)
- assert.Equal(t, tt.config.UpstreamSwapConfig.HeaderStrategy, params.Config.HeaderStrategy)
- assert.Equal(t, tt.config.UpstreamSwapConfig.CustomHeaderName, params.Config.CustomHeaderName)
- } else {
- // Should use defaults (empty config is valid)
- require.NotNil(t, params.Config)
+ // For upstreamswap type, verify serialized params
+ if tt.wantType == upstreamswap.MiddlewareType {
+ var params upstreamswap.MiddlewareParams
+ require.NoError(t, json.Unmarshal(added.Parameters, ¶ms))
+
+ if tt.config.UpstreamSwapConfig != nil {
+ require.NotNil(t, params.Config)
+ assert.Equal(t, tt.config.UpstreamSwapConfig.HeaderStrategy, params.Config.HeaderStrategy)
+ assert.Equal(t, tt.config.UpstreamSwapConfig.CustomHeaderName, params.Config.CustomHeaderName)
+ } else {
+ require.NotNil(t, params.Config)
+ }
}
})
}
@@ -350,6 +364,7 @@ func TestPopulateMiddlewareConfigs_UpstreamSwap(t *testing.T) {
name string
config *RunConfig
wantUpstreamSwap bool
+ wantStripAuth bool
wantHeaderStrategy string
}{
{
@@ -362,6 +377,16 @@ func TestPopulateMiddlewareConfigs_UpstreamSwap(t *testing.T) {
config: &RunConfig{EmbeddedAuthServerConfig: nil},
wantUpstreamSwap: false,
},
+ {
+ name: "DisableUpstreamTokenInjection adds strip-auth instead of upstream-swap",
+ config: func() *RunConfig {
+ cfg := createMinimalAuthServerConfig()
+ cfg.DisableUpstreamTokenInjection = true
+ return &RunConfig{EmbeddedAuthServerConfig: cfg}
+ }(),
+ wantUpstreamSwap: false,
+ wantStripAuth: true,
+ },
{
name: "explicit UpstreamSwapConfig is used",
config: &RunConfig{
@@ -382,20 +407,25 @@ func TestPopulateMiddlewareConfigs_UpstreamSwap(t *testing.T) {
err := PopulateMiddlewareConfigs(tt.config)
require.NoError(t, err)
- var found bool
+ var foundSwap bool
+ var foundStrip bool
var foundConfig *types.MiddlewareConfig
for i, mw := range tt.config.MiddlewareConfigs {
if mw.Type == upstreamswap.MiddlewareType {
- found = true
+ foundSwap = true
foundConfig = &tt.config.MiddlewareConfigs[i]
- break
+ }
+ if mw.Type == stripAuthMiddlewareType {
+ foundStrip = true
}
}
- assert.Equal(t, tt.wantUpstreamSwap, found,
+ assert.Equal(t, tt.wantUpstreamSwap, foundSwap,
"upstream-swap middleware presence mismatch")
+ assert.Equal(t, tt.wantStripAuth, foundStrip,
+ "strip-auth middleware presence mismatch")
// Verify config values if we expect the middleware and have specific expectations
- if found && tt.wantHeaderStrategy != "" {
+ if foundSwap && tt.wantHeaderStrategy != "" {
var params upstreamswap.MiddlewareParams
require.NoError(t, json.Unmarshal(foundConfig.Parameters, ¶ms))
require.NotNil(t, params.Config)
diff --git a/pkg/transport/proxy/transparent/transparent_proxy.go b/pkg/transport/proxy/transparent/transparent_proxy.go
index f5d9599b9a..c1b07010fe 100644
--- a/pkg/transport/proxy/transparent/transparent_proxy.go
+++ b/pkg/transport/proxy/transparent/transparent_proxy.go
@@ -522,6 +522,14 @@ func (t *tracingTransport) RoundTrip(req *http.Request) (*http.Response, error)
req.Host = req.URL.Host
}
+ slog.Debug("outbound request to upstream",
+ "method", req.Method,
+ "url", req.URL.String(),
+ "host", req.Host,
+ "accept", req.Header.Get("Accept"),
+ "content_type", req.Header.Get("Content-Type"),
+ )
+
reqBody := readRequestBody(req)
// thv proxy does not provide the transport type, so we need to detect it from the request
@@ -611,6 +619,13 @@ func (t *tracingTransport) RoundTrip(req *http.Request) (*http.Response, error)
return nil, err
}
+ slog.Debug("upstream response received",
+ "status", resp.StatusCode,
+ "url", req.URL.String(),
+ "content_type", resp.Header.Get("Content-Type"),
+ "mcp_session_id", resp.Header.Get("Mcp-Session-Id"),
+ )
+
// Check for 401 Unauthorized response (bearer token authentication failure)
if resp.StatusCode == http.StatusUnauthorized {
//nolint:gosec // G706: logging target URI from config
@@ -1006,7 +1021,15 @@ func (p *TransparentProxy) Start(ctx context.Context) error {
FlushInterval: -1,
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(targetURL)
- pr.SetXForwarded()
+
+ // Only set X-Forwarded-* headers for local backends.
+ // For remote upstreams, these headers leak the proxy's hostname
+ // (X-Forwarded-Host) to third-party servers, which can cause
+ // 307 redirect loops when the upstream uses that header to
+ // construct redirect URLs pointing back to the proxy.
+ if !p.isRemote {
+ pr.SetXForwarded()
+ }
// Route to the originating backend pod when session metadata contains backend_url.
// Falls back to static targetURL when the session doesn't exist or has no backend_url.