116 lines
4.7 KiB
Python
116 lines
4.7 KiB
Python
"""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.gitea import GiteaExecutor
|
|
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 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",
|
|
"/token",
|
|
"/revoke",
|
|
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 = GiteaExecutor(
|
|
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,
|
|
)
|