You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Server] Bind Streamable HTTP sessions to the principal that opened them - #627
A handshake-era session now only serves the principal that opened it, as the security best practices recommend. When another principal sends the session id, the server answers 404 like for an unknown session, and its DELETE doesn't end the session.
I found this while building an MCP server for the Symfony showcase app: carol could call tools on the session alice opened.
The binding always applies. Clients keep using their own token, and a 404 makes them re-initialize, so I see nothing to opt in to.
It's in Protocol, next to the other session checks, so custom stores don't need to know about it.
A token without sub identifies no principal. An app that authenticates requests itself sets the new StreamableHttpTransport::PRINCIPAL_ATTRIBUTE request attribute.
AuthorizationMiddleware builds the principal from iss, sub and client_id/azp, like #625 does for requestState. Protocol only gets an opaque string and stores its SHA-256 in the session.
McpBundle (symfony/ai) authenticates with Symfony Security, not with AuthorizationMiddleware. It'll need to set this attribute from the user identifier for the binding to work there.
This PR adds principal binding to handshake-era Streamable HTTP sessions, addressing #529 within Protocol without requiring custom session stores to understand authentication.
Changes:
Derives a stable principal from token claims and passes it through transport callbacks.
Stores a principal fingerprint and checks ownership before session use or termination.
Adds binding tests and documents external authentication integration and compatibility changes.
Thanks @nicolas-grekas - rebased this onto main and added four commits on top, since there were cases where the binding silently didn't apply. Feel free to squash or rework them, WDYT?
50be587: the transport now gets the principal from the new AccessToken::getPrincipal(), so AuthorizationMiddleware doesn't set the attribute anymore - before, an app that only set AccessToken::class had no binding at all
Same commit: tokens without sub now bind to issuer & client instead of all sharing null. I didn't go for the fail closed of [Server] Bind requestState to the request it was minted for #625, since the owner couldn't use the session then. Tokens of one client still share sessions unless the app sets PRINCIPAL_ATTRIBUTE
Also there: an invalid PRINCIPAL_ATTRIBUTE now gets a 500 - an integer user id used to end up as null, so user 7 was served on the session of user 42
406ae6a: a failed session read or a store outage now gets a 503 instead of sending the owner to re-initialize, and a DELETE doesn't end the session then. Stdio & in-memory log a store outage at shutdown instead of throwing
999c304: getPrincipal() uses serialize(), so a claim that isn't UTF-8 can't make it throw
aa88e32: malformed JSON and an initialize with a session id now get a 400, and a failure before the session is checked a 500, instead of a 200 - those answers used to carry and remove the messages queued for the session in the header, also for another principal
Not perfect yet:
during a failed read, another principal gets the 503 as well, so it can tell the session exists
session data that decodes to nothing gets the 503 until its TTL is over
Unit, integration & interop suites, phpstan and php-cs-fixer pass locally - didn't run conformance. The description needs an update as well: it still says that a token without sub identifies no principal and that AuthorizationMiddleware builds the principal.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
A non-UTF-8 identity claim can make getPrincipal() throw outside the exception guard, causing POST and DELETE to fail without a controlled HTTP error response.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Error responses generated before session ownership validation can expose and drain another principal’s queued notifications, creating a critical cross-user data leak.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Prevent rejected requests from draining another principal’s queue
A caller authenticated as Carol can POST malformed JSON or an initialize request with Alice's session ID: both errors are returned before ownership validation, but createJsonResponse() then drains Alice's outgoing queue and exposes her queued notifications as SSE. Only retrieve queued messages after successful principal validation, or route these pre-validation errors through the sessionless immediate-response path. Add HTTP regression tests verifying that both rejected requests neither expose nor consume another principal's queue.
A session opened without a principal now records it, so a session the
store fails to read serves no one. Who opened a session is checked before
waiting for its lock, so another principal can't tell the session is busy,
and an initialize never takes the lock.
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
authIssues and PRs related to Authentication / OAuthbreaking changeBreaking the Backwards Compatibility PromisebugSomething isn't workingServerIssues & PRs related to the Server component
3 participants
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.
Fixes #529
A handshake-era session now only serves the principal that opened it, as the security best practices recommend. When another principal sends the session id, the server answers 404 like for an unknown session, and its
DELETEdoesn't end the session.I found this while building an MCP server for the Symfony showcase app: carol could call tools on the session alice opened.
On the open questions of #529:
Protocol, next to the other session checks, so custom stores don't need to know about it.subidentifies no principal. An app that authenticates requests itself sets the newStreamableHttpTransport::PRINCIPAL_ATTRIBUTErequest attribute.AuthorizationMiddlewarebuilds the principal fromiss,subandclient_id/azp, like #625 does forrequestState.Protocolonly gets an opaque string and stores its SHA-256 in the session.McpBundle (symfony/ai) authenticates with Symfony Security, not with
AuthorizationMiddleware. It'll need to set this attribute from the user identifier for the binding to work there.