Skip to content

fix: ensure user identity is propagated across A2A requests/sessions#1775

Merged
jmhbh merged 10 commits into
kagent-dev:mainfrom
onematchfox:fix-a2a-user
May 14, 2026
Merged

fix: ensure user identity is propagated across A2A requests/sessions#1775
jmhbh merged 10 commits into
kagent-dev:mainfrom
onematchfox:fix-a2a-user

Conversation

@onematchfox
Copy link
Copy Markdown
Contributor

@onematchfox onematchfox commented Apr 29, 2026

Ensures that caller identity correctly propagates from controller->agent->controller.

Addresses #1293 (comment) and potentially also #1771

Copilot AI review requested due to automatic review settings April 29, 2026 08:16
@github-actions github-actions Bot added the bug Something isn't working label Apr 29, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes identity propagation for proxy-based authentication by ensuring the controller forwards the authenticated user ID to downstream A2A runtimes via the X-User-Id header (in addition to forwarding Authorization when present), aligning behavior with UnsecureAuthenticator.UpstreamAuth.

Changes:

  • Update ProxyAuthenticator.UpstreamAuth to set X-User-Id from the authenticated session principal.
  • Extend TestProxyAuthenticator_UpstreamAuth to assert X-User-Id forwarding.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
go/core/internal/httpserver/auth/proxy_authn.go Forward X-User-Id (and Authorization when available) for upstream A2A requests under proxy auth.
go/core/internal/httpserver/auth/proxy_authn_test.go Add test assertion ensuring X-User-Id is forwarded in UpstreamAuth.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@onematchfox onematchfox marked this pull request as draft April 29, 2026 13:30
@onematchfox
Copy link
Copy Markdown
Contributor Author

