Authorization server for MCP clients (SDK provider with dynamic client registration, PKCE, refresh rotation and revocation) that signs users in through Gitea and runs tool calls with the user's own Gitea token. - consent page with CSRF protection and a browser-binding cookie on the Gitea callback (confused-deputy protection for the shared Gitea app) - encrypted token store (Fernet, HKDF-derived key, 0600, atomic writes, symlink and corrupt-file refusal); our tokens stored only as hashes - tokens bound to the MCP endpoint resource and the gitea scope; the static bearer keeps working next to OAuth - code replay and refresh token reuse revoke the grant - automatic upstream refresh with per-session locking; invalid_grant ends the session and never falls back to the admin token - OAuth-only deployments (no GITEA_TOKEN, no bearer) may start - rate limit on the OAuth endpoints, query strings dropped from access logs
142 lines
5.5 KiB
Python
142 lines
5.5 KiB
Python
"""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 asyncio
|
|
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"
|
|
|
|
#: The one OAuth scope this server grants and requires on the MCP endpoint.
|
|
MCP_SCOPE = "gitea"
|
|
|
|
# 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.
|
|
resource: Resource (MCP endpoint URL) reported on the access token, so
|
|
the SDK's RFC 8707 audience check accepts the static bearer when
|
|
per-user OAuth enables it.
|
|
"""
|
|
|
|
def __init__(self, token: str, *, resource: str | None = None) -> None:
|
|
self._expected = _digest(token)
|
|
self._resource = resource
|
|
|
|
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=[MCP_SCOPE],
|
|
resource=self._resource,
|
|
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
|
|
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."""
|
|
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:
|
|
# 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:
|
|
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
|