Repository navigation
fix(sts): key the ADK token cache by acting subject, not session alone (Python) - #2333
Closed
QuentinBisson wants to merge 2 commits into
Closed
QuentinBisson wants to merge 2 commits into
QuentinBisson wants to merge 2 commits into
Conversation
The Python token-propagation plugin cached the exchanged token under the session id, so a session carrying messages from more than one subject reused whichever subject's token seeded the cache first. Every later caller's MCP tool calls then ran under that first subject's delegated identity. The acting subject is now resolved from session state before the cache lookup and folded into the key as the issuer-scoped sub claim, falling back to a hash of the raw token for opaque or sub-less tokens. after_run_callback sweeps every expired entry, since a session now holds one per subject and only the acting subject's key is derivable there. Signed-off-by: QuentinBisson <[email protected]>
…y note Signed-off-by: QuentinBisson <[email protected]>
QuentinBisson
force-pushed
the
fix/py-sts-cache-per-subject
branch
from
July 25, 2026 13:44
3b591e5 to
be7a504
Compare
|
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. |
QuentinBisson
added a commit
to QuentinBisson/kagent
that referenced
this pull request
Oct 14, 2026
…e (Python) (kagent-dev#2460) Supersedes kagent-dev#2333 (auto-closed by stale-bot; a rebase onto main conflicted with the resource/audience support, so GitHub would not let it be reopened). Ref kagent-dev#2181. The Go counterpart is kagent-dev#2459 and carries the closing reference. ## Problem Same bug as the Go plugin: the cache was keyed by session ID alone, so a session shared by several subjects reused whichever caller's exchanged token was cached first. ## Change Key the cache by `(session_id, caller credential)`, the credential hashed. `before_run_callback` resolves the caller before the cache lookup, so each caller gets its own exchanged token. ## What the key is derived from The caller part comes from the inbound `Authorization` header, not from `get_subject_token`. That hook receives the whole session state, so an implementation reading a session-scoped field returns the same value for every caller and collapses the key back to one entry per session. With no inbound credential the hook's output stands in for the caller: that mode has no per-caller identity to preserve, and keying on the header alone would drop propagation entirely. The key hashes the raw token rather than its `iss`/`sub`. A cache hit returns a delegated token without an exchange, and nothing on this path verifies a signature, so a forged token carrying a victim's claims would select the victim's entry. Hashing the raw token makes it a cache miss, so it reaches the STS and fails there. The cost is one extra exchange when a caller's bearer rotates mid-session. ## Cache lifetime An entry never outlives the credentials it rests on: `expiry` is the earliest `exp` of the exchanged token, the pre-exchange subject token and the caller (the inbound bearer, or the hook's token when there is none), and both `header_provider` and the sweep refuse an entry past it. When no token states an `exp` there is no expiry, and nothing may invent one. The entry carries a second field, `evict_after`, which bounds memory alone and is renewed on use. ## Per-invocation outcome The cache only serves reuse across runs. `before_run_callback` records each invocation's outcome, the entry it resolved or its refusal, keyed by `invocation_id`, and `header_provider` serves that record alone, still checked against `expiry`. Another run caching an entry for the same caller cannot undo a refusal, and the sweep or a capacity eviction cannot strip a run in flight of its token. `after_run_callback` and `on_run_error_callback` drop the record. ADK calls neither for a cancelled run, so records are bounded per session (32) and across sessions (1024), least recently used first; a run whose record is dropped injects no credential, logged at warning level. ## Cache capacity The cache is bounded per session (`MAX_ENTRIES_PER_SESSION`, 32) and across sessions (`MAX_CACHE_ENTRIES`, 1024), the same bounds as the Go plugin. The per-session bound is what matters: without it, one session presenting many distinct credentials evicts the entries of unrelated sessions, and `before_run_callback` does not run again for a run that is between tool calls. The whole-cache bound backstops it so that many sessions cannot grow the cache without limit. Both bounds evict the least recently used entries first. A run in flight keeps its own token either way; the evicted caller's next run exchanges again. Unlike the sweep, a capacity eviction drops credentials that are still valid, so it is logged at warning level. ## Failing closed `header_provider` injects no headers when there is no invocation context, no record for the invocation, a recorded refusal, or an expired entry. `before_run_callback` records a refusal and drops the caller's cache entry on every path where it refuses to propagate: no resolvable subject token, a failed actor fetch, or a rejected exchange. Injecting nothing is not a denial. ADK merges the provider's headers with the ones the toolset already carries, so a toolset configured with its own `Authorization` header still reaches the backend under that identity. ## Scope Propagate-only mode (no STS configured) forwards the caller's own token. This fix stops one caller's token reaching another, but does not add exchange, audience narrowing or an `act` claim to that mode: there is no STS to exchange with when none is configured. --------- Signed-off-by: QuentinBisson <[email protected]> Signed-off-by: Quentin Bisson <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The Python ADK 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.
This is the Python counterpart of #2317, which fixes the same defect in the Go
ADK runtime. The two runtimes had the same cache shape and the same bug.
Changes
cache_keyis nowsession.idplus the acting subject._subject_key()derives the discriminator from the caller's token as
iss+sub(subisonly unique within an issuer), or a hash of the raw token when there is no
sub. The token is parsed unverified and used only to partition the cache,never to gate a security decision; it is validated server-side on exchange.
before_run_callbackresolves the subject token before the cache lookupinstead of after it, so the lookup can be keyed by subject at all. The
resolution is centralized in
_read_subject_tokenand reused rather thanrepeated.
after_run_callbacksweeps every expired entry. A session now holds one entryper subject, and only the acting subject's key is derivable there, so the
previous single-key eviction would leave other subjects' expired entries
behind.
Both the write path (
before_run_callback) and the read path(
header_provider) resolve the subject fromsession.state, which_agent_executor.pyrewrites from the current request on every normal run. Noequivalent of the Go fix's CallContext fallback is needed here.
Behavior change
Same shape as #2317: in a read-write shared session, a visitor's tool calls move
from the owner's exchanged token to the visitor's own. That corrects an identity
collapse rather than introducing one, but it is externally observable.
A request that carries no resolvable subject token no longer reuses a token the
session cached earlier; it skips propagation. That is the fail-closed half of
keying by subject.
Known gap, not addressed here
_agent_executor.pyrefreshessession.state["headers"]only on the normal runbranch. The HITL-resume branch skips it, so a resume keeps the previous request's
headers and the subject resolves to the original caller. If a different user
approves an interrupted run, they get the original caller's token. Per-subject
keying cannot see that, because the approver's identity never reaches session
state. Fixing it means writing headers on the HITL branch too, in
kagent-adkrather than
agentsts-adk.Tests
Adds six cases: two subjects in one session keep separate tokens and each gets
its own back from
header_provider; the same subject twice exchanges once; thesame
subat two issuers does not collide; opaque and sub-less tokens stillpartition via the hash branch; an expired entry belonging to a subject other
than the acting one is still evicted; and a tokenless caller in a session that
already holds a cached token gets nothing back from
header_provider(thefail-closed read path).
Verified the new tests catch the original defect: with the key forced back to
session.idalone, two subjects collapse to one cache entry and one exchange,with the second caller inheriting the first's token.
The 57 existing plugin tests assert on cache contents keyed by session id and
are updated to key through
plugin.cache_key(...)so they no longer pin the keyformat.