Seeing some unexpected behaviour when running this change in a live environment (that I didn't see in testing) - may need some additional changes.so reverting to draft for now until I understand what's going on.

Without this, downstream A2A runtimes fall back to deriving the caller
identity from the context ID (`A2A_USER_<contextID>`) instead of the
real authenticated user, because `UserIDCallInterceptor` reads `X-User-Id`
rather than the forwarded `Bearer` token.

Mirrors the behaviour of [`UnsecureAuthenticator.UpstreamAuth`](https://github.com/kagent-dev/kagent/blob/b50661f858787f6c4de2a73479f492ba82af6ca4/go/core/internal/httpserver/auth/authn.go#L48) which
already sets this header.

Addresses kagent-dev#1293 (comment)

Signed-off-by: Brian Fox <878612+onematchfox@users.noreply.github.com>
@onematchfox onematchfox force-pushed the fix-a2a-user branch 3 times, most recently from 66ee32e to 6750b68 Compare April 30, 2026 08:16
@onematchfox
Copy link
Copy Markdown
Contributor Author

FYI @supreme-gg-gg, I think I might just end up fixing #1771 as part of this

@onematchfox onematchfox changed the title fix(controller): forward X-User-Id in ProxyAuthenticator.UpstreamAuth fix: ensure user identity is propagated across A2A requests/sessions Apr 30, 2026
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Apr 30, 2026
Previously, agent calls authenticated with a k8s SA Bearer token were attributed
to the SA identity (e.g. system:serviceaccount:ns:name) instead of the real caller.
This caused sessions to be scoped to the wrong identity.

Signed-off-by: Brian Fox <878612+onematchfox@users.noreply.github.com>
Go (ADK executor):
- Extend TokenRoundTripper to inject X-User-Id from Go context on every
  outgoing request
- Wire the authenticated user ID into the Go context in A2aAgentExecutor
  after extracting it from the call context

Python (ADK):
- Add a ContextVar in kagent-core to carry the caller's user ID for the
  duration of an async request task
- Set it once in KAgentRequestContextBuilder.build() so it is available
  before any downstream HTTP calls are made
- Read it in KAgentTokenService._add_headers so every outgoing
  httpx request automatically carries X-User-Id
- Remove the redundant per-call X-User-ID headers from KAgentSessionService
  now that the event hook handles injection uniformly

Should fix kagent-dev#1771 as well.

Signed-off-by: Brian Fox <878612+onematchfox@users.noreply.github.com>
Prevents unwanted breakage when rolling this out as the new controller won't reject calls from existing agents. May also be desirable in situations were users call A2A endpoints on agents directly (bypassing oauth2-proxy and the controller).

Signed-off-by: Brian Fox <878612+onematchfox@users.noreply.github.com>
}
tokenString, ok := strings.CutPrefix(authHeader, "Bearer ")
if !ok {
return nil, ErrUnauthenticated
Copy link
Copy Markdown
Contributor Author

@onematchfox onematchfox Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a change in behaviour as it requires that a Bearer token be provided whereas the original code allowed any caller that knew the X-Agent-Name and X-User-Id to bypass use of a Bearer token. By default any agent created via the Agent CRD will supply a Bearer token - so this would only affect direct consumers of the API that are currently relying on this insecurity.

@onematchfox onematchfox marked this pull request as ready for review April 30, 2026 13:01
@onematchfox onematchfox requested a review from EItanya as a code owner April 30, 2026 13:01
Copy link
Copy Markdown
Contributor

@EItanya EItanya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks so much for the change. I think we need to really think through this though. This adds an implicit x-user-id reliance around the system when we are already authenticated, which doesn't carry the same gaurantee as a JWT. Is there a way we could get this information from the JWT instead?

Also, yes I recognize that we already have a weird reliance on x-user-id in some places, so trying to make that better going forward

@onematchfox
Copy link
Copy Markdown
Contributor Author

Is there a way we could get this information from the JWT instead?

The tricky part is here is that agent pods don't use the forwarded user JWT when making their own outbound calls back to the controller. They authenticate with their SA token (via withBearerToken / KAgentTokenService), and the user identity has to ride alongside as X-User-Id.

Also, as you acknowledge, X-User-Id is already deeply embedded across the codebase; not just in the paths this PR touches, but in other agent runtimes (e.g. langgraph) that I'm not comfortable changing here - let alone me touching Agent STS (guessing this is something the enterprise version uses). So, ripping it out would be a much larger, higher-risk change that is well beyond the intent of this PR. The goal here was simply to ensure that the existing X-User-Id mechanism is applied consistently and correctly across A2A calls, closing the identity propagation gap rather than redesigning the mechanism. Can we perhaps tackle that as a separate issue?

@EItanya
Copy link
Copy Markdown
Contributor

EItanya commented May 11, 2026

Is there a way we could get this information from the JWT instead?

The tricky part is here is that agent pods don't use the forwarded user JWT when making their own outbound calls back to the controller. They authenticate with their SA token (via withBearerToken / KAgentTokenService), and the user identity has to ride alongside as X-User-Id.

Also, as you acknowledge, X-User-Id is already deeply embedded across the codebase; not just in the paths this PR touches, but in other agent runtimes (e.g. langgraph) that I'm not comfortable changing here - let alone me touching Agent STS (guessing this is something the enterprise version uses). So, ripping it out would be a much larger, higher-risk change that is well beyond the intent of this PR. The goal here was simply to ensure that the existing X-User-Id mechanism is applied consistently and correctly across A2A calls, closing the identity propagation gap rather than redesigning the mechanism. Can we perhaps tackle that as a separate issue?

Ok I buy that, my one remaining question is why then you are still using the user_id query param instead of just using the x-user-id. Specifically I'm looking at the Python diff

@onematchfox
Copy link
Copy Markdown
Contributor Author

Ok I buy that, my one remaining question is why then you are still using the user_id query param instead of just using the x-user-id. Specifically I'm looking at the Python diff

The changes to those lines were made specifically to avoid duplicating setting the X-User-Id header. The query string parameter remains because I was purely focussed on the X-User-Id header and didn't really want to touch all the other code that might be using the query string parameter. I wasn't sure if this is still needed in the event that oauth2-proxy is not enabled or whether other agent types still require this. 😅

I asked my clanker to check and yes, it looks like user_id should now be redundant although it did call out one edge case

for comparison of flow between auth types (oauth2-proxy vs unsecure)

The query param is redundant in the normal A2A flow — but ContextVar doesn't propagate across async task boundaries, so if a session call is made outside the A2A request handling context, get_request_user_id() returns None, the token service doesn't inject the header, and the query param is the only thing carrying identity.
...
So yes, user_id query param is now redundant in both modes. The header chain is complete in both oauth2-proxy and non-oauth2-proxy paths.
...
The only remaining caveat is the async context boundary case (a session call made from a task spawned before set_request_user_id runs), but that's not a realistic path for session
service calls made during A2A request handling.
...
Should we drop it? Yes, it's safe to drop — but as a separate cleanup PR, since it requires verifying no call site bypasses the A2A request handler context.

and for other agent types

The other agent types don't use KAgentTokenService at all — they're a completely different picture.

  • kagent-openai (_session_service.py): explicitly sets headers={"X-User-ID": self.user_id, "X-Agent-Name": self.app_name} AND user_id query params on every call. No token service, no context var. Both are hardcoded per request.
  • kagent-langgraph (_checkpointer.py): plain httpx.AsyncClient passed in from outside, manually sets headers={"X-User-ID": user_id} on each call. No SA token, no token service.
  • kagent-crewai (_memory.py): creates a fresh httpx.Client() (sync) per call, manually sets headers={"X-User-ID": self.user_id}. No token service at all.

None of them go through the KAgentTokenService event hook, so set_request_user_id / get_request_user_id is irrelevant to them. They rely entirely on the manually-set X-User-ID header for identity.

Dropping user_id query params would be safe for all these runtimes (their explicit X-User-ID header covers both the auth layer and getUserID() fallback in the Go handlers), but this PR only touched kagent-adk and these others weren't updated. A cleanup of user_id query params across all runtimes would need to touch all four packages.

So, what are your thoughts? Want to do this cleanup as part of this PR or as a separate piece of work? I'm happy with either.

@jmhbh jmhbh enabled auto-merge (squash) May 14, 2026 00:11
@jmhbh jmhbh merged commit f85ad95 into kagent-dev:main May 14, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants