Add OAuth2/OIDC per-user Gitea authentication
Some checks failed
docker / lint (push) Has been cancelled
docker / test (push) Has been cancelled
docker / docker-build (push) Has been cancelled
lint / lint (push) Has been cancelled
test / test (push) Has been cancelled

Introduce a GiteaOAuthValidator for JWT and userinfo validation and
fallbacks, add /oauth/token proxy, and thread per-user tokens through
the
request context and automation paths. Update config and .env.example for
OAuth-first mode, add OpenAPI, extensive unit/integration tests,
GitHub/Gitea CI workflows, docs, and lint/test enforcement (>=80% cov).
This commit is contained in:
2026-02-25 16:54:01 +01:00
parent a00b6a0ba2
commit 59e1ea53a8
31 changed files with 2575 additions and 660 deletions

View File

@@ -79,6 +79,7 @@ class AutomationManager:
job_name: str,
owner: str,
repo: str,
user_token: str | None = None,
finding_title: str | None = None,
finding_body: str | None = None,
) -> dict[str, Any]:
@@ -109,11 +110,12 @@ class AutomationManager:
if job_name == "dependency_hygiene_scan":
return await self._dependency_hygiene_scan(owner, repo)
if job_name == "stale_issue_detection":
return await self._stale_issue_detection(owner, repo)
return await self._stale_issue_detection(owner, repo, user_token=user_token)
if job_name == "auto_issue_creation":
return await self._auto_issue_creation(
owner,
repo,
user_token=user_token,
finding_title=finding_title,
finding_body=finding_body,
)
@@ -142,13 +144,17 @@ class AutomationManager:
"findings": [],
}
async def _stale_issue_detection(self, owner: str, repo: str) -> dict[str, Any]:
async def _stale_issue_detection(
self, owner: str, repo: str, user_token: str | None
) -> dict[str, Any]:
"""Detect stale issues using repository issue metadata."""
repository = f"{owner}/{repo}"
cutoff = datetime.now(timezone.utc) - timedelta(days=self.settings.automation_stale_days)
if not user_token:
raise AutomationError("missing authenticated user token")
stale_issue_numbers: list[int] = []
async with GiteaClient() as gitea:
async with GiteaClient(token=user_token) as gitea:
issues = await gitea.list_issues(
owner,
repo,
@@ -187,6 +193,7 @@ class AutomationManager:
self,
owner: str,
repo: str,
user_token: str | None,
finding_title: str | None,
finding_body: str | None,
) -> dict[str, Any]:
@@ -194,8 +201,10 @@ class AutomationManager:
repository = f"{owner}/{repo}"
title = finding_title or "Automated security finding"
body = finding_body or "Automated finding created by Aegis automation workflow."
if not user_token:
raise AutomationError("missing authenticated user token")
async with GiteaClient() as gitea:
async with GiteaClient(token=user_token) as gitea:
issue = await gitea.create_issue(
owner,
repo,

View File

@@ -31,7 +31,10 @@ class Settings(BaseSettings):
# Gitea configuration
gitea_url: HttpUrl = Field(..., description="Base URL of the Gitea instance")
gitea_token: str = Field(..., description="Bot user access token for Gitea API", min_length=1)
gitea_token: str = Field(
default="",
description=("Deprecated shared bot token. Not used for MCP tool execution in OAuth mode."),
)
# MCP server configuration
mcp_host: str = Field(
@@ -96,6 +99,40 @@ class Settings(BaseSettings):
description="Secret detection mode: off, mask, or block",
)
# OAuth2 configuration (for ChatGPT per-user Gitea authentication)
oauth_mode: bool = Field(
default=False,
description=(
"Enable per-user OAuth2 authentication mode. "
"When true, each ChatGPT user authenticates with their own Gitea account. "
"GITEA_TOKEN and MCP_API_KEYS are not required in this mode."
),
)
gitea_oauth_client_id: str = Field(
default="",
description="Gitea OAuth2 application client ID (required when oauth_mode=true)",
)
gitea_oauth_client_secret: str = Field(
default="",
description="Gitea OAuth2 application client secret (required when oauth_mode=true)",
)
oauth_expected_audience: str = Field(
default="",
description=(
"Expected OIDC audience for access tokens. "
"Defaults to GITEA_OAUTH_CLIENT_ID when unset."
),
)
oauth_cache_ttl_seconds: int = Field(
default=300,
description="OIDC discovery/JWKS cache TTL in seconds",
ge=30,
)
oauth_resource_documentation: str = Field(
default="https://hiddenden.cafe/docs/mcp-gitea",
description="Public documentation URL for OAuth-protected MCP resource behavior",
)
# Authentication configuration
auth_enabled: bool = Field(
default=True,
@@ -170,10 +207,10 @@ class Settings(BaseSettings):
@field_validator("gitea_token")
@classmethod
def validate_token_not_empty(cls, value: str) -> str:
"""Validate Gitea token is non-empty and trimmed."""
"""Validate Gitea token is trimmed (empty string allowed for oauth_mode)."""
cleaned = value.strip()
if not cleaned:
raise ValueError("gitea_token cannot be empty or whitespace")
if value and not cleaned:
raise ValueError("gitea_token cannot be whitespace-only")
return cleaned
@field_validator("secret_detection_mode")
@@ -217,11 +254,21 @@ class Settings(BaseSettings):
"Set ALLOW_INSECURE_BIND=true to explicitly permit this."
)
if self.auth_enabled and not parsed_keys:
raise ValueError(
"At least one API key must be configured when auth_enabled=True. "
"Set MCP_API_KEYS or disable auth explicitly for controlled testing."
)
if self.oauth_mode:
# In OAuth mode, per-user Gitea tokens are used; no shared bot token or API keys needed.
if not self.gitea_oauth_client_id.strip():
raise ValueError("GITEA_OAUTH_CLIENT_ID is required when OAUTH_MODE=true.")
if not self.gitea_oauth_client_secret.strip():
raise ValueError("GITEA_OAUTH_CLIENT_SECRET is required when OAUTH_MODE=true.")
else:
# Standard API key mode: require bot token and at least one API key.
if not self.gitea_token.strip():
raise ValueError("GITEA_TOKEN is required unless OAUTH_MODE=true.")
if self.auth_enabled and not parsed_keys:
raise ValueError(
"At least one API key must be configured when auth_enabled=True. "
"Set MCP_API_KEYS or disable auth explicitly for controlled testing."
)
# Enforce minimum key length to reduce brute-force success probability.
for key in parsed_keys:

View File

@@ -19,7 +19,7 @@ class GiteaAuthenticationError(GiteaError):
class GiteaAuthorizationError(GiteaError):
"""Raised when bot user lacks permission for an operation."""
"""Raised when the authenticated user lacks permission for an operation."""
class GiteaNotFoundError(GiteaError):
@@ -27,19 +27,21 @@ class GiteaNotFoundError(GiteaError):
class GiteaClient:
"""Client for interacting with Gitea API as a bot user."""
"""Client for interacting with Gitea API as the authenticated end-user."""
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
def __init__(self, token: str, base_url: str | None = None) -> None:
"""Initialize Gitea client.
Args:
token: OAuth access token for the authenticated user.
base_url: Optional base URL override.
token: Optional token override.
"""
self.settings = get_settings()
self.audit = get_audit_logger()
self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/")
self.token = token or self.settings.gitea_token
self.token = token.strip()
if not self.token:
raise ValueError("GiteaClient requires a non-empty per-user OAuth token")
self.client: AsyncClient | None = None
async def __aenter__(self) -> GiteaClient:
@@ -47,7 +49,7 @@ class GiteaClient:
self.client = AsyncClient(
base_url=self.base_url,
headers={
"Authorization": f"token {self.token}",
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
timeout=self.settings.request_timeout_seconds,
@@ -79,15 +81,15 @@ class GiteaClient:
severity="high",
metadata={"correlation_id": correlation_id},
)
raise GiteaAuthenticationError("Authentication failed - check bot token")
raise GiteaAuthenticationError("Authentication failed - user token rejected")
if response.status_code == 403:
self.audit.log_access_denied(
tool_name="gitea_api",
reason="bot user lacks permission",
reason="authenticated user lacks permission",
correlation_id=correlation_id,
)
raise GiteaAuthorizationError("Bot user lacks permission for this operation")
raise GiteaAuthorizationError("Authenticated user lacks permission for this operation")
if response.status_code == 404:
raise GiteaNotFoundError("Resource not found")
@@ -123,7 +125,7 @@ class GiteaClient:
return self._handle_response(response, correlation_id)
async def get_current_user(self) -> dict[str, Any]:
"""Get current bot user profile."""
"""Get current authenticated user profile."""
correlation_id = self.audit.log_tool_invocation(
tool_name="get_current_user",
result_status="pending",
@@ -146,7 +148,7 @@ class GiteaClient:
raise
async def list_repositories(self) -> list[dict[str, Any]]:
"""List all repositories visible to the bot user."""
"""List repositories visible to the authenticated user."""
correlation_id = self.audit.log_tool_invocation(
tool_name="list_repositories",
result_status="pending",

View File

@@ -55,7 +55,7 @@ def _tool(
return MCPTool(
name=name,
description=description,
input_schema=schema,
inputSchema=schema,
write_operation=write_operation,
)

View File

@@ -0,0 +1,366 @@
"""OAuth2/OIDC token validation for per-user Gitea authentication."""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from typing import Any, cast
import httpx
import jwt
from jwt import InvalidTokenError
from jwt.algorithms import RSAAlgorithm
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
class OAuthTokenValidationError(RuntimeError):
"""Raised when a provided OAuth token cannot be trusted."""
def __init__(self, public_message: str, reason: str) -> None:
"""Initialize validation error details."""
super().__init__(public_message)
self.public_message = public_message
self.reason = reason
class GiteaOAuthValidator:
"""Validate per-user OAuth access tokens issued by Gitea."""
def __init__(self) -> None:
"""Initialize OAuth validator state and caches."""
self.settings = get_settings()
self.audit = get_audit_logger()
self._failed_attempts: dict[str, list[datetime]] = {}
self._discovery_cache: tuple[dict[str, Any], float] | None = None
self._jwks_cache: dict[str, tuple[dict[str, Any], float]] = {}
@staticmethod
def extract_bearer_token(authorization_header: str | None) -> str | None:
"""Extract token from `Authorization: Bearer <token>` header."""
if not authorization_header:
return None
scheme, separator, token = authorization_header.partition(" ")
if separator != " " or scheme != "Bearer":
return None
stripped = token.strip()
if not stripped or " " in stripped:
return None
return stripped
def _check_rate_limit(self, identifier: str) -> bool:
"""Check whether authentication failures exceed configured threshold."""
now = datetime.now(timezone.utc)
boundary = now.timestamp() - self.settings.auth_failure_window
if identifier in self._failed_attempts:
self._failed_attempts[identifier] = [
attempt
for attempt in self._failed_attempts[identifier]
if attempt.timestamp() > boundary
]
return len(self._failed_attempts.get(identifier, [])) < self.settings.max_auth_failures
def _record_failed_attempt(self, identifier: str) -> None:
"""Record a failed authentication attempt for rate limiting."""
attempt_time = datetime.now(timezone.utc)
self._failed_attempts.setdefault(identifier, []).append(attempt_time)
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
self.audit.log_security_event(
event_type="oauth_rate_limit_exceeded",
description="OAuth authentication failure threshold exceeded",
severity="high",
metadata={
"identifier": identifier,
"failure_count": len(self._failed_attempts[identifier]),
"window_seconds": self.settings.auth_failure_window,
},
)
@staticmethod
def _looks_like_jwt(token: str) -> bool:
"""Return True when token has JWT segment structure."""
return token.count(".") == 2
@staticmethod
def _normalize_scopes(raw: Any) -> set[str]:
"""Normalize scope claim variations to a set."""
normalized: set[str] = set()
if isinstance(raw, str):
normalized.update(scope for scope in raw.split(" ") if scope)
elif isinstance(raw, list):
normalized.update(str(scope).strip() for scope in raw if str(scope).strip())
return normalized
def _extract_scopes(self, payload: dict[str, Any]) -> set[str]:
"""Extract scopes from JWT or userinfo payload."""
scopes = set()
scopes.update(self._normalize_scopes(payload.get("scope")))
scopes.update(self._normalize_scopes(payload.get("scopes")))
scopes.update(self._normalize_scopes(payload.get("scp")))
return scopes
async def _fetch_json_document(self, url: str) -> dict[str, Any]:
"""Fetch a JSON document from a trusted OAuth endpoint."""
try:
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.get(url, headers={"Accept": "application/json"})
except httpx.RequestError as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_network_error",
) from exc
if response.status_code != 200:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_metadata_unavailable",
)
try:
data = response.json()
except ValueError as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_metadata_invalid_json",
) from exc
if not isinstance(data, dict):
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_metadata_invalid_type",
)
return data
async def _get_discovery_document(self) -> dict[str, Any]:
"""Get cached OIDC discovery metadata."""
now = time.monotonic()
if self._discovery_cache and now < self._discovery_cache[1]:
return self._discovery_cache[0]
discovery_url = f"{self.settings.gitea_base_url}/.well-known/openid-configuration"
discovery = await self._fetch_json_document(discovery_url)
issuer = discovery.get("issuer")
jwks_uri = discovery.get("jwks_uri")
if not isinstance(issuer, str) or not issuer.strip():
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_discovery_missing_issuer",
)
if not isinstance(jwks_uri, str) or not jwks_uri.strip():
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_discovery_missing_jwks_uri",
)
self._discovery_cache = (discovery, now + self.settings.oauth_cache_ttl_seconds)
return discovery
async def _get_jwks(self, jwks_uri: str) -> dict[str, Any]:
"""Get cached JWKS document."""
now = time.monotonic()
cached = self._jwks_cache.get(jwks_uri)
if cached and now < cached[1]:
return cached[0]
jwks = await self._fetch_json_document(jwks_uri)
keys = jwks.get("keys")
if not isinstance(keys, list) or not keys:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_jwks_missing_keys",
)
self._jwks_cache[jwks_uri] = (jwks, now + self.settings.oauth_cache_ttl_seconds)
return jwks
async def _validate_jwt(self, token: str) -> dict[str, Any]:
"""Validate JWT access token using OIDC discovery and JWKS."""
discovery = await self._get_discovery_document()
issuer = str(discovery["issuer"]).rstrip("/")
jwks_uri = str(discovery["jwks_uri"])
jwks = await self._get_jwks(jwks_uri)
try:
header = jwt.get_unverified_header(token)
except InvalidTokenError as exc:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_jwt_header"
) from exc
algorithm = header.get("alg")
key_id = header.get("kid")
if algorithm != "RS256":
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_alg")
if not isinstance(key_id, str) or not key_id.strip():
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_kid")
matching_key = None
for key in jwks.get("keys", []):
if isinstance(key, dict) and key.get("kid") == key_id:
matching_key = key
break
if matching_key is None:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_jwt_key_not_found"
)
try:
public_key = RSAAlgorithm.from_jwk(json.dumps(matching_key))
except Exception as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_jwt_invalid_jwk",
) from exc
expected_audience = (
self.settings.oauth_expected_audience.strip()
or self.settings.gitea_oauth_client_id.strip()
)
decode_options = cast(Any, {"verify_aud": bool(expected_audience)})
try:
claims = jwt.decode(
token,
key=cast(Any, public_key),
algorithms=["RS256"],
issuer=issuer,
audience=expected_audience or None,
options=decode_options,
)
except InvalidTokenError as exc:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_jwt_invalid"
) from exc
if not isinstance(claims, dict):
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_claims")
scopes = self._extract_scopes(claims)
login = (
str(claims.get("preferred_username", "")).strip()
or str(claims.get("name", "")).strip()
or str(claims.get("sub", "unknown")).strip()
)
subject = str(claims.get("sub", login)).strip() or "unknown"
return {
"login": login,
"subject": subject,
"scopes": sorted(scopes),
}
async def _validate_userinfo(self, token: str) -> dict[str, Any]:
"""Validate token via Gitea userinfo endpoint (opaque token fallback)."""
userinfo_url = f"{self.settings.gitea_base_url}/login/oauth/userinfo"
try:
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.get(
userinfo_url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
)
except httpx.RequestError as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_userinfo_network",
) from exc
if response.status_code in {401, 403}:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_userinfo_denied"
)
if response.status_code != 200:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_userinfo_unavailable",
)
try:
payload = response.json()
except ValueError as exc:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_userinfo_json"
) from exc
if not isinstance(payload, dict):
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_userinfo_type"
)
scopes = self._extract_scopes(payload)
login = (
str(payload.get("preferred_username", "")).strip()
or str(payload.get("login", "")).strip()
or str(payload.get("name", "")).strip()
or str(payload.get("sub", "unknown")).strip()
)
subject = str(payload.get("sub", login)).strip() or "unknown"
return {
"login": login,
"subject": subject,
"scopes": sorted(scopes),
}
async def validate_oauth_token(
self,
token: str | None,
client_ip: str,
user_agent: str,
) -> tuple[bool, str | None, dict[str, Any] | None]:
"""Validate an incoming OAuth token and return principal context."""
if not self._check_rate_limit(client_ip):
return False, "Too many failed authentication attempts. Try again later.", None
if not token:
self._record_failed_attempt(client_ip)
return False, "Authorization header missing or empty.", None
try:
if self._looks_like_jwt(token):
try:
principal = await self._validate_jwt(token)
except OAuthTokenValidationError:
# Some providers issue opaque access tokens; verify those via userinfo.
principal = await self._validate_userinfo(token)
else:
principal = await self._validate_userinfo(token)
except OAuthTokenValidationError as exc:
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
tool_name="oauth_authentication",
reason=exc.reason,
)
return False, exc.public_message, None
self.audit.log_tool_invocation(
tool_name="oauth_authentication",
result_status="success",
params={
"client_ip": client_ip,
"user_agent": user_agent,
"gitea_user": principal.get("login", "unknown"),
},
)
return True, None, principal
_oauth_validator: GiteaOAuthValidator | None = None
def get_oauth_validator() -> GiteaOAuthValidator:
"""Get or create the global OAuth validator instance."""
global _oauth_validator
if _oauth_validator is None:
_oauth_validator = GiteaOAuthValidator()
return _oauth_validator
def reset_oauth_validator() -> None:
"""Reset the global OAuth validator instance (primarily for testing)."""
global _oauth_validator
_oauth_validator = None

