@@ -13277,6 +13277,271 @@ async def test_normalize_jwt_payload_session_admin_no_email_no_bypass():
1327713277 assert result["is_admin"] is True
1327813278
1327913279
13280+ # ---------------------------------------------------------------------------
13281+ # _resolve_subject_email tests (issue #5215)
13282+ #
13283+ # Session tokens carry sub = EmailUser.id (a UUID); legacy/API tokens carry the
13284+ # email. The MCP transport used to treat sub as an email unconditionally, so
13285+ # session tokens produced a 401 "User not found in database" on
13286+ # /servers/{id}/mcp and empty-team public-only visibility on /mcp.
13287+ # ---------------------------------------------------------------------------
13288+
13289+ SESSION_SUB_UUID = "387e3345-7996-4496-aa2d-09729ec8b3be"
13290+
13291+
13292+ @pytest.mark.asyncio
13293+ async def test_resolve_subject_email_uuid_sub_resolves_to_email():
13294+ """A session token's UUID sub is resolved to the user's email via the DB."""
13295+ lookup = Mock(return_value="user@example.com")
13296+ with patch("mcpgateway.auth._get_email_by_id_sync", lookup):
13297+ result = await tr._resolve_subject_email({"sub": SESSION_SUB_UUID, "token_use": "session"})
13298+
13299+ assert result == "user@example.com"
13300+ lookup.assert_called_once_with(SESSION_SUB_UUID)
13301+
13302+
13303+ @pytest.mark.asyncio
13304+ async def test_resolve_subject_email_legacy_sub_skips_db_lookup():
13305+ """An email sub short-circuits before any DB round-trip (hot-path guard)."""
13306+ lookup = Mock(return_value="should-not-be-used@example.com")
13307+ with patch("mcpgateway.auth._get_email_by_id_sync", lookup):
13308+ result = await tr._resolve_subject_email({"sub": "legacy@example.com", "token_use": "api"})
13309+
13310+ assert result == "legacy@example.com"
13311+ lookup.assert_not_called()
13312+
13313+
13314+ @pytest.mark.asyncio
13315+ async def test_resolve_subject_email_session_token_with_email_sub():
13316+ """Session tokens issued without a user row carry an email sub (admin.py:4273)."""
13317+ lookup = Mock(return_value="should-not-be-used@example.com")
13318+ with patch("mcpgateway.auth._get_email_by_id_sync", lookup):
13319+ result = await tr._resolve_subject_email({"sub": "admin@example.com", "token_use": "session"})
13320+
13321+ assert result == "admin@example.com"
13322+ lookup.assert_not_called()
13323+
13324+
13325+ @pytest.mark.asyncio
13326+ async def test_resolve_subject_email_unresolvable_uuid_returns_raw_subject():
13327+ """An unresolvable UUID keeps the raw subject so require_user_in_db still denies.
13328+
13329+ SECURITY: the request must fail closed on the caller's existing
13330+ "User not found in database" branch, never silently downgrade to anonymous.
13331+ """
13332+ with patch("mcpgateway.auth._get_email_by_id_sync", Mock(return_value=None)):
13333+ result = await tr._resolve_subject_email({"sub": SESSION_SUB_UUID, "token_use": "session"})
13334+
13335+ assert result == SESSION_SUB_UUID
13336+
13337+
13338+ @pytest.mark.asyncio
13339+ async def test_resolve_subject_email_falls_back_to_email_claim():
13340+ """Falls back to the 'email' claim when 'sub' is absent."""
13341+ result = await tr._resolve_subject_email({"email": "fallback@example.com"})
13342+ assert result == "fallback@example.com"
13343+
13344+
13345+ @pytest.mark.asyncio
13346+ async def test_resolve_subject_email_prefers_email_over_conflicting_sub():
13347+ """SECURITY: a non-empty 'email' claim wins over 'sub' when both are present.
13348+
13349+ Mirrors the repository's canonical email-over-sub precedence
13350+ (mcpgateway.auth_context.get_user_email). Without this, a token with a
13351+ forged/stale 'sub' UUID could resolve to a different principal than the
13352+ canonical email, mismatching cache keys, team visibility, and RBAC
13353+ (CWE-287/CWE-863).
13354+ """
13355+ lookup = Mock(return_value="should-not-be-used@example.com")
13356+ with patch("mcpgateway.auth._get_email_by_id_sync", lookup):
13357+ result = await tr._resolve_subject_email({"email": "canonical@example.com", "sub": SESSION_SUB_UUID, "token_use": "session"})
13358+
13359+ assert result == "canonical@example.com"
13360+ lookup.assert_not_called()
13361+
13362+
13363+ @pytest.mark.asyncio
13364+ async def test_resolve_subject_email_non_string_email_falls_back_to_sub():
13365+ """A malformed (non-string) 'email' claim is ignored in favor of 'sub'."""
13366+ result = await tr._resolve_subject_email({"email": ["not", "a", "string"], "sub": "legacy@example.com", "token_use": "api"})
13367+ assert result == "legacy@example.com"
13368+
13369+
13370+ @pytest.mark.asyncio
13371+ async def test_resolve_subject_email_empty_email_falls_back_to_sub():
13372+ """An empty-string 'email' claim is treated as absent and falls back to 'sub'."""
13373+ result = await tr._resolve_subject_email({"email": "", "sub": "legacy@example.com", "token_use": "api"})
13374+ assert result == "legacy@example.com"
13375+
13376+
13377+ @pytest.mark.asyncio
13378+ async def test_resolve_subject_email_no_subject_returns_none():
13379+ """A payload with no subject yields None rather than raising."""
13380+ assert await tr._resolve_subject_email({}) is None
13381+
13382+
13383+ @pytest.mark.asyncio
13384+ async def test_resolve_subject_email_db_error_propagates():
13385+ """A DB failure propagates so _auth_jwt's SQLAlchemyError handler returns 503.
13386+
13387+ SECURITY: identity resolution must fail closed. This deliberately differs
13388+ from the fail-open revocation/user lookups later in _auth_jwt — an
13389+ unresolvable subject means we do not know who the caller is.
13390+ """
13391+ # Third-Party
13392+ from sqlalchemy.exc import SQLAlchemyError
13393+
13394+ with patch("mcpgateway.auth._get_email_by_id_sync", Mock(side_effect=SQLAlchemyError("db down"))):
13395+ with pytest.raises(SQLAlchemyError):
13396+ await tr._resolve_subject_email({"sub": SESSION_SUB_UUID, "token_use": "session"})
13397+
13398+
13399+ @pytest.mark.asyncio
13400+ async def test_normalize_jwt_payload_resolves_uuid_sub(monkeypatch):
13401+ """The stateful-session fallback path resolves a UUID sub to the email too."""
13402+ monkeypatch.setattr("mcpgateway.auth._get_email_by_id_sync", lambda user_id: "dev@example.com")
13403+
13404+ raw = {"sub": SESSION_SUB_UUID, "token_use": "session"}
13405+ with patch("mcpgateway.auth.resolve_session_teams", new_callable=AsyncMock, return_value=["team-x"]):
13406+ result = await _normalize_jwt_payload_ref()(raw)
13407+
13408+ assert result["email"] == "dev@example.com"
13409+ assert result["teams"] == ["team-x"]
13410+
13411+
13412+ def _normalize_jwt_payload_ref():
13413+ """Return the module's _normalize_jwt_payload (kept out of the test body for import hygiene).
13414+
13415+ Returns:
13416+ The _normalize_jwt_payload coroutine function under test.
13417+ """
13418+ return tr._normalize_jwt_payload
13419+
13420+
13421+ # ---------------------------------------------------------------------------
13422+ # _auth_jwt: session-token identity resolution (issue #5215 / #5750)
13423+ # ---------------------------------------------------------------------------
13424+
13425+
13426+ def _auth_handler():
13427+ """Build a _StreamableHttpAuthHandler over a minimal ASGI triple.
13428+
13429+ Returns:
13430+ A handler instance whose scope/receive/send are test doubles.
13431+ """
13432+ return tr._StreamableHttpAuthHandler({"type": "http", "headers": []}, AsyncMock(), AsyncMock())
13433+
13434+
13435+ @pytest.mark.asyncio
13436+ async def test_auth_jwt_session_token_keys_cache_and_lookups_by_email(monkeypatch):
13437+ """A session token's UUID sub must never reach the auth cache or the user lookup.
13438+
13439+ Regression guard for the sticky 401: before the fix the transport missed on a
13440+ UUID cache key, fell through to a UUID-keyed DB lookup, then wrote the empty
13441+ result back under that same UUID key.
13442+ """
13443+ handler = _auth_handler()
13444+
13445+ monkeypatch.setattr(tr, "verify_credentials", AsyncMock(return_value={"sub": SESSION_SUB_UUID, "token_use": "session", "jti": "jti-1"}))
13446+ monkeypatch.setattr(tr._StreamableHttpAuthHandler, "_route_idp_issued_token", AsyncMock(return_value=None))
13447+ monkeypatch.setattr("mcpgateway.auth._get_email_by_id_sync", lambda user_id: "dev@example.com")
13448+
13449+ auth_cache = MagicMock()
13450+ auth_cache.get_auth_context = AsyncMock(return_value=None)
13451+ auth_cache.get_user_teams = AsyncMock(return_value=None)
13452+ auth_cache.set_auth_context = AsyncMock()
13453+ auth_cache.set_user_teams = AsyncMock()
13454+ monkeypatch.setattr("mcpgateway.cache.auth_cache.get_auth_cache", lambda: auth_cache)
13455+ monkeypatch.setattr(tr.settings, "auth_cache_enabled", True)
13456+ monkeypatch.setattr(tr.settings, "auth_cache_batch_queries", True)
13457+
13458+ monkeypatch.setattr(
13459+ "mcpgateway.auth._get_auth_context_batched_sync",
13460+ lambda email, jti: {"user": {"email": email, "is_admin": False, "is_active": True}, "team_ids": ["team-x"], "is_token_revoked": False, "personal_team_id": None},
13461+ )
13462+ monkeypatch.setattr("mcpgateway.auth.resolve_session_teams", AsyncMock(return_value=["team-x"]))
13463+ monkeypatch.setattr("mcpgateway.auth.resolve_trace_team_name", AsyncMock(return_value=None))
13464+
13465+ assert await handler._auth_jwt(token="tok") is True
13466+
13467+ # Every identity-keyed call must use the email, never the UUID.
13468+ assert auth_cache.get_auth_context.await_args.args[0] == "dev@example.com"
13469+ assert auth_cache.set_auth_context.await_args.args[0] == "dev@example.com"
13470+ assert auth_cache.set_user_teams.await_args.args[0] == "dev@example.com:True"
13471+
13472+ ctx = tr.user_context_var.get()
13473+ assert ctx["email"] == "dev@example.com"
13474+ assert ctx["teams"] == ["team-x"]
13475+
13476+
13477+ @pytest.mark.asyncio
13478+ async def test_auth_jwt_session_token_unknown_user_still_denied(monkeypatch):
13479+ """Deny path preserved: an unresolvable UUID sub still 401s under require_user_in_db."""
13480+ handler = _auth_handler()
13481+ sent = []
13482+ monkeypatch.setattr(tr._StreamableHttpAuthHandler, "_send_error", AsyncMock(side_effect=lambda **kw: (sent.append(kw), False)[1]))
13483+
13484+ monkeypatch.setattr(tr, "verify_credentials", AsyncMock(return_value={"sub": SESSION_SUB_UUID, "token_use": "session", "jti": "jti-2"}))
13485+ monkeypatch.setattr(tr._StreamableHttpAuthHandler, "_route_idp_issued_token", AsyncMock(return_value=None))
13486+ monkeypatch.setattr("mcpgateway.auth._get_email_by_id_sync", lambda user_id: None)
13487+ monkeypatch.setattr(tr.settings, "auth_cache_enabled", False)
13488+ monkeypatch.setattr(tr.settings, "auth_cache_batch_queries", False)
13489+ monkeypatch.setattr(tr.settings, "require_user_in_db", True)
13490+ monkeypatch.setattr("mcpgateway.auth._check_token_revoked_sync", lambda jti: False)
13491+ monkeypatch.setattr("mcpgateway.auth._get_user_by_email_sync", lambda email: None)
13492+
13493+ assert await handler._auth_jwt(token="tok") is False
13494+ assert sent and sent[0]["detail"] == "User not found in database"
13495+
13496+
13497+ @pytest.mark.asyncio
13498+ async def test_auth_jwt_session_token_platform_admin_escape_hatch(monkeypatch):
13499+ """Resolving sub lets the platform-admin escape hatch apply to session tokens.
13500+
13501+ SECURITY: this is the one place the fix widens access. Before the fix a
13502+ platform-admin session token carried a UUID that never equalled
13503+ platform_admin_email, so the hatch could not apply.
13504+ """
13505+ handler = _auth_handler()
13506+
13507+ monkeypatch.setattr(tr, "verify_credentials", AsyncMock(return_value={"sub": SESSION_SUB_UUID, "token_use": "session", "is_admin": True, "jti": "jti-3"}))
13508+ monkeypatch.setattr(tr._StreamableHttpAuthHandler, "_route_idp_issued_token", AsyncMock(return_value=None))
13509+ monkeypatch.setattr("mcpgateway.auth._get_email_by_id_sync", lambda user_id: "admin@example.com")
13510+ monkeypatch.setattr(tr.settings, "auth_cache_enabled", False)
13511+ monkeypatch.setattr(tr.settings, "auth_cache_batch_queries", False)
13512+ monkeypatch.setattr(tr.settings, "require_user_in_db", True)
13513+ monkeypatch.setattr(tr.settings, "platform_admin_email", "admin@example.com")
13514+ monkeypatch.setattr("mcpgateway.auth._check_token_revoked_sync", lambda jti: False)
13515+ monkeypatch.setattr("mcpgateway.auth._get_user_by_email_sync", lambda email: None)
13516+ monkeypatch.setattr("mcpgateway.auth.resolve_session_teams", AsyncMock(return_value=None))
13517+ monkeypatch.setattr("mcpgateway.auth.resolve_trace_team_name", AsyncMock(return_value=None))
13518+
13519+ assert await handler._auth_jwt(token="tok") is True
13520+ assert tr.user_context_var.get()["email"] == "admin@example.com"
13521+
13522+
13523+ @pytest.mark.asyncio
13524+ async def test_auth_jwt_legacy_token_performs_no_subject_lookup(monkeypatch):
13525+ """Legacy/API tokens must not incur an extra DB read on the auth hot path."""
13526+ handler = _auth_handler()
13527+ lookup = Mock(return_value="should-not-be-used@example.com")
13528+
13529+ monkeypatch.setattr(tr, "verify_credentials", AsyncMock(return_value={"sub": "legacy@example.com", "token_use": "api", "jti": "jti-4"}))
13530+ monkeypatch.setattr(tr._StreamableHttpAuthHandler, "_route_idp_issued_token", AsyncMock(return_value=None))
13531+ monkeypatch.setattr("mcpgateway.auth._get_email_by_id_sync", lookup)
13532+ monkeypatch.setattr(tr.settings, "auth_cache_enabled", False)
13533+ monkeypatch.setattr(tr.settings, "auth_cache_batch_queries", False)
13534+ monkeypatch.setattr(tr.settings, "require_user_in_db", False)
13535+ monkeypatch.setattr("mcpgateway.auth._check_token_revoked_sync", lambda jti: False)
13536+ monkeypatch.setattr("mcpgateway.auth._get_user_by_email_sync", lambda email: None)
13537+ monkeypatch.setattr("mcpgateway.auth.normalize_token_teams", lambda payload: [])
13538+ monkeypatch.setattr("mcpgateway.auth.resolve_trace_team_name", AsyncMock(return_value=None))
13539+
13540+ assert await handler._auth_jwt(token="tok") is True
13541+ lookup.assert_not_called()
13542+ assert tr.user_context_var.get()["email"] == "legacy@example.com"
13543+
13544+
1328013545# ---------------------------------------------------------------------------
1328113546# call_tool: recovered context propagation regression test
1328213547# ---------------------------------------------------------------------------
0 commit comments