feat: harden Claude MCP OAuth transport
This commit is contained in:
1 parent
ed3130ef74
commit
541124e92a
11 files changed
+1377
-68
No files matched your search
+385
-43
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user