feat: add per-user OAuth mode proxying sign-in to Gitea
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
This commit is contained in:
1 parent
5e144772b2
commit
566d0b65dd
10 files changed
+1813
-31
No files matched your search
@@ -32,8 +32,15 @@ from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from aegis_gitea_mcp import __version__
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.auth import CompositeVerifier, IdentityResolver, StaticBearerVerifier
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.auth import (
|
||||
MCP_SCOPE,
|
||||
CompositeVerifier,
|
||||
IdentityResolver,
|
||||
StaticBearerVerifier,
|
||||
)
|
||||
from aegis_gitea_mcp.config import Settings, is_loopback_host
|
||||
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||
from aegis_gitea_mcp.rate_limit import RequestRateLimiter
|
||||
from aegis_gitea_mcp.server import (
|
||||
SERVER_NAME,
|
||||
@@ -69,6 +76,8 @@ class HttpAuthExtras:
|
||||
extra_routes: Extra unauthenticated routes (for example ``/oauth/callback``).
|
||||
client_registration_options: Dynamic client registration settings.
|
||||
revocation_options: Token revocation settings.
|
||||
executor: Gitea executor shared by the server and the resolver.
|
||||
rate_limited_paths: Unauthenticated paths that get the per-IP rate limit.
|
||||
"""
|
||||
|
||||
token_verifier: TokenVerifier | None = None
|
||||
@@ -77,12 +86,19 @@ class HttpAuthExtras:
|
||||
extra_routes: tuple[Route, ...] = ()
|
||||
client_registration_options: ClientRegistrationOptions | None = None
|
||||
revocation_options: RevocationOptions | None = None
|
||||
executor: GiteaExecutor | None = None
|
||||
rate_limited_paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def load_oauth_components(settings: Settings) -> HttpAuthExtras | None:
|
||||
"""Return the per-user OAuth components for ``OAUTH_ENABLED``, or None when unavailable."""
|
||||
del settings
|
||||
return None
|
||||
def load_oauth_components(settings: Settings) -> HttpAuthExtras:
|
||||
"""Build the per-user OAuth components for ``OAUTH_ENABLED``.
|
||||
|
||||
Raises:
|
||||
OAuthSetupError: Incomplete configuration or an unusable token store.
|
||||
"""
|
||||
from aegis_gitea_mcp.oauth.components import build_oauth_components
|
||||
|
||||
return build_oauth_components(settings)
|
||||
|
||||
|
||||
# Health probe
|
||||
@@ -133,15 +149,24 @@ class GiteaProbe:
|
||||
|
||||
|
||||
class RateLimitMiddleware:
|
||||
"""ASGI middleware applying the per-IP and per-token limits to the MCP path."""
|
||||
"""ASGI middleware applying the per-IP and per-token limits to the MCP path.
|
||||
|
||||
def __init__(self, app: ASGIApp, mcp_path: str, limiter: RequestRateLimiter) -> None:
|
||||
``extra_paths`` (for example the unauthenticated OAuth endpoints) share the limits.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
mcp_path: str,
|
||||
limiter: RequestRateLimiter,
|
||||
extra_paths: Sequence[str] = (),
|
||||
) -> None:
|
||||
self.app = app
|
||||
self._mcp_path = mcp_path
|
||||
self._paths = frozenset({mcp_path, *(path.rstrip("/") for path in extra_paths)})
|
||||
self._limiter = limiter
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope["path"].rstrip("/") != self._mcp_path:
|
||||
if scope["type"] != "http" or scope["path"].rstrip("/") not in self._paths:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
client = scope.get("client")
|
||||
@@ -191,19 +216,27 @@ def transport_security(settings: Settings) -> TransportSecuritySettings:
|
||||
)
|
||||
|
||||
|
||||
def _resource_url(settings: Settings) -> str:
|
||||
return f"{_base_url(settings)}{settings.mcp_path}"
|
||||
|
||||
|
||||
def _auth_settings(
|
||||
settings: Settings,
|
||||
client_registration_options: ClientRegistrationOptions | None,
|
||||
revocation_options: RevocationOptions | None,
|
||||
*,
|
||||
authorization_server: bool,
|
||||
) -> AuthSettings:
|
||||
issuer = _base_url(settings)
|
||||
return AuthSettings(
|
||||
issuer_url=issuer, # type: ignore[arg-type]
|
||||
resource_server_url=f"{issuer}{settings.mcp_path}", # type: ignore[arg-type]
|
||||
required_scopes=None,
|
||||
# The static bearer carries no RFC 8707 resource; a per-user verifier
|
||||
# that binds tokens to this resource checks the audience itself.
|
||||
validate_token_resource=False,
|
||||
resource_server_url=_resource_url(settings), # type: ignore[arg-type]
|
||||
# With our own authorization server every token (OAuth-issued and the
|
||||
# static bearer) names this endpoint as its RFC 8707 resource and carries
|
||||
# the one scope we grant, so the SDK enforces both. Without it, extra
|
||||
# verifiers are trusted to check the audience themselves.
|
||||
required_scopes=[MCP_SCOPE] if authorization_server else None,
|
||||
validate_token_resource=authorization_server,
|
||||
client_registration_options=client_registration_options,
|
||||
revocation_options=revocation_options,
|
||||
)
|
||||
@@ -219,6 +252,7 @@ def build_http_app(
|
||||
client_registration_options: ClientRegistrationOptions | None = None,
|
||||
revocation_options: RevocationOptions | None = None,
|
||||
probe: GiteaProbe | None = None,
|
||||
rate_limited_paths: Sequence[str] = (),
|
||||
) -> Starlette:
|
||||
"""Build the streamable HTTP ASGI app (MCP endpoint, ``/health``, ``/``).
|
||||
|
||||
@@ -236,6 +270,8 @@ def build_http_app(
|
||||
client_registration_options: Dynamic client registration settings.
|
||||
revocation_options: Token revocation settings.
|
||||
probe: Gitea reachability probe for ``/health`` (defaults to a new one).
|
||||
rate_limited_paths: Further paths (besides the MCP endpoint) that get the
|
||||
rate limit, for example the OAuth endpoints.
|
||||
|
||||
Raises:
|
||||
ValueError: ``auth_server_provider`` given without any token verifier.
|
||||
@@ -245,7 +281,11 @@ def build_http_app(
|
||||
|
||||
verifiers: list[TokenVerifier] = []
|
||||
if settings.mcp_bearer_token is not None:
|
||||
verifiers.append(StaticBearerVerifier(settings.mcp_bearer_token.get_secret_value()))
|
||||
verifiers.append(
|
||||
StaticBearerVerifier(
|
||||
settings.mcp_bearer_token.get_secret_value(), resource=_resource_url(settings)
|
||||
)
|
||||
)
|
||||
if token_verifier is not None:
|
||||
verifiers.append(token_verifier)
|
||||
if auth_server_provider is not None and not verifiers:
|
||||
@@ -289,7 +329,12 @@ def build_http_app(
|
||||
transport_security=transport_security(settings),
|
||||
host=settings.mcp_host,
|
||||
auth=(
|
||||
_auth_settings(settings, client_registration_options, revocation_options)
|
||||
_auth_settings(
|
||||
settings,
|
||||
client_registration_options,
|
||||
revocation_options,
|
||||
authorization_server=auth_server_provider is not None,
|
||||
)
|
||||
if verifier is not None
|
||||
else None
|
||||
),
|
||||
@@ -310,7 +355,10 @@ def build_http_app(
|
||||
|
||||
app.router.lifespan_context = lifespan
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware, mcp_path=settings.mcp_path, limiter=RequestRateLimiter(settings)
|
||||
RateLimitMiddleware,
|
||||
mcp_path=settings.mcp_path,
|
||||
limiter=RequestRateLimiter(settings),
|
||||
extra_paths=tuple(rate_limited_paths),
|
||||
)
|
||||
return app
|
||||
|
||||
@@ -319,13 +367,22 @@ def build_http_app(
|
||||
|
||||
|
||||
def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> list[str]:
|
||||
"""List reasons the HTTP server must not start (empty when startup may proceed)."""
|
||||
"""List reasons the HTTP server must not start (empty when startup may proceed).
|
||||
|
||||
An OAuth-only deployment (OAuth on, no ``MCP_BEARER_TOKEN``, no
|
||||
``GITEA_TOKEN``) may start: every user brings their own Gitea token and
|
||||
admin-token mode is simply unavailable. ``GITEA_TOKEN`` stays required
|
||||
whenever admin-token mode is reachable (a static bearer, or no HTTP auth).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
bearer = settings.mcp_bearer_token is not None
|
||||
if settings.oauth_enabled:
|
||||
errors.extend(settings.oauth_config_errors())
|
||||
if not settings.oauth_config_errors() and extras is None and not bearer:
|
||||
errors.append("OAUTH_ENABLED=true but per-user OAuth is not available in this build")
|
||||
if not settings.oauth_config_errors() and extras is None:
|
||||
errors.append("OAUTH_ENABLED=true but per-user OAuth could not be set up")
|
||||
public = urlsplit(settings.public_base_url or "")
|
||||
if public.scheme == "http" and not is_loopback_host(public.hostname or ""):
|
||||
errors.append("PUBLIC_BASE_URL must use https unless the host is loopback")
|
||||
authenticated = bearer or extras is not None
|
||||
if not settings.is_loopback_bind() and not authenticated:
|
||||
errors.append(
|
||||
@@ -333,7 +390,10 @@ def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> li
|
||||
"MCP_BEARER_TOKEN (or enable OAuth), or bind to 127.0.0.1"
|
||||
)
|
||||
if (bearer or extras is None) and settings.gitea_token is None:
|
||||
errors.append("GITEA_TOKEN is required for admin-token mode (static bearer or no auth)")
|
||||
message = "GITEA_TOKEN is required for admin-token mode (static bearer or no auth)"
|
||||
if settings.oauth_enabled and bearer:
|
||||
message += "; for an OAuth-only deployment unset MCP_BEARER_TOKEN"
|
||||
errors.append(message)
|
||||
return errors
|
||||
|
||||
|
||||
@@ -351,11 +411,6 @@ def http_startup_warnings(settings: Settings, extras: HttpAuthExtras | None) ->
|
||||
"bind address are rejected with 421. Set PUBLIC_BASE_URL (the public https URL) "
|
||||
"or ALLOWED_HOSTS (comma separated host names clients use)"
|
||||
)
|
||||
if settings.oauth_enabled and extras is None and settings.mcp_bearer_token is not None:
|
||||
warnings.append(
|
||||
"OAUTH_ENABLED=true but per-user OAuth is not available in this build: only "
|
||||
"MCP_BEARER_TOKEN is accepted and every call runs as the GITEA_TOKEN owner"
|
||||
)
|
||||
if settings.forwarded_allow_ips == "*":
|
||||
warnings.append(
|
||||
"FORWARDED_ALLOW_IPS=*: X-Forwarded-For is trusted from any peer, so clients can "
|
||||
@@ -382,13 +437,35 @@ def _audit_startup_error(settings: Settings) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
class AccessLogQueryFilter(logging.Filter):
|
||||
"""Drops query strings from uvicorn access log lines.
|
||||
|
||||
OAuth redirects carry one-time codes and states in the query string
|
||||
(``/oauth/callback?code=...&state=...``); they must not reach the logs.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Rewrite the path argument of an access log record; never drops the record."""
|
||||
args = record.args
|
||||
if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str):
|
||||
path, separator, _ = args[2].partition("?")
|
||||
if separator:
|
||||
record.args = (*args[:2], f"{path}?[redacted]", *args[3:])
|
||||
return True
|
||||
|
||||
|
||||
def run_http(settings: Settings) -> int:
|
||||
"""Run the streamable HTTP server; return a process exit code.
|
||||
|
||||
Returns 2 without starting when a startup check fails (including an audit
|
||||
log that cannot be opened for append).
|
||||
"""
|
||||
extras = load_oauth_components(settings) if settings.oauth_enabled else None
|
||||
extras: HttpAuthExtras | None = None
|
||||
if settings.oauth_enabled and not settings.oauth_config_errors():
|
||||
try:
|
||||
extras = load_oauth_components(settings)
|
||||
except OAuthSetupError as exc:
|
||||
return _fail(f"per-user OAuth cannot start: {exc}")
|
||||
errors = http_startup_errors(settings, extras)
|
||||
if errors:
|
||||
for message in errors:
|
||||
@@ -400,7 +477,11 @@ def run_http(settings: Settings) -> int:
|
||||
for message in http_startup_warnings(settings, extras):
|
||||
print(f"aegis-gitea-mcp: warning: {message}", file=sys.stderr)
|
||||
try:
|
||||
server = build_server(settings, resolver=extras.resolver if extras else None)
|
||||
server = build_server(
|
||||
settings,
|
||||
resolver=extras.resolver if extras else None,
|
||||
executor=extras.executor if extras else None,
|
||||
)
|
||||
except ServerConfigError as exc:
|
||||
return _fail(str(exc))
|
||||
app = build_http_app(
|
||||
@@ -411,7 +492,11 @@ def run_http(settings: Settings) -> int:
|
||||
extra_routes=extras.extra_routes if extras else (),
|
||||
client_registration_options=extras.client_registration_options if extras else None,
|
||||
revocation_options=extras.revocation_options if extras else None,
|
||||
rate_limited_paths=extras.rate_limited_paths if extras else (),
|
||||
)
|
||||
access_logger = logging.getLogger("uvicorn.access")
|
||||
if not any(isinstance(item, AccessLogQueryFilter) for item in access_logger.filters):
|
||||
access_logger.addFilter(AccessLogQueryFilter())
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.mcp_host,
|
||||
|
||||
Reference in new issue
Block a user