View File

@@ -5,6 +5,9 @@ from __future__ import annotations
from contextvars import ContextVar
_REQUEST_ID: ContextVar[str] = ContextVar("request_id", default="-")
_GITEA_USER_TOKEN: ContextVar[str | None] = ContextVar("gitea_user_token", default=None)
_GITEA_USER_LOGIN: ContextVar[str | None] = ContextVar("gitea_user_login", default=None)
_GITEA_USER_SCOPES: ContextVar[tuple[str, ...]] = ContextVar("gitea_user_scopes", default=())
def set_request_id(request_id: str) -> None:
@@ -15,3 +18,40 @@ def set_request_id(request_id: str) -> None:
def get_request_id() -> str:
"""Get current request id from context-local state."""
return _REQUEST_ID.get()
def set_gitea_user_token(token: str) -> None:
"""Store the per-request Gitea OAuth user token in context-local state."""
_GITEA_USER_TOKEN.set(token)
def get_gitea_user_token() -> str | None:
"""Get the per-request Gitea OAuth user token from context-local state."""
return _GITEA_USER_TOKEN.get()
def set_gitea_user_login(login: str) -> None:
"""Store the authenticated Gitea username in context-local state."""
_GITEA_USER_LOGIN.set(login)
def get_gitea_user_login() -> str | None:
"""Get the authenticated Gitea username from context-local state."""
return _GITEA_USER_LOGIN.get()
def set_gitea_user_scopes(scopes: list[str] | set[str] | tuple[str, ...]) -> None:
"""Store normalized OAuth scopes for the current request."""
_GITEA_USER_SCOPES.set(tuple(sorted({scope.strip() for scope in scopes if scope.strip()})))
def get_gitea_user_scopes() -> tuple[str, ...]:
"""Get OAuth scopes attached to the current request."""
return _GITEA_USER_SCOPES.get()
def clear_gitea_auth_context() -> None:
"""Reset per-request Gitea authentication context values."""
_GITEA_USER_TOKEN.set(None)
_GITEA_USER_LOGIN.set(None)
_GITEA_USER_SCOPES.set(())

