feat: add MCP server core, stdio and streamable HTTP transports and serve command
Low-level SDK server built from the catalog plus gitea_api_request, with TOOLSETS/READ_ONLY/DISABLED_TOOLS filters, schema validation without value echo, one audit event per call, static bearer verifier, identity resolver hook, /health, DNS-rebinding protection, rate limiting and fail-closed startup checks.
This commit is contained in:
1 parent
581ca67bf8
commit
c9e2c481e7
11 files changed
+2846
-14
No files matched your search
@@ -0,0 +1,128 @@
|
||||
"""HTTP authentication: static bearer verification and identity resolution.
|
||||
|
||||
The SDK validates the ``Authorization: Bearer`` header with a
|
||||
:class:`~mcp.server.auth.provider.TokenVerifier`. This module provides the
|
||||
static-token verifier (admin-token mode), a composite verifier so per-user OAuth
|
||||
can be added next to it, and the :class:`IdentityResolver` that maps the verified
|
||||
token to the :class:`~aegis_gitea_mcp.identity.Identity` a tool call runs as.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.identity import Identity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||
|
||||
#: ``client_id`` of the access token the static bearer verifier returns.
|
||||
STATIC_CLIENT_ID = "aegis-static-bearer"
|
||||
|
||||
# After a failed ``GET /user`` the admin login lookup is not retried for this long.
|
||||
_LOGIN_RETRY_SECONDS = 30.0
|
||||
|
||||
|
||||
def _digest(value: str) -> bytes:
|
||||
return hashlib.sha256(value.encode("utf-8")).digest()
|
||||
|
||||
|
||||
class StaticBearerVerifier:
|
||||
"""Accepts exactly one shared secret (``MCP_BEARER_TOKEN``).
|
||||
|
||||
The comparison is constant time and does not leak the length of the secret:
|
||||
both values are hashed first and the digests are compared with
|
||||
:func:`hmac.compare_digest`. The returned access token never carries the real
|
||||
secret.
|
||||
|
||||
Args:
|
||||
token: The expected bearer token.
|
||||
"""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
self._expected = _digest(token)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Return an admin access token when ``token`` matches, else ``None``."""
|
||||
if not hmac.compare_digest(_digest(token), self._expected):
|
||||
return None
|
||||
return AccessToken(
|
||||
token="static", # noqa: S106 (placeholder, never the real secret)
|
||||
client_id=STATIC_CLIENT_ID,
|
||||
scopes=["gitea"],
|
||||
subject="admin",
|
||||
)
|
||||
|
||||
|
||||
class CompositeVerifier:
|
||||
"""Tries several verifiers in order; the first one that accepts wins."""
|
||||
|
||||
def __init__(self, *verifiers: TokenVerifier) -> None:
|
||||
self._verifiers = tuple(verifiers)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Return the first non-``None`` result of the wrapped verifiers."""
|
||||
for verifier in self._verifiers:
|
||||
access = await verifier.verify_token(token)
|
||||
if access is not None:
|
||||
return access
|
||||
return None
|
||||
|
||||
|
||||
class IdentityResolver(Protocol):
|
||||
"""Maps the verified access token of a request to the identity a call runs as."""
|
||||
|
||||
async def resolve(self, access: AccessToken | None) -> Identity:
|
||||
"""Return the identity for ``access`` (``None`` on stdio and in-memory).
|
||||
|
||||
Raises:
|
||||
ToolError: When the access token maps to no known identity.
|
||||
"""
|
||||
... # pragma: no cover (protocol)
|
||||
|
||||
|
||||
class AdminIdentityResolver:
|
||||
"""Admin-token mode: every call runs with ``GITEA_TOKEN``.
|
||||
|
||||
Applies to stdio and in-memory use (no access token) and to the static
|
||||
bearer. Any other client id is refused so a per-user resolver can be plugged
|
||||
in instead. The login of the token owner is looked up once with
|
||||
``GET /user`` and cached; when the lookup fails the call still proceeds with
|
||||
an unknown login.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings, executor: GiteaExecutor) -> None:
|
||||
self._settings = settings
|
||||
self._executor = executor
|
||||
self._login: str | None = None
|
||||
self._retry_at = 0.0
|
||||
|
||||
async def resolve(self, access: AccessToken | None) -> Identity:
|
||||
"""Return the admin identity, or raise for an access token of another kind."""
|
||||
if access is not None and access.client_id != STATIC_CLIENT_ID:
|
||||
raise ToolError("unknown identity", status_code=401)
|
||||
secret = self._settings.gitea_token
|
||||
if secret is None:
|
||||
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
|
||||
return Identity(login=self._login, mode="admin-token", token=token)
|
||||
|
||||
async def _lookup_login(self, token: str) -> str | None:
|
||||
probe = Identity(login=None, mode="admin-token", token=token)
|
||||
outcome = await self._executor.request("GET", "/user", probe)
|
||||
if outcome.is_error:
|
||||
return None
|
||||
data = outcome.payload.get("data")
|
||||
login = data.get("login") if isinstance(data, dict) else None
|
||||
return login if isinstance(login, str) and login else None
|
||||
Reference in new issue
Block a user