Skip to content

Commit 9805c71

Browse files
marekdanoMarek Dano
authored andcommitted
fix: address oauth-status review feedback (N+1 queries, error visibility)
Batch /oauth/status now issues one gateway SELECT and reuses a single TokenStorageService across all ids instead of one per id. Batch access checks distinguish expected 403/404 omissions from 5xx/unexpected failures, which are now logged instead of silently dropped. Database token lookups re-raise on failure instead of collapsing to the same "missing" result as a token that was never issued, so a transient DB error is distinguishable in logs from "never authorized". Signed-off-by: Marek Dano <mk.dano@gmail.com>
1 parent 8dee1c7 commit 9805c71

6 files changed

Lines changed: 183 additions & 47 deletions

File tree

docs/docs/manage/oauth-troubleshooting.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ state_data = {
7676
| `/oauth/authorize/{gateway_id}` | GET | Initiates OAuth flow, redirects to provider |
7777
| `/vault/authorize/{server_id}` | GET | Per-user OAuth credential connection for team virtual servers (shared access control with `/oauth/authorize/{gateway_id}`) |
7878
| `/oauth/callback` | GET | Handles OAuth callback, exchanges code for tokens |
79-
| `/oauth/status/{gateway_id}` | GET | Returns OAuth configuration status |
79+
| `/oauth/status/{gateway_id}` | GET | Returns OAuth configuration status; for `authorization_code` gateways also includes the caller's own `user_token_status` (valid/near_expiry/expired/missing) |
80+
| `/oauth/status?gateway_ids=a&gateway_ids=b` | GET | Batch equivalent of the above, keyed by gateway id, so a grid of cards issues one request instead of N |
8081
| `/oauth/fetch-tools/{gateway_id}` | POST | Fetches tools from MCP server after OAuth completion |
8182
| `/oauth/registered-clients` | GET | Lists all DCR-registered OAuth clients. Requires `admin.oauth_clients:read` and un-narrowed platform admin access |
8283
| `/oauth/registered-clients/{gateway_id}` | GET | Gets registered client for specific gateway. Requires `admin.oauth_clients:read` and un-narrowed platform admin access |

mcpgateway/routers/oauth_router.py

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,7 +1333,7 @@ def custom_redirect_after_callback(url: str, status_code: int) -> RedirectRespon
13331333
return RedirectResponse(url=url, status_code=status_code, headers={"Referrer-Policy": "no-referrer"})
13341334

13351335

1336-
async def _get_caller_token_status(db: Session, current_user: Any, gateway_id: str) -> Dict[str, Any]:
1336+
async def _get_caller_token_status(db: Session, current_user: Any, gateway_id: str, *, token_storage: Optional[TokenStorageService] = None) -> Dict[str, Any]:
13371337
"""Look up the caller's own OAuth token state for a gateway.
13381338
13391339
Wires the already-implemented ``TokenStorageService.get_token_info`` into
@@ -1344,6 +1344,9 @@ async def _get_caller_token_status(db: Session, current_user: Any, gateway_id: s
13441344
db: Active database session.
13451345
current_user: Authenticated requester context (dict or EmailUserResponse).
13461346
gateway_id: Gateway identifier to look up.
1347+
token_storage: Optional pre-built ``TokenStorageService`` to reuse across
1348+
multiple lookups (batch endpoint) instead of constructing a new one
1349+
per gateway.
13471350
13481351
Returns:
13491352
Dict with ``authorized`` (bool) and ``status`` (one of "missing",
@@ -1354,9 +1357,16 @@ async def _get_caller_token_status(db: Session, current_user: Any, gateway_id: s
13541357
if requester_email == "unknown" or not requester_email.strip():
13551358
return {"status": "missing", "authorized": False}
13561359

1357-
user_context = _build_user_context(current_user)
1358-
token_storage = TokenStorageService(db, user_context)
1359-
info = await token_storage.get_token_info(gateway_id, requester_email)
1360+
if token_storage is None:
1361+
token_storage = TokenStorageService(db, _build_user_context(current_user))
1362+
1363+
try:
1364+
info = await token_storage.get_token_info(gateway_id, requester_email)
1365+
except Exception as e:
1366+
# The backend already logs its own failure; this line ties it to the caller/gateway
1367+
# so a transient lookup failure is distinguishable in logs from a genuinely missing token.
1368+
logger.error("OAuth token status lookup failed for gateway=%s user=%s: %s", gateway_id, requester_email, str(e))
1369+
return {"status": "missing", "authorized": False}
13601370

13611371
if not info:
13621372
return {"status": "missing", "authorized": False}
@@ -1371,6 +1381,45 @@ async def _get_caller_token_status(db: Session, current_user: Any, gateway_id: s
13711381
}
13721382

13731383

1384+
def _build_oauth_status_payload(gateway: Gateway) -> Dict[str, Any]:
1385+
"""Build the OAuth config portion of a gateway's status payload (no I/O).
1386+
1387+
Shared by the single-gateway and batch status endpoints so the two never
1388+
drift. Caller is responsible for gateway lookup, access enforcement, and
1389+
attaching ``user_token_status`` for ``authorization_code`` grants.
1390+
1391+
Args:
1392+
gateway: Gateway record with ``oauth_config`` already loaded.
1393+
1394+
Returns:
1395+
Dict describing OAuth enablement and, when configured, grant details.
1396+
"""
1397+
if not gateway.oauth_config:
1398+
return {"oauth_enabled": False, "message": "Gateway is not configured for OAuth"}
1399+
1400+
oauth_config = gateway.oauth_config
1401+
grant_type = oauth_config.get("grant_type")
1402+
1403+
if grant_type == "authorization_code":
1404+
return {
1405+
"oauth_enabled": True,
1406+
"grant_type": grant_type,
1407+
"client_id": oauth_config.get("client_id"),
1408+
"scopes": oauth_config.get("scopes", []),
1409+
"authorization_url": oauth_config.get("authorization_url"),
1410+
"redirect_uri": oauth_config.get("redirect_uri"),
1411+
"message": "Gateway configured for Authorization Code flow",
1412+
}
1413+
1414+
return {
1415+
"oauth_enabled": True,
1416+
"grant_type": grant_type,
1417+
"client_id": oauth_config.get("client_id"),
1418+
"scopes": oauth_config.get("scopes", []),
1419+
"message": f"Gateway configured for {grant_type} flow",
1420+
}
1421+
1422+
13741423
@oauth_router.get("/status/{gateway_id}")
13751424
async def get_oauth_status(
13761425
gateway_id: str,
@@ -1408,32 +1457,10 @@ async def get_oauth_status(
14081457

14091458
await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
14101459

1411-
if not gateway.oauth_config:
1412-
return {"oauth_enabled": False, "message": "Gateway is not configured for OAuth"}
1413-
1414-
# Get OAuth configuration info
1415-
oauth_config = gateway.oauth_config
1416-
grant_type = oauth_config.get("grant_type")
1417-
1418-
if grant_type == "authorization_code":
1419-
return {
1420-
"oauth_enabled": True,
1421-
"grant_type": grant_type,
1422-
"client_id": oauth_config.get("client_id"),
1423-
"scopes": oauth_config.get("scopes", []),
1424-
"authorization_url": oauth_config.get("authorization_url"),
1425-
"redirect_uri": oauth_config.get("redirect_uri"),
1426-
"message": "Gateway configured for Authorization Code flow",
1427-
"user_token_status": await _get_caller_token_status(db, current_user, gateway_id),
1428-
}
1429-
else:
1430-
return {
1431-
"oauth_enabled": True,
1432-
"grant_type": grant_type,
1433-
"client_id": oauth_config.get("client_id"),
1434-
"scopes": oauth_config.get("scopes", []),
1435-
"message": f"Gateway configured for {grant_type} flow",
1436-
}
1460+
payload = _build_oauth_status_payload(gateway)
1461+
if payload.get("grant_type") == "authorization_code":
1462+
payload["user_token_status"] = await _get_caller_token_status(db, current_user, gateway_id)
1463+
return payload
14371464

14381465
except HTTPException:
14391466
raise
@@ -1480,13 +1507,39 @@ async def get_oauth_status_batch(
14801507
if len(deduped_ids) > OAUTH_STATUS_BATCH_MAX_IDS:
14811508
raise HTTPException(status_code=400, detail=f"Too many gateway_ids requested (max {OAUTH_STATUS_BATCH_MAX_IDS})")
14821509

1510+
# Single query for all requested gateways, and one TokenStorageService reused across
1511+
# the loop, instead of get_oauth_status()'s per-id gateway SELECT + TokenStorageService
1512+
# construction - the batch route exists specifically to avoid N+1 round trips.
1513+
gateways_by_id = {gw.id: gw for gw in db.execute(select(Gateway).where(Gateway.id.in_(deduped_ids))).scalars().all()}
1514+
token_storage = TokenStorageService(db, _build_user_context(current_user))
1515+
14831516
results: Dict[str, Dict[str, Any]] = {}
14841517
for gateway_id in deduped_ids:
1518+
gateway = gateways_by_id.get(gateway_id)
1519+
if not gateway:
1520+
# Not found - omit rather than failing the batch.
1521+
continue
1522+
14851523
try:
1486-
results[gateway_id] = await get_oauth_status(gateway_id, request, current_user, db)
1487-
except HTTPException:
1488-
# Not found / not accessible to this caller - omit rather than failing the batch.
1524+
await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
1525+
except HTTPException as exc:
1526+
if exc.status_code >= 500:
1527+
logger.error("OAuth status batch: access check failed for gateway=%s: %s", gateway_id, exc.detail)
1528+
# Not accessible to this caller (or a lookup failure, logged above) - omit rather than failing the batch.
14891529
continue
1530+
except Exception as exc:
1531+
logger.error("OAuth status batch: access check raised for gateway=%s: %s", gateway_id, str(exc))
1532+
continue
1533+
1534+
try:
1535+
payload = _build_oauth_status_payload(gateway)
1536+
if payload.get("grant_type") == "authorization_code":
1537+
payload["user_token_status"] = await _get_caller_token_status(db, current_user, gateway_id, token_storage=token_storage)
1538+
results[gateway_id] = payload
1539+
except Exception as exc:
1540+
logger.error("OAuth status batch: failed to build status for gateway=%s: %s", gateway_id, str(exc))
1541+
continue
1542+
14901543
return results
14911544

14921545

mcpgateway/services/token_backends/db_backend.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,24 @@ async def get_token_info(
267267
268268
Phase 1: team_id parameter is IGNORED.
269269
270+
Returns ``None`` only when no token record exists for this
271+
(gateway_id, app_user_email) pair. An unexpected failure (e.g. a DB
272+
error) is logged and re-raised rather than swallowed to ``None``, so
273+
callers exposing this through a user-facing status field (see
274+
``mcpgateway.routers.oauth_router._get_caller_token_status``) can
275+
distinguish "never authorized" from "lookup failed" - the two read
276+
identically to a caller if both collapse to ``None``.
277+
270278
Args:
271279
gateway_id: Gateway ID
272280
team_id: Team identifier (IGNORED in Phase 1)
273281
app_user_email: ContextForge user email
274282
275283
Returns:
276-
Token info dict or None
284+
Token info dict, or None if no token is stored.
285+
286+
Raises:
287+
Exception: Propagated from the underlying database query on failure.
277288
"""
278289
try:
279290
# PHASE 1: Query by (gateway_id, app_user_email) - team_id IGNORED
@@ -302,7 +313,7 @@ async def get_token_info(
302313

303314
except Exception as e:
304315
logger.error("Failed to get token info: %s", str(e))
305-
return None
316+
raise
306317

307318
async def revoke_user_tokens(
308319
self,

tests/unit/mcpgateway/routers/test_oauth_router.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,10 +1274,29 @@ async def test_get_oauth_status_user_token_status_not_shared_across_users(self,
12741274

12751275
mock_token_storage.get_token_info.assert_awaited_once_with("gateway123", "other@example.com")
12761276

1277+
@pytest.mark.asyncio
1278+
async def test_get_oauth_status_user_token_status_lookup_failure_logs_and_reads_missing(self, mock_db, mock_gateway, mock_current_user, mock_request):
1279+
"""A backend lookup failure (e.g. a DB error) is logged, distinguishing it from a genuinely missing token in logs,
1280+
while still surfacing the same safe "missing"/unauthorized shape to the client rather than a 500."""
1281+
mock_db.execute.return_value.scalar_one_or_none.return_value = mock_gateway
1282+
1283+
from mcpgateway.routers.oauth_router import get_oauth_status
1284+
1285+
with patch("mcpgateway.routers.oauth_router.TokenStorageService") as mock_token_storage_class:
1286+
mock_token_storage = Mock()
1287+
mock_token_storage.get_token_info = AsyncMock(side_effect=RuntimeError("db unavailable"))
1288+
mock_token_storage_class.return_value = mock_token_storage
1289+
1290+
with patch("mcpgateway.routers.oauth_router.logger") as mock_logger:
1291+
result = await get_oauth_status("gateway123", mock_request, mock_current_user, mock_db)
1292+
1293+
assert result["user_token_status"] == {"status": "missing", "authorized": False}
1294+
mock_logger.error.assert_called_once()
1295+
12771296
@pytest.mark.asyncio
12781297
async def test_get_oauth_status_batch_success(self, mock_db, mock_gateway, mock_current_user, mock_request):
12791298
"""Batch endpoint returns the same per-gateway payload as the single endpoint, keyed by gateway id."""
1280-
mock_db.execute.return_value.scalar_one_or_none.return_value = mock_gateway
1299+
mock_db.execute.return_value.scalars.return_value.all.return_value = [mock_gateway]
12811300

12821301
from mcpgateway.routers.oauth_router import get_oauth_status_batch
12831302

@@ -1293,17 +1312,63 @@ async def test_get_oauth_status_batch_success(self, mock_db, mock_gateway, mock_
12931312
assert result["gateway123"]["oauth_enabled"] is True
12941313
assert result["gateway123"]["user_token_status"]["status"] == "missing"
12951314

1315+
@pytest.mark.asyncio
1316+
async def test_get_oauth_status_batch_single_gateway_query(self, mock_db, mock_gateway, mock_current_user, mock_request):
1317+
"""Batch endpoint issues one Gateway SELECT and one TokenStorageService for the whole batch, not one per id."""
1318+
mock_db.execute.return_value.scalars.return_value.all.return_value = [mock_gateway]
1319+
1320+
from mcpgateway.routers.oauth_router import get_oauth_status_batch
1321+
1322+
with patch("mcpgateway.routers.oauth_router.TokenStorageService") as mock_token_storage_class:
1323+
mock_token_storage = Mock()
1324+
mock_token_storage.get_token_info = AsyncMock(return_value=None)
1325+
mock_token_storage_class.return_value = mock_token_storage
1326+
1327+
result = await get_oauth_status_batch(mock_request, ["gateway123", "gateway123"], mock_current_user, mock_db)
1328+
1329+
assert mock_db.execute.call_count == 1
1330+
mock_token_storage_class.assert_called_once()
1331+
assert result["gateway123"]["oauth_enabled"] is True
1332+
12961333
@pytest.mark.asyncio
12971334
async def test_get_oauth_status_batch_omits_inaccessible_gateways(self, mock_db, mock_current_user, mock_request):
12981335
"""A gateway id that 404s or 403s for this caller is silently dropped, not surfaced as a batch failure."""
1299-
mock_db.execute.return_value.scalar_one_or_none.return_value = None # every id -> gateway not found
1336+
mock_db.execute.return_value.scalars.return_value.all.return_value = [] # every id -> gateway not found
13001337

13011338
from mcpgateway.routers.oauth_router import get_oauth_status_batch
13021339

13031340
result = await get_oauth_status_batch(mock_request, ["missing1", "missing2"], mock_current_user, mock_db)
13041341

13051342
assert result == {}
13061343

1344+
@pytest.mark.asyncio
1345+
async def test_get_oauth_status_batch_logs_access_check_server_errors(self, mock_db, mock_gateway, mock_current_user, mock_request):
1346+
"""A 5xx from the per-gateway access check is logged and omitted, not silently swallowed like a 404/403."""
1347+
mock_db.execute.return_value.scalars.return_value.all.return_value = [mock_gateway]
1348+
1349+
from mcpgateway.routers.oauth_router import get_oauth_status_batch
1350+
1351+
with patch("mcpgateway.routers.oauth_router._enforce_gateway_access", new=AsyncMock(side_effect=HTTPException(status_code=500, detail="boom"))):
1352+
with patch("mcpgateway.routers.oauth_router.logger") as mock_logger:
1353+
result = await get_oauth_status_batch(mock_request, ["gateway123"], mock_current_user, mock_db)
1354+
1355+
assert result == {}
1356+
mock_logger.error.assert_called_once()
1357+
1358+
@pytest.mark.asyncio
1359+
async def test_get_oauth_status_batch_does_not_log_404_or_403(self, mock_db, mock_gateway, mock_current_user, mock_request):
1360+
"""A 404/403 from the per-gateway access check is omitted without an error log - it's an expected outcome, not a fault."""
1361+
mock_db.execute.return_value.scalars.return_value.all.return_value = [mock_gateway]
1362+
1363+
from mcpgateway.routers.oauth_router import get_oauth_status_batch
1364+
1365+
with patch("mcpgateway.routers.oauth_router._enforce_gateway_access", new=AsyncMock(side_effect=HTTPException(status_code=403, detail="nope"))):
1366+
with patch("mcpgateway.routers.oauth_router.logger") as mock_logger:
1367+
result = await get_oauth_status_batch(mock_request, ["gateway123"], mock_current_user, mock_db)
1368+
1369+
assert result == {}
1370+
mock_logger.error.assert_not_called()
1371+
13071372
@pytest.mark.asyncio
13081373
async def test_get_oauth_status_batch_requires_gateway_ids(self, mock_db, mock_current_user, mock_request):
13091374
from mcpgateway.routers.oauth_router import get_oauth_status_batch

tests/unit/mcpgateway/services/test_token_storage_service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -828,9 +828,10 @@ async def test_get_token_info_not_found(service, mock_db):
828828

829829
@pytest.mark.asyncio
830830
async def test_get_token_info_exception(service, mock_db):
831+
"""A backend failure propagates rather than collapsing to None, which is reserved for "no token stored"."""
831832
mock_db.execute.side_effect = Exception("DB error")
832-
result = await service.get_token_info("gw-1", "user@test.com")
833-
assert result is None
833+
with pytest.raises(Exception, match="DB error"):
834+
await service.get_token_info("gw-1", "user@test.com")
834835

835836

836837
# ---------- revoke_user_tokens ----------

tests/unit/mcpgateway/services/token_backends/test_db_backend.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -603,18 +603,23 @@ async def test_get_token_info_expired_token(backend_with_encryption, mock_db):
603603

604604
@pytest.mark.asyncio
605605
async def test_get_token_info_exception(backend_with_encryption, mock_db):
606-
"""Test get_token_info handles exceptions gracefully."""
606+
"""get_token_info logs and re-raises on failure rather than returning None.
607+
608+
None is reserved for "no token stored" - collapsing a lookup failure into the same
609+
value would make it indistinguishable from "never authorized" to callers exposing
610+
this through a user-facing status field.
611+
"""
607612
mock_db.execute.side_effect = Exception("Database error")
608613

609614
with patch("mcpgateway.services.token_backends.db_backend.logger") as mock_logger:
610-
result = await backend_with_encryption.get_token_info(
611-
gateway_id="gw-1",
612-
team_id="team-1",
613-
app_user_email="user@test.com",
614-
)
615+
with pytest.raises(Exception, match="Database error"):
616+
await backend_with_encryption.get_token_info(
617+
gateway_id="gw-1",
618+
team_id="team-1",
619+
app_user_email="user@test.com",
620+
)
615621

616622
mock_logger.error.assert_called_once()
617-
assert result is None
618623

619624

620625
@pytest.mark.asyncio

0 commit comments

Comments
 (0)