Skip to content

fix: key STS token cache by acting subject, not session alone - #2459

Open
QuentinBisson wants to merge 9 commits into
kagent-dev:mainfrom
QuentinBisson:fix/sts-token-cache-per-subject-rebased
Open

fix: key STS token cache by acting subject, not session alone#2459
QuentinBisson wants to merge 9 commits into
kagent-dev:mainfrom
QuentinBisson:fix/sts-token-cache-per-subject-rebased

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Supersedes #2317 (auto-closed by stale-bot; a rebase onto main conflicted with #2106, so GitHub would not let it be reopened).

Closes #2181. The Python counterpart is #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 earlier of the exchange's own lifetime (expires_in, or the exchanged token's exp) and the caller bearer's exp. Without that cap, a caller replaying an expired bearer keeps hitting the cached delegated token instead of reaching the STS that would reject it.

One entry per caller instead of one per session also means a token with no exp at all would pin an entry per caller for the lifetime of the process. Those entries fall back to a 5 minute lifetime, so every entry stays evictable.

AfterRunCallback tracks the earliest expiry in the cache and only walks it when something is actually evictable. The sweep removes expired entries only, so the cache is sized by the number of distinct (session, subject) pairs seen within a token lifetime rather than by session count. No size cap or LRU is added; the bounded TTL is what keeps it from growing without limit.

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

#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.

Note

go-unit-tests fails for a reason unrelated to this diff. go test ./... runs api/v1alpha2 and api/v1alpha3 in parallel and both download the envtest binaries into the same bin dir on demand, so on a cold runner cache one package execs a half-written etcd ("text file busy") while the other fails to unpack kubectl. It reproduces on main with an empty go/bin/k8s, and passes once the binaries are provisioned first.

@QuentinBisson
QuentinBisson marked this pull request as ready for review August 17, 2026 13:07
Copilot AI lite review requested due to automatic review settings August 17, 2026 13:07
Rebased onto current main; adapts to the RFC 8707 resource/audience
constructor params and the a2a-go v2 CallContext API.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@QuentinBisson
QuentinBisson force-pushed the fix/sts-token-cache-per-subject-rebased branch from ab5600f to 5503c06 Compare August 17, 2026 13:08
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 17, 2026
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 a cross-subject identity leak in the Go STS token-propagation plugin by ensuring exchanged (delegated) tokens are cached per (session, acting subject) rather than per session alone. This aligns token propagation with the shared-session model where different callers within the same session must not inherit each other’s delegated authority.

Changes:

  • Re-keys the STS token cache from sessionID to a composite (sessionID, subject) key, where subject is derived from the acting bearer’s issuer-scoped sub (or a hash fallback for opaque/sub-less tokens).
  • Ensures both the exchange path (BeforeRunCallback) and MCP injection path (HeaderProvider) resolve the acting bearer/subject before cache lookup, including a CallContext fallback for transport-layer requests.
  • Updates cache cleanup behavior to sweep expired entries across all (session, subject) entries and adds/updates unit tests for the new 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 Implements per-(session, subject) caching and acting-bearer recovery for both exchange and header injection paths.
go/adk/pkg/sts/plugin_test.go Adds coverage for issuer-scoped subject partitioning, shared-session per-subject behavior, and CallContext-based bearer recovery.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Keying the cache by (session, subject) multiplies entries per session, so an
entry whose token carries no exp claim now pins one slot per caller instead of
one per session. Give those a bounded lifetime so every entry stays evictable.

Track the earliest expiry so AfterRunCallback only walks the cache once
something can actually be evicted, matching the Python plugin.

An issuer-less token leaves sub unqualified; those partition by token hash
rather than by a key two issuers could both produce.

Signed-off-by: Quentin Bisson <quentin@giantswarm.io>
@QuentinBisson
QuentinBisson force-pushed the fix/sts-token-cache-per-subject-rebased branch from 74ccaaa to 7ac8cd3 Compare August 17, 2026 19:46
An empty subject identifies no principal, so an entry stored under it
would be shared by every credential-less caller in a session. The cache
accessors now refuse it.

Name the key's input for what it is, the credential the request
authenticates with, rather than the caller's bearer specifically.

Signed-off-by: Quentin Bisson <quentin@giantswarm.io>
@QuentinBisson
QuentinBisson force-pushed the fix/sts-token-cache-per-subject-rebased branch from 7ac8cd3 to a63d354 Compare August 17, 2026 19:49
A cache hit returns a delegated token without performing an STS exchange,
so the cache key decides who receives someone else's authority. Deriving it
from the unverified iss/sub claims let a forged, unsigned token select a
victim's entry and never reach the STS that would have rejected it.

subjectKey now hashes the raw token, so a forged token is a cache miss and
goes to the STS.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 18, 2026
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 18, 2026
The entry is keyed by the caller's bearer, so it must not outlive it.
A caller replaying an expired bearer kept hitting the cached delegated
token instead of reaching the STS that would have rejected it.

Move the bearer parsing shared with a2a/executor.go into models, so the
two copies of a security-relevant parser cannot drift, and drop the now
always-true expiry guard on the cached-token log line.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 18, 2026
Five tests each stood up their own httptest server, discovery document and
NewSTSIntegration call, so what each test actually varied was buried. They
now pass a token-endpoint function to newSTSIntegration.

Assertions move off the server goroutine: t.Fatal there only stops that
goroutine, so a failed check reported the wrong thing.

The credential-without-exp case was an earlierExpiry unit test wrapped in
an exchange, and is now a table test.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 18, 2026
The cache keys on a hash of the bearer, which stands in for the subject
token only because the hook derives one from the other. An implementation
that mints or fetches a token instead would have its first result served
for the entry's lifetime.

parseUnverifiedClaims had one caller, so the parse moves back into it.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@QuentinBisson
QuentinBisson force-pushed the fix/sts-token-cache-per-subject-rebased branch from a9eac4a to 843f8f1 Compare August 18, 2026 10:43
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 18, 2026
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.

STS token cache keyed by session ID collapses per-user identity in shared sessions

2 participants