diff --git a/src/aegis_gitea_mcp/auth.py b/src/aegis_gitea_mcp/auth.py index fb0c57f..7598b93 100644 --- a/src/aegis_gitea_mcp/auth.py +++ b/src/aegis_gitea_mcp/auth.py @@ -9,6 +9,7 @@ token to the :class:`~aegis_gitea_mcp.identity.Identity` a tool call runs as. from __future__ import annotations +import asyncio import hashlib import hmac import time @@ -103,6 +104,7 @@ class AdminIdentityResolver: self._executor = executor self._login: str | None = None self._retry_at = 0.0 + self._lookup_lock = asyncio.Lock() async def resolve(self, access: AccessToken | None) -> Identity: """Return the admin identity, or raise for an access token of another kind.""" @@ -113,9 +115,12 @@ class AdminIdentityResolver: raise ToolError("admin-token mode needs GITEA_TOKEN, which is not configured") token = secret.get_secret_value() if self._login is None and time.monotonic() >= self._retry_at: - self._login = await self._lookup_login(token) - if self._login is None: - self._retry_at = time.monotonic() + _LOGIN_RETRY_SECONDS + # One lookup for concurrent first calls: later callers wait, then see the result. + async with self._lookup_lock: + if self._login is None and time.monotonic() >= self._retry_at: + self._login = await self._lookup_login(token) + if self._login is None: + self._retry_at = time.monotonic() + _LOGIN_RETRY_SECONDS return Identity(login=self._login, mode="admin-token", token=token) async def _lookup_login(self, token: str) -> str | None: diff --git a/tests/test_auth.py b/tests/test_auth.py index 747b174..b6aed0b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -17,8 +17,13 @@ from aegis_gitea_mcp.auth import ( StaticBearerVerifier, ) from aegis_gitea_mcp.errors import ToolError -from aegis_gitea_mcp.gitea import GiteaExecutor -from tests.test_server_helpers import API, BEARER_TOKEN, GITEA_TOKEN, server_settings +from aegis_gitea_mcp.gitea import GiteaExecutor, ToolOutcome +from tests.test_server_helpers import ( + API, + BEARER_TOKEN, + GITEA_TOKEN, + server_settings, +) # Static bearer verifier @@ -171,3 +176,23 @@ async def test_missing_gitea_token_is_refused() -> None: with pytest.raises(ToolError, match="GITEA_TOKEN"): await resolver.resolve(None) + + +async def test_concurrent_first_calls_send_one_login_lookup() -> None: + import asyncio + + lookups: list[str] = [] + + class SlowExecutor(GiteaExecutor): + async def request(self, method: str, path: str, *args: Any, **kwargs: Any) -> ToolOutcome: + lookups.append(path) + await asyncio.sleep(0.01) # a real network round trip yields to other calls + return ToolOutcome(False, {"data": {"login": "admin"}}, 200) + + settings = server_settings() + resolver = AdminIdentityResolver(settings, SlowExecutor(settings)) + + identities = await asyncio.gather(*(resolver.resolve(None) for _ in range(10))) + + assert {identity.login for identity in identities} == {"admin"} + assert lookups == ["/user"]