Repository navigation
fix: key STS token cache by acting subject, not session alone - #2317
QuentinBisson wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes an identity-collapsing bug in the STS token-propagation plugin where exchanged tokens were cached by session ID only, causing shared sessions to reuse the first subject’s delegated token for later callers. It updates the cache keying strategy to scope exchanged tokens by both session ID and the acting subject (derived from the caller’s bearer), so shared sessions preserve per-caller delegated identity.
Changes:
- Key STS token cache entries by
(sessionID, subject)rather thansessionIDalone, usingsub(or a hash fallback) as the subject discriminator. - Update
BeforeRunCallbackandHeaderProviderto derive the acting subject from the request’s bearer before cache lookup/injection. - Sweep all expired cache entries in
AfterRunCallbackinstead of only a single session entry, and add tests covering shared-session multi-subject behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| go/adk/pkg/sts/plugin.go | Introduces subject-aware cache keys and updates token exchange + header injection to preserve per-caller identity in shared sessions. |
| go/adk/pkg/sts/plugin_test.go | Adds tests for per-subject token behavior in shared sessions and same-subject cache reuse. |
Comments suppressed due to low confidence (1)
go/adk/pkg/sts/plugin_test.go:78
- TestHeaderProvider_UsesSessionIDMethod currently seeds the cache with an empty subject and calls HeaderProvider with a context that has no bearer token. With the new session+subject cache keying, this scenario is not representative of production behavior (HeaderProvider is intended to select the cached token based on the request’s bearer-derived subject). Updating the test to include a bearer and cache entry for that bearer’s subject will better validate the new behavior and avoid implying that token injection works without a bearer.
func TestHeaderProvider_UsesSessionIDMethod(t *testing.T) {
t.Parallel()
plugin := NewTokenPropagationPlugin(nil, logr.Discard())
plugin.setCachedToken("sess-123", "", "token-abc", 0)
headers := plugin.HeaderProvider(fakeSessionContext{
Context: context.Background(),
sessionID: "sess-123",
})
if headers["Authorization"] != "Bearer token-abc" {
t.Fatalf("Authorization header = %q, want %q", headers["Authorization"], "Bearer token-abc")
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
93ff7c0 to
70069e4
Compare
70069e4 to
703f7f4
Compare
The STS token-propagation plugin cached the exchanged token keyed by session ID alone, so a session carrying messages from multiple subjects reused whichever subject seeded the cache first. Later callers' MCP tool calls ran under the first subject's delegated identity, and re-seeding on expiry made the acting identity drift nondeterministically. Key the cache by (sessionID, subject), where subject is the acting bearer's sub claim, with a hash fallback for opaque or sub-less tokens. BeforeRunCallback extracts the bearer before the cache lookup, HeaderProvider recovers the subject from the request's own bearer, and AfterRunCallback sweeps all expired entries. Session sharing pins X-User-Id to the owner for history but keeps Authorization as the caller's own token, so a shared session is meant to run each caller's tool calls under that caller's delegated token. The session-keyed cache defeated that and leaked one user's authority to others. Signed-off-by: QuentinBisson <[email protected]>
Recover the acting bearer in HeaderProvider from the A2A CallContext, the same source the MCP round-tripper's propagateToken path reads, falling back to it when models.BearerTokenKey is not threaded to the MCP request context. This removes a silent failure mode where the per-subject lookup could miss and inject no Authorization header. Key the subject on iss+sub rather than sub alone, since sub is only unique within an issuer, and share a single unverified-claims parse between the subject key and the expiry helper. Signed-off-by: QuentinBisson <[email protected]>
f36c08d to
8aa2704
Compare
The subject half of the cache key is derived by the unexported subjectKey, so no caller outside the package can build a valid argument for it. Signed-off-by: QuentinBisson <[email protected]>
|
This pull request has been marked as stale because of no activity in the last 15 days. It will be closed in the next 5 days unless it is tagged "no stalebot" or other activity occurs. |
|
This pull request has been closed after 15 days of inactivity and an additional 5 days after being marked as stale. |
…-dev#2459) Supersedes kagent-dev#2317 (auto-closed by stale-bot; a rebase onto main conflicted with kagent-dev#2106, so GitHub would not let it be reopened). Closes kagent-dev#2181. The Python counterpart is kagent-dev#2460. ## Problem The STS token cache was keyed by session ID alone. When several subjects share one session (for example several users on the same A2A session), every caller runs its tool calls with whichever exchanged token was cached first. ## Change Key the cache by `(sessionID, subject)`, where `subject` is a SHA-256 hash of the caller's bearer token. `BeforeRunCallback` and `HeaderProvider` both resolve the caller's bearer before the cache lookup, so each caller gets its own exchanged token. The bearer resolution moves to `models.BearerTokenFromContext`. It prefers the value the executor stored under `BearerTokenKey` and falls back to the A2A call context, which is what reaches the MCP transport layer when `BearerTokenKey` was not threaded into the request context. `a2a/executor.go` now calls the same helper, so there is one copy of the parser instead of two. `GetSubjectTokenFunc` documents that it must be a pure function of the bearer. The hash of the bearer stands in for the subject token only because the hook derives one from the other; an implementation that mints or fetches a token would have its first result served for the entry's lifetime. ## Why the key is a token hash and not iss+sub A cache hit returns a delegated token without performing an exchange, so the key decides who receives whose authority. `iss` and `sub` are not verified anywhere on this path: a forged, unsigned token carrying a victim's claims would select the victim's entry and never reach the STS that would have rejected it. Hashing the raw token makes a forged token a cache miss, so it goes to the STS and fails there. The cost is one extra exchange when a caller's bearer rotates mid-session. An empty subject identifies no principal, so it yields no key at all. An entry stored under it would be shared by every credential-less caller in the session. ## Cache lifetime An entry never outlives the credential that keyed it. The expiry is the earliest of the exchange's own lifetime (`expires_in`, or the exchanged token's `exp`), the subject token's `exp`, and the caller bearer's `exp`. Without that cap, a caller replaying an expired credential keeps hitting the cached delegated token instead of reaching the STS that would reject it. When no token states an `exp` there is no expiry, and nothing may invent one: `BeforeRunCallback` runs once per run, so a run outliving a made-up deadline would lose its credential part way through. The entry carries a second field, `evictAfter`, which bounds memory alone. `Expiry` decides whether the token may still be injected; `evictAfter` decides only when the entry may be dropped, and it comes due 5 minutes after the last use when nothing states an expiry. A cache hit renews `evictAfter` and records a use sequence, so neither the sweep nor either capacity bound can drop an entry a run is still using, and a bound drops the least recently used entries first. `getCachedToken` therefore takes the write lock, and returns a copy so that no caller reads a cached entry's fields once the lock is released. `AfterRunCallback` sweeps every entry whose `evictAfter` has come due. ## Cache capacity The cache is bounded per session (`maxEntriesPerSession`) and across sessions (`maxCacheEntries`). The per-session bound is what matters: without it, one session presenting many distinct credentials evicts the entries of unrelated sessions, and a victim run that is between tool calls has nothing more recent to show. `BeforeRunCallback` has already run by then, so that run would reach its backends with no delegated identity. The whole-cache bound backstops it so that many sessions cannot grow the cache without limit. Unlike the sweep, a capacity eviction drops entries whose credentials are still valid, so it is reported above debug. ## API change `GetTokenForSession` is removed. A session no longer has a single token, so the signature cannot answer the question it asks. It had no callers outside the plugin. ## Out of scope kagent-dev#2181 also describes session state being keyed by `userID`. A shared session still works: `userID` defaults to `A2A_USER_<contextID>`, so callers on one context share a session and this fix gives each of them its own exchanged token. What remains is that `userID` is part of the session address, so sending a per-sender `x-user-id` resolves those senders onto separate sessions instead of one conversation. That affects attribution, not authorization, and is untouched here. ## Header path `getCachedToken` checks the entry's expiry, and both `BeforeRunCallback` and `HeaderProvider` go through it, so an entry past the caller credential's expiry is never injected and a rejected exchange leaves nothing usable behind. Two regression tests pin both, matching the cases the Python plugin needed a fix for. A request whose caller is identified but has no usable entry is reported above debug. It loses its delegated identity, and nothing else says so. --------- Signed-off-by: QuentinBisson <[email protected]> Signed-off-by: Quentin Bisson <[email protected]> Signed-off-by: Eitan Yarmush <[email protected]> Co-authored-by: Eitan Yarmush <[email protected]>
…-dev#2459) Supersedes kagent-dev#2317 (auto-closed by stale-bot; a rebase onto main conflicted with kagent-dev#2106, so GitHub would not let it be reopened). Closes kagent-dev#2181. The Python counterpart is kagent-dev#2460. ## Problem The STS token cache was keyed by session ID alone. When several subjects share one session (for example several users on the same A2A session), every caller runs its tool calls with whichever exchanged token was cached first. ## Change Key the cache by `(sessionID, subject)`, where `subject` is a SHA-256 hash of the caller's bearer token. `BeforeRunCallback` and `HeaderProvider` both resolve the caller's bearer before the cache lookup, so each caller gets its own exchanged token. The bearer resolution moves to `models.BearerTokenFromContext`. It prefers the value the executor stored under `BearerTokenKey` and falls back to the A2A call context, which is what reaches the MCP transport layer when `BearerTokenKey` was not threaded into the request context. `a2a/executor.go` now calls the same helper, so there is one copy of the parser instead of two. `GetSubjectTokenFunc` documents that it must be a pure function of the bearer. The hash of the bearer stands in for the subject token only because the hook derives one from the other; an implementation that mints or fetches a token would have its first result served for the entry's lifetime. ## Why the key is a token hash and not iss+sub A cache hit returns a delegated token without performing an exchange, so the key decides who receives whose authority. `iss` and `sub` are not verified anywhere on this path: a forged, unsigned token carrying a victim's claims would select the victim's entry and never reach the STS that would have rejected it. Hashing the raw token makes a forged token a cache miss, so it goes to the STS and fails there. The cost is one extra exchange when a caller's bearer rotates mid-session. An empty subject identifies no principal, so it yields no key at all. An entry stored under it would be shared by every credential-less caller in the session. ## Cache lifetime An entry never outlives the credential that keyed it. The expiry is the earliest of the exchange's own lifetime (`expires_in`, or the exchanged token's `exp`), the subject token's `exp`, and the caller bearer's `exp`. Without that cap, a caller replaying an expired credential keeps hitting the cached delegated token instead of reaching the STS that would reject it. When no token states an `exp` there is no expiry, and nothing may invent one: `BeforeRunCallback` runs once per run, so a run outliving a made-up deadline would lose its credential part way through. The entry carries a second field, `evictAfter`, which bounds memory alone. `Expiry` decides whether the token may still be injected; `evictAfter` decides only when the entry may be dropped, and it comes due 5 minutes after the last use when nothing states an expiry. A cache hit renews `evictAfter` and records a use sequence, so neither the sweep nor either capacity bound can drop an entry a run is still using, and a bound drops the least recently used entries first. `getCachedToken` therefore takes the write lock, and returns a copy so that no caller reads a cached entry's fields once the lock is released. `AfterRunCallback` sweeps every entry whose `evictAfter` has come due. ## Cache capacity The cache is bounded per session (`maxEntriesPerSession`) and across sessions (`maxCacheEntries`). The per-session bound is what matters: without it, one session presenting many distinct credentials evicts the entries of unrelated sessions, and a victim run that is between tool calls has nothing more recent to show. `BeforeRunCallback` has already run by then, so that run would reach its backends with no delegated identity. The whole-cache bound backstops it so that many sessions cannot grow the cache without limit. Unlike the sweep, a capacity eviction drops entries whose credentials are still valid, so it is reported above debug. ## API change `GetTokenForSession` is removed. A session no longer has a single token, so the signature cannot answer the question it asks. It had no callers outside the plugin. ## Out of scope kagent-dev#2181 also describes session state being keyed by `userID`. A shared session still works: `userID` defaults to `A2A_USER_<contextID>`, so callers on one context share a session and this fix gives each of them its own exchanged token. What remains is that `userID` is part of the session address, so sending a per-sender `x-user-id` resolves those senders onto separate sessions instead of one conversation. That affects attribution, not authorization, and is untouched here. ## Header path `getCachedToken` checks the entry's expiry, and both `BeforeRunCallback` and `HeaderProvider` go through it, so an entry past the caller credential's expiry is never injected and a rejected exchange leaves nothing usable behind. Two regression tests pin both, matching the cases the Python plugin needed a fix for. A request whose caller is identified but has no usable entry is reported above debug. It loses its delegated identity, and nothing else says so. --------- Signed-off-by: QuentinBisson <[email protected]> Signed-off-by: Quentin Bisson <[email protected]> Signed-off-by: Eitan Yarmush <[email protected]> Co-authored-by: Eitan Yarmush <[email protected]>
What
The STS token-propagation plugin cached the exchanged (delegated) token keyed
by session ID alone. A session that carries messages from more than one subject
therefore reused whichever subject's token seeded the cache first, so every
later caller's MCP tool calls ran under that first subject's delegated identity.
On cache expiry the next request re-seeded the entry, so the acting identity
could also drift nondeterministically.
This keys the cache by
(sessionID, subject), wheresubjectis the actingbearer's issuer-scoped
subclaim (iss+sub), falling back to a hash ofthe raw token for opaque or sub-less tokens. Each caller in a shared session now
gets and reuses its own exchanged token.
Why
Shared sessions already deliver per-caller identity everywhere except this
cache. The session-sharing feature (#1935)
pins
X-User-Idto the session owner for history, but deliberately leavesAuthorizationas the caller's own token and records the real caller asinitiated_by. So in a shared session a visitor's tool calls are meant to rununder the visitor's own delegated token. The
sessionID-only cache defeatedthat: the owner's (or first visitor's) exchanged token was reused for everyone,
collapsing per-caller identity and leaking one user's delegated authority to
another.
The condition is reproducible entirely within the in-tree share feature: two
users in one shared session, each authenticated with their own token, produce
tool calls that all execute under the first-seen subject.
Changes
cacheKey{sessionID, subject}.subjectKey()derives thediscriminator from the bearer as
iss+sub(subis only unique within anissuer), or a hash of the raw token when there is no
sub. The token is parsedunverified and used only to partition the cache, never to gate a security
decision; it is validated server-side on exchange.
BeforeRunCallbackandHeaderProviderboth recover the acting bearer throughactingBearer(), which prefers the value the executor stored(
models.BearerTokenKey) and falls back to the A2A CallContextAuthorizationheader. The fallback is the same source the round-tripper's
propagateTokenpath reads, so the acting subject is recoverable at the MCP transport layer
even when
BearerTokenKeyis not threaded onto the MCP request context;without it a lookup could silently miss and inject no token.
AfterRunCallbacksweeps all expired entries rather than a single session key.GetTokenForSession, which had no callers and could not gain one: thesubject half of the key is derived by the unexported
subjectKey.Behavior change
For existing read-write shared sessions, a visitor's tool calls move from the
owner's exchanged token to the visitor's own token. This corrects an identity
collapse (one user's authority was borrowed by others), but it is externally
observable for anyone who relied on the prior behavior.
Second, bearer recovery now happens before the cache lookup instead of after it,
so a request carrying no bearer no longer reuses a token the session cached
earlier. It skips propagation instead. That is the fail-closed half of keying by
subject (an unidentified caller must not inherit an identified one's token), but
it means any path that fails to thread the bearer loses token injection silently
rather than falling back.
actingBearer's CallContext fallback exists to keepthat from happening on the MCP transport path.
Open question (does not block this fix)
This fix makes the cache honor whichever acting token arrives; it does not
choose an identity model. Whether a shared session should run tools per-caller
or under a single pinned identity (owner/initiator) is orthogonal, and is
decided by which token the fronting proxy forwards, not by this cache.
Worth confirming the intended direction: single-identity tool pinning cannot be
provided in-cluster for UI shares, because no component holds a non-speaking
principal's live token to exchange or refresh. It is only realizable where a
credential-forwarding layer exists (for example an A2A gateway that can forward
the pinned principal's token every turn). The controller already owns the
history half via the share feature. If you agree, kagent stays mode-neutral here
and this fix stands on correctness alone.
Fixes #2181