Skip to content

fix(mcp): resolve session-token sub UUID to email in streamable-HTTP auth#5802

Merged
ja8zyjits merged 2 commits into
mainfrom
fix/5215-session-token-sub-uuid-mcp-auth
Jul 27, 2026
Merged

fix(mcp): resolve session-token sub UUID to email in streamable-HTTP auth#5802
ja8zyjits merged 2 commits into
mainfrom
fix/5215-session-token-sub-uuid-mcp-auth

Conversation

@msureshkumar88

Copy link
Copy Markdown
Collaborator

Closes #5215
Closes #5750

Problem

Session tokens issued by /admin/login carry sub as the EmailUser.id UUID (mcpgateway/admin.py:4273), while legacy/API tokens carry the email. The MCP streamable-HTTP auth path treated sub as an email unconditionally:

  • streamablehttp_transport.py:5089_auth_jwt()
  • streamablehttp_transport.py:2221_normalize_jwt_payload() (stateful-session fallback)

So the EmailUser lookup missed for every session token, with two different symptoms depending on configuration:

The failure was also sticky: the transport writes back to the auth cache (:5153, :5164, :5206) under the same unresolved key, so it cached CachedAuthContext(user=None) against the UUID. Subsequent requests took the cache-hit branch and returned the same 401 without re-querying.

Fix

Add _resolve_subject_email() and call it in both sites, before any cache lookup or DB query. This mirrors resolution the rest of the codebase already does — mcpgateway/auth.py:1581 (get_current_user) and auth.py:553 (resolve_session_teams) — reusing the existing _get_email_by_id_sync() helper. The MCP transport was the one path that never got it.

Notes on the approach:

  • Fixed at the root rather than in llmchat_router. The issue proposed minting a fresh loopback token in connect(). That resolves this one call site but leaves every external MCP client on a session token broken, so llmchat_router is unchanged — it keeps forwarding the session cookie, and the loopback call now authenticates as the real user, correctly scoped to their teams and RBAC.
  • No extra DB read on the hot path. A UUID() parse guard short-circuits legacy/API tokens before any lookup. Net effect is a reduction: session tokens currently miss the cache on every request and re-run the full fallback path.
  • Fails closed. An unresolvable subject returns the raw value so the existing require_user_in_db checks still deny, rather than downgrading to anonymous. A DB failure raises SQLAlchemyError, which the existing handler at :5344 turns into 503 — deliberately unlike the fail-open revocation/user lookups at :5183/:5194, since an unresolvable subject means we don't know who the caller is.
  • All MCP transports (/mcp, /servers/{id}/mcp, /mcp/sse, /mcp/message) share _auth_jwt, so all are fixed together.

Behaviour change worth a look during review

The three deny branches read elif settings.require_user_in_db and user_email != platform_admin_email. Previously a platform-admin session token carried a UUID that never equalled platform_admin_email, so the admin escape hatch could not apply. It now does. That is the intended semantics of the hatch, but it is a widening on a security branch, so it has a dedicated test (test_auth_jwt_session_token_platform_admin_escape_hatch).

Related, not included

mcpgateway/routers/reverse_proxy.py:450/:481 use the same sub-or-email shape for session ownership. It is not broken the same way — _validate_session_ownership compares sub against sub, so both forms match. The only divergence is a session created under one token type and accessed under the other, which yields 403 (fail-closed). Left alone here; happy to file separately if it's worth normalizing.

Tests

Added to tests/unit/mcpgateway/transports/test_streamablehttp_transport.py, covering the resolver directly, both patched call sites, and the deny paths: unknown user still 401s, legacy tokens perform no subject lookup, DB errors propagate to 503, and cache/lookup calls are keyed by email rather than UUID.

Verified the behavioural tests fail against the unpatched transport and pass with the fix.

…auth

Session tokens issued by /admin/login carry `sub` as the EmailUser.id UUID,
while legacy/API tokens carry the email. The MCP streamable-HTTP auth path
treated `sub` as an email unconditionally, so the user lookup missed for every
session token.

With require_user_in_db enabled this surfaced as 401 "User not found in
database" on /servers/{id}/mcp — LLM Chat could not connect to a virtual server
hosted by the same gateway. On the unified /mcp endpoint the same miss resolved
empty teams instead, yielding public-only visibility (tools/list returns 0) and
a UUID-keyed RBAC check that denied tools/call.

The miss was also sticky: the transport wrote the empty result back into the
auth cache under the UUID key, so subsequent requests took the cache-hit branch
and returned the same 401 without re-querying.

