[Fix][Auth]: fix identity drift in stateful session fallback by using header-first token precedence in streamablehttp#3055
Conversation
crivetimihai
left a comment
There was a problem hiding this comment.
Good fix for a real identity drift bug. The root cause is clear: require_auth() reads cookies before headers (lines 389-397), while streamable_http_auth middleware reads headers first. When both an Authorization header and a jwt_token cookie are present, the stateful session fallback (_get_request_context_or_default) would resolve to the cookie identity while the middleware had already authenticated using the header — causing the user context to silently drift.
The new require_auth_header_first() implements the correct precedence (header > request.cookies > jwt_token param), matching the middleware. The function is self-contained and avoids the cookie-first ordering baked into require_auth.
Tests are thorough — 14 cases covering the precedence matrix, edge cases (empty Bearer, non-Bearer scheme, no-request fallback), proxy auth paths, and the key integration test test_get_request_context_header_wins_over_cookie that directly reproduces the drift scenario.
One minor note: the require_auth_override function is now unused by this transport but remains exported — that's fine since other callers may still use it. No issues found.
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
132b6d1 to
568a498
Compare
… header-first token precedence in streamablehttp (#3055) * fix auth precedence for streamable http Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * increase cov Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> --------- Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
🐛 Bug-fix PR
Closes #3019
📌 Summary
The stateful session fallback path in
_get_request_context_or_default()resolved user identity throughrequire_auth_override()->require_auth(), which reads cookie tokens before Authorization headers. The primary ASGI middleware (streamable_http_auth) resolves identity using the Authorization header only. This asymmetry caused identity drift: a request carrying both a valid Authorization: Bearer<token-A>header and ajwt_token=<token-B>cookie would authenticate as user-A through the middleware but as user-B through the stateful session fallback, silently producing two different identities within the same request lifecycle.🔁 Reproduction Steps
/servers/{id}/mcp/with both:Bearer <token-for-user-A>jwt_token=<token-for-user-B>_get_request_context_or_default()fires its fallback (ContextVar stale / not yet set), user identity resolves to user B (cookie wins in require_auth)The practical trigger — both a valid header and a different valid cookie on the same request — is uncommon in normal operation (MCP SDK clients use headers; Admin UI uses cookies, not both). However, it is a correctness and security issue whenever it does occur.
🐞 Root Cause
Three layers interact to produce the bug.
Layer 1 - ASGI middleware (streamable_http_auth, streamablehttp_transport.py:2200-2209)
Extracts the token from the Authorization header only. If no Bearer token is present the middleware raises immediately; cookies are never consulted.
Layer 2 - Fallback (_get_request_context_or_default, streamablehttp_transport.py:996-1002)
Called when ContextVars are stale. Passed both the Authorization header value and the jwt_token cookie value to require_auth_override().
Layer 3 - require_auth token resolution (verify_credentials.py:389-401)
require_auth_override delegates to require_auth, whose token resolution order is:
The cookie always beats the Authorization header, the exact inverse of the middleware.
A wrong comment in the fallback (# In stateful session, cookie might be more reliable) made this look intentional.
💡 Fix Description
New function: require_auth_header_first() (verify_credentials.py)
A dedicated variant of require_auth_override() with reversed token precedence matching the middleware:
All other behaviour is identical to require_auth_override: proxy auth, Basic auth (docs_allow_basic_auth), anonymous when auth not required, and 401 when required but no token is found. require_auth itself is not changed — its cookie-first ordering is intentional for browser/Admin UI routes and protected by explicit API/browser checks in rbac.py.
Call-site change (streamablehttp_transport.py)
_get_request_context_or_default() now calls require_auth_header_first() instead of require_auth_override(), and the misleading comment is replaced with an accurate one explaining the precedence alignment:
Tests
🧪 Verification
make lintmake testmake coverage📐 MCP Compliance (if relevant)
✅ Checklist
make black isort pre-commit)