Skip to content

Commit 9ab37cc

Browse files
committed
fix(auth): extend proxy auth enrichment to MCP transport path
The original PR #4320 added DB-backed team/admin resolution to require_auth, but left require_auth_header_first returning the old minimal payload (sub/source/token only). require_auth_header_first is the authentication entry point used by the MCP streamable HTTP transport, so proxy-authenticated MCP clients continued to receive public-only access via _normalize_jwt_payload -> normalize_token_teams([]) - the exact symptom #4262 reports. Changes: - Extract _authenticate_proxy_user(request, proxy_user) helper owning DB lookup, team resolution, payload construction, platform-admin bootstrap, and request.state caching. - Reuse the helper from both require_auth and require_auth_header_first so REST admin paths and the MCP transport path return the same enriched payload (sub, source, token, is_admin, teams, email). - Fix the now-inaccurate comment on require_auth_header_first's proxy branch that claimed it was 'identical to require_auth'. - Update the existing test_require_auth_header_first_proxy_auth_returns_proxy_user to assert the enriched payload shape. - Add regression tests covering admin-user DB bypass (is_admin=True, teams=None), multi-team membership, and require_auth_header_first parity (enriched payload and platform-admin bootstrap). Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent d144578 commit 9ab37cc

3 files changed

Lines changed: 228 additions & 50 deletions

File tree

mcpgateway/utils/verify_credentials.py

Lines changed: 85 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,86 @@ async def _enforce_revocation_and_active_user(payload: dict) -> None:
399399
_raise_auth_401("Account disabled")
400400

401401

