Add OAuth2/OIDC per-user Gitea authentication
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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -55,7 +55,7 @@ def _tool(
|
||||
return MCPTool(
|
||||
name=name,
|
||||
description=description,
|
||||
input_schema=schema,
|
||||
inputSchema=schema,
|
||||
write_operation=write_operation,
|
||||
)
|
||||
|
||||
|
||||
366
src/aegis_gitea_mcp/oauth.py
Normal file
366
src/aegis_gitea_mcp/oauth.py
Normal 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
|
||||
@@ -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(())
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user