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:
@@ -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