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
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user