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:
Latte committed 2026-10-14 16:41:18 +02:00
1 parent 5e144772b2
commit 566d0b65dd
10 files changed
+1813 -31

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
"""Assembles the per-user OAuth pieces for the HTTP transport."""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import cast
import httpx
from mcp.server.auth.routes import create_protected_resource_routes
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
from pydantic import AnyHttpUrl, SecretStr
from starlette.routing import Route
from aegis_gitea_mcp.audit import AuditLogger
from aegis_gitea_mcp.auth import MCP_SCOPE, AdminIdentityResolver
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.oauth import OAuthSetupError
from aegis_gitea_mcp.oauth.callback import build_browser_routes
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider, OAuthTokenVerifier
from aegis_gitea_mcp.oauth.resolver import OAuthGiteaExecutor, OAuthIdentityResolver
from aegis_gitea_mcp.oauth.store import OAuthStore, OAuthStoreError
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH, GiteaUpstream
from aegis_gitea_mcp.transports import HttpAuthExtras
#: Unauthenticated OAuth endpoints that get the per-IP rate limit.
RATE_LIMITED_PATHS = ("/register", "/authorize", CONSENT_PATH, CALLBACK_PATH)
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
def bare_protected_resource_route(settings: Settings) -> Route:
"""``/.well-known/oauth-protected-resource`` serving the same document as the suffixed path.
The SDK only mounts the RFC 9728 path-suffixed URL; some clients probe the
bare path too.
"""
base = settings.public_base_url or ""
auth = AuthSettings(
issuer_url=base, # type: ignore[arg-type]
resource_server_url=f"{base}{settings.mcp_path}", # type: ignore[arg-type]
validate_token_resource=True,
)
(suffixed,) = create_protected_resource_routes(
resource_url=cast(AnyHttpUrl, auth.resource_server_url),
authorization_servers=[auth.issuer_url],
scopes_supported=[MCP_SCOPE],
)
return Route(PROTECTED_RESOURCE_PATH, endpoint=suffixed.endpoint, methods=["GET", "OPTIONS"])
def build_oauth_components(
settings: Settings,
*,
clock: Callable[[], float] = time.time,
client_factory: Callable[[], httpx.AsyncClient] | None = None,
executor_client: httpx.AsyncClient | None = None,
audit: AuditLogger | None = None,
) -> HttpAuthExtras:
"""Build the provider, verifier, resolver, executor and routes for per-user OAuth.
An OAuth-only deployment (no ``GITEA_TOKEN``) is supported: users bring
their own Gitea tokens, and anything that would need admin-token mode
fails closed.
Args:
settings: Application settings with a complete OAuth configuration.
clock: Wall clock (epoch seconds); tests inject a fake one.
client_factory: Creates the HTTP client for Gitea OAuth exchanges.
executor_client: HTTP client for the tool executor (tests).
audit: Audit logger for OAuth security events.
Raises:
OAuthSetupError: The configuration is incomplete or the token store
cannot be opened.
"""
problems = settings.oauth_config_errors()
if not settings.oauth_enabled or problems:
raise OAuthSetupError("; ".join(problems) or "OAUTH_ENABLED is not set")
key = cast(SecretStr, settings.token_encryption_key) # checked by oauth_config_errors
try:
store = OAuthStore.load(settings.data_dir, key.get_secret_value(), clock)
except OAuthStoreError as exc:
raise OAuthSetupError(str(exc)) from None
except OSError as exc:
raise OAuthSetupError(
f"cannot read the OAuth token store in {settings.data_dir} ({type(exc).__name__})"
) from None
upstream = GiteaUpstream(settings, client_factory=client_factory, clock=clock)
provider = GiteaOAuthProvider(settings, store, upstream, clock=clock, audit=audit)
executor = OAuthGiteaExecutor(
settings, client=executor_client, extra_secrets=[key.get_secret_value()]
)
admin = AdminIdentityResolver(settings, executor) if settings.gitea_token is not None else None
resolver = OAuthIdentityResolver(store, upstream, admin, clock=clock, audit=audit)
return HttpAuthExtras(
token_verifier=OAuthTokenVerifier(provider),
auth_server_provider=provider,
resolver=resolver,
executor=executor,
extra_routes=(*build_browser_routes(provider), bare_protected_resource_route(settings)),
client_registration_options=ClientRegistrationOptions(
enabled=True, valid_scopes=[MCP_SCOPE], default_scopes=[MCP_SCOPE]
),
revocation_options=RevocationOptions(enabled=True),
rate_limited_paths=RATE_LIMITED_PATHS,
)