From db1efd08f75832dbed2d7f64e13b731d757e5d41 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Thu, 25 Jun 2026 12:05:51 +0200 Subject: [PATCH 1/3] feat(adk): forward displaced static token as actor for OBO delegation Add KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC. When set, a forwarded or STS-exchanged Authorization takes precedence over a static Authorization configured on an MCP server, and the displaced static (M2M) token is sent as X-Actor-Token. A downstream gateway can then run an RFC 8693 delegation with subject=end user and actor=agent, instead of the static M2M identity winning on every tool call. With no forwarded token the static Authorization is left in place and no actor token is added, so autonomous runs stay pure M2M. Default behaviour is unchanged: static headers keep the highest precedence. Signed-off-by: QuentinBisson --- go/adk/pkg/agent/agent.go | 3 +- go/adk/pkg/constants/const.go | 6 ++ go/adk/pkg/mcp/registry.go | 149 +++++++++++++++++++++----------- go/adk/pkg/mcp/registry_test.go | 128 +++++++++++++++++++++++++++ go/core/pkg/env/kagent.go | 7 ++ 5 files changed, 243 insertions(+), 50 deletions(-) diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index fa9d633d14..c5b163bbf0 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -51,11 +51,12 @@ func CreateGoogleADKAgentWithSubagentSessionIDs(ctx context.Context, agentConfig } propagateToken := strings.ToLower(os.Getenv("KAGENT_PROPAGATE_TOKEN")) == "true" + overrideStaticWithForwardedToken := strings.ToLower(os.Getenv("KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC")) == "true" var dynamicHeaderProvider mcp.DynamicHeaderProvider if stsPlugin != nil { dynamicHeaderProvider = stsPlugin.HeaderProvider } - toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, propagateToken, dynamicHeaderProvider) + toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, propagateToken, overrideStaticWithForwardedToken, dynamicHeaderProvider) subagentSessionIDs := make(map[string]string) var remoteAgentTools []tool.Tool diff --git a/go/adk/pkg/constants/const.go b/go/adk/pkg/constants/const.go index 2926e96e4f..f4dca27214 100644 --- a/go/adk/pkg/constants/const.go +++ b/go/adk/pkg/constants/const.go @@ -4,4 +4,10 @@ const ( // A2A call context's NewRequestMeta normalizes header names to lowercase. // This is why we use "authorization" instead of "Authorization". AuthorizationHeader = "authorization" + + // ActorTokenHeader carries the agent's own workload token alongside a + // forwarded end-user Authorization, so a downstream gateway can run an + // RFC 8693 delegation (subject=user, actor=agent). It is set on the + // outgoing request, so it uses the canonical header form. + ActorTokenHeader = "X-Actor-Token" ) diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index 97cf20ea41..a4b5f82e40 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" "github.com/a2aproject/a2a-go/a2asrv" @@ -65,17 +66,18 @@ func allowedRequestHeaders(ctx context.Context, allowed []string) map[string]str // mcpServerParams groups connection parameters for an MCP server, // reducing parameter sprawl across createTransport / initializeToolSet. type mcpServerParams struct { - URL string - Headers map[string]string - AllowedHeaders []string // header names to forward from incoming request - PropagateToken bool // when true, Authorization is forwarded independently of AllowedHeaders - HeaderProvider DynamicHeaderProvider // optional per-request headers derived from invocation context (e.g., STS exchanged access tokens) - ServerType string // "http" or "sse" - Timeout *float64 - SseReadTimeout *float64 - TLSInsecureSkipVerify *bool - TLSCACertPath *string - TLSDisableSystemCAs *bool + URL string + Headers map[string]string + AllowedHeaders []string // header names to forward from incoming request + PropagateToken bool // when true, Authorization is forwarded independently of AllowedHeaders + OverrideStaticWithForwardedToken bool // when true, a forwarded/STS Authorization wins over a static Authorization, and the displaced static token is sent as the actor token + HeaderProvider DynamicHeaderProvider // optional per-request headers derived from invocation context (e.g., STS exchanged access tokens) + ServerType string // "http" or "sse" + Timeout *float64 + SseReadTimeout *float64 + TLSInsecureSkipVerify *bool + TLSCACertPath *string + TLSDisableSystemCAs *bool } // CreateToolsets creates toolsets from all configured HTTP and SSE MCP servers, @@ -86,6 +88,12 @@ type mcpServerParams struct { // independently of AllowedHeaders, mirroring the Python ADKTokenPropagationPlugin // behaviour triggered by KAGENT_PROPAGATE_TOKEN. // +// When overrideStaticWithForwardedToken is true, a forwarded or STS-exchanged +// Authorization takes precedence over a static Authorization configured on the +// server (KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC), and the displaced static +// token is forwarded as the actor token. Default false keeps static headers at +// the highest precedence. +// // Optional headerProvider can be used to inject per-request headers // derived from invocation context (e.g., STS exchanged access tokens). func CreateToolsets( @@ -93,6 +101,7 @@ func CreateToolsets( httpTools []adk.HttpMcpServerConfig, sseTools []adk.SseMcpServerConfig, propagateToken bool, + overrideStaticWithForwardedToken bool, headerProvider DynamicHeaderProvider, ) []tool.Toolset { log := logr.FromContextOrDiscard(ctx) @@ -101,17 +110,18 @@ func CreateToolsets( log.Info("Processing HTTP MCP tools", "httpToolsCount", len(httpTools)) for i, httpTool := range httpTools { params := mcpServerParams{ - URL: httpTool.Params.Url, - Headers: httpTool.Params.Headers, - AllowedHeaders: httpTool.AllowedHeaders, - PropagateToken: propagateToken, - HeaderProvider: headerProvider, - ServerType: "http", - Timeout: httpTool.Params.Timeout, - SseReadTimeout: httpTool.Params.SseReadTimeout, - TLSInsecureSkipVerify: httpTool.Params.TLSInsecureSkipVerify, - TLSCACertPath: httpTool.Params.TLSCACertPath, - TLSDisableSystemCAs: httpTool.Params.TLSDisableSystemCAs, + URL: httpTool.Params.Url, + Headers: httpTool.Params.Headers, + AllowedHeaders: httpTool.AllowedHeaders, + PropagateToken: propagateToken, + OverrideStaticWithForwardedToken: overrideStaticWithForwardedToken, + HeaderProvider: headerProvider, + ServerType: "http", + Timeout: httpTool.Params.Timeout, + SseReadTimeout: httpTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: httpTool.Params.TLSInsecureSkipVerify, + TLSCACertPath: httpTool.Params.TLSCACertPath, + TLSDisableSystemCAs: httpTool.Params.TLSDisableSystemCAs, } ts, err := addToolset(ctx, log, params, httpTool.Tools, "HTTP", i+1) if err != nil { @@ -123,17 +133,18 @@ func CreateToolsets( log.Info("Processing SSE MCP tools", "sseToolsCount", len(sseTools)) for i, sseTool := range sseTools { params := mcpServerParams{ - URL: sseTool.Params.Url, - Headers: sseTool.Params.Headers, - AllowedHeaders: sseTool.AllowedHeaders, - PropagateToken: propagateToken, - HeaderProvider: headerProvider, - ServerType: "sse", - Timeout: sseTool.Params.Timeout, - SseReadTimeout: sseTool.Params.SseReadTimeout, - TLSInsecureSkipVerify: sseTool.Params.TLSInsecureSkipVerify, - TLSCACertPath: sseTool.Params.TLSCACertPath, - TLSDisableSystemCAs: sseTool.Params.TLSDisableSystemCAs, + URL: sseTool.Params.Url, + Headers: sseTool.Params.Headers, + AllowedHeaders: sseTool.AllowedHeaders, + PropagateToken: propagateToken, + OverrideStaticWithForwardedToken: overrideStaticWithForwardedToken, + HeaderProvider: headerProvider, + ServerType: "sse", + Timeout: sseTool.Params.Timeout, + SseReadTimeout: sseTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: sseTool.Params.TLSInsecureSkipVerify, + TLSCACertPath: sseTool.Params.TLSCACertPath, + TLSDisableSystemCAs: sseTool.Params.TLSDisableSystemCAs, } ts, err := addToolset(ctx, log, params, sseTool.Tools, "SSE", i+1) if err != nil { @@ -227,11 +238,12 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp var httpTransport http.RoundTripper = baseTransport if len(params.Headers) > 0 || len(params.AllowedHeaders) > 0 || params.PropagateToken || params.HeaderProvider != nil { httpTransport = &headerRoundTripper{ - base: baseTransport, - headers: params.Headers, - allowedHeaders: params.AllowedHeaders, - propagateToken: params.PropagateToken, - headerProvider: params.HeaderProvider, + base: baseTransport, + headers: params.Headers, + allowedHeaders: params.AllowedHeaders, + propagateToken: params.PropagateToken, + overrideStaticWithForwardedToken: params.OverrideStaticWithForwardedToken, + headerProvider: params.HeaderProvider, } } @@ -257,25 +269,50 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp } // headerRoundTripper wraps an http.RoundTripper to add custom headers to all -// requests. It supports four sources of headers, applied in this order so that -// higher-priority sources win on collision: +// requests. By default static headers configured on the MCP server spec have the +// highest precedence; sources are applied in this order so higher-priority ones +// win on collision: // 1. propagateToken: when true, Authorization is read from the incoming A2A // CallContext and forwarded unconditionally (independent of allowedHeaders). // 2. allowedHeaders: explicit per-header forwarding from the A2A CallContext. // 3. headerProvider: runtime headers derived from ADK context, such as STS tokens. // 4. headers: static key/value pairs configured on the MCP server spec (highest -// priority — always wins). +// priority by default). +// +// When overrideStaticWithForwardedToken is set, static headers are applied first +// as defaults and the forwarded/STS sources last, so a propagated or +// STS-exchanged Authorization wins over a static one. The displaced static +// Authorization is then sent as the actor token (X-Actor-Token), letting a +// downstream gateway perform an RFC 8693 delegation with subject=end user and +// actor=agent. This supports gateways that require a static M2M token for tool +// discovery but must act on behalf of the end user on tool calls; when no token +// is forwarded the static Authorization remains and no actor token is added, so +// autonomous runs stay pure M2M. type headerRoundTripper struct { - base http.RoundTripper - headers map[string]string - allowedHeaders []string // header names (case-insensitive) to forward from A2A context - propagateToken bool // when true, Authorization is forwarded independently - headerProvider DynamicHeaderProvider + base http.RoundTripper + headers map[string]string + allowedHeaders []string // header names (case-insensitive) to forward from A2A context + propagateToken bool // when true, Authorization is forwarded independently + overrideStaticWithForwardedToken bool // when true, forwarded/STS Authorization wins over static headers + headerProvider DynamicHeaderProvider } func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) + // In override mode static headers are applied first as defaults so a + // forwarded/STS Authorization can win. Remember the static Authorization so + // the displaced value can be carried as the actor token below. + staticAuthorization := "" + if rt.overrideStaticWithForwardedToken { + for key, value := range rt.headers { + if strings.EqualFold(key, constants.AuthorizationHeader) { + staticAuthorization = value + } + req.Header.Set(key, value) + } + } + // When KAGENT_PROPAGATE_TOKEN is set, forward Authorization from the incoming // A2A request independently of allowedHeaders. if rt.propagateToken { @@ -300,9 +337,23 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro } } - // Apply static headers last — they take precedence over all dynamic sources. - for key, value := range rt.headers { - req.Header.Set(key, value) + if rt.overrideStaticWithForwardedToken { + // A forwarded or STS Authorization has now overwritten the static one when + // present. If it differs from the static M2M token, the call is on behalf + // of an end user: carry the displaced static token as the actor so a + // downstream gateway can delegate (subject=user, actor=agent). With no + // forwarded token the static Authorization is unchanged and no actor token + // is added, keeping autonomous runs pure M2M. + if staticAuthorization != "" && + req.Header.Get(constants.AuthorizationHeader) != staticAuthorization && + req.Header.Get(constants.ActorTokenHeader) == "" { + req.Header.Set(constants.ActorTokenHeader, staticAuthorization) + } + } else { + // Apply static headers last so they retain the default highest precedence. + for key, value := range rt.headers { + req.Header.Set(key, value) + } } return rt.base.RoundTrip(req) diff --git a/go/adk/pkg/mcp/registry_test.go b/go/adk/pkg/mcp/registry_test.go index 2931a94bd3..d54b213bc8 100644 --- a/go/adk/pkg/mcp/registry_test.go +++ b/go/adk/pkg/mcp/registry_test.go @@ -396,3 +396,131 @@ func TestStaticHeaders_OverrideDynamic(t *testing.T) { t.Errorf("Authorization: got %q, want %q", capturedAuth, "Bearer static") } } + +// TestOverrideStatic_PropagatedTokenWinsAsSubject verifies that with +// overrideStaticWithForwardedToken set, a token forwarded via propagateToken +// beats the static Authorization (becoming the OBO subject), the displaced +// static token is carried as the actor (X-Actor-Token), and other static +// headers still apply. +func TestOverrideStatic_PropagatedTokenWinsAsSubject(t *testing.T) { + t.Parallel() + var capturedAuth, capturedActor, capturedStatic string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedActor = r.Header.Get("X-Actor-Token") + capturedStatic = r.Header.Get("X-Static") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx := a2aCtx(map[string][]string{ + "Authorization": {"Bearer user-dex"}, + }) + + rt := &headerRoundTripper{ + base: http.DefaultTransport, + propagateToken: true, + overrideStaticWithForwardedToken: true, + headers: map[string]string{ + "Authorization": "Bearer m2m-sa", + "X-Static": "keep-me", + }, + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + resp.Body.Close() + + if capturedAuth != "Bearer user-dex" { + t.Errorf("Authorization: got %q, want %q", capturedAuth, "Bearer user-dex") + } + if capturedActor != "Bearer m2m-sa" { + t.Errorf("X-Actor-Token: got %q, want %q", capturedActor, "Bearer m2m-sa") + } + if capturedStatic != "keep-me" { + t.Errorf("X-Static: got %q, want %q", capturedStatic, "keep-me") + } +} + +// TestOverrideStatic_DisplacedStaticBecomesActor verifies the displacement also +// holds when the winning Authorization comes from the STS headerProvider rather +// than a propagated A2A token. +func TestOverrideStatic_DisplacedStaticBecomesActor(t *testing.T) { + t.Parallel() + var capturedAuth, capturedActor string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedActor = r.Header.Get("X-Actor-Token") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + rt := &headerRoundTripper{ + base: http.DefaultTransport, + overrideStaticWithForwardedToken: true, + headers: map[string]string{"Authorization": "Bearer m2m-sa"}, + headerProvider: func(context.Context) map[string]string { + return map[string]string{"Authorization": "Bearer sts-exchanged"} + }, + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + resp.Body.Close() + + if capturedAuth != "Bearer sts-exchanged" { + t.Errorf("Authorization: got %q, want %q", capturedAuth, "Bearer sts-exchanged") + } + if capturedActor != "Bearer m2m-sa" { + t.Errorf("X-Actor-Token: got %q, want %q", capturedActor, "Bearer m2m-sa") + } +} + +// TestOverrideStatic_NoForwardedToken_StaticStaysNoActor verifies that with no +// forwarded or STS token the static Authorization is preserved and no actor +// token is added, so autonomous runs stay pure M2M. +func TestOverrideStatic_NoForwardedToken_StaticStaysNoActor(t *testing.T) { + t.Parallel() + var capturedAuth, capturedActor string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedActor = r.Header.Get("X-Actor-Token") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // propagateToken is on but the incoming request carries no Authorization. + ctx := a2aCtx(map[string][]string{ + "X-Trace-Id": {"abc"}, + }) + + rt := &headerRoundTripper{ + base: http.DefaultTransport, + propagateToken: true, + overrideStaticWithForwardedToken: true, + headers: map[string]string{"Authorization": "Bearer m2m-sa"}, + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + resp.Body.Close() + + if capturedAuth != "Bearer m2m-sa" { + t.Errorf("Authorization: got %q, want %q", capturedAuth, "Bearer m2m-sa") + } + if capturedActor != "" { + t.Errorf("X-Actor-Token: got %q, want empty", capturedActor) + } +} diff --git a/go/core/pkg/env/kagent.go b/go/core/pkg/env/kagent.go index 5d158b2060..e11bc7e4f4 100644 --- a/go/core/pkg/env/kagent.go +++ b/go/core/pkg/env/kagent.go @@ -70,6 +70,13 @@ var ( ComponentAgentRuntime, ) + KagentPropagateTokenOverridesStatic = RegisterStringVar( + "KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC", + "", + "When true, a forwarded or STS-exchanged Authorization takes precedence over a static Authorization on an MCP server, and the displaced static token is sent as the X-Actor-Token actor token for downstream RFC 8693 delegation.", + ComponentAgentRuntime, + ) + StsWellKnownURI = RegisterStringVar( "STS_WELL_KNOWN_URI", "", From 6433b979ebd6e35f5dd51248cb8238c6a2c6e416 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Thu, 25 Jun 2026 12:27:28 +0200 Subject: [PATCH 2/3] refactor(adk): model MCP token precedence as a type, route env vars through the registry Addresses review of the static-token-as-actor change. - Replace the OverrideStaticWithForwardedToken bool threaded through CreateToolsets, mcpServerParams and headerRoundTripper with a TokenPrecedence type (StaticTokenWins | ForwardedTokenWins), removing the opaque second positional bool from CreateToolsets. - Scope the forwarded-wins override to the Authorization header only. Other static headers now keep the highest precedence in both modes; previously enabling the flag silently demoted every static header to a default, so a forwarded header could override a static one of the same name. - Collapse the duplicated static-header application in RoundTrip into a single applyStaticHeaders pass. - Register KAGENT_PROPAGATE_TOKEN and KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC as bools and read them through env.*.Get() in agent.go and adapter.go, replacing three divergent raw os.Getenv parses that bypassed the registry. - Add tests: non-Authorization static header still wins under ForwardedTokenWins, forwarded token equal to static adds no actor, pre-existing X-Actor-Token is preserved. Signed-off-by: QuentinBisson --- go/adk/pkg/agent/agent.go | 10 +- go/adk/pkg/mcp/registry.go | 218 ++++++++++++++++++-------------- go/adk/pkg/mcp/registry_test.go | 141 +++++++++++++++++++-- go/adk/pkg/runner/adapter.go | 3 +- go/core/pkg/env/kagent.go | 10 +- 5 files changed, 264 insertions(+), 118 deletions(-) diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index c5b163bbf0..beaa0beb02 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -13,6 +13,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/sts" "github.com/kagent-dev/kagent/go/adk/pkg/tools" "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/core/pkg/env" "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" adkmodel "google.golang.org/adk/model" @@ -50,13 +51,16 @@ func CreateGoogleADKAgentWithSubagentSessionIDs(ctx context.Context, agentConfig return nil, nil, fmt.Errorf("agent config is required") } - propagateToken := strings.ToLower(os.Getenv("KAGENT_PROPAGATE_TOKEN")) == "true" - overrideStaticWithForwardedToken := strings.ToLower(os.Getenv("KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC")) == "true" + propagateToken := env.KagentPropagateToken.Get() + tokenPrecedence := mcp.StaticTokenWins + if env.KagentPropagateTokenOverridesStatic.Get() { + tokenPrecedence = mcp.ForwardedTokenWins + } var dynamicHeaderProvider mcp.DynamicHeaderProvider if stsPlugin != nil { dynamicHeaderProvider = stsPlugin.HeaderProvider } - toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, propagateToken, overrideStaticWithForwardedToken, dynamicHeaderProvider) + toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, propagateToken, tokenPrecedence, dynamicHeaderProvider) subagentSessionIDs := make(map[string]string) var remoteAgentTools []tool.Tool diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index a4b5f82e40..e493a8191a 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -24,6 +24,22 @@ import ( // This is used for dynamic token injection (e.g., STS tokens) per session. type DynamicHeaderProvider func(ctx context.Context) map[string]string +// TokenPrecedence selects how a static Authorization configured on an MCP +// server relates to a forwarded or STS-exchanged Authorization. +type TokenPrecedence int + +const ( + // StaticTokenWins keeps a static Authorization at the highest precedence: it + // overrides any forwarded or STS-exchanged Authorization. This is the default. + StaticTokenWins TokenPrecedence = iota + + // ForwardedTokenWins lets a forwarded or STS-exchanged Authorization win over + // a static Authorization. The displaced static token is sent as the actor + // token (X-Actor-Token) so a downstream gateway can run an RFC 8693 + // delegation with subject=end user and actor=agent. + ForwardedTokenWins +) + const ( // Default timeout matching Python KAGENT_REMOTE_AGENT_TIMEOUT defaultTimeout = 30 * time.Minute @@ -66,18 +82,18 @@ func allowedRequestHeaders(ctx context.Context, allowed []string) map[string]str // mcpServerParams groups connection parameters for an MCP server, // reducing parameter sprawl across createTransport / initializeToolSet. type mcpServerParams struct { - URL string - Headers map[string]string - AllowedHeaders []string // header names to forward from incoming request - PropagateToken bool // when true, Authorization is forwarded independently of AllowedHeaders - OverrideStaticWithForwardedToken bool // when true, a forwarded/STS Authorization wins over a static Authorization, and the displaced static token is sent as the actor token - HeaderProvider DynamicHeaderProvider // optional per-request headers derived from invocation context (e.g., STS exchanged access tokens) - ServerType string // "http" or "sse" - Timeout *float64 - SseReadTimeout *float64 - TLSInsecureSkipVerify *bool - TLSCACertPath *string - TLSDisableSystemCAs *bool + URL string + Headers map[string]string + AllowedHeaders []string // header names to forward from incoming request + PropagateToken bool // when true, Authorization is forwarded independently of AllowedHeaders + TokenPrecedence TokenPrecedence // how a static Authorization relates to a forwarded/STS Authorization + HeaderProvider DynamicHeaderProvider // optional per-request headers derived from invocation context (e.g., STS exchanged access tokens) + ServerType string // "http" or "sse" + Timeout *float64 + SseReadTimeout *float64 + TLSInsecureSkipVerify *bool + TLSCACertPath *string + TLSDisableSystemCAs *bool } // CreateToolsets creates toolsets from all configured HTTP and SSE MCP servers, @@ -88,11 +104,11 @@ type mcpServerParams struct { // independently of AllowedHeaders, mirroring the Python ADKTokenPropagationPlugin // behaviour triggered by KAGENT_PROPAGATE_TOKEN. // -// When overrideStaticWithForwardedToken is true, a forwarded or STS-exchanged -// Authorization takes precedence over a static Authorization configured on the -// server (KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC), and the displaced static -// token is forwarded as the actor token. Default false keeps static headers at -// the highest precedence. +// tokenPrecedence selects how a static Authorization relates to a forwarded or +// STS-exchanged one. ForwardedTokenWins (KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC) +// lets the forwarded Authorization win and carries the displaced static token as +// the actor token; StaticTokenWins keeps the static Authorization at the highest +// precedence. // // Optional headerProvider can be used to inject per-request headers // derived from invocation context (e.g., STS exchanged access tokens). @@ -101,7 +117,7 @@ func CreateToolsets( httpTools []adk.HttpMcpServerConfig, sseTools []adk.SseMcpServerConfig, propagateToken bool, - overrideStaticWithForwardedToken bool, + tokenPrecedence TokenPrecedence, headerProvider DynamicHeaderProvider, ) []tool.Toolset { log := logr.FromContextOrDiscard(ctx) @@ -110,18 +126,18 @@ func CreateToolsets( log.Info("Processing HTTP MCP tools", "httpToolsCount", len(httpTools)) for i, httpTool := range httpTools { params := mcpServerParams{ - URL: httpTool.Params.Url, - Headers: httpTool.Params.Headers, - AllowedHeaders: httpTool.AllowedHeaders, - PropagateToken: propagateToken, - OverrideStaticWithForwardedToken: overrideStaticWithForwardedToken, - HeaderProvider: headerProvider, - ServerType: "http", - Timeout: httpTool.Params.Timeout, - SseReadTimeout: httpTool.Params.SseReadTimeout, - TLSInsecureSkipVerify: httpTool.Params.TLSInsecureSkipVerify, - TLSCACertPath: httpTool.Params.TLSCACertPath, - TLSDisableSystemCAs: httpTool.Params.TLSDisableSystemCAs, + URL: httpTool.Params.Url, + Headers: httpTool.Params.Headers, + AllowedHeaders: httpTool.AllowedHeaders, + PropagateToken: propagateToken, + TokenPrecedence: tokenPrecedence, + HeaderProvider: headerProvider, + ServerType: "http", + Timeout: httpTool.Params.Timeout, + SseReadTimeout: httpTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: httpTool.Params.TLSInsecureSkipVerify, + TLSCACertPath: httpTool.Params.TLSCACertPath, + TLSDisableSystemCAs: httpTool.Params.TLSDisableSystemCAs, } ts, err := addToolset(ctx, log, params, httpTool.Tools, "HTTP", i+1) if err != nil { @@ -133,18 +149,18 @@ func CreateToolsets( log.Info("Processing SSE MCP tools", "sseToolsCount", len(sseTools)) for i, sseTool := range sseTools { params := mcpServerParams{ - URL: sseTool.Params.Url, - Headers: sseTool.Params.Headers, - AllowedHeaders: sseTool.AllowedHeaders, - PropagateToken: propagateToken, - OverrideStaticWithForwardedToken: overrideStaticWithForwardedToken, - HeaderProvider: headerProvider, - ServerType: "sse", - Timeout: sseTool.Params.Timeout, - SseReadTimeout: sseTool.Params.SseReadTimeout, - TLSInsecureSkipVerify: sseTool.Params.TLSInsecureSkipVerify, - TLSCACertPath: sseTool.Params.TLSCACertPath, - TLSDisableSystemCAs: sseTool.Params.TLSDisableSystemCAs, + URL: sseTool.Params.Url, + Headers: sseTool.Params.Headers, + AllowedHeaders: sseTool.AllowedHeaders, + PropagateToken: propagateToken, + TokenPrecedence: tokenPrecedence, + HeaderProvider: headerProvider, + ServerType: "sse", + Timeout: sseTool.Params.Timeout, + SseReadTimeout: sseTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: sseTool.Params.TLSInsecureSkipVerify, + TLSCACertPath: sseTool.Params.TLSCACertPath, + TLSDisableSystemCAs: sseTool.Params.TLSDisableSystemCAs, } ts, err := addToolset(ctx, log, params, sseTool.Tools, "SSE", i+1) if err != nil { @@ -238,12 +254,12 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp var httpTransport http.RoundTripper = baseTransport if len(params.Headers) > 0 || len(params.AllowedHeaders) > 0 || params.PropagateToken || params.HeaderProvider != nil { httpTransport = &headerRoundTripper{ - base: baseTransport, - headers: params.Headers, - allowedHeaders: params.AllowedHeaders, - propagateToken: params.PropagateToken, - overrideStaticWithForwardedToken: params.OverrideStaticWithForwardedToken, - headerProvider: params.HeaderProvider, + base: baseTransport, + headers: params.Headers, + allowedHeaders: params.AllowedHeaders, + propagateToken: params.PropagateToken, + tokenPrecedence: params.TokenPrecedence, + headerProvider: params.HeaderProvider, } } @@ -269,50 +285,35 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp } // headerRoundTripper wraps an http.RoundTripper to add custom headers to all -// requests. By default static headers configured on the MCP server spec have the -// highest precedence; sources are applied in this order so higher-priority ones -// win on collision: +// requests. Header sources are applied lowest to highest precedence: // 1. propagateToken: when true, Authorization is read from the incoming A2A // CallContext and forwarded unconditionally (independent of allowedHeaders). // 2. allowedHeaders: explicit per-header forwarding from the A2A CallContext. // 3. headerProvider: runtime headers derived from ADK context, such as STS tokens. -// 4. headers: static key/value pairs configured on the MCP server spec (highest -// priority by default). +// 4. headers: static key/value pairs configured on the MCP server spec. // -// When overrideStaticWithForwardedToken is set, static headers are applied first -// as defaults and the forwarded/STS sources last, so a propagated or -// STS-exchanged Authorization wins over a static one. The displaced static -// Authorization is then sent as the actor token (X-Actor-Token), letting a -// downstream gateway perform an RFC 8693 delegation with subject=end user and -// actor=agent. This supports gateways that require a static M2M token for tool -// discovery but must act on behalf of the end user on tool calls; when no token -// is forwarded the static Authorization remains and no actor token is added, so -// autonomous runs stay pure M2M. +// Static headers (4) always have the highest precedence, except for the +// Authorization header when tokenPrecedence is ForwardedTokenWins. In that mode +// a forwarded or STS-exchanged Authorization (1-3) wins over the static one, and +// the displaced static Authorization is sent as the actor token (X-Actor-Token) +// so a downstream gateway can perform an RFC 8693 delegation with subject=end +// user and actor=agent. This supports gateways that require a static M2M token +// for tool discovery but must act on behalf of the end user on tool calls. With +// no forwarded token the static Authorization remains and no actor token is +// added, so autonomous runs stay pure M2M. Only Authorization participates in +// this swap; every other static header keeps the highest precedence in both modes. type headerRoundTripper struct { - base http.RoundTripper - headers map[string]string - allowedHeaders []string // header names (case-insensitive) to forward from A2A context - propagateToken bool // when true, Authorization is forwarded independently - overrideStaticWithForwardedToken bool // when true, forwarded/STS Authorization wins over static headers - headerProvider DynamicHeaderProvider + base http.RoundTripper + headers map[string]string + allowedHeaders []string // header names (case-insensitive) to forward from A2A context + propagateToken bool // when true, Authorization is forwarded independently + tokenPrecedence TokenPrecedence // resolves static vs forwarded Authorization + headerProvider DynamicHeaderProvider } func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) - // In override mode static headers are applied first as defaults so a - // forwarded/STS Authorization can win. Remember the static Authorization so - // the displaced value can be carried as the actor token below. - staticAuthorization := "" - if rt.overrideStaticWithForwardedToken { - for key, value := range rt.headers { - if strings.EqualFold(key, constants.AuthorizationHeader) { - staticAuthorization = value - } - req.Header.Set(key, value) - } - } - // When KAGENT_PROPAGATE_TOKEN is set, forward Authorization from the incoming // A2A request independently of allowedHeaders. if rt.propagateToken { @@ -337,26 +338,47 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro } } - if rt.overrideStaticWithForwardedToken { - // A forwarded or STS Authorization has now overwritten the static one when - // present. If it differs from the static M2M token, the call is on behalf - // of an end user: carry the displaced static token as the actor so a - // downstream gateway can delegate (subject=user, actor=agent). With no - // forwarded token the static Authorization is unchanged and no actor token - // is added, keeping autonomous runs pure M2M. - if staticAuthorization != "" && - req.Header.Get(constants.AuthorizationHeader) != staticAuthorization && - req.Header.Get(constants.ActorTokenHeader) == "" { - req.Header.Set(constants.ActorTokenHeader, staticAuthorization) - } - } else { - // Apply static headers last so they retain the default highest precedence. - for key, value := range rt.headers { - req.Header.Set(key, value) + rt.applyStaticHeaders(req) + + return rt.base.RoundTrip(req) +} + +// applyStaticHeaders writes the static headers configured on the MCP server spec +// onto req. Non-Authorization headers always overwrite forwarded values. The +// Authorization header honours tokenPrecedence: StaticTokenWins overwrites any +// forwarded token, while ForwardedTokenWins keeps a forwarded/STS token and, when +// it differs from the static one, carries the displaced static token as the actor +// (X-Actor-Token) for a downstream RFC 8693 delegation. With no forwarded token +// the static Authorization is applied and no actor is added; a forwarded token +// equal to the static one is treated as M2M (no actor); an actor token already +// forwarded via allowedHeaders is left untouched. +func (rt *headerRoundTripper) applyStaticHeaders(req *http.Request) { + staticAuthorization := "" + for key, value := range rt.headers { + if strings.EqualFold(key, constants.AuthorizationHeader) { + staticAuthorization = value + continue } + req.Header.Set(key, value) } - return rt.base.RoundTrip(req) + if staticAuthorization == "" { + return + } + + if rt.tokenPrecedence == StaticTokenWins { + req.Header.Set(constants.AuthorizationHeader, staticAuthorization) + return + } + + forwardedAuthorization := req.Header.Get(constants.AuthorizationHeader) + if forwardedAuthorization == "" { + req.Header.Set(constants.AuthorizationHeader, staticAuthorization) + return + } + if forwardedAuthorization != staticAuthorization && req.Header.Get(constants.ActorTokenHeader) == "" { + req.Header.Set(constants.ActorTokenHeader, staticAuthorization) + } } // initializeToolSet fetches tools from an MCP server using Google ADK's mcptoolset. diff --git a/go/adk/pkg/mcp/registry_test.go b/go/adk/pkg/mcp/registry_test.go index d54b213bc8..fb87bdbb9b 100644 --- a/go/adk/pkg/mcp/registry_test.go +++ b/go/adk/pkg/mcp/registry_test.go @@ -398,7 +398,7 @@ func TestStaticHeaders_OverrideDynamic(t *testing.T) { } // TestOverrideStatic_PropagatedTokenWinsAsSubject verifies that with -// overrideStaticWithForwardedToken set, a token forwarded via propagateToken +// ForwardedTokenWins, a token forwarded via propagateToken // beats the static Authorization (becoming the OBO subject), the displaced // static token is carried as the actor (X-Actor-Token), and other static // headers still apply. @@ -419,9 +419,9 @@ func TestOverrideStatic_PropagatedTokenWinsAsSubject(t *testing.T) { }) rt := &headerRoundTripper{ - base: http.DefaultTransport, - propagateToken: true, - overrideStaticWithForwardedToken: true, + base: http.DefaultTransport, + propagateToken: true, + tokenPrecedence: ForwardedTokenWins, headers: map[string]string{ "Authorization": "Bearer m2m-sa", "X-Static": "keep-me", @@ -461,9 +461,9 @@ func TestOverrideStatic_DisplacedStaticBecomesActor(t *testing.T) { defer srv.Close() rt := &headerRoundTripper{ - base: http.DefaultTransport, - overrideStaticWithForwardedToken: true, - headers: map[string]string{"Authorization": "Bearer m2m-sa"}, + base: http.DefaultTransport, + tokenPrecedence: ForwardedTokenWins, + headers: map[string]string{"Authorization": "Bearer m2m-sa"}, headerProvider: func(context.Context) map[string]string { return map[string]string{"Authorization": "Bearer sts-exchanged"} }, @@ -504,10 +504,10 @@ func TestOverrideStatic_NoForwardedToken_StaticStaysNoActor(t *testing.T) { }) rt := &headerRoundTripper{ - base: http.DefaultTransport, - propagateToken: true, - overrideStaticWithForwardedToken: true, - headers: map[string]string{"Authorization": "Bearer m2m-sa"}, + base: http.DefaultTransport, + propagateToken: true, + tokenPrecedence: ForwardedTokenWins, + headers: map[string]string{"Authorization": "Bearer m2m-sa"}, } req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) @@ -524,3 +524,122 @@ func TestOverrideStatic_NoForwardedToken_StaticStaysNoActor(t *testing.T) { t.Errorf("X-Actor-Token: got %q, want empty", capturedActor) } } + +// TestOverrideStatic_NonAuthStaticHeaderWins verifies that in ForwardedTokenWins +// mode the override is scoped to Authorization: a non-Authorization static header +// still overrides a forwarded header of the same name. +func TestOverrideStatic_NonAuthStaticHeaderWins(t *testing.T) { + t.Parallel() + var capturedTenant string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedTenant = r.Header.Get("X-Tenant") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // X-Tenant is both forwarded via allowedHeaders and configured statically. + ctx := a2aCtx(map[string][]string{ + "X-Tenant": {"forwarded-tenant"}, + }) + + rt := &headerRoundTripper{ + base: http.DefaultTransport, + allowedHeaders: []string{"X-Tenant"}, + tokenPrecedence: ForwardedTokenWins, + headers: map[string]string{"X-Tenant": "static-tenant"}, + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + resp.Body.Close() + + if capturedTenant != "static-tenant" { + t.Errorf("X-Tenant: got %q, want %q", capturedTenant, "static-tenant") + } +} + +// TestOverrideStatic_ForwardedEqualsStatic_NoActor verifies that when the +// forwarded Authorization equals the static one the call is treated as M2M: no +// actor token is added. +func TestOverrideStatic_ForwardedEqualsStatic_NoActor(t *testing.T) { + t.Parallel() + var capturedAuth, capturedActor string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedActor = r.Header.Get("X-Actor-Token") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx := a2aCtx(map[string][]string{ + "Authorization": {"Bearer m2m-sa"}, + }) + + rt := &headerRoundTripper{ + base: http.DefaultTransport, + propagateToken: true, + tokenPrecedence: ForwardedTokenWins, + headers: map[string]string{"Authorization": "Bearer m2m-sa"}, + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + resp.Body.Close() + + if capturedAuth != "Bearer m2m-sa" { + t.Errorf("Authorization: got %q, want %q", capturedAuth, "Bearer m2m-sa") + } + if capturedActor != "" { + t.Errorf("X-Actor-Token: got %q, want empty", capturedActor) + } +} + +// TestOverrideStatic_PreexistingActorTokenPreserved verifies that an actor token +// already forwarded via allowedHeaders is not overwritten by the displaced +// static token. +func TestOverrideStatic_PreexistingActorTokenPreserved(t *testing.T) { + t.Parallel() + var capturedAuth, capturedActor string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedActor = r.Header.Get("X-Actor-Token") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx := a2aCtx(map[string][]string{ + "Authorization": {"Bearer user-dex"}, + "X-Actor-Token": {"Bearer forwarded-actor"}, + }) + + rt := &headerRoundTripper{ + base: http.DefaultTransport, + propagateToken: true, + allowedHeaders: []string{"X-Actor-Token"}, + tokenPrecedence: ForwardedTokenWins, + headers: map[string]string{"Authorization": "Bearer m2m-sa"}, + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + resp.Body.Close() + + if capturedAuth != "Bearer user-dex" { + t.Errorf("Authorization: got %q, want %q", capturedAuth, "Bearer user-dex") + } + if capturedActor != "Bearer forwarded-actor" { + t.Errorf("X-Actor-Token: got %q, want %q", capturedActor, "Bearer forwarded-actor") + } +} diff --git a/go/adk/pkg/runner/adapter.go b/go/adk/pkg/runner/adapter.go index 0441f778c0..2634e22cc9 100644 --- a/go/adk/pkg/runner/adapter.go +++ b/go/adk/pkg/runner/adapter.go @@ -12,6 +12,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/session" "github.com/kagent-dev/kagent/go/adk/pkg/sts" "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/core/pkg/env" adkmemory "google.golang.org/adk/memory" adkplugin "google.golang.org/adk/plugin" "google.golang.org/adk/runner" @@ -97,7 +98,7 @@ func CreateRunnerConfig( } func buildTokenPropagationPlugin(ctx context.Context, log logr.Logger) (*sts.TokenPropagationPlugin, error) { - propagateToken := strings.EqualFold(strings.TrimSpace(os.Getenv("KAGENT_PROPAGATE_TOKEN")), "true") + propagateToken := env.KagentPropagateToken.Get() stsWellKnownURI := strings.TrimSpace(os.Getenv("STS_WELL_KNOWN_URI")) if !propagateToken && stsWellKnownURI == "" { return nil, nil diff --git a/go/core/pkg/env/kagent.go b/go/core/pkg/env/kagent.go index e11bc7e4f4..56fe5cdfc5 100644 --- a/go/core/pkg/env/kagent.go +++ b/go/core/pkg/env/kagent.go @@ -63,16 +63,16 @@ var ( ComponentAgentRuntime, ) - KagentPropagateToken = RegisterStringVar( + KagentPropagateToken = RegisterBoolVar( "KAGENT_PROPAGATE_TOKEN", - "", - "When set, propagates the authentication token to downstream services.", + false, + "When true, the incoming Authorization token is propagated to downstream MCP servers and A2A agents.", ComponentAgentRuntime, ) - KagentPropagateTokenOverridesStatic = RegisterStringVar( + KagentPropagateTokenOverridesStatic = RegisterBoolVar( "KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC", - "", + false, "When true, a forwarded or STS-exchanged Authorization takes precedence over a static Authorization on an MCP server, and the displaced static token is sent as the X-Actor-Token actor token for downstream RFC 8693 delegation.", ComponentAgentRuntime, ) From cd2a52de1145e3c934ea218f758e78d9196d79f6 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Thu, 25 Jun 2026 12:44:27 +0200 Subject: [PATCH 3/3] refactor(adk): tighten token-precedence model from review - collapse the triplicated displacement narrative to a single home in applyStaticHeaders; struct and CreateToolsets docs point to it - document tokenPrecedence as a runtime-global policy, not per-server - warn when ForwardedTokenWins is active on a TLS-insecure MCP server, since the actor token carries a privileged M2M credential - route STS_WELL_KNOWN_URI through the env registry (env.StsWellKnownURI), matching the other propagate-token vars - note the single-Authorization-key assumption in applyStaticHeaders - cover ForwardedTokenWins with no static Authorization configured Signed-off-by: QuentinBisson --- go/adk/pkg/mcp/registry.go | 26 ++++++++----------- go/adk/pkg/mcp/registry_test.go | 45 +++++++++++++++++++++++++++++++++ go/adk/pkg/runner/adapter.go | 3 +-- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index e493a8191a..270293d3e0 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -104,11 +104,8 @@ type mcpServerParams struct { // independently of AllowedHeaders, mirroring the Python ADKTokenPropagationPlugin // behaviour triggered by KAGENT_PROPAGATE_TOKEN. // -// tokenPrecedence selects how a static Authorization relates to a forwarded or -// STS-exchanged one. ForwardedTokenWins (KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC) -// lets the forwarded Authorization win and carries the displaced static token as -// the actor token; StaticTokenWins keeps the static Authorization at the highest -// precedence. +// tokenPrecedence is a runtime-global policy (KAGENT_PROPAGATE_TOKEN_OVERRIDES_STATIC) +// applied uniformly to every server here; see TokenPrecedence and applyStaticHeaders. // // Optional headerProvider can be used to inject per-request headers // derived from invocation context (e.g., STS exchanged access tokens). @@ -251,6 +248,11 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp baseTransport.TLSClientConfig = tlsConfig } + if params.TokenPrecedence == ForwardedTokenWins && + params.TLSInsecureSkipVerify != nil && *params.TLSInsecureSkipVerify { + log.Info("WARNING: ForwardedTokenWins sends the static M2M credential as X-Actor-Token, but TLS verification is disabled for this MCP server - the actor token can leak to an unverified endpoint", "url", params.URL) + } + var httpTransport http.RoundTripper = baseTransport if len(params.Headers) > 0 || len(params.AllowedHeaders) > 0 || params.PropagateToken || params.HeaderProvider != nil { httpTransport = &headerRoundTripper{ @@ -292,16 +294,8 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp // 3. headerProvider: runtime headers derived from ADK context, such as STS tokens. // 4. headers: static key/value pairs configured on the MCP server spec. // -// Static headers (4) always have the highest precedence, except for the -// Authorization header when tokenPrecedence is ForwardedTokenWins. In that mode -// a forwarded or STS-exchanged Authorization (1-3) wins over the static one, and -// the displaced static Authorization is sent as the actor token (X-Actor-Token) -// so a downstream gateway can perform an RFC 8693 delegation with subject=end -// user and actor=agent. This supports gateways that require a static M2M token -// for tool discovery but must act on behalf of the end user on tool calls. With -// no forwarded token the static Authorization remains and no actor token is -// added, so autonomous runs stay pure M2M. Only Authorization participates in -// this swap; every other static header keeps the highest precedence in both modes. +// Static headers (4) have the highest precedence; the one exception is the +// Authorization header under ForwardedTokenWins, resolved in applyStaticHeaders. type headerRoundTripper struct { base http.RoundTripper headers map[string]string @@ -353,6 +347,8 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro // equal to the static one is treated as M2M (no actor); an actor token already // forwarded via allowedHeaders is left untouched. func (rt *headerRoundTripper) applyStaticHeaders(req *http.Request) { + // headers is assumed to hold at most one Authorization key; with case-variant + // duplicates map iteration order decides which wins. staticAuthorization := "" for key, value := range rt.headers { if strings.EqualFold(key, constants.AuthorizationHeader) { diff --git a/go/adk/pkg/mcp/registry_test.go b/go/adk/pkg/mcp/registry_test.go index fb87bdbb9b..c3ac9cee14 100644 --- a/go/adk/pkg/mcp/registry_test.go +++ b/go/adk/pkg/mcp/registry_test.go @@ -643,3 +643,48 @@ func TestOverrideStatic_PreexistingActorTokenPreserved(t *testing.T) { t.Errorf("X-Actor-Token: got %q, want %q", capturedActor, "Bearer forwarded-actor") } } + +// TestOverrideStatic_NoStaticAuthorization_ForwardedPassesThrough verifies that +// in ForwardedTokenWins mode with no static Authorization configured (only a +// non-Authorization static header), the forwarded Authorization passes through +// untouched and no actor token is synthesized. +func TestOverrideStatic_NoStaticAuthorization_ForwardedPassesThrough(t *testing.T) { + t.Parallel() + var capturedAuth, capturedActor, capturedStatic string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedActor = r.Header.Get("X-Actor-Token") + capturedStatic = r.Header.Get("X-Static") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx := a2aCtx(map[string][]string{ + "Authorization": {"Bearer user-dex"}, + }) + + rt := &headerRoundTripper{ + base: http.DefaultTransport, + propagateToken: true, + tokenPrecedence: ForwardedTokenWins, + headers: map[string]string{"X-Static": "keep-me"}, + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + resp.Body.Close() + + if capturedAuth != "Bearer user-dex" { + t.Errorf("Authorization: got %q, want %q", capturedAuth, "Bearer user-dex") + } + if capturedActor != "" { + t.Errorf("X-Actor-Token: got %q, want empty", capturedActor) + } + if capturedStatic != "keep-me" { + t.Errorf("X-Static: got %q, want %q", capturedStatic, "keep-me") + } +} diff --git a/go/adk/pkg/runner/adapter.go b/go/adk/pkg/runner/adapter.go index 2634e22cc9..c7d3baac25 100644 --- a/go/adk/pkg/runner/adapter.go +++ b/go/adk/pkg/runner/adapter.go @@ -3,7 +3,6 @@ package runner import ( "context" "fmt" - "os" "strings" "github.com/go-logr/logr" @@ -99,7 +98,7 @@ func CreateRunnerConfig( func buildTokenPropagationPlugin(ctx context.Context, log logr.Logger) (*sts.TokenPropagationPlugin, error) { propagateToken := env.KagentPropagateToken.Get() - stsWellKnownURI := strings.TrimSpace(os.Getenv("STS_WELL_KNOWN_URI")) + stsWellKnownURI := strings.TrimSpace(env.StsWellKnownURI.Get()) if !propagateToken && stsWellKnownURI == "" { return nil, nil }