Skip to content

Commit 6172a4e

Browse files
authored
Merge pull request #5212 from IBM/fix/session-affinity-auth-context
fix(session-affinity): carry the edge-validated auth context across cross-worker forwards AUTH_ENCRYPTION_SECRET is the critical security here, before it goes to merge we need to add a way to validate if this was changed in production. It should not use default value.
2 parents 6b1e24b + e2e57a2 commit 6172a4e

11 files changed

Lines changed: 672 additions & 143 deletions

File tree

mcpgateway/auth_context.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,25 @@ def get_internal_mcp_auth_context(request: Request) -> Optional[Dict[str, Any]]:
221221
return None
222222

223223

224+
def encode_internal_mcp_auth_context(auth_context: Dict[str, Any]) -> str:
225+
"""Encode an edge-validated auth context for forwarding to a trusted internal dispatcher.
226+
227+
Mirror of :func:`decode_internal_mcp_auth_context`. Packages the auth context
228+
so a trusted internal dispatcher (e.g. ``/_internal/mcp/rpc``) can use it
229+
without re-running authentication. The unpadded base64url shape keeps the
230+
value header-safe.
231+
232+
Args:
233+
auth_context: Auth-context dict to encode.
234+
235+
Returns:
236+
Unpadded base64url-encoded JSON payload for the
237+
``x-contextforge-auth-context`` header.
238+
"""
239+
encoded = base64.urlsafe_b64encode(orjson.dumps(auth_context)).decode("ascii")
240+
return encoded.rstrip("=")
241+
242+
224243
def decode_internal_mcp_auth_context(header_value: str) -> Dict[str, Any]:
225244
"""Decode the trusted internal MCP auth header payload.
226245

mcpgateway/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ def validate_auth_header_name(cls, v: str) -> str:
458458
"/sse", # Exempt: SSE is a server-sent event stream, not vulnerable to CSRF
459459
"/message", # Exempt: MCP SSE message endpoint
460460
"/rpc", # Exempt: JSON-RPC is a programmatic protocol, not browser-based
461+
"/_internal/mcp/", # Exempt: loopback-only, HMAC-gated internal dispatch (affinity/Rust forwards); not browser-reachable
461462
],
462463
description="Paths exempt from CSRF protection",
463464
)

mcpgateway/main.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,18 +277,28 @@
277277

278278

279279
def _is_trusted_internal_mcp_runtime_request(request: Request) -> bool:
280-
"""Return whether the request came from the local Rust runtime sidecar.
280+
"""Return whether the request came from a trusted local internal source.
281+
282+
Two callers are trusted today:
283+
284+
- ``"rust"`` — the local Rust runtime sidecar (over loopback).
285+
- ``"affinity"`` — the in-process dispatch used by session-affinity
286+
forwarding to reach the owner worker, carrying the identity the edge
287+
already validated.
288+
289+
Both share the same gates: a shared-secret HMAC header AND a loopback client
290+
address. Only the ``x-contextforge-mcp-runtime`` marker value differs.
281291

282292
Args:
283293
request: Incoming request to inspect.
284294

285295
Returns:
286-
``True`` when the request carries the trusted Rust runtime marker from
287-
loopback, otherwise ``False``.
296+
``True`` when the request carries a trusted internal-runtime marker
297+
from loopback, otherwise ``False``.
288298
"""
289299
runtime_marker = request.headers.get("x-contextforge-mcp-runtime")
290300
client_host = getattr(getattr(request, "client", None), "host", None)
291-
if runtime_marker != "rust" or not has_valid_internal_mcp_runtime_auth_header(request) or client_host not in ("127.0.0.1", "::1"):
301+
if runtime_marker not in ("rust", "affinity") or not has_valid_internal_mcp_runtime_auth_header(request) or client_host not in ("127.0.0.1", "::1"):
292302
return False
293303
# Defense-in-depth: /_internal/a2a/* endpoints must refuse requests when
294304
# A2A support is disabled, even from an otherwise-trusted local sidecar.