402+
async def _authenticate_proxy_user(request: Request, proxy_user: str) -> dict:
403+
"""Authenticate a proxy-identified user and build an enriched auth payload.
404+
405+
Performs a DB lookup for the proxy-identified user, resolves their teams
406+
and admin status via ``_resolve_teams_from_db``, caches the payload on
407+
``request.state._jwt_verified_payload``, and returns it.
408+
409+
Supports a platform-admin bootstrap flow: when
410+
``settings.require_user_in_db`` is ``False`` **and** the proxy header
411+
matches ``settings.platform_admin_email``, an admin payload is returned
412+
without requiring a DB record (same policy applied to JWTs in
413+
``_enforce_revocation_and_active_user``).
414+
415+
This helper is shared by :func:`require_auth` and
416+
:func:`require_auth_header_first` so that proxy-authenticated callers get
417+
the same enriched context regardless of the entry point (REST admin paths
418+
vs MCP streamable HTTP transport).
419+
420+
Args:
421+
request: FastAPI request used to cache the payload for downstream code.
422+
proxy_user: The authenticated user identifier from the configured
423+
proxy header (e.g. ``X-Authenticated-User``).
424+
425+
Returns:
426+
dict: Enriched auth payload with keys ``sub``, ``source``, ``token``,
427+
``is_admin``, ``teams``, and ``email``. ``teams`` is ``None`` for
428+
admin bypass, ``[]`` for public-only, or a list of team ID strings.
429+
430+
Raises:
431+
HTTPException: 401 when the proxy-identified user is not present in
432+
the DB and the platform-admin bootstrap conditions do not apply.
433+
"""
434+
# First-Party
435+
from mcpgateway.auth import _resolve_teams_from_db # pylint: disable=import-outside-toplevel
436+
from mcpgateway.db import get_db # pylint: disable=import-outside-toplevel
437+
from mcpgateway.services.email_auth_service import EmailAuthService # pylint: disable=import-outside-toplevel
438+
439+
db = next(get_db())
440+
try:
441+
auth_service = EmailAuthService(db)
442+
user_info = await auth_service.get_user_by_email(proxy_user)
443+
444+
if user_info:
445+
# Resolve teams from DB (returns None for admin bypass, [] for no teams, or list of team IDs)
446+
token_teams = await _resolve_teams_from_db(proxy_user, user_info)
447+
payload = {
448+
"sub": proxy_user,
449+
"source": "proxy",
450+
"token": None, # nosec B105 - None is not a password
451+
"is_admin": user_info.is_admin,
452+
"teams": token_teams, # None for admin bypass, [] for public-only, or list of team IDs
453+
"email": proxy_user,
454+
}
455+
else:
456+
# User not in DB - handle based on REQUIRE_USER_IN_DB setting
457+
platform_admin_email = getattr(settings, "platform_admin_email", "admin@example.com")
458+
if not settings.require_user_in_db and proxy_user == platform_admin_email:
459+
# Platform admin bootstrap (matches the JWT path in _enforce_revocation_and_active_user)
460+
payload = {
461+
"sub": proxy_user,
462+
"source": "proxy",
463+
"token": None, # nosec B105 - None is not a password
464+
"is_admin": True,
465+
"teams": None, # Admin bypass
466+
"email": proxy_user,
467+
}
468+
else:
469+
raise HTTPException(
470+
status_code=status.HTTP_401_UNAUTHORIZED,
471+
detail="User not found in database",
472+
headers={"WWW-Authenticate": "Bearer"},
473+
)
474+
475+
# Cache in request state for downstream use (same pattern as JWT tokens)
476+
request.state._jwt_verified_payload = (None, payload)
477+
return payload
478+
finally:
479+
db.close()
480+
481+
402482
async def require_auth(request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), jwt_token: Optional[str] = Cookie(default=None)) -> str | dict:
403483
"""Require authentication via JWT token or proxy headers.
404484
@@ -486,52 +566,7 @@ async def require_auth(request: Request, credentials: Optional[HTTPAuthorization
486566
# Extract user from proxy header
487567
proxy_user = request.headers.get(settings.proxy_user_header)
488568
if proxy_user:
489-
# First-Party
490-
from mcpgateway.auth import _resolve_teams_from_db
491-
from mcpgateway.db import get_db
492-
from mcpgateway.services.email_auth_service import EmailAuthService
493-
494-
# Query database for user info
495-
db = next(get_db())
496-
try:
497-
auth_service = EmailAuthService(db)
498-
user_info = await auth_service.get_user_by_email(proxy_user)
499-
500-
if user_info:
501-
# Resolve teams from DB (returns None for admin bypass, [] for no teams, or list of team IDs)
502-
token_teams = await _resolve_teams_from_db(proxy_user, user_info)
503-
is_admin = user_info.is_admin
504-
505-
# Build enriched payload similar to session tokens
506-
payload = {
507-
"sub": proxy_user,
508-
"source": "proxy",
509-
"token": None,
510-
"is_admin": is_admin,
511-
"teams": token_teams, # None for admin bypass, [] for public-only, or list of team IDs
512-
"email": proxy_user,
513-
}
514-
515-
# Cache in request state for downstream use (same pattern as JWT tokens)
516-
request.state._jwt_verified_payload = (None, payload)
517-
518-
return payload
519-
else:
520-
# User not in DB - handle based on REQUIRE_USER_IN_DB setting
521-
platform_admin_email = getattr(settings, "platform_admin_email", "admin@example.com")
522-
if not settings.require_user_in_db and proxy_user == platform_admin_email:
523-
# Platform admin bootstrap
524-
payload = {"sub": proxy_user, "source": "proxy", "token": None, "is_admin": True, "teams": None, "email": proxy_user} # Admin bypass
525-
request.state._jwt_verified_payload = (None, payload)
526-
return payload
527-
else:
528-
raise HTTPException(
529-
status_code=status.HTTP_401_UNAUTHORIZED,
530-
detail="User not found in database",
531-
headers={"WWW-Authenticate": "Bearer"},
532-
)
533-
finally:
534-
db.close()
569+
return await _authenticate_proxy_user(request, proxy_user)
535570
# No proxy header - check auth_required (matches RBAC/WebSocket behavior)
536571
if settings.auth_required:
537572
raise HTTPException(
@@ -1089,12 +1124,14 @@ async def require_auth_header_first(
10891124
if request is None:
10901125
request = Request(scope={"type": "http", "headers": []})
10911126

1092-
# Proxy auth path — identical to require_auth
1127+
# Proxy auth path — shares _authenticate_proxy_user() with require_auth
1128+
# so proxy-authenticated callers get the same enriched payload (teams,
1129+
# is_admin, email, request.state caching) regardless of entry point.
10931130
if not settings.mcp_client_auth_enabled:
10941131
if is_proxy_auth_trust_active():
10951132
proxy_user = request.headers.get(settings.proxy_user_header)
10961133
if proxy_user:
1097-
return {"sub": proxy_user, "source": "proxy", "token": None} # nosec B105 - None is not a password
1134+
return await _authenticate_proxy_user(request, proxy_user)
10981135
if settings.auth_required:
10991136
raise HTTPException(
11001137
status_code=status.HTTP_401_UNAUTHORIZED,

tests/unit/mcpgateway/utils/test_proxy_auth.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,127 @@ async def test_proxy_auth_user_not_found_raises_401(self, mock_settings, mock_re
321321
assert exc_info.value.status_code == 401
322322
assert "User not found in database" in exc_info.value.detail
323323

324+
@pytest.mark.asyncio
325+
async def test_proxy_auth_admin_user_in_db_yields_admin_bypass(self, mock_settings, mock_request):
326+
"""Admin user found in DB gets is_admin=True and teams=None (admin bypass)."""
327+
mock_settings.mcp_client_auth_enabled = False
328+
mock_settings.trust_proxy_auth = True
329+
mock_settings.trust_proxy_auth_dangerously = True
330+
mock_request.headers = {"X-Authenticated-User": "admin-user@example.com"}
331+
mock_request.state = Mock()
332+
333+
mock_user = Mock()
334+
mock_user.is_admin = True
335+
mock_user.email = "admin-user@example.com"
336+
337+
with patch.object(vc, "settings", mock_settings), patch(
338+
"mcpgateway.db.get_db"
339+
) as mock_get_db, patch(
340+
"mcpgateway.services.email_auth_service.EmailAuthService"
341+
) as mock_auth_service, patch(
342+
"mcpgateway.auth._resolve_teams_from_db", new_callable=AsyncMock
343+
) as mock_resolve_teams:
344+
mock_get_db.return_value = iter([Mock()])
345+
mock_auth_service.return_value.get_user_by_email = AsyncMock(return_value=mock_user)
346+
# _resolve_teams_from_db returns None for admins (admin bypass)
347+
mock_resolve_teams.return_value = None
348+
349+
result = await vc.require_auth(mock_request, None, None)
350+
351+
assert result["sub"] == "admin-user@example.com"
352+
assert result["is_admin"] is True
353+
assert result["teams"] is None
354+
assert result["source"] == "proxy"
355+
356+
@pytest.mark.asyncio
357+
async def test_proxy_auth_multiple_teams(self, mock_settings, mock_request):
358+
"""Non-admin user with multiple team memberships returns the full list."""
359+
mock_settings.mcp_client_auth_enabled = False
360+
mock_settings.trust_proxy_auth = True
361+
mock_settings.trust_proxy_auth_dangerously = True
362+
mock_request.headers = {"X-Authenticated-User": "multi-team-user@example.com"}
363+
mock_request.state = Mock()
364+
365+
mock_user = Mock()
366+
mock_user.is_admin = False
367+
mock_user.email = "multi-team-user@example.com"
368+
369+
with patch.object(vc, "settings", mock_settings), patch(
370+
"mcpgateway.db.get_db"
371+
) as mock_get_db, patch(
372+
"mcpgateway.services.email_auth_service.EmailAuthService"
373+
) as mock_auth_service, patch(
374+
"mcpgateway.auth._resolve_teams_from_db", new_callable=AsyncMock
375+
) as mock_resolve_teams:
376+
mock_get_db.return_value = iter([Mock()])
377+
mock_auth_service.return_value.get_user_by_email = AsyncMock(return_value=mock_user)
378+
mock_resolve_teams.return_value = ["team-a", "team-b", "team-c"]
379+
380+
result = await vc.require_auth(mock_request, None, None)
381+
382+
assert result["is_admin"] is False
383+
assert result["teams"] == ["team-a", "team-b", "team-c"]
384+
385+
@pytest.mark.asyncio
386+
async def test_require_auth_header_first_proxy_returns_enriched_payload(self, mock_settings, mock_request):
387+
"""Parity test: require_auth_header_first returns the enriched payload (same helper)."""
388+
mock_settings.mcp_client_auth_enabled = False
389+
mock_settings.trust_proxy_auth = True
390+
mock_settings.trust_proxy_auth_dangerously = True
391+
mock_request.headers = {"X-Authenticated-User": "mcp-user@example.com"}
392+
mock_request.cookies = {}
393+
mock_request.state = Mock()
394+
395+
mock_user = Mock()
396+
mock_user.is_admin = False
397+
mock_user.email = "mcp-user@example.com"
398+
399+
with patch.object(vc, "settings", mock_settings), patch(
400+
"mcpgateway.db.get_db"
401+
) as mock_get_db, patch(
402+
"mcpgateway.services.email_auth_service.EmailAuthService"
403+
) as mock_auth_service, patch(
404+
"mcpgateway.auth._resolve_teams_from_db", new_callable=AsyncMock
405+
) as mock_resolve_teams:
406+
mock_get_db.return_value = iter([Mock()])
407+
mock_auth_service.return_value.get_user_by_email = AsyncMock(return_value=mock_user)
408+
mock_resolve_teams.return_value = ["team1"]
409+
410+
result = await vc.require_auth_header_first(auth_header=None, jwt_token=None, request=mock_request)
411+
412+
# Same enriched shape as require_auth (both go through _authenticate_proxy_user)
413+
assert result["sub"] == "mcp-user@example.com"
414+
assert result["source"] == "proxy"
415+
assert result["token"] is None
416+
assert result["is_admin"] is False
417+
assert result["teams"] == ["team1"]
418+
assert result["email"] == "mcp-user@example.com"
419+
420+
@pytest.mark.asyncio
421+
async def test_require_auth_header_first_proxy_admin_bootstrap(self, mock_settings, mock_request):
422+
"""Parity test: require_auth_header_first supports platform-admin bootstrap."""
423+
mock_settings.mcp_client_auth_enabled = False
424+
mock_settings.trust_proxy_auth = True
425+
mock_settings.trust_proxy_auth_dangerously = True
426+
mock_settings.require_user_in_db = False
427+
mock_settings.platform_admin_email = "admin@example.com"
428+
mock_request.headers = {"X-Authenticated-User": "admin@example.com"}
429+
mock_request.cookies = {}
430+
mock_request.state = Mock()
431+
432+
with patch.object(vc, "settings", mock_settings), patch(
433+
"mcpgateway.db.get_db"
434+
) as mock_get_db, patch(
435+
"mcpgateway.services.email_auth_service.EmailAuthService"
436+
) as mock_auth_service:
437+
mock_get_db.return_value = iter([Mock()])
438+
mock_auth_service.return_value.get_user_by_email = AsyncMock(return_value=None)
439+
440+
result = await vc.require_auth_header_first(auth_header=None, jwt_token=None, request=mock_request)
441+
442+
assert result["is_admin"] is True
443+
assert result["teams"] is None
444+
324445

325446
class TestRBACProxyAuthentication:
326447
"""Test cases for RBAC middleware proxy authentication functionality."""

tests/unit/mcpgateway/utils/test_verify_credentials.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1653,20 +1653,40 @@ async def test_require_auth_header_first_no_request_uses_jwt_token_param(monkeyp
16531653

16541654
@pytest.mark.asyncio
16551655
async def test_require_auth_header_first_proxy_auth_returns_proxy_user(monkeypatch):
1656-
"""Proxy user is returned when mcp_client_auth_enabled=False and trust_proxy_auth=True."""
1656+
"""Proxy user returns the enriched payload (shared helper with require_auth)."""
16571657
monkeypatch.setattr(vc.settings, "mcp_client_auth_enabled", False, raising=False)
16581658
monkeypatch.setattr(vc.settings, "trust_proxy_auth", True, raising=False)
16591659
monkeypatch.setattr(vc.settings, "trust_proxy_auth_dangerously", True, raising=False)
16601660
monkeypatch.setattr(vc.settings, "proxy_user_header", "x-authenticated-user", raising=False)
16611661
monkeypatch.setattr(vc.settings, "auth_required", True, raising=False)
1662+
monkeypatch.setattr(vc.settings, "require_user_in_db", True, raising=False)
16621663

16631664
mock_request = Mock(spec=Request)
16641665
mock_request.headers = {"x-authenticated-user": "proxy-user@example.com"}
16651666
mock_request.cookies = {}
1667+
mock_request.state = Mock()
1668+
1669+
mock_user = Mock()
1670+
mock_user.is_admin = False
1671+
mock_user.email = "proxy-user@example.com"
1672+
1673+
with patch("mcpgateway.db.get_db") as mock_get_db, patch(
1674+
"mcpgateway.services.email_auth_service.EmailAuthService"
1675+
) as mock_auth_service, patch(
1676+
"mcpgateway.auth._resolve_teams_from_db", new_callable=AsyncMock
1677+
) as mock_resolve_teams:
1678+
mock_get_db.return_value = iter([Mock()])
1679+
mock_auth_service.return_value.get_user_by_email = AsyncMock(return_value=mock_user)
1680+
mock_resolve_teams.return_value = ["team1"]
1681+
1682+
result = await vc.require_auth_header_first(auth_header=None, jwt_token=None, request=mock_request)
16661683

1667-
result = await vc.require_auth_header_first(auth_header=None, jwt_token=None, request=mock_request)
16681684
assert result["sub"] == "proxy-user@example.com"
16691685
assert result["source"] == "proxy"
1686+
assert result["token"] is None
1687+
assert result["is_admin"] is False
1688+
assert result["teams"] == ["team1"]
1689+
assert result["email"] == "proxy-user@example.com"
16701690

16711691

16721692
@pytest.mark.asyncio

0 commit comments

Comments
 (0)