View File

@@ -9,19 +9,15 @@ import uuid
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from pydantic import BaseModel, Field, ValidationError
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.auth import get_validator
from aegis_gitea_mcp.automation import AutomationError, AutomationManager
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaClient,
)
from aegis_gitea_mcp.gitea_client import GiteaClient
from aegis_gitea_mcp.logging_utils import configure_logging
from aegis_gitea_mcp.mcp_protocol import (
AVAILABLE_TOOLS,
@@ -30,10 +26,19 @@ from aegis_gitea_mcp.mcp_protocol import (
MCPToolCallResponse,
get_tool_by_name,
)
from aegis_gitea_mcp.oauth import get_oauth_validator
from aegis_gitea_mcp.observability import get_metrics_registry, monotonic_seconds
from aegis_gitea_mcp.policy import PolicyError, get_policy_engine
from aegis_gitea_mcp.rate_limit import get_rate_limiter
from aegis_gitea_mcp.request_context import set_request_id
from aegis_gitea_mcp.request_context import (
clear_gitea_auth_context,
get_gitea_user_scopes,
get_gitea_user_token,
set_gitea_user_login,
set_gitea_user_scopes,
set_gitea_user_token,
set_request_id,
)
from aegis_gitea_mcp.security import sanitize_data
from aegis_gitea_mcp.tools.arguments import extract_repository, extract_target_path
from aegis_gitea_mcp.tools.read_tools import (
@@ -66,6 +71,9 @@ from aegis_gitea_mcp.tools.write_tools import (
logger = logging.getLogger(__name__)
READ_SCOPE = "read:repository"
WRITE_SCOPE = "write:repository"
app = FastAPI(
title="AegisGitea MCP Server",
description="Security-first MCP server for controlled AI access to self-hosted Gitea",
@@ -121,6 +129,32 @@ TOOL_HANDLERS: dict[str, ToolHandler] = {
}
def _oauth_metadata_url(request: Request) -> str:
"""Build absolute metadata URL for OAuth challenge responses."""
return f"{str(request.base_url).rstrip('/')}/.well-known/oauth-protected-resource"
def _oauth_unauthorized_response(
request: Request,
message: str,
scope: str = READ_SCOPE,
) -> JSONResponse:
"""Return RFC-compliant OAuth challenge response for protected MCP endpoints."""
metadata_url = _oauth_metadata_url(request)
response = JSONResponse(
status_code=401,
content={
"error": "Authentication failed",
"message": message,
"request_id": getattr(request.state, "request_id", "-"),
},
)
response.headers["WWW-Authenticate"] = (
f'Bearer resource_metadata="{metadata_url}", scope="{scope}"'
)
return response
@app.middleware("http")
async def request_context_middleware(
request: Request,
@@ -160,6 +194,7 @@ async def authenticate_and_rate_limit(
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Apply rate-limiting and authentication for MCP endpoints."""
clear_gitea_auth_context()
settings = get_settings()
if request.url.path in {"/", "/health"}:
@@ -169,21 +204,27 @@ async def authenticate_and_rate_limit(
# Metrics endpoint is intentionally left unauthenticated for pull-based scraping.
return await call_next(request)
# OAuth discovery and token endpoints must be public so ChatGPT can complete the flow.
if request.url.path in {
"/oauth/token",
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-authorization-server",
}:
return await call_next(request)
if not (request.url.path.startswith("/mcp/") or request.url.path.startswith("/automation/")):
return await call_next(request)
validator = get_validator()
oauth_validator = get_oauth_validator()
limiter = get_rate_limiter()
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "unknown")
auth_header = request.headers.get("authorization")
api_key = validator.extract_bearer_token(auth_header)
if not api_key and request.url.path in {"/mcp/tool/call", "/mcp/sse"}:
api_key = request.query_params.get("api_key")
access_token = oauth_validator.extract_bearer_token(auth_header)
rate_limit = limiter.check(client_ip=client_ip, token=api_key)
rate_limit = limiter.check(client_ip=client_ip, token=access_token)
if not rate_limit.allowed:
return JSONResponse(
status_code=429,
@@ -198,18 +239,46 @@ async def authenticate_and_rate_limit(
if request.url.path == "/mcp/tools":
return await call_next(request)
is_valid, error_message = validator.validate_api_key(api_key, client_ip, user_agent)
if not is_valid:
if not access_token:
if request.url.path.startswith("/mcp/"):
return _oauth_unauthorized_response(
request,
"Provide Authorization: Bearer <token>.",
scope=READ_SCOPE,
)
return JSONResponse(
status_code=401,
content={
"error": "Authentication failed",
"message": error_message,
"detail": "Provide Authorization: Bearer <api-key> or ?api_key=<api-key>",
"message": "Provide Authorization: Bearer <token>.",
"request_id": getattr(request.state, "request_id", "-"),
},
)
is_valid, error_message, user_data = await oauth_validator.validate_oauth_token(
access_token, client_ip, user_agent
)
if not is_valid:
if request.url.path.startswith("/mcp/"):
return _oauth_unauthorized_response(
request,
error_message or "Invalid or expired OAuth token.",
scope=READ_SCOPE,
)
return JSONResponse(
status_code=401,
content={
"error": "Authentication failed",
"message": error_message or "Invalid or expired OAuth token.",
"request_id": getattr(request.state, "request_id", "-"),
},
)
if user_data:
set_gitea_user_token(access_token)
set_gitea_user_login(str(user_data.get("login", "unknown")))
set_gitea_user_scopes(user_data.get("scopes", []))
return await call_next(request)
@@ -240,23 +309,25 @@ async def startup_event() -> None:
raise
if settings.startup_validate_gitea and settings.environment != "test":
discovery_url = f"{settings.gitea_base_url}/.well-known/openid-configuration"
try:
async with GiteaClient() as gitea:
user = await gitea.get_current_user()
logger.info("gitea_connected", extra={"bot_user": user.get("login", "unknown")})
except GiteaAuthenticationError as exc:
logger.error("gitea_connection_failed_authentication")
async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client:
response = await client.get(discovery_url, headers={"Accept": "application/json"})
except httpx.RequestError as exc:
logger.error("gitea_oidc_discovery_request_failed")
raise RuntimeError(
"Startup validation failed: Gitea authentication was rejected. Check GITEA_TOKEN."
"Startup validation failed: unable to reach Gitea OIDC discovery endpoint."
) from exc
except GiteaAuthorizationError as exc:
logger.error("gitea_connection_failed_authorization")
if response.status_code != 200:
logger.error(
"gitea_oidc_discovery_non_200", extra={"status_code": response.status_code}
)
raise RuntimeError(
"Startup validation failed: Gitea token lacks permission for /api/v1/user."
) from exc
except Exception as exc:
logger.error("gitea_connection_failed")
raise RuntimeError("Startup validation failed: unable to connect to Gitea.") from exc
"Startup validation failed: Gitea OIDC discovery endpoint returned non-200."
)
logger.info("gitea_oidc_discovery_ready", extra={"issuer": settings.gitea_base_url})
@app.on_event("shutdown")
@@ -282,6 +353,108 @@ async def health() -> dict[str, str]:
return {"status": "healthy"}
@app.get("/.well-known/oauth-protected-resource")
async def oauth_protected_resource_metadata() -> JSONResponse:
"""OAuth 2.0 Protected Resource Metadata (RFC 9728).
Required by the MCP Authorization spec so that OAuth clients (e.g. ChatGPT)
can discover the authorization server that protects this resource.
ChatGPT fetches this endpoint when it first connects to the MCP server via SSE.
"""
settings = get_settings()
gitea_base = settings.gitea_base_url
return JSONResponse(
content={
"resource": gitea_base,
"authorization_servers": [gitea_base],
"bearer_methods_supported": ["header"],
"scopes_supported": [READ_SCOPE, WRITE_SCOPE],
"resource_documentation": str(settings.oauth_resource_documentation),
}
)
@app.get("/.well-known/oauth-authorization-server")
async def oauth_authorization_server_metadata(request: Request) -> JSONResponse:
"""OAuth 2.0 Authorization Server Metadata (RFC 8414).
Proxies Gitea's OAuth authorization server metadata so that ChatGPT can
discover the authorize URL, token URL, and supported features directly
from this server without needing to know the Gitea URL upfront.
"""
settings = get_settings()
base_url = str(request.base_url).rstrip("/")
gitea_base = settings.gitea_base_url
return JSONResponse(
content={
"issuer": gitea_base,
"authorization_endpoint": f"{gitea_base}/login/oauth/authorize",
"token_endpoint": f"{base_url}/oauth/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": [READ_SCOPE, WRITE_SCOPE],
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
}
)
@app.post("/oauth/token")
async def oauth_token_proxy(request: Request) -> JSONResponse:
"""Proxy OAuth2 token exchange to Gitea.
ChatGPT sends the authorization code here after the user logs in to Gitea.
This endpoint forwards the code to Gitea's token endpoint and returns the
access_token to ChatGPT, completing the OAuth2 Authorization Code flow.
"""
settings = get_settings()
try:
form_data = await request.form()
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid request body") from exc
code = form_data.get("code")
redirect_uri = form_data.get("redirect_uri", "")
# ChatGPT sends the client_id and client_secret (that were configured in the GPT Action
# settings) in the POST body. Use those directly; fall back to env vars if not provided.
client_id = form_data.get("client_id") or settings.gitea_oauth_client_id
client_secret = form_data.get("client_secret") or settings.gitea_oauth_client_secret
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
gitea_token_url = f"{settings.gitea_base_url}/login/oauth/access_token"
payload = {
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
}
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
gitea_token_url,
data=payload,
headers={"Accept": "application/json"},
)
except httpx.RequestError as exc:
logger.error("oauth_token_proxy_error", extra={"error": str(exc)})
raise HTTPException(status_code=502, detail="Failed to reach Gitea token endpoint") from exc
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail="Token exchange failed with Gitea",
)
return JSONResponse(content=response.json())
@app.get("/metrics")
async def metrics() -> PlainTextResponse:
"""Prometheus-compatible metrics endpoint."""
@@ -316,6 +489,7 @@ async def automation_run_job(request: AutomationJobRequest) -> JSONResponse:
job_name=request.job_name,
owner=request.owner,
repo=request.repo,
user_token=get_gitea_user_token(),
finding_title=request.finding_title,
finding_body=request.finding_body,
)
@@ -343,6 +517,19 @@ async def _execute_tool_call(
if not tool_def:
raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found")
required_scope = WRITE_SCOPE if tool_def.write_operation else READ_SCOPE
granted_scopes = set(get_gitea_user_scopes())
if required_scope not in granted_scopes:
audit.log_access_denied(
tool_name=tool_name,
reason=f"insufficient_scope:{required_scope}",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail=f"Insufficient scope. Required scope: {required_scope}",
)
handler = TOOL_HANDLERS.get(tool_name)
if not handler:
raise HTTPException(
@@ -370,7 +557,11 @@ async def _execute_tool_call(
status = "error"
try:
async with GiteaClient() as gitea:
user_token = get_gitea_user_token()
if not user_token:
raise HTTPException(status_code=401, detail="Missing authenticated user token context")
async with GiteaClient(token=user_token) as gitea:
result = await handler(gitea, arguments)
if settings.secret_detection_mode != "off":
@@ -542,6 +733,20 @@ async def sse_message_handler(request: Request) -> JSONResponse:
"result": {"content": [{"type": "text", "text": json.dumps(result)}]},
}
)
except HTTPException as exc:
audit.log_tool_invocation(
tool_name=str(tool_name),
correlation_id=correlation_id,
result_status="error",
error=str(exc.detail),
)
return JSONResponse(
content={
"jsonrpc": "2.0",
"id": message_id,
"error": {"code": -32000, "message": str(exc.detail)},
}
)
except Exception as exc:
audit.log_tool_invocation(
tool_name=str(tool_name),