fix: key STS token cache by acting subject, not session alone - #2459
Open
QuentinBisson wants to merge 9 commits into
Open
fix: key STS token cache by acting subject, not session alone#2459QuentinBisson wants to merge 9 commits into
QuentinBisson wants to merge 9 commits into
Conversation
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
force-pushed
the
fix/sts-token-cache-per-subject-rebased
branch
from
August 17, 2026 13:08
ab5600f to
5503c06
Compare
Contributor
There was a problem hiding this comment.
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
sessionIDto a composite(sessionID, subject)key, wheresubjectis derived from the acting bearer’s issuer-scopedsub(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
force-pushed
the
fix/sts-token-cache-per-subject-rebased
branch
from
August 17, 2026 19:46
74ccaaa to
7ac8cd3
Compare
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
force-pushed
the
fix/sts-token-cache-per-subject-rebased
branch
from
August 17, 2026 19:49
7ac8cd3 to
a63d354
Compare
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>
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>
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>
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
force-pushed
the
fix/sts-token-cache-per-subject-rebased
branch
from
August 18, 2026 10:43
a9eac4a to
843f8f1
Compare
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.
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), wheresubjectis a SHA-256 hash of the caller's bearer token.BeforeRunCallbackandHeaderProviderboth 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 underBearerTokenKeyand falls back to the A2A call context, which is what reaches the MCP transport layer whenBearerTokenKeywas not threaded into the request context.a2a/executor.gonow calls the same helper, so there is one copy of the parser instead of two.GetSubjectTokenFuncdocuments 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.
issandsubare 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'sexp) and the caller bearer'sexp. 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
expat 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.AfterRunCallbacktracks 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
GetTokenForSessionis 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:userIDdefaults toA2A_USER_<contextID>, so callers on one context share a session and this fix gives each of them its own exchanged token. What remains is thatuserIDis part of the session address, so sending a per-senderx-user-idresolves those senders onto separate sessions instead of one conversation. That affects attribution, not authorization, and is untouched here.Note
go-unit-testsfails for a reason unrelated to this diff.go test ./...runsapi/v1alpha2andapi/v1alpha3in 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 onmainwith an emptygo/bin/k8s, and passes once the binaries are provisioned first.