Add _resolve_subject_email() and call it in both _auth_jwt() and
_normalize_jwt_payload(), before any cache lookup or DB query, so MCP keys the
same identity as the REST paths already do via get_current_user() and
resolve_session_teams(). Legacy/API tokens short-circuit on a UUID parse guard
and take no extra database round-trip. An unresolvable subject returns the raw
value so the existing require_user_in_db checks still deny the request rather
than downgrading it to anonymous access.

Closes #5215
Closes #5750

Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
@msureshkumar88
msureshkumar88 force-pushed the fix/5215-session-token-sub-uuid-mcp-auth branch from 5859e12 to cdd5cbe Compare July 24, 2026 08:51

@Lang-Akshay Lang-Akshay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the PR @msureshkumar88

Please address following blocking Changes

# Area File Line Blocking reason Required change
1 Security / Quality mcpgateway/transports/streamablehttp_transport.py 2237 (also consumed at 5140) _resolve_subject_email() uses payload.get("sub") or payload.get("email"). The repository security invariant requires canonical email-over-sub precedence when both claims exist. Selecting sub first can make cache keys, team visibility, and RBAC evaluate a different principal than the canonical email, causing identity/authz confusion. The security specialist classified this as Critical, CWE-287/CWE-863. Validate a non-empty string email first, fall back to sub, then UUID-resolve only the selected subject. Add deny-path coverage for conflicting email/sub claims.

_resolve_subject_email() picked sub before email, opposite of the
repo's canonical get_user_email() precedence, letting a conflicting
sub claim override the authoritative email (CWE-287/CWE-863). Prefer
a non-empty string email claim, falling back to sub only when absent.

Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
@msureshkumar88

Copy link
Copy Markdown
Collaborator Author

Thanks for the catch, @Lang-Akshay — good call flagging the precedence order.

Fixed in 5688ee8: _resolve_subject_email() now validates a non-empty string email claim first and only falls back to sub when it's absent/malformed, matching the canonical email-over-sub precedence in get_user_email() (mcpgateway/auth_context.py).

Added deny-path coverage:

  • conflicting email/sub claims → email wins, no UUID lookup performed
  • non-string email claim → falls back to sub
  • empty-string email claim → falls back to sub

Full transport suite (563 tests) still green. Ready for another look.

@Lang-Akshay Lang-Akshay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ LGTM

@ja8zyjits

Copy link
Copy Markdown
Collaborator

⚠️ Warnings (Can be a followup)

Session token sub claim structure undocumented

Context: PR fixes bug where MCP transport treated sub as email unconditionally. Session tokens carry sub as EmailUser.id UUID (admin.py:4273), legacy/API tokens carry email directly.

Files:

  • mcpgateway/transports/streamablehttp_transport.py:2203-2260 (new _resolve_subject_email())
  • mcpgateway/admin.py:4273 (session token minting)
  • docs/docs/architecture/multitenancy.md:323-363 (session token behavior)
  • docs/docs/architecture/oauth-design.md:250 (session-semantics payload)

Gap: Docs describe session token team resolution but never document sub claim structure differs between token types.

Impact: Medium — developers integrating MCP clients or debugging auth lack visibility. Fix correct and well-tested.

Recommendation: Add subsection to docs/docs/architecture/multitenancy.md under "Session Tokens" (L343-363):

#### JWT Payload Structure by Token Type

**Session tokens** (`token_use: "session"`):
- `sub`: `EmailUser.id` (UUID) — resolved to email via DB lookup
- Minted by `/admin/login` and SSO flows
- Example: `{"sub": "387e3345-7996-4496-aa2d-09729ec8b3be", "token_use": "session", ...}`

**API/Legacy tokens** (`token_use: "api"` or absent):
- `sub`: User email address (string)
- Minted by `create_jwt_token()` utility
- Example: `{"sub": "user@example.com", "token_use": "api", ...}`

MCP transport resolves session token UUIDs to emails before cache lookups and RBAC checks (`_resolve_subject_email()` in `streamablehttp_transport.py`), ensuring consistent identity keying.

@ja8zyjits ja8zyjits left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@ja8zyjits
ja8zyjits added this pull request to the merge queue Jul 27, 2026
@ja8zyjits ja8zyjits self-assigned this Jul 27, 2026
Merged via the queue into main with commit 3fe3035 Jul 27, 2026
35 checks passed
@ja8zyjits
ja8zyjits deleted the fix/5215-session-token-sub-uuid-mcp-auth branch July 27, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants