"""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, )