mcpgateway/services/session_affinity.py

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
MessageHandlerFactory,
5050
)
5151
from mcpgateway.utils.internal_http import (
52+
internal_loopback_base_url,
5253
post_rpc_in_process,
5354
)
5455

@@ -1021,27 +1022,32 @@ async def _execute_forwarded_request(self, request: Dict[str, Any]) -> Dict[str,
10211022
async def _execute_forwarded_http_request(self, request: Dict[str, Any], redis: Any) -> None:
10221023
"""Execute a forwarded Streamable HTTP request in-process and reply via Redis.
10231024
1024-
A forwarded request always lands here on the worker that OWNS the
1025-
downstream session (its ``WORKER_ID`` matched the Redis owner key), so the
1026-
bound upstream session in this process's ``UpstreamSessionRegistry`` can
1027-
serve it. We therefore dispatch the JSON-RPC call to the local ``/rpc``
1028-
route via an **in-process ASGI transport** instead of a network loopback
1029-
to ``127.0.0.1``: a real loopback hits the shared gunicorn socket and the
1030-
kernel routes it to an arbitrary worker that does not hold this session,
1031-
which breaks upstream-session reuse and fails the request. The
1032-
``x-forwarded-internally`` header stops the re-entered handler from
1033-
forwarding again.
1025+
The request lands on the worker that owns the downstream session, so the
1026+
bound upstream session in this process can serve it. It is dispatched
1027+
in-process to the trusted-internal ``/_internal/mcp/rpc`` endpoint rather
1028+
than the public ``/rpc``.
1029+
1030+
Public ``/rpc`` only understands ContextForge JWTs and cookies, so an
1031+
OAuth bearer or an ``MCP_REQUIRE_AUTH=false`` public-only request would
1032+
401 if re-authenticated there. Instead, the originating worker's
1033+
already-validated identity rides in ``auth_context`` (the encoded
1034+
``x-contextforge-auth-context`` header); with the
1035+
``x-contextforge-mcp-runtime: affinity`` marker, the shared-secret HMAC,
1036+
and the loopback client address, the trusted endpoint accepts the request
1037+
and reconstructs the same user the edge established.
10341038
10351039
Args:
10361040
request: Serialized HTTP request data from Redis Pub/Sub containing:
10371041
- type: "http_forward"
10381042
- response_channel: Redis channel to publish response to
10391043
- mcp_session_id: Session identifier
1040-
- method: HTTP method (GET, POST, DELETE)
1041-
- path: Request path (e.g., /servers/{id}/mcp)
1042-
- headers: Request headers dict (lowercased keys)
1043-
- body: Hex-encoded request body
1044-
redis: Redis client for publishing response
1044+
- method: HTTP method (POST primarily)
1045+
- path: Original request path (e.g., /servers/{id}/mcp)
1046+
- headers: Original request headers (lowercased keys)
1047+
- body: Hex-encoded JSON-RPC request body
1048+
- auth_context: base64url-encoded auth context from the
1049+
originating worker's ``streamable_http_auth()`` result
1050+
redis: Redis client for publishing the response
10451051
"""
10461052
response_channel = request.get("response_channel")
10471053

@@ -1057,6 +1063,7 @@ async def _publish(status: int, body: bytes, headers: Optional[Dict[str, str]] =
10571063
headers = request.get("headers", {})
10581064
body_hex = request.get("body", "")
10591065
mcp_session_id = request.get("mcp_session_id")
1066+
auth_context_header = request.get("auth_context") or ""
10601067
body = bytes.fromhex(body_hex) if body_hex else b""
10611068

10621069
session_short = mcp_session_id[:8] if mcp_session_id and len(mcp_session_id) >= 8 else "unknown"
@@ -1079,7 +1086,8 @@ async def _publish(status: int, body: bytes, headers: Optional[Dict[str, str]] =
10791086
await _publish(202, b"")
10801087
return
10811088

1082-
# Inject the virtual server id (from the path) into params so /rpc routes correctly.
1089+
# Inject the virtual server id (from the path) into params so the
1090+
# dispatcher routes to the right virtual server.
10831091
server_match = _SERVER_ID_RE.search(path)
10841092
if server_match and isinstance(json_body, dict):
10851093
if not isinstance(json_body.get("params"), dict):
@@ -1088,31 +1096,53 @@ async def _publish(status: int, body: bytes, headers: Optional[Dict[str, str]] =
10881096
body = orjson.dumps(json_body)
10891097

10901098
# First-Party - lazy imports avoid a circular dependency with main/transport.
1091-
# First-Party
1099+
from mcpgateway.auth_context import _expected_internal_mcp_runtime_auth_header, encode_internal_mcp_auth_context # pylint: disable=import-outside-toplevel,protected-access
1100+
from mcpgateway.main import app # pylint: disable=import-outside-toplevel
10921101
from mcpgateway.utils.passthrough_headers import safe_extract_and_filter_for_loopback # pylint: disable=import-outside-toplevel
1093-
from mcpgateway.utils.verify_credentials import _resolve_auth_header_name # pylint: disable=import-outside-toplevel
1094-
1102+
from mcpgateway.utils.verify_credentials import _resolve_auth_header_name # pylint: disable=import-outside-toplevel,protected-access
1103+
1104+
# A forwarded payload may omit the auth context; fall back to an
1105+
# encoded public-only ({}) context so the endpoint applies default
1106+
# visibility instead of rejecting the dispatch with 400.
1107+
if not auth_context_header:
1108+
auth_context_header = encode_internal_mcp_auth_context({})
1109+
1110+
# Trust headers for the internal /_internal/mcp/rpc endpoint:
1111+
# - x-contextforge-mcp-runtime: "affinity" caller marker
1112+
# - x-contextforge-mcp-runtime-auth: shared-secret HMAC
1113+
# - x-contextforge-auth-context: the encoded edge auth context, so the
1114+
# endpoint reconstructs the same user without re-authenticating.
10951115
rpc_headers = {
10961116
"content-type": "application/json",
10971117
"x-mcp-session-id": mcp_session_id or "",
1098-
"x-forwarded-internally": "true",
1118+
"x-contextforge-mcp-runtime": "affinity",
1119+
"x-contextforge-mcp-runtime-auth": _expected_internal_mcp_runtime_auth_header(),
1120+
"x-contextforge-auth-context": auth_context_header,
10991121
}
1100-
auth_header = _resolve_auth_header_name(settings).lower()
1101-
if auth_header in headers:
1102-
rpc_headers[auth_header] = headers[auth_header]
1122+
# Preserve the bearer under the configured auth header (AUTH_HEADER_NAME),
1123+
# not a hardcoded "authorization": the CSRF bearer short-circuit keys on
1124+
# the configured header, so a custom header would otherwise be dropped.
1125+
# This is defense-in-depth; the endpoint trusts the auth-context above.
1126+
auth_header_name = _resolve_auth_header_name(settings).lower()
1127+
original_auth = headers.get(auth_header_name) or headers.get(_resolve_auth_header_name(settings))
1128+
if original_auth:
1129+
rpc_headers[auth_header_name] = original_auth
11031130
# Preserve passthrough headers destined for upstream MCP servers (#3640).
11041131
rpc_headers.update(safe_extract_and_filter_for_loopback(headers))
11051132

1106-
# Dispatch IN-PROCESS so /rpc resolves the bound upstream session from
1107-
# this worker's registry instead of bouncing back through the shared
1108-
# socket to a random worker (see post_rpc_in_process).
1109-
response = await post_rpc_in_process(
1110-
content=body,
1111-
headers=rpc_headers,
1112-
timeout=settings.mcpgateway_pool_rpc_forward_timeout,
1113-
)
1133+
# Dispatch IN-PROCESS to the trusted internal endpoint. The explicit
1134+
# client=("127.0.0.1", 0) tells ASGITransport to set scope["client"]
1135+
# to a loopback address so the trust check accepts the request.
1136+
transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 0))
1137+
async with httpx.AsyncClient(transport=transport, base_url=internal_loopback_base_url()) as client:
1138+
response = await client.post(
1139+
"/_internal/mcp/rpc",
1140+
content=body,
1141+
headers=rpc_headers,
1142+
timeout=settings.mcpgateway_pool_rpc_forward_timeout,
1143+
)
11141144

1115-
logger.debug(f"[HTTP_AFFINITY] Worker {WORKER_ID} | Session {session_short}... | Executed in-process: {response.status_code}")
1145+
logger.debug(f"[HTTP_AFFINITY] Worker {WORKER_ID} | Session {session_short}... | Executed in-process via /_internal/mcp/rpc: {response.status_code}")
11161146

11171147
resp_headers = {"content-type": "application/json"}
11181148
if mcp_session_id:
@@ -1149,6 +1179,7 @@ async def forward_to_owner(
11491179
headers: Dict[str, str],
11501180
body: bytes,
11511181
query_string: str = "",
1182+
auth_context: Optional[str] = None,
11521183
) -> Optional[Dict[str, Any]]:
11531184
"""Forward a Streamable HTTP request to the worker that owns the session via Redis Pub/Sub.
11541185
@@ -1165,6 +1196,10 @@ async def forward_to_owner(
11651196
headers: Request headers.
11661197
body: Request body bytes.
11671198
query_string: Query string if any.
1199+
auth_context: Encoded ``x-contextforge-auth-context`` value carrying the
1200+
originating worker's already-validated identity, so the owner can
1201+
dispatch to the trusted internal endpoint without re-authenticating
1202+
(OAuth bearers and ``MCP_REQUIRE_AUTH=false`` public-only survive).
11681203
11691204
Returns:
11701205
Dict with 'status', 'headers', and 'body' from the owner worker's response,
@@ -1206,6 +1241,8 @@ async def forward_to_owner(
12061241
"body": body.hex() if body else "", # Hex encode binary body
12071242
"original_worker": WORKER_ID,
12081243
"timestamp": time.time(),
1244+
# Encoded edge identity; lets the owner dispatch without re-authenticating.
1245+
"auth_context": auth_context,
12091246
}
12101247

12111248
# Subscribe to response channel BEFORE publishing request (prevent race)

mcpgateway/transports/rust_mcp_runtime_proxy.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,17 @@
1515

1616
# Standard
1717
import asyncio
18-
import base64
1918
import logging
2019
import re
2120
from urllib.parse import urlsplit, urlunsplit
2221

2322
# Third-Party
2423
import httpx
25-
import orjson
2624
from sqlalchemy import exists as sa_exists
2725
from starlette.types import Receive, Scope, Send
2826

2927
# First-Party
28+
from mcpgateway.auth_context import encode_internal_mcp_auth_context
3029
from mcpgateway.config import settings
3130
from mcpgateway.db import fresh_db_session
3231
from mcpgateway.db import Server as DbServer
@@ -338,5 +337,4 @@ def _build_forwarded_auth_context_header() -> str | None:
338337
auth_context = get_streamable_http_auth_context()
339338
if not auth_context:
340339
return None
341-
encoded = base64.urlsafe_b64encode(orjson.dumps(auth_context)).decode("ascii")
342-
return encoded.rstrip("=")
340+
return encode_internal_mcp_auth_context(auth_context)

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4209,6 +4209,14 @@ async def handle_streamable_http( # noqa: PLR0911,PLR0912,PLR0915 — pylint: d
42094209
# Session owned by another worker - forward the entire HTTP request
42104210
logger.info("[HTTP_AFFINITY] Worker %s | Session %s... | Owner: %s | Forwarding HTTP request", WORKER_ID, mcp_session_id[:8], owner)
42114211

4212+
# Package the edge-validated identity so the owner can dispatch via
4213+
# the trusted internal /_internal/mcp/rpc endpoint without
4214+
# re-authenticating, letting OAuth and public-only sessions survive.
4215+
# First-Party
4216+
from mcpgateway.auth_context import encode_internal_mcp_auth_context # pylint: disable=import-outside-toplevel
4217+
4218+
encoded_auth_context = encode_internal_mcp_auth_context(get_streamable_http_auth_context())
4219+
42124220
# Read request body
42134221
body_parts = []
42144222
while True:
@@ -4230,6 +4238,7 @@ async def handle_streamable_http( # noqa: PLR0911,PLR0912,PLR0915 — pylint: d
42304238
headers=headers,
42314239
body=body,
42324240
query_string=query_string,
4241+
auth_context=encoded_auth_context,
42334242
)
42344243

42354244
if response:
@@ -4313,24 +4322,45 @@ async def handle_streamable_http( # noqa: PLR0911,PLR0912,PLR0915 — pylint: d
43134322
body = orjson.dumps(json_body)
43144323
logger.debug("[HTTP_AFFINITY_LOCAL] Injected server_id %s into /rpc params", server_id)
43154324

4325+
# Owner-direct path: dispatch to the trusted internal
4326+
# /_internal/mcp/rpc endpoint carrying the edge-validated auth
4327+
# context, so OAuth and public-only sessions are honored without
4328+
# re-authenticating against public /rpc (JWTs/cookies only).
4329+
# First-Party
4330+
from mcpgateway.auth_context import _expected_internal_mcp_runtime_auth_header, encode_internal_mcp_auth_context # pylint: disable=import-outside-toplevel,protected-access
4331+
from mcpgateway.main import app # pylint: disable=import-outside-toplevel
4332+
from mcpgateway.utils.internal_http import internal_loopback_base_url # pylint: disable=import-outside-toplevel
4333+
from mcpgateway.utils.passthrough_headers import safe_extract_and_filter_for_loopback # pylint: disable=import-outside-toplevel
4334+
43164335
rpc_headers = {
43174336
"content-type": "application/json",
43184337
"x-mcp-session-id": mcp_session_id,
4319-
"x-forwarded-internally": "true",
4338+
"x-contextforge-mcp-runtime": "affinity",
4339+
"x-contextforge-mcp-runtime-auth": _expected_internal_mcp_runtime_auth_header(),
4340+
"x-contextforge-auth-context": encode_internal_mcp_auth_context(get_streamable_http_auth_context()),
43204341
}
4321-
_gw_auth_lower = _resolve_auth_header_name(settings).lower()
4322-
if _gw_auth_lower in headers:
4323-
rpc_headers[_gw_auth_lower] = headers[_gw_auth_lower]
4342+
# Preserve the bearer under the configured auth header (AUTH_HEADER_NAME),
4343+
# not a hardcoded "authorization": the CSRF bearer short-circuit keys on
4344+
# the configured header, so a custom header would otherwise be dropped.
4345+
# This is defense-in-depth; the endpoint trusts the auth-context above.
4346+
_auth_header_name = _resolve_auth_header_name(settings).lower()
4347+
_original_auth = headers.get(_auth_header_name) or headers.get(_resolve_auth_header_name(settings))
4348+
if _original_auth:
4349+
rpc_headers[_auth_header_name] = _original_auth
43244350
# Forward passthrough headers for upstream MCP servers (see #3640).
4325-
# First-Party
4326-
from mcpgateway.utils.passthrough_headers import safe_extract_and_filter_for_loopback # pylint: disable=import-outside-toplevel
4327-
43284351
rpc_headers.update(safe_extract_and_filter_for_loopback(headers))
43294352

4330-
# Dispatch IN-PROCESS so /rpc runs on this worker — the session
4331-
# owner that holds the bound upstream session — instead of looping
4332-
# back over the shared socket to a random worker (#4205 isolation).
4333-
response = await post_rpc_in_process(content=body, headers=rpc_headers, timeout=30.0)
4353+
# Dispatch in-process so the request runs on this worker, the
4354+
# session owner that holds the bound upstream session, instead of
4355+
# looping back over the shared socket to a random worker.
4356+
transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 0))
4357+
async with httpx.AsyncClient(transport=transport, base_url=internal_loopback_base_url()) as client:
4358+
response = await client.post(
4359+
"/_internal/mcp/rpc",
4360+
content=body,
4361+
headers=rpc_headers,
4362+
timeout=settings.mcpgateway_pool_rpc_forward_timeout,
4363+
)
43344364

43354365
response_headers = [
43364366
(b"content-type", b"application/json"),

0 commit comments

Comments
 (0)