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

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