feat: harden Claude MCP OAuth transport

This commit is contained in:
Latte committed 2026-06-13 21:05:11 +02:00
1 parent ed3130ef74
commit 541124e92a
11 files changed
+1377 -68

No files matched your search

+385 -43
View File
@@ -3,10 +3,10 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import time
import urllib.parse
import uuid
from collections.abc import AsyncGenerator, Awaitable, Callable
@@ -36,11 +36,20 @@ from aegis_gitea_mcp.mcp_protocol import (
get_tool_by_name,
)
from aegis_gitea_mcp.oauth import get_oauth_validator
from aegis_gitea_mcp.oauth_flow import (
OAuthRegistrationRequest,
decode_proxy_state,
encode_proxy_state,
get_oauth_client_registry,
is_origin_allowed,
is_redirect_uri_allowed,
)
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 (
clear_gitea_auth_context,
get_gitea_user_login,
get_gitea_user_scopes,
get_gitea_user_token,
set_gitea_user_login,
@@ -94,9 +103,40 @@ _api_scope_cache: BoundedTTLCache[str, bool] = BoundedTTLCache(
_REAUTH_GUIDANCE = (
"Your OAuth token lacks Gitea API scopes (e.g. read:repository). "
"Revoke the authorization in Gitea (Settings > Applications > Authorized OAuth2 Applications) "
"and in ChatGPT (Settings > Connected apps), then re-authorize."
"and in your client, then re-authorize."
)
_repo_authz_cache: BoundedTTLCache[str, bool] | None = None
def _get_repo_authz_cache() -> BoundedTTLCache[str, bool]:
"""Get the bounded cache for per-user repository permission checks."""
global _repo_authz_cache
settings = get_settings()
if _repo_authz_cache is None:
_repo_authz_cache = BoundedTTLCache(
ttl_seconds=settings.repo_authz_cache_ttl_seconds,
max_size=2048,
)
return _repo_authz_cache
def reset_repo_authz_cache() -> None:
"""Reset the repository authorization cache (primarily for tests)."""
global _repo_authz_cache
_repo_authz_cache = None
def _repo_authz_cache_key(login: str, repository: str, required_scope: str) -> str:
"""Build a bounded cache key for a user/repository permission check."""
normalized_login = login.strip().lower()
return f"{normalized_login}:{repository.lower()}:{required_scope}"
def _is_mcp_transport_path(path: str) -> bool:
"""Return whether a request targets the MCP transport surface."""
return path in {"/mcp", "/mcp/sse"} or path.startswith("/mcp/")
def _has_required_scope(required_scope: str, granted_scopes: set[str]) -> bool:
"""Return whether granted scopes satisfy the required MCP tool scope."""
@@ -116,6 +156,129 @@ def _has_required_scope(required_scope: str, granted_scopes: set[str]) -> bool:
return required_scope in expanded
def _repo_permission_satisfied(permission: dict[str, Any], required_scope: str) -> bool:
"""Return whether a repository permission payload satisfies the requested scope."""
permission_name = str(permission.get("permission", "")).lower().strip()
if permission_name in {"admin", "owner"}:
return True
if required_scope == WRITE_SCOPE and permission_name == "write":
return True
if required_scope == READ_SCOPE and permission_name in {"read", "write"}:
return True
nested_permissions = permission.get("permissions")
if isinstance(nested_permissions, dict):
return _repo_permission_satisfied(nested_permissions, required_scope)
if required_scope == WRITE_SCOPE:
return bool(permission.get("push") or permission.get("admin"))
return bool(permission.get("pull") or permission.get("push") or permission.get("admin"))
async def _verify_user_repository_access(
*,
repository: str,
required_scope: str,
user_login: str,
correlation_id: str,
tool_name: str,
) -> None:
"""Verify the authenticated user can access the target repository before PAT fallback."""
settings = get_settings()
audit = get_audit_logger()
service_token = settings.gitea_token.strip()
if not service_token:
raise HTTPException(status_code=500, detail="Repository authorization misconfigured")
if not user_login.strip() or user_login == "unknown":
audit.log_access_denied(
tool_name=tool_name,
repository=repository,
reason="repository_permission_missing_user",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail="Unable to verify repository permission for this user.",
)
cache_key = _repo_authz_cache_key(user_login, repository, required_scope)
cached = _get_repo_authz_cache().get(cache_key)
if cached is True:
return
owner, repo = repository.split("/", 1)
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
encoded_user = urllib.parse.quote(user_login, safe="")
permission_url = (
f"{settings.gitea_base_url}/api/v1/repos/{encoded_owner}/{encoded_repo}"
f"/collaborators/{encoded_user}/permission"
)
try:
async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client:
response = await client.get(
permission_url,
headers={"Authorization": f"token {service_token}", "Accept": "application/json"},
)
except httpx.RequestError as exc:
audit.log_access_denied(
tool_name=tool_name,
repository=repository,
reason="repository_permission_probe_failed",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail="Unable to verify repository permission for this user.",
) from exc
if response.status_code != 200:
audit.log_access_denied(
tool_name=tool_name,
repository=repository,
reason=f"repository_permission_probe:{response.status_code}",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail="User does not have permission for the requested repository.",
)
try:
permission_payload = response.json()
except ValueError as exc:
audit.log_access_denied(
tool_name=tool_name,
repository=repository,
reason="repository_permission_invalid_json",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail="Unable to verify repository permission for this user.",
) from exc
if isinstance(permission_payload, dict) and _repo_permission_satisfied(
permission_payload, required_scope
):
_get_repo_authz_cache().set(cache_key, True)
return
audit.log_access_denied(
tool_name=tool_name,
repository=repository,
reason="repository_permission_denied",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail="User does not have permission for the requested repository.",
)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
"""Run startup and shutdown hooks via the FastAPI lifespan protocol."""
@@ -243,6 +406,67 @@ async def request_context_middleware(
metrics.record_http_request(request.method, request.url.path, status_code)
def _cors_headers(origin: str) -> dict[str, str]:
"""Build strict CORS headers for a validated browser origin."""
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Authorization,Content-Type,MCP-Protocol-Version,X-Request-ID",
"Access-Control-Expose-Headers": "X-Request-ID,WWW-Authenticate",
"Vary": "Origin",
}
@app.middleware("http")
async def strict_origin_and_cors_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Enforce strict browser origins for MCP transport requests."""
if request.url.path not in {"/mcp", "/mcp/sse"}:
return await call_next(request)
settings = get_settings()
origin = request.headers.get("origin")
expected_base = settings.public_base or str(request.base_url).rstrip("/")
if origin and not is_origin_allowed(origin, expected_base, settings.public_base):
return JSONResponse(
status_code=403,
content={
"error": "Origin not allowed",
"message": "The request origin is not allowed for this MCP transport.",
"request_id": getattr(request.state, "request_id", "-"),
},
)
if request.method == "OPTIONS":
response = Response(status_code=204)
else:
response = await call_next(request)
if origin and is_origin_allowed(origin, expected_base, settings.public_base):
for header, value in _cors_headers(origin).items():
response.headers[header] = value
return response
def _oauth_invalid_client_response() -> JSONResponse:
"""Return an RFC 6749 invalid_client error for token endpoint failures."""
response = JSONResponse(status_code=401, content={"error": "invalid_client"})
response.headers["WWW-Authenticate"] = 'Basic realm="oauth"'
return response
def _jsonrpc_error(message_id: Any, code: int, message: str) -> JSONResponse:
"""Build a JSON-RPC error response envelope."""
return JSONResponse(
content={"jsonrpc": "2.0", "id": message_id, "error": {"code": code, "message": message}}
)
@app.middleware("http")
async def authenticate_and_rate_limit(
request: Request,
@@ -255,11 +479,14 @@ async def authenticate_and_rate_limit(
if request.url.path in {"/", "/health"}:
return await call_next(request)
if request.method == "OPTIONS" and request.url.path in {"/mcp", "/mcp/sse"}:
return await call_next(request)
if request.url.path == "/metrics" and settings.metrics_enabled:
# 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.
# OAuth discovery and token endpoints must be public so MCP clients can complete the flow.
if request.url.path in {
"/oauth/token",
"/.well-known/oauth-protected-resource",
@@ -268,7 +495,11 @@ async def authenticate_and_rate_limit(
}:
return await call_next(request)
if not (request.url.path.startswith("/mcp/") or request.url.path.startswith("/automation/")):
if not (
request.url.path in {"/mcp/tools"}
or _is_mcp_transport_path(request.url.path)
or request.url.path.startswith("/automation/")
):
return await call_next(request)
oauth_validator = get_oauth_validator()
@@ -296,7 +527,7 @@ async def authenticate_and_rate_limit(
return await call_next(request)
if not access_token:
if request.url.path.startswith("/mcp/"):
if _is_mcp_transport_path(request.url.path):
return _oauth_unauthorized_response(
request,
"Provide Authorization: Bearer <token>.",
@@ -315,7 +546,7 @@ async def authenticate_and_rate_limit(
access_token, client_ip, user_agent
)
if not is_valid:
if request.url.path.startswith("/mcp/"):
if _is_mcp_transport_path(request.url.path):
return _oauth_unauthorized_response(
request,
error_message or "Invalid or expired OAuth token.",
@@ -400,7 +631,7 @@ async def authenticate_and_rate_limit(
"OAuth token is valid but lacks required Gitea API access. "
"Re-authorize this OAuth app in Gitea and try again."
)
if request.url.path.startswith("/mcp/"):
if _is_mcp_transport_path(request.url.path):
return _oauth_unauthorized_response(
request,
message,
@@ -508,9 +739,14 @@ async def health() -> dict[str, str]:
async def oauth_protected_resource_metadata(request: Request) -> 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.
Required by the MCP Authorization spec so that OAuth clients (Claude's
connector infrastructure) can discover the authorization server that
protects this resource. Claude fetches this endpoint when it first connects.
The ``resource`` value MUST be THIS server's own canonical public URL: the
MCP client verifies that the resource identifier matches the origin it
derived the MCP server URL from (RFC 9728 / RFC 8707). Returning the upstream
Gitea URL here would fail that check.
"""
settings = get_settings()
gitea_base = settings.gitea_base_url
@@ -521,7 +757,7 @@ async def oauth_protected_resource_metadata(request: Request) -> JSONResponse:
return JSONResponse(
content={
"resource": gitea_base,
"resource": base_url,
"authorization_servers": authorization_servers,
"bearer_methods_supported": ["header"],
"scopes_supported": [READ_SCOPE, WRITE_SCOPE],
@@ -534,24 +770,52 @@ async def oauth_protected_resource_metadata(request: Request) -> JSONResponse:
async def oauth_authorize_proxy(request: Request) -> RedirectResponse:
"""Proxy OAuth authorization to Gitea, replacing redirect_uri with our own callback.
Clients (ChatGPT, Claude, etc.) send their own redirect_uri which Gitea doesn't know
Clients (Claude, Claude Code, Cowork, etc.) send their own redirect_uri which Gitea doesn't know
about. This endpoint intercepts the request, encodes the original redirect_uri and
state into a new state parameter, and forwards the request to Gitea using the MCP
server's own callback URI — the only URI that needs to be registered in Gitea.
"""
settings = get_settings()
base_url = settings.public_base or str(request.base_url).rstrip("/")
registry = get_oauth_client_registry(settings.dcr_storage_path)
params = dict(request.query_params)
client_redirect_uri = params.pop("redirect_uri", "")
client_redirect_uri = params.pop("redirect_uri", "").strip()
client_id = params.get("client_id", "").strip() or settings.gitea_oauth_client_id.strip()
original_state = params.get("state", "")
params.pop("client_secret", None)
# Encode the client's redirect_uri + original state into a tamper-evident wrapper.
# We simply base64-encode a JSON blob; Gitea will echo it back on the callback.
proxy_state_data = {"redirect_uri": client_redirect_uri, "state": original_state}
proxy_state = base64.urlsafe_b64encode(json.dumps(proxy_state_data).encode()).decode()
if not client_id:
raise HTTPException(status_code=400, detail="Missing client_id")
if not registry.is_known_client(
client_id,
fallback_client_id=settings.gitea_oauth_client_id,
):
raise HTTPException(status_code=401, detail="invalid_client")
if not client_redirect_uri:
raise HTTPException(status_code=400, detail="Missing redirect_uri")
if not is_redirect_uri_allowed(client_redirect_uri, settings.oauth_redirect_allowlist):
raise HTTPException(status_code=400, detail="redirect_uri is not allowed")
code_challenge = params.get("code_challenge", "").strip()
code_challenge_method = params.get("code_challenge_method", "S256").strip().upper()
if not code_challenge:
raise HTTPException(status_code=400, detail="PKCE code_challenge is required")
if code_challenge_method != "S256":
raise HTTPException(status_code=400, detail="PKCE code_challenge_method must be S256")
proxy_state = encode_proxy_state(
settings.oauth_state_secret,
client_redirect_uri,
original_state,
ttl_seconds=600,
)
params["client_id"] = settings.gitea_oauth_client_id
params["state"] = proxy_state
params["code_challenge"] = code_challenge
params["code_challenge_method"] = "S256"
params["redirect_uri"] = f"{base_url}/oauth/callback"
gitea_authorize_url = f"{settings.gitea_base_url}/login/oauth/authorize"
@@ -568,14 +832,17 @@ async def oauth_callback_proxy(request: Request) -> RedirectResponse:
error_description = request.query_params.get("error_description", "")
try:
state_data = json.loads(base64.urlsafe_b64decode(proxy_state.encode()))
state_data = decode_proxy_state(get_settings().oauth_state_secret, proxy_state)
client_redirect_uri = state_data["redirect_uri"]
original_state = state_data["state"]
except Exception as exc:
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid or missing state parameter") from exc
settings = get_settings()
if not client_redirect_uri:
raise HTTPException(status_code=400, detail="No client redirect_uri in state")
if not is_redirect_uri_allowed(client_redirect_uri, settings.oauth_redirect_allowlist):
raise HTTPException(status_code=400, detail="redirect_uri is not allowed")
result_params: dict[str, str] = {}
if error:
@@ -595,26 +862,31 @@ async def oauth_callback_proxy(request: Request) -> RedirectResponse:
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.
Advertises this server's OAuth proxy endpoints so that Claude's connector
infrastructure can discover the authorize URL, token URL, and dynamic client
registration endpoint directly from this server without knowing the Gitea URL
upfront. The authorize/token endpoints are this server's proxy routes because
Gitea does not know Claude's redirect_uri.
"""
settings = get_settings()
base_url = settings.public_base or str(request.base_url).rstrip("/")
gitea_base = settings.gitea_base_url
return JSONResponse(
content={
"issuer": gitea_base,
"authorization_endpoint": f"{base_url}/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"],
}
)
metadata: dict[str, Any] = {
"issuer": gitea_base,
"authorization_endpoint": f"{base_url}/oauth/authorize",
"token_endpoint": f"{base_url}/oauth/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": [READ_SCOPE, WRITE_SCOPE],
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
}
if settings.dcr_enabled:
# RFC 7591 dynamic client registration endpoint (Claude registers here).
metadata["registration_endpoint"] = f"{base_url}/register"
return JSONResponse(content=metadata)
@app.get("/.well-known/openid-configuration")
@@ -649,15 +921,47 @@ async def openid_configuration(request: Request) -> JSONResponse:
)
@app.post("/register")
async def oauth_dynamic_client_registration(request: Request) -> JSONResponse:
"""Persist a new OAuth client registration for Claude and similar MCP clients."""
settings = get_settings()
if not settings.dcr_enabled:
raise HTTPException(status_code=404, detail="Dynamic client registration is disabled")
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type != "application/json":
raise HTTPException(status_code=415, detail="Content-Type must be application/json")
registry = get_oauth_client_registry(settings.dcr_storage_path)
try:
payload = await request.json()
registration_request = OAuthRegistrationRequest.model_validate(payload)
except ValidationError as exc:
raise HTTPException(status_code=400, detail="Invalid registration payload") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid registration payload") from exc
for redirect_uri in registration_request.redirect_uris:
if not is_redirect_uri_allowed(redirect_uri, settings.oauth_redirect_allowlist):
raise HTTPException(status_code=400, detail="redirect_uri is not allowed")
response = registry.register(registration_request)
response["client_id_issued_at"] = int(time.time())
response["client_secret_expires_at"] = 0
return JSONResponse(content=response)
@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.
The client 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.
access_token to the client, completing the OAuth2 Authorization Code flow.
"""
settings = get_settings()
registry = get_oauth_client_registry(settings.dcr_storage_path)
try:
form_data = await request.form()
@@ -683,32 +987,45 @@ async def oauth_token_proxy(request: Request) -> JSONResponse:
# URI to Gitea, we must use the same URI here — not the client's original redirect_uri.
base_url = settings.public_base or str(request.base_url).rstrip("/")
if not client_id:
return _oauth_invalid_client_response()
if not registry.validate_client_secret(
client_id,
client_secret or None,
fallback_client_id=settings.gitea_oauth_client_id,
fallback_client_secret=settings.gitea_oauth_client_secret,
):
return _oauth_invalid_client_response()
gitea_token_url = f"{settings.gitea_base_url}/login/oauth/access_token"
upstream_client_id = settings.gitea_oauth_client_id
upstream_client_secret = settings.gitea_oauth_client_secret
if grant_type == "refresh_token":
if not refresh_token:
raise HTTPException(status_code=400, detail="Missing refresh_token")
payload: dict[str, str] = {
"client_id": client_id,
"client_secret": client_secret,
"client_id": upstream_client_id,
"client_secret": upstream_client_secret,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}
else:
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
if not code_verifier:
raise HTTPException(status_code=400, detail="Missing code_verifier")
payload = {
"client_id": client_id,
"client_secret": client_secret,
"client_id": upstream_client_id,
"client_secret": upstream_client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": f"{base_url}/oauth/callback",
}
if code_verifier:
payload["code_verifier"] = code_verifier
payload["code_verifier"] = code_verifier
try:
async with httpx.AsyncClient(timeout=30) as client:
async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client:
response = await client.post(
gitea_token_url,
data=payload,
@@ -846,6 +1163,29 @@ async def _execute_tool_call(
if not user_token:
raise HTTPException(status_code=401, detail="Missing authenticated user token context")
if settings.gitea_token.strip():
if not repository:
audit.log_access_denied(
tool_name=tool_name,
reason="service_pat_requires_repository_target",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail=(
"Service PAT mode requires a repository target so per-user "
"permission can be verified."
),
)
user_login = get_gitea_user_login()
await _verify_user_repository_access(
repository=repository,
required_scope=required_scope,
user_login=user_login or "",
correlation_id=correlation_id,
tool_name=tool_name,
)
# In OAuth mode, Gitea OIDC access_tokens can't call the Gitea REST API
# (they only carry OIDC scopes). If a service PAT is configured via
# GITEA_TOKEN, use that for API calls while OIDC handles identity/authz.
@@ -970,6 +1310,7 @@ async def call_tool(request: MCPToolCallRequest) -> JSONResponse:
)
@app.get("/mcp")
@app.get("/mcp/sse")
async def sse_endpoint(request: Request) -> StreamingResponse:
"""Server-Sent Events endpoint for MCP transport."""
@@ -1004,6 +1345,7 @@ async def sse_endpoint(request: Request) -> StreamingResponse:
)
@app.post("/mcp")
@app.post("/mcp/sse")
async def sse_message_handler(request: Request) -> JSONResponse:
"""Handle POST messages for MCP SSE transport."""