Skip to content

[Fix][Auth]: fix identity drift in stateful session fallback by using header-first token precedence in streamablehttp#3055

Merged
crivetimihai merged 2 commits intomainfrom
3019_token_precedence_fix_streamablehttp
Feb 20, 2026
Merged

[Fix][Auth]: fix identity drift in stateful session fallback by using header-first token precedence in streamablehttp#3055
crivetimihai merged 2 commits intomainfrom
3019_token_precedence_fix_streamablehttp

Conversation

@kevalmahajan
Copy link
Copy Markdown
Member

🐛 Bug-fix PR

Closes #3019

📌 Summary

The stateful session fallback path in _get_request_context_or_default() resolved user identity through require_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 a jwt_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

  1. Set USE_STATEFUL_SESSIONS=true
  2. Send a request to /servers/{id}/mcp/ with both:
    • Authorization: Bearer <token-for-user-A>
    • Cookie jwt_token=<token-for-user-B>
  3. streamable_http_auth middleware sets ContextVar identity to user A (header-only path)
  4. In any code path where _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.

token: str | None = None
if authorization:
    scheme, credentials = get_authorization_scheme_param(authorization)
    if scheme.lower() == "bearer" and credentials:
        token = credentials

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:

  1. request.cookies.get("jwt_token") ← COOKIE WINS (highest priority)
  2. credentials.credentials ← Authorization header (only if no cookie)
  3. jwt_token parameter ← cookie param fallback

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:

  1. Authorization Bearer header ← header wins (highest priority)
  2. request.cookies jwt_token ← cookie fallback
  3. jwt_token keyword argument ← parameter fallback

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:

# Use require_auth_header_first to match streamable_http_auth token precedence:
# Authorization header > request cookies > jwt_token parameter
raw_payload = await require_auth_header_first(auth_header=auth_header, jwt_token=cookie_token, request=request)

Tests

  • test_verify_credentials.py - 15 new tests for require_auth_header_first: header beats request.cookies, header beats jwt_token param, cookie fallback when no header, jwt_token param fallback, anonymous/401 modes, proxy auth path, Basic auth path, and edge cases (auth_header="", "Bearer " with empty token, request.cookies wins over jwt_token param)
  • test_streamablehttp_transport.py - 1 new test (test_get_request_context_header_wins_over_cookie) that intercepts at verify_credentials_cached to assert the header token is passed when both header and cookie are present; 5 existing tests updated to mock require_auth_header_first instead of the now-removed require_auth_override import

🧪 Verification

Check Command Status
Lint suite make lint
Unit tests make test
Coverage ≥ 80 % make coverage
Manual regression no longer fails steps / screenshots

📐 MCP Compliance (if relevant)

  • Matches current MCP spec
  • No breaking change to MCP clients

✅ Checklist

  • Code formatted (make black isort pre-commit)
  • No secrets/credentials committed

Copy link
Copy Markdown
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@crivetimihai crivetimihai force-pushed the 3019_token_precedence_fix_streamablehttp branch from 132b6d1 to 568a498 Compare February 20, 2026 13:58
@crivetimihai crivetimihai merged commit efbae92 into main Feb 20, 2026
54 checks passed
@crivetimihai crivetimihai deleted the 3019_token_precedence_fix_streamablehttp branch February 20, 2026 14:28
vishu-bh pushed a commit that referenced this pull request Feb 24, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][SECURITY]: Fallback auth token precedence differs from middleware in stateful sessions

2 participants