Skip to content

fix(sts): key the ADK token cache by acting subject, not session alone (Python) - #2333

Closed
QuentinBisson wants to merge 2 commits into
kagent-dev:mainfrom
QuentinBisson:fix/py-sts-cache-per-subject
Closed

QuentinBisson wants to merge 2 commits into
kagent-dev:mainfrom
QuentinBisson:fix/py-sts-cache-per-subject

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

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_key is now session.id plus the acting subject. _subject_key()
    derives the discriminator from the caller's token as iss + sub (sub is
    only 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_callback resolves the subject token before the cache lookup
    instead of after it, so the lookup can be keyed by subject at all. The
    resolution is centralized in _read_subject_token and reused rather than
    repeated.
  • after_run_callback sweeps every expired entry. A session now holds one entry
    per 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 from session.state, which
_agent_executor.py rewrites from the current request on every normal run. No
equivalent 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.py refreshes session.state["headers"] only on the normal run
branch. 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-adk
rather 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; the
same sub at two issuers does not collide; opaque and sub-less tokens still
partition 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 (the
fail-closed read path).

Verified the new tests catch the original defect: with the key forced back to
session.id alone, 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 key
format.

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]>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Jul 25, 2026
@QuentinBisson
QuentinBisson force-pushed the fix/py-sts-cache-per-subject branch from 3b591e5 to be7a504 Compare July 25, 2026 13:44
@github-actions

Copy link
Copy Markdown

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.

@github-actions github-actions Bot added the stale This issue or PR has become stale label Aug 10, 2026
@github-actions

Copy link
Copy Markdown

This pull request has been closed after 15 days of inactivity and an additional 5 days after being marked as stale.

@github-actions github-actions Bot closed this Aug 16, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working stale This issue or PR has become stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant