feat: add per-user OAuth mode proxying sign-in to Gitea
Authorization server for MCP clients (SDK provider with dynamic client registration, PKCE, refresh rotation and revocation) that signs users in through Gitea and runs tool calls with the user's own Gitea token. - consent page with CSRF protection and a browser-binding cookie on the Gitea callback (confused-deputy protection for the shared Gitea app) - encrypted token store (Fernet, HKDF-derived key, 0600, atomic writes, symlink and corrupt-file refusal); our tokens stored only as hashes - tokens bound to the MCP endpoint resource and the gitea scope; the static bearer keeps working next to OAuth - code replay and refresh token reuse revoke the grant - automatic upstream refresh with per-session locking; invalid_grant ends the session and never falls back to the admin token - OAuth-only deployments (no GITEA_TOKEN, no bearer) may start - rate limit on the OAuth endpoints, query strings dropped from access logs
This commit is contained in:
1 parent
5e144772b2
commit
566d0b65dd
10 files changed
+1813
-31
No files matched your search
@@ -38,3 +38,36 @@ Chat requires dynamic client registration and Claude prefers it. Details:
|
||||
|
||||
The server holds per-user Gitea tokens. That is the most sensitive asset after
|
||||
the admin token and is covered by the threat model.
|
||||
|
||||
## Implementation notes (chunk B5)
|
||||
|
||||
- **OAuth-only deployments are allowed.** With `OAUTH_ENABLED=true`, no
|
||||
`MCP_BEARER_TOKEN` and no `GITEA_TOKEN`, the server starts and every user
|
||||
brings their own Gitea token. Admin-token mode is then unavailable and fails
|
||||
closed. `GITEA_TOKEN` stays required whenever admin-token mode is reachable
|
||||
(a static bearer, or an unauthenticated loopback endpoint).
|
||||
- **Own consent step.** Gitea remembers consent for our single Gitea
|
||||
application, so a dynamically registered client with an attacker's redirect
|
||||
URI could otherwise obtain a token for a victim who merely opens a link (the
|
||||
confused-deputy problem of OAuth proxies). `/authorize` therefore leads to our
|
||||
consent page, which names the client and the redirect URI. Approving needs a
|
||||
CSRF token bound by a `SameSite=Strict` cookie and a matching `Origin`, and
|
||||
sets a browser-binding cookie that `/oauth/callback` requires, so a Gitea
|
||||
redirect URL obtained by someone else cannot be completed in another browser.
|
||||
- **Token binding.** Tokens are bound to the MCP endpoint URL (RFC 8707) and
|
||||
carry the single scope `gitea`; the SDK enforces both
|
||||
(`validate_token_resource=True`, `required_scopes=["gitea"]`), and the static
|
||||
bearer reports the same resource and scope. `/authorize` with another
|
||||
`resource` is refused with `invalid_target`.
|
||||
- **Replay handling.** A replayed authorization code, or a rotated-out refresh
|
||||
token presented again, revokes the whole grant (RFC 6749 4.1.2, OAuth 2.1
|
||||
refresh token reuse detection). Revoking either token ends the grant.
|
||||
- **Store key.** The Fernet key is HKDF-SHA256(`TOKEN_ENCRYPTION_KEY`,
|
||||
info `aegis-gitea-mcp oauth store`), so the setting can be any long random
|
||||
string. Changing it makes the store unreadable; startup then stops instead of
|
||||
wiping the file.
|
||||
- **Limits.** At most 500 registered clients (the least recently used client
|
||||
without a session is evicted, otherwise registration is refused), 50
|
||||
sessions per Gitea login (the oldest ends), and 1024 pending sign-ins. The
|
||||
unauthenticated OAuth endpoints share the per-IP rate limit, and query
|
||||
strings are removed from access log lines.
|
||||
@@ -27,6 +27,9 @@ if TYPE_CHECKING:
|
||||
#: ``client_id`` of the access token the static bearer verifier returns.
|
||||
STATIC_CLIENT_ID = "aegis-static-bearer"
|
||||
|
||||
#: The one OAuth scope this server grants and requires on the MCP endpoint.
|
||||
MCP_SCOPE = "gitea"
|
||||
|
||||
# After a failed ``GET /user`` the admin login lookup is not retried for this long.
|
||||
_LOGIN_RETRY_SECONDS = 30.0
|
||||
|
||||
@@ -45,10 +48,14 @@ class StaticBearerVerifier:
|
||||
|
||||
Args:
|
||||
token: The expected bearer token.
|
||||
resource: Resource (MCP endpoint URL) reported on the access token, so
|
||||
the SDK's RFC 8707 audience check accepts the static bearer when
|
||||
per-user OAuth enables it.
|
||||
"""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
def __init__(self, token: str, *, resource: str | None = None) -> None:
|
||||
self._expected = _digest(token)
|
||||
self._resource = resource
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Return an admin access token when ``token`` matches, else ``None``."""
|
||||
@@ -57,7 +64,8 @@ class StaticBearerVerifier:
|
||||
return AccessToken(
|
||||
token="static", # noqa: S106 (placeholder, never the real secret)
|
||||
client_id=STATIC_CLIENT_ID,
|
||||
scopes=["gitea"],
|
||||
scopes=[MCP_SCOPE],
|
||||
resource=self._resource,
|
||||
subject="admin",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Per-user OAuth mode (ADR 0004): authorization server for MCP clients, Gitea sign-in.
|
||||
|
||||
Import :func:`build_oauth_components` from :mod:`aegis_gitea_mcp.oauth.components`;
|
||||
this package module stays import-light so the transport can load it lazily.
|
||||
"""
|
||||
|
||||
|
||||
class OAuthSetupError(Exception):
|
||||
"""Per-user OAuth cannot start (the message is safe to print)."""
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Browser-facing OAuth routes: the consent page and the Gitea callback.
|
||||
|
||||
``GET /oauth/consent`` shows which client asks for access and where the result
|
||||
is sent; ``POST /oauth/consent`` applies the decision (CSRF protected with a
|
||||
``SameSite=Strict`` double-submit cookie and an ``Origin`` check) and sends the
|
||||
browser to Gitea, setting a browser-binding cookie. ``GET /oauth/callback``
|
||||
completes the sign-in only in the browser that holds that cookie.
|
||||
|
||||
Responses never echo request parameters: an invalid or expired request gets a
|
||||
fixed message with status 400. Pages are not cacheable and cannot be framed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider
|
||||
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH
|
||||
|
||||
COOKIE_TTL_SECONDS = 600
|
||||
_INVALID_MESSAGE = (
|
||||
"This sign-in request is invalid or has expired. Start the sign-in again from your MCP client."
|
||||
)
|
||||
_SECURITY_HEADERS = {
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": (
|
||||
"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"
|
||||
),
|
||||
}
|
||||
_PAGE = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;max-width:34rem;margin:3rem auto;padding:0 1rem;
|
||||
line-height:1.5;color:#1f2328;background:#fff}}
|
||||
code{{background:#f3f4f6;padding:.1rem .3rem;border-radius:4px;word-break:break-all}}
|
||||
.warn{{background:#fff8e1;border:1px solid #f0c36d;padding:.75rem;border-radius:6px}}
|
||||
button{{font-size:1rem;padding:.5rem 1.25rem;margin-right:.5rem;cursor:pointer}}
|
||||
</style></head><body>{body}</body></html>"""
|
||||
|
||||
|
||||
class _Cookies:
|
||||
"""Cookie names and attributes; ``__Host-`` names and ``Secure`` on https."""
|
||||
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.secure = urlsplit(base_url).scheme == "https"
|
||||
prefix = "__Host-" if self.secure else ""
|
||||
self.csrf = f"{prefix}aegis-oauth-csrf"
|
||||
self.binding = f"{prefix}aegis-oauth-flow"
|
||||
|
||||
def set(self, response: Response, name: str, value: str, *, same_site: str) -> None:
|
||||
response.set_cookie(
|
||||
name,
|
||||
value,
|
||||
max_age=COOKIE_TTL_SECONDS,
|
||||
path="/",
|
||||
secure=self.secure,
|
||||
httponly=True,
|
||||
samesite=same_site, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def clear(self, response: Response, name: str, *, same_site: str) -> None:
|
||||
response.delete_cookie(
|
||||
name,
|
||||
path="/",
|
||||
secure=self.secure,
|
||||
httponly=True,
|
||||
samesite=same_site, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _page(title: str, body: str, status_code: int = 200) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
_PAGE.format(title=html.escape(title), body=body),
|
||||
status_code=status_code,
|
||||
headers=_SECURITY_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
def _invalid() -> HTMLResponse:
|
||||
return _page("Sign-in failed", f"<h1>Sign-in failed</h1><p>{_INVALID_MESSAGE}</p>", 400)
|
||||
|
||||
|
||||
def _redirect(location: str, status_code: int) -> RedirectResponse:
|
||||
return RedirectResponse(location, status_code=status_code, headers=_SECURITY_HEADERS)
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = 200) -> str:
|
||||
return value[:limit] if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
|
||||
"""Return the consent and callback routes for ``provider``."""
|
||||
cookies = _Cookies(provider.base_url)
|
||||
origin_parts = urlsplit(provider.base_url)
|
||||
expected_origin = f"{origin_parts.scheme}://{origin_parts.netloc}"
|
||||
|
||||
async def consent_page(request: Request) -> Response:
|
||||
request_id = _text(request.query_params.get("request"))
|
||||
pending = provider.consent_request(request_id)
|
||||
csrf = provider.begin_consent(request_id)
|
||||
if pending is None or csrf is None:
|
||||
return _invalid()
|
||||
client = html.escape(pending.client_name or "An unnamed client")
|
||||
destination = urlsplit(pending.redirect_uri)
|
||||
host = html.escape(destination.netloc)
|
||||
body = (
|
||||
"<h1>Allow access to Gitea?</h1>"
|
||||
f"<p><strong>{client}</strong> wants to use Gitea as you through this MCP server. "
|
||||
"It can do everything your Gitea account can do within the configured scopes.</p>"
|
||||
f"<p>The result goes to <code>{host}</code> "
|
||||
f"(<code>{html.escape(pending.redirect_uri)}</code>).</p>"
|
||||
'<p class="warn">Only continue if you started this sign-in yourself from your '
|
||||
"MCP client. If a link sent by someone else brought you here, deny.</p>"
|
||||
f'<form method="post" action="{CONSENT_PATH}">'
|
||||
f'<input type="hidden" name="request" value="{html.escape(request_id)}">'
|
||||
f'<input type="hidden" name="csrf" value="{html.escape(csrf)}">'
|
||||
'<button type="submit" name="decision" value="approve">Continue to Gitea</button>'
|
||||
'<button type="submit" name="decision" value="deny">Deny</button>'
|
||||
"</form>"
|
||||
)
|
||||
response = _page("Allow access to Gitea?", body)
|
||||
cookies.set(response, cookies.csrf, csrf, same_site="strict")
|
||||
return response
|
||||
|
||||
async def consent_submit(request: Request) -> Response:
|
||||
origin = request.headers.get("origin")
|
||||
if origin is not None and origin != expected_origin:
|
||||
return _invalid()
|
||||
form = await request.form()
|
||||
decision = provider.finish_consent(
|
||||
_text(form.get("request")),
|
||||
_text(form.get("csrf")),
|
||||
_text(request.cookies.get(cookies.csrf)),
|
||||
approve=form.get("decision") == "approve",
|
||||
)
|
||||
if decision is None:
|
||||
return _invalid()
|
||||
response = _redirect(decision.redirect, 303)
|
||||
cookies.clear(response, cookies.csrf, same_site="strict")
|
||||
if decision.binding_token is not None:
|
||||
# Lax: the cookie must come back on Gitea's top-level redirect to us.
|
||||
cookies.set(response, cookies.binding, decision.binding_token, same_site="lax")
|
||||
return response
|
||||
|
||||
async def callback(request: Request) -> Response:
|
||||
params = request.query_params
|
||||
location = await provider.complete_callback(
|
||||
_text(params.get("state")),
|
||||
_text(request.cookies.get(cookies.binding)),
|
||||
_text(params.get("code"), 4096),
|
||||
_text(params.get("error")),
|
||||
)
|
||||
response = _invalid() if location is None else _redirect(location, 302)
|
||||
cookies.clear(response, cookies.binding, same_site="lax")
|
||||
return response
|
||||
|
||||
return [
|
||||
Route(CONSENT_PATH, consent_page, methods=["GET"]),
|
||||
Route(CONSENT_PATH, consent_submit, methods=["POST"]),
|
||||
Route(CALLBACK_PATH, callback, methods=["GET"]),
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Assembles the per-user OAuth pieces for the HTTP transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from mcp.server.auth.routes import create_protected_resource_routes
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
|
||||
from pydantic import AnyHttpUrl, SecretStr
|
||||
from starlette.routing import Route
|
||||
|
||||
from aegis_gitea_mcp.audit import AuditLogger
|
||||
from aegis_gitea_mcp.auth import MCP_SCOPE, AdminIdentityResolver
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||
from aegis_gitea_mcp.oauth.callback import build_browser_routes
|
||||
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider, OAuthTokenVerifier
|
||||
from aegis_gitea_mcp.oauth.resolver import OAuthGiteaExecutor, OAuthIdentityResolver
|
||||
from aegis_gitea_mcp.oauth.store import OAuthStore, OAuthStoreError
|
||||
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH, GiteaUpstream
|
||||
from aegis_gitea_mcp.transports import HttpAuthExtras
|
||||
|
||||
#: Unauthenticated OAuth endpoints that get the per-IP rate limit.
|
||||
RATE_LIMITED_PATHS = ("/register", "/authorize", CONSENT_PATH, CALLBACK_PATH)
|
||||
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
|
||||
|
||||
|
||||
def bare_protected_resource_route(settings: Settings) -> Route:
|
||||
"""``/.well-known/oauth-protected-resource`` serving the same document as the suffixed path.
|
||||
|
||||
The SDK only mounts the RFC 9728 path-suffixed URL; some clients probe the
|
||||
bare path too.
|
||||
"""
|
||||
base = settings.public_base_url or ""
|
||||
auth = AuthSettings(
|
||||
issuer_url=base, # type: ignore[arg-type]
|
||||
resource_server_url=f"{base}{settings.mcp_path}", # type: ignore[arg-type]
|
||||
validate_token_resource=True,
|
||||
)
|
||||
(suffixed,) = create_protected_resource_routes(
|
||||
resource_url=cast(AnyHttpUrl, auth.resource_server_url),
|
||||
authorization_servers=[auth.issuer_url],
|
||||
scopes_supported=[MCP_SCOPE],
|
||||
)
|
||||
return Route(PROTECTED_RESOURCE_PATH, endpoint=suffixed.endpoint, methods=["GET", "OPTIONS"])
|
||||
|
||||
|
||||
def build_oauth_components(
|
||||
settings: Settings,
|
||||
*,
|
||||
clock: Callable[[], float] = time.time,
|
||||
client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
||||
executor_client: httpx.AsyncClient | None = None,
|
||||
audit: AuditLogger | None = None,
|
||||
) -> HttpAuthExtras:
|
||||
"""Build the provider, verifier, resolver, executor and routes for per-user OAuth.
|
||||
|
||||
An OAuth-only deployment (no ``GITEA_TOKEN``) is supported: users bring
|
||||
their own Gitea tokens, and anything that would need admin-token mode
|
||||
fails closed.
|
||||
|
||||
Args:
|
||||
settings: Application settings with a complete OAuth configuration.
|
||||
clock: Wall clock (epoch seconds); tests inject a fake one.
|
||||
client_factory: Creates the HTTP client for Gitea OAuth exchanges.
|
||||
executor_client: HTTP client for the tool executor (tests).
|
||||
audit: Audit logger for OAuth security events.
|
||||
|
||||
Raises:
|
||||
OAuthSetupError: The configuration is incomplete or the token store
|
||||
cannot be opened.
|
||||
"""
|
||||
problems = settings.oauth_config_errors()
|
||||
if not settings.oauth_enabled or problems:
|
||||
raise OAuthSetupError("; ".join(problems) or "OAUTH_ENABLED is not set")
|
||||
key = cast(SecretStr, settings.token_encryption_key) # checked by oauth_config_errors
|
||||
try:
|
||||
store = OAuthStore.load(settings.data_dir, key.get_secret_value(), clock)
|
||||
except OAuthStoreError as exc:
|
||||
raise OAuthSetupError(str(exc)) from None
|
||||
except OSError as exc:
|
||||
raise OAuthSetupError(
|
||||
f"cannot read the OAuth token store in {settings.data_dir} ({type(exc).__name__})"
|
||||
) from None
|
||||
|
||||
upstream = GiteaUpstream(settings, client_factory=client_factory, clock=clock)
|
||||
provider = GiteaOAuthProvider(settings, store, upstream, clock=clock, audit=audit)
|
||||
executor = OAuthGiteaExecutor(
|
||||
settings, client=executor_client, extra_secrets=[key.get_secret_value()]
|
||||
)
|
||||
admin = AdminIdentityResolver(settings, executor) if settings.gitea_token is not None else None
|
||||
resolver = OAuthIdentityResolver(store, upstream, admin, clock=clock, audit=audit)
|
||||
return HttpAuthExtras(
|
||||
token_verifier=OAuthTokenVerifier(provider),
|
||||
auth_server_provider=provider,
|
||||
resolver=resolver,
|
||||
executor=executor,
|
||||
extra_routes=(*build_browser_routes(provider), bare_protected_resource_route(settings)),
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True, valid_scopes=[MCP_SCOPE], default_scopes=[MCP_SCOPE]
|
||||
),
|
||||
revocation_options=RevocationOptions(enabled=True),
|
||||
rate_limited_paths=RATE_LIMITED_PATHS,
|
||||
)
|
||||
@@ -0,0 +1,670 @@
|
||||
"""OAuth 2.1 authorization server for MCP clients, proxying sign-in to Gitea.
|
||||
|
||||
Implements the SDK's ``OAuthAuthorizationServerProvider``. The SDK mounts
|
||||
``/authorize``, ``/token``, ``/register``, ``/revoke`` and the metadata, and
|
||||
does PKCE, redirect URI matching and client authentication; this module holds
|
||||
the state behind them.
|
||||
|
||||
Sign-in flow (each step single use, every value 256-bit random):
|
||||
|
||||
1. ``/authorize`` (SDK) calls :meth:`GiteaOAuthProvider.authorize`, which parks
|
||||
the client's request under a random request id and sends the browser to our
|
||||
consent page.
|
||||
2. The consent page (``callback.py``) shows which client asks for access and
|
||||
where the result goes. Approving needs a CSRF token that is bound to the
|
||||
browser by a ``SameSite=Strict`` cookie. On approval a fresh Gitea ``state``
|
||||
and a browser-binding secret are created; the binding secret goes into a
|
||||
cookie and the browser is sent to Gitea with our own PKCE challenge.
|
||||
3. ``/oauth/callback`` pops the state, checks the binding cookie (so a Gitea
|
||||
redirect URL obtained by someone else cannot be completed in a victim's
|
||||
browser), exchanges Gitea's code, looks up the user and mints our
|
||||
authorization code for the client's registered redirect URI.
|
||||
4. ``/token`` (SDK, PKCE checked) calls :meth:`exchange_authorization_code`,
|
||||
which issues opaque ``agm_`` access and refresh tokens. Only their SHA-256
|
||||
hashes are stored.
|
||||
|
||||
The consent step exists because Gitea remembers consent for our (single,
|
||||
static) Gitea application: without it, anyone could register a client with
|
||||
their own redirect URI and turn a victim's click on a link into a token for
|
||||
the victim's account (the confused-deputy problem of OAuth proxies).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Generic, TypeVar
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
AuthorizeError,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
RegistrationError,
|
||||
TokenError,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
from pydantic import AnyHttpUrl, ValidationError
|
||||
|
||||
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
||||
from aegis_gitea_mcp.auth import MCP_SCOPE
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.oauth.store import (
|
||||
ClientRecord,
|
||||
OAuthStore,
|
||||
OAuthStoreError,
|
||||
SessionRecord,
|
||||
TokenRecord,
|
||||
token_hash,
|
||||
)
|
||||
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOKEN_PREFIX = "agm_" # noqa: S105 (public token prefix, not a secret)
|
||||
ACCESS_TOKEN_TTL = 3600
|
||||
REFRESH_TOKEN_TTL = 30 * 24 * 3600
|
||||
AUTHORIZATION_CODE_TTL = 300
|
||||
PENDING_TTL = 600
|
||||
MAX_PENDING = 1024
|
||||
MAX_CODES = 1024
|
||||
MAX_CLIENTS = 500
|
||||
MAX_CLIENT_METADATA_BYTES = 8192
|
||||
MAX_REDIRECT_URIS = 10
|
||||
MAX_SESSIONS_PER_LOGIN = 50
|
||||
CONSENT_PATH = "/oauth/consent"
|
||||
|
||||
_LOOPBACK_REDIRECT_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||
_CLIENT_AUTH_METHODS = frozenset({"none", "client_secret_post", "client_secret_basic"})
|
||||
_REDIRECT_RULE = (
|
||||
"redirect URIs must use https://, or http:// on localhost, 127.0.0.1 or [::1], "
|
||||
"without credentials or a fragment"
|
||||
)
|
||||
|
||||
V = TypeVar("V")
|
||||
Clock = Callable[[], float]
|
||||
|
||||
|
||||
def redirect_uri_problem(uri: str) -> str | None:
|
||||
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is."""
|
||||
try:
|
||||
parts = urlsplit(uri)
|
||||
parts.port # noqa: B018 (accessing .port validates it)
|
||||
except ValueError:
|
||||
return _REDIRECT_RULE
|
||||
if parts.fragment or "#" in uri or parts.username is not None or parts.password is not None:
|
||||
return _REDIRECT_RULE
|
||||
host = (parts.hostname or "").lower()
|
||||
if not host:
|
||||
return _REDIRECT_RULE
|
||||
if parts.scheme == "https":
|
||||
return None
|
||||
if parts.scheme == "http" and host in _LOOPBACK_REDIRECT_HOSTS:
|
||||
return None
|
||||
return _REDIRECT_RULE
|
||||
|
||||
|
||||
def same_resource(left: str, right: str) -> bool:
|
||||
"""Compare two resource URLs the way the SDK's bearer middleware does."""
|
||||
try:
|
||||
normalized = [str(AnyHttpUrl(value)).removesuffix("/") for value in (left, right)]
|
||||
except ValidationError:
|
||||
return False
|
||||
return normalized[0] == normalized[1]
|
||||
|
||||
|
||||
def s256(verifier: str) -> str:
|
||||
"""PKCE S256 challenge for ``verifier``."""
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _matches(value: str | None, expected_hash: str | None) -> bool:
|
||||
if not value or not expected_hash:
|
||||
return False
|
||||
return secrets.compare_digest(token_hash(value), expected_hash)
|
||||
|
||||
|
||||
class _ExpiringMap(Generic[V]):
|
||||
"""Bounded in-memory map with per-entry expiry; the oldest entry is evicted when full."""
|
||||
|
||||
def __init__(self, capacity: int, clock: Clock) -> None:
|
||||
self._capacity = capacity
|
||||
self._clock = clock
|
||||
self._entries: dict[str, tuple[float, V]] = {}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def _purge(self) -> None:
|
||||
now = self._clock()
|
||||
for key in [key for key, (expires, _) in self._entries.items() if expires <= now]:
|
||||
del self._entries[key]
|
||||
|
||||
def put(self, key: str, value: V, ttl: float) -> None:
|
||||
self._purge()
|
||||
while len(self._entries) >= self._capacity:
|
||||
del self._entries[next(iter(self._entries))]
|
||||
self._entries[key] = (self._clock() + ttl, value)
|
||||
|
||||
def get(self, key: str) -> V | None:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry[0] <= self._clock():
|
||||
del self._entries[key]
|
||||
return None
|
||||
return entry[1]
|
||||
|
||||
def pop(self, key: str) -> V | None:
|
||||
value = self.get(key)
|
||||
self._entries.pop(key, None)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingAuthorization:
|
||||
"""A client's authorization request waiting for consent and the Gitea sign-in."""
|
||||
|
||||
client_id: str
|
||||
client_name: str | None
|
||||
redirect_uri: str
|
||||
redirect_uri_provided_explicitly: bool
|
||||
code_challenge: str
|
||||
client_state: str | None = field(repr=False)
|
||||
scopes: list[str]
|
||||
resource: str
|
||||
code_verifier: str = field(repr=False)
|
||||
csrf_hash: str | None = field(default=None, repr=False)
|
||||
binding_hash: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
class StoredAuthorizationCode(AuthorizationCode):
|
||||
"""Our authorization code plus the session it grants (``code`` holds only its hash)."""
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
class StoredRefreshToken(RefreshToken):
|
||||
"""A loaded refresh token (``token`` holds only its hash)."""
|
||||
|
||||
session_id: str
|
||||
token_hash: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsentDecision:
|
||||
"""Where to send the browser after the consent form, plus the binding cookie value."""
|
||||
|
||||
redirect: str = field(repr=False)
|
||||
binding_token: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
class GiteaOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[StoredAuthorizationCode, StoredRefreshToken, AccessToken]
|
||||
):
|
||||
"""The SDK authorization server provider backed by Gitea sign-in.
|
||||
|
||||
Args:
|
||||
settings: Application settings (``PUBLIC_BASE_URL``, ``MCP_PATH``, scopes).
|
||||
store: Persistent store for clients, sessions and token hashes.
|
||||
upstream: Gitea OAuth client.
|
||||
clock: Wall clock (epoch seconds).
|
||||
audit: Audit logger for sign-in and revocation events (defaults to the
|
||||
process-wide one).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
store: OAuthStore,
|
||||
upstream: GiteaUpstream,
|
||||
*,
|
||||
clock: Clock = time.time,
|
||||
audit: AuditLogger | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._store = store
|
||||
self._upstream = upstream
|
||||
self._clock = clock
|
||||
self._audit_logger = audit
|
||||
base = settings.public_base_url or ""
|
||||
self.base_url = base
|
||||
self.resource = f"{base}{settings.mcp_path}"
|
||||
self._requests: _ExpiringMap[PendingAuthorization] = _ExpiringMap(MAX_PENDING, clock)
|
||||
self._states: _ExpiringMap[PendingAuthorization] = _ExpiringMap(MAX_PENDING, clock)
|
||||
self._codes: _ExpiringMap[StoredAuthorizationCode] = _ExpiringMap(MAX_CODES, clock)
|
||||
# Hashes of exchanged codes -> session id, to detect replays (RFC 6749 4.1.2).
|
||||
self._used_codes: _ExpiringMap[str] = _ExpiringMap(MAX_CODES, clock)
|
||||
|
||||
# Helpers
|
||||
|
||||
def _audit(self, event: str, description: str, severity: str, **metadata: Any) -> None:
|
||||
try:
|
||||
audit = self._audit_logger or get_audit_logger()
|
||||
audit.log_security_event(event, description, severity=severity, metadata=metadata)
|
||||
except Exception as exc:
|
||||
logger.error("audit log write failed: %s", type(exc).__name__)
|
||||
|
||||
def _end_session(self, session_id: str, event: str, description: str) -> None:
|
||||
session = self._store.sessions.get(session_id)
|
||||
if self._store.end_session(session_id) and session is not None:
|
||||
self._audit(
|
||||
event, description, "medium", login=session.login, client_id=session.client_id
|
||||
)
|
||||
|
||||
async def _issue_tokens(
|
||||
self, session: SessionRecord, access_scopes: list[str], refresh_scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
now = self._clock()
|
||||
access = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
refresh = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
self._store.tokens[token_hash(access)] = TokenRecord(
|
||||
kind="access",
|
||||
session_id=session.session_id,
|
||||
client_id=session.client_id,
|
||||
scopes=list(access_scopes),
|
||||
expires_at=now + ACCESS_TOKEN_TTL,
|
||||
)
|
||||
self._store.tokens[token_hash(refresh)] = TokenRecord(
|
||||
kind="refresh",
|
||||
session_id=session.session_id,
|
||||
client_id=session.client_id,
|
||||
scopes=list(refresh_scopes),
|
||||
expires_at=now + REFRESH_TOKEN_TTL,
|
||||
)
|
||||
await self._store.save()
|
||||
return OAuthToken(
|
||||
access_token=access,
|
||||
token_type="Bearer", # noqa: S106 (token type name)
|
||||
expires_in=ACCESS_TOKEN_TTL,
|
||||
refresh_token=refresh,
|
||||
scope=" ".join(access_scopes),
|
||||
)
|
||||
|
||||
# Client registration
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
"""Return a registered client (and note that it was used)."""
|
||||
record = self._store.clients.get(client_id)
|
||||
if record is None:
|
||||
return None
|
||||
record.last_used_at = self._clock()
|
||||
return OAuthClientInformationFull.model_validate(record.info)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Validate and persist a dynamically registered client.
|
||||
|
||||
Redirect URIs must be https, or http on a loopback host. When
|
||||
:data:`MAX_CLIENTS` is reached, the least recently used client without
|
||||
any session is evicted; when every client has a session, registration
|
||||
is refused.
|
||||
|
||||
Raises:
|
||||
RegistrationError: Invalid redirect URIs, metadata or the client cap.
|
||||
"""
|
||||
uris = [str(uri) for uri in client_info.redirect_uris or []]
|
||||
if not uris or len(uris) > MAX_REDIRECT_URIS:
|
||||
raise RegistrationError(
|
||||
"invalid_redirect_uri", f"between 1 and {MAX_REDIRECT_URIS} redirect URIs required"
|
||||
)
|
||||
for uri in uris:
|
||||
problem = redirect_uri_problem(uri)
|
||||
if problem is not None:
|
||||
raise RegistrationError("invalid_redirect_uri", problem)
|
||||
if client_info.token_endpoint_auth_method not in _CLIENT_AUTH_METHODS:
|
||||
raise RegistrationError(
|
||||
"invalid_client_metadata", "unsupported token_endpoint_auth_method"
|
||||
)
|
||||
info = client_info.model_dump(mode="json", exclude_none=True)
|
||||
if len(json.dumps(info)) > MAX_CLIENT_METADATA_BYTES:
|
||||
raise RegistrationError("invalid_client_metadata", "client metadata is too large")
|
||||
if len(self._store.clients) >= MAX_CLIENTS and not self._evict_one_client():
|
||||
logger.warning("oauth client registration refused: client limit reached")
|
||||
raise RegistrationError(
|
||||
"invalid_client_metadata", "client registration limit reached; try again later"
|
||||
)
|
||||
now = self._clock()
|
||||
self._store.clients[client_info.client_id] = ClientRecord(
|
||||
info=info, registered_at=now, last_used_at=now
|
||||
)
|
||||
await self._store.save()
|
||||
logger.info("oauth client registered: %s", client_info.client_id)
|
||||
|
||||
def _evict_one_client(self) -> bool:
|
||||
candidates = [
|
||||
(record.last_used_at, client_id)
|
||||
for client_id, record in self._store.clients.items()
|
||||
if not self._store.client_has_sessions(client_id)
|
||||
]
|
||||
if not candidates:
|
||||
return False
|
||||
_, client_id = min(candidates)
|
||||
del self._store.clients[client_id]
|
||||
logger.info("oauth client evicted (least recently used, no sessions): %s", client_id)
|
||||
return True
|
||||
|
||||
# Authorization
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Park the request and send the browser to our consent page.
|
||||
|
||||
Raises:
|
||||
AuthorizeError: ``invalid_target`` when the client asks for a token
|
||||
for another resource than this server's MCP endpoint.
|
||||
"""
|
||||
if params.resource is not None and not same_resource(params.resource, self.resource):
|
||||
raise AuthorizeError("invalid_target", "unknown resource")
|
||||
request_id = secrets.token_urlsafe(32)
|
||||
pending = PendingAuthorization(
|
||||
client_id=client.client_id,
|
||||
client_name=client.client_name,
|
||||
redirect_uri=str(params.redirect_uri),
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
code_challenge=params.code_challenge,
|
||||
client_state=params.state,
|
||||
scopes=list(params.scopes or [MCP_SCOPE]),
|
||||
resource=self.resource,
|
||||
code_verifier=secrets.token_urlsafe(64),
|
||||
)
|
||||
self._requests.put(token_hash(request_id), pending, PENDING_TTL)
|
||||
return f"{self.base_url}{CONSENT_PATH}?{urlencode({'request': request_id})}"
|
||||
|
||||
def consent_request(self, request_id: str) -> PendingAuthorization | None:
|
||||
"""The pending request shown on the consent page, or None when unknown or expired."""
|
||||
return self._requests.get(token_hash(request_id)) if request_id else None
|
||||
|
||||
def begin_consent(self, request_id: str) -> str | None:
|
||||
"""Mint the CSRF token for the consent form (the newest page wins)."""
|
||||
pending = self.consent_request(request_id)
|
||||
if pending is None:
|
||||
return None
|
||||
csrf = secrets.token_urlsafe(32)
|
||||
pending.csrf_hash = token_hash(csrf)
|
||||
return csrf
|
||||
|
||||
def finish_consent(
|
||||
self, request_id: str, csrf_form: str, csrf_cookie: str, *, approve: bool
|
||||
) -> ConsentDecision | None:
|
||||
"""Apply the consent decision; None when the request or CSRF proof is invalid."""
|
||||
key = token_hash(request_id) if request_id else ""
|
||||
pending = self._requests.get(key) if key else None
|
||||
if pending is None:
|
||||
return None
|
||||
# Double submit: the form value and the SameSite=Strict cookie must both
|
||||
# carry the token minted for this request.
|
||||
if not (
|
||||
_matches(csrf_form, pending.csrf_hash) and _matches(csrf_cookie, pending.csrf_hash)
|
||||
):
|
||||
logger.warning("oauth consent rejected: CSRF check failed")
|
||||
return None
|
||||
self._requests.pop(key)
|
||||
if not approve:
|
||||
logger.info("oauth consent denied for client %s", pending.client_id)
|
||||
return ConsentDecision(
|
||||
redirect=construct_redirect_uri(
|
||||
pending.redirect_uri, error="access_denied", state=pending.client_state
|
||||
)
|
||||
)
|
||||
state = secrets.token_urlsafe(32)
|
||||
binding = secrets.token_urlsafe(32)
|
||||
pending.binding_hash = token_hash(binding)
|
||||
self._states.put(token_hash(state), pending, PENDING_TTL)
|
||||
location = self._upstream.authorize_url(
|
||||
state=state, code_challenge=s256(pending.code_verifier)
|
||||
)
|
||||
return ConsentDecision(redirect=location, binding_token=binding)
|
||||
|
||||
async def complete_callback(
|
||||
self, state: str, binding: str, code: str, error: str
|
||||
) -> str | None:
|
||||
"""Finish the Gitea sign-in; return the client redirect, or None for a bad request.
|
||||
|
||||
A bad request (unknown, expired or replayed state, or a browser that
|
||||
did not start the flow) never redirects anywhere.
|
||||
"""
|
||||
pending = self._states.pop(token_hash(state)) if state else None
|
||||
if pending is None:
|
||||
logger.warning("oauth callback rejected: unknown, expired or replayed state")
|
||||
return None
|
||||
if not _matches(binding, pending.binding_hash):
|
||||
logger.warning("oauth callback rejected: the browser did not start this sign-in")
|
||||
self._audit(
|
||||
"oauth_callback_binding_mismatch",
|
||||
"OAuth callback from a browser that did not start the sign-in",
|
||||
"high",
|
||||
client_id=pending.client_id,
|
||||
)
|
||||
return None
|
||||
if pending.client_id not in self._store.clients:
|
||||
logger.warning("oauth callback rejected: client no longer registered")
|
||||
return None
|
||||
|
||||
def back(**params: str | None) -> str:
|
||||
return construct_redirect_uri(
|
||||
pending.redirect_uri, **params, state=pending.client_state
|
||||
)
|
||||
|
||||
if error:
|
||||
logger.info("oauth sign-in cancelled or refused at Gitea")
|
||||
return back(error="access_denied")
|
||||
if not code:
|
||||
logger.warning("oauth sign-in failed: Gitea sent no code")
|
||||
return back(error="server_error")
|
||||
try:
|
||||
tokens = await self._upstream.exchange_code(code, pending.code_verifier)
|
||||
user = await self._upstream.fetch_user(tokens.access_token)
|
||||
except UpstreamError as exc:
|
||||
logger.warning("oauth sign-in failed: %s", exc)
|
||||
return back(error="server_error")
|
||||
|
||||
now = self._clock()
|
||||
session = SessionRecord(
|
||||
session_id=uuid.uuid4().hex,
|
||||
client_id=pending.client_id,
|
||||
login=user.login,
|
||||
is_admin=user.is_admin,
|
||||
gitea_access_token=tokens.access_token,
|
||||
gitea_refresh_token=tokens.refresh_token,
|
||||
gitea_expires_at=tokens.expires_at,
|
||||
scopes=list(pending.scopes),
|
||||
resource=pending.resource,
|
||||
created_at=now,
|
||||
)
|
||||
existing = sorted(self._store.sessions_of(user.login), key=lambda item: item.created_at)
|
||||
for old in existing[: max(0, len(existing) - MAX_SESSIONS_PER_LOGIN + 1)]:
|
||||
self._end_session(
|
||||
old.session_id, "oauth_session_evicted", "Oldest OAuth session of the user ended"
|
||||
)
|
||||
self._store.sessions[session.session_id] = session
|
||||
try:
|
||||
await self._store.save()
|
||||
except (OSError, OAuthStoreError) as exc:
|
||||
self._store.sessions.pop(session.session_id, None)
|
||||
logger.error(
|
||||
"oauth sign-in failed: could not save the session (%s)", type(exc).__name__
|
||||
)
|
||||
return back(error="server_error")
|
||||
|
||||
our_code = secrets.token_urlsafe(32)
|
||||
self._codes.put(
|
||||
token_hash(our_code),
|
||||
StoredAuthorizationCode(
|
||||
code=token_hash(our_code),
|
||||
scopes=list(pending.scopes),
|
||||
expires_at=now + AUTHORIZATION_CODE_TTL,
|
||||
client_id=pending.client_id,
|
||||
code_challenge=pending.code_challenge,
|
||||
redirect_uri=pending.redirect_uri, # type: ignore[arg-type]
|
||||
redirect_uri_provided_explicitly=pending.redirect_uri_provided_explicitly,
|
||||
resource=pending.resource,
|
||||
subject=user.login,
|
||||
session_id=session.session_id,
|
||||
),
|
||||
AUTHORIZATION_CODE_TTL,
|
||||
)
|
||||
logger.info("oauth sign-in: Gitea user %s via client %s", user.login, pending.client_id)
|
||||
self._audit(
|
||||
"oauth_login",
|
||||
"User signed in with Gitea OAuth",
|
||||
"low",
|
||||
login=user.login,
|
||||
client_id=pending.client_id,
|
||||
gitea_admin=user.is_admin,
|
||||
)
|
||||
return back(code=our_code)
|
||||
|
||||
# Authorization codes
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> StoredAuthorizationCode | None:
|
||||
"""Look up our code; a replayed code revokes the grant it produced."""
|
||||
key = token_hash(authorization_code)
|
||||
used_by = self._used_codes.get(key)
|
||||
if used_by is not None:
|
||||
logger.warning("oauth authorization code replayed; revoking the grant")
|
||||
self._end_session(
|
||||
used_by, "oauth_code_replay", "Authorization code replayed; grant revoked"
|
||||
)
|
||||
await self._store.save()
|
||||
return None
|
||||
code = self._codes.get(key)
|
||||
if code is None or code.client_id != client.client_id:
|
||||
return None
|
||||
return code
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: StoredAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Consume the code (single use) and issue our access and refresh tokens."""
|
||||
code = self._codes.pop(authorization_code.code)
|
||||
if code is None or code.client_id != client.client_id:
|
||||
raise TokenError("invalid_grant", "authorization code does not exist")
|
||||
self._used_codes.put(authorization_code.code, code.session_id, AUTHORIZATION_CODE_TTL)
|
||||
session = self._store.sessions.get(code.session_id)
|
||||
if session is None or session.client_id != client.client_id:
|
||||
raise TokenError("invalid_grant", "authorization code does not exist")
|
||||
return await self._issue_tokens(session, code.scopes, code.scopes)
|
||||
|
||||
# Refresh tokens
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> StoredRefreshToken | None:
|
||||
"""Look up a refresh token; reuse of a rotated-out token revokes the grant."""
|
||||
if not refresh_token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
key = token_hash(refresh_token)
|
||||
record = self._store.tokens.get(key)
|
||||
if record is None:
|
||||
reused = self._store.session_by_previous_refresh(key)
|
||||
if reused is not None:
|
||||
logger.warning("oauth refresh token reused; revoking the grant")
|
||||
self._end_session(
|
||||
reused.session_id,
|
||||
"oauth_refresh_reuse",
|
||||
"Rotated refresh token reused; grant revoked",
|
||||
)
|
||||
await self._store.save()
|
||||
return None
|
||||
session = self._store.sessions.get(record.session_id)
|
||||
if record.kind != "refresh" or session is None or record.client_id != client.client_id:
|
||||
return None
|
||||
if record.expires_at <= self._clock():
|
||||
return None
|
||||
return StoredRefreshToken(
|
||||
token=f"sha256:{key}",
|
||||
client_id=record.client_id,
|
||||
scopes=list(record.scopes),
|
||||
expires_at=int(record.expires_at),
|
||||
resource=session.resource,
|
||||
subject=session.login,
|
||||
session_id=record.session_id,
|
||||
token_hash=key,
|
||||
)
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: StoredRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Rotate: revoke the old pair and issue a new pair for the same session."""
|
||||
record = self._store.tokens.get(refresh_token.token_hash)
|
||||
session = self._store.sessions.get(refresh_token.session_id)
|
||||
if (
|
||||
record is None
|
||||
or record.kind != "refresh"
|
||||
or record.client_id != client.client_id
|
||||
or session is None
|
||||
):
|
||||
raise TokenError("invalid_grant", "refresh token does not exist")
|
||||
for key in self._store.session_token_hashes(session.session_id):
|
||||
del self._store.tokens[key]
|
||||
session.previous_refresh_hash = refresh_token.token_hash
|
||||
logger.info("oauth tokens refreshed for %s", session.login)
|
||||
return await self._issue_tokens(session, scopes, record.scopes)
|
||||
|
||||
# Access tokens and revocation
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify one of our access tokens (``token`` on the result is only a hash)."""
|
||||
if not token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
key = token_hash(token)
|
||||
record = self._store.tokens.get(key)
|
||||
if record is None or record.kind != "access" or record.expires_at <= self._clock():
|
||||
return None
|
||||
session = self._store.sessions.get(record.session_id)
|
||||
if session is None:
|
||||
return None
|
||||
return AccessToken(
|
||||
token=f"sha256:{key}",
|
||||
client_id=record.client_id,
|
||||
scopes=list(record.scopes),
|
||||
expires_at=int(record.expires_at),
|
||||
resource=session.resource,
|
||||
subject=session.login,
|
||||
claims={"auth": "oauth", "session_id": session.session_id},
|
||||
)
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
"""Revoke the whole grant (access and refresh tokens and the Gitea session)."""
|
||||
if isinstance(token, StoredRefreshToken):
|
||||
session_id: Any = token.session_id
|
||||
elif isinstance(token, AccessToken):
|
||||
session_id = (token.claims or {}).get("session_id")
|
||||
else:
|
||||
return
|
||||
if not isinstance(session_id, str):
|
||||
return
|
||||
self._end_session(session_id, "oauth_revoke", "OAuth grant revoked by the client")
|
||||
await self._store.save()
|
||||
logger.info("oauth grant revoked")
|
||||
|
||||
|
||||
class OAuthTokenVerifier:
|
||||
"""``TokenVerifier`` for our issued access tokens (anything else is not ours)."""
|
||||
|
||||
def __init__(self, provider: GiteaOAuthProvider) -> None:
|
||||
self._provider = provider
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Return the access token for an issued ``agm_`` token, else None."""
|
||||
if not token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
return await self._provider.load_access_token(token)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Identity resolution for per-user OAuth, with automatic upstream refresh.
|
||||
|
||||
Static and OAuth identities never mix:
|
||||
|
||||
* an access token carrying our OAuth marker (``claims["auth"] == "oauth"``)
|
||||
always resolves to that session's Gitea user, or fails; it never falls back
|
||||
to the admin token;
|
||||
* only the static bearer (``client_id == STATIC_CLIENT_ID`` and no OAuth
|
||||
marker) or no token at all reaches the admin resolver, and in an OAuth-only
|
||||
deployment (no ``GITEA_TOKEN``) that fails closed;
|
||||
* any other access token is refused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
||||
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID, IdentityResolver
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||
from aegis_gitea_mcp.gitea.redaction import normalize_secrets
|
||||
from aegis_gitea_mcp.identity import Identity
|
||||
from aegis_gitea_mcp.oauth.store import OAuthStore, OAuthStoreError, SessionRecord
|
||||
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError, UpstreamInvalidGrant
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aegis_gitea_mcp.gitea.executor import _Call
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Refresh the user's Gitea token when it expires within this many seconds.
|
||||
REFRESH_MARGIN_SECONDS = 60.0
|
||||
|
||||
SIGN_IN_AGAIN = "your Gitea sign-in has ended; sign in again through your MCP client"
|
||||
OAUTH_ONLY = (
|
||||
"this server accepts only per-user OAuth sign-in (no GITEA_TOKEN is configured for "
|
||||
"admin-token mode)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionIdentity(Identity):
|
||||
"""An OAuth identity that also names session secrets to redact from results.
|
||||
|
||||
Attributes:
|
||||
redact: Further secrets of the session (the Gitea refresh token) that must
|
||||
never appear in a result. Excluded from ``repr`` and comparisons.
|
||||
"""
|
||||
|
||||
redact: tuple[str, ...] = field(default=(), repr=False, compare=False)
|
||||
|
||||
|
||||
class OAuthGiteaExecutor(GiteaExecutor):
|
||||
"""Executor that also redacts the session secrets carried by a :class:`SessionIdentity`."""
|
||||
|
||||
def _secrets(self, identity: Identity, call: _Call) -> tuple[str, ...]:
|
||||
extra = identity.redact if isinstance(identity, SessionIdentity) else ()
|
||||
return normalize_secrets([*super()._secrets(identity, call), *extra])
|
||||
|
||||
|
||||
class OAuthIdentityResolver:
|
||||
"""Maps a verified access token to the identity a tool call runs as.
|
||||
|
||||
Args:
|
||||
store: OAuth store (sessions and Gitea tokens).
|
||||
upstream: Gitea OAuth client used to refresh the user's Gitea token.
|
||||
admin: Resolver for the static bearer (admin-token mode), or None in an
|
||||
OAuth-only deployment.
|
||||
clock: Wall clock (epoch seconds).
|
||||
audit: Audit logger for ended sessions (defaults to the process-wide one).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: OAuthStore,
|
||||
upstream: GiteaUpstream,
|
||||
admin: IdentityResolver | None,
|
||||
*,
|
||||
clock: Callable[[], float] = time.time,
|
||||
audit: AuditLogger | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._upstream = upstream
|
||||
self._admin = admin
|
||||
self._clock = clock
|
||||
self._audit_logger = audit
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
async def resolve(self, access: AccessToken | None) -> Identity:
|
||||
"""Return the user's identity for an OAuth token, the admin's for the static bearer.
|
||||
|
||||
Raises:
|
||||
ToolError: Unknown token kind, ended session, failed upstream refresh,
|
||||
or admin-token mode requested while it is not configured.
|
||||
"""
|
||||
claims = (access.claims or {}) if access is not None else {}
|
||||
if access is not None and claims.get("auth") == "oauth":
|
||||
return await self._resolve_session(access, claims.get("session_id"))
|
||||
if access is None or access.client_id == STATIC_CLIENT_ID:
|
||||
if self._admin is None:
|
||||
raise ToolError(OAUTH_ONLY, status_code=401)
|
||||
return await self._admin.resolve(access)
|
||||
raise ToolError("unknown identity", status_code=401)
|
||||
|
||||
async def _resolve_session(self, access: AccessToken, session_id: object) -> Identity:
|
||||
if not isinstance(session_id, str):
|
||||
raise ToolError("unknown identity", status_code=401)
|
||||
session = self._store.sessions.get(session_id)
|
||||
if session is None or session.login != access.subject:
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401)
|
||||
if self._needs_refresh(session):
|
||||
session = await self._refresh(session_id)
|
||||
redact = (session.gitea_refresh_token,) if session.gitea_refresh_token else ()
|
||||
return SessionIdentity(
|
||||
login=session.login, mode="oauth", token=session.gitea_access_token, redact=redact
|
||||
)
|
||||
|
||||
def _needs_refresh(self, session: SessionRecord) -> bool:
|
||||
expires = session.gitea_expires_at
|
||||
return expires is not None and expires - self._clock() <= REFRESH_MARGIN_SECONDS
|
||||
|
||||
async def _refresh(self, session_id: str) -> SessionRecord:
|
||||
lock = self._locks.setdefault(session_id, asyncio.Lock())
|
||||
async with lock:
|
||||
session = self._store.sessions.get(session_id)
|
||||
if session is None:
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401)
|
||||
if not self._needs_refresh(session):
|
||||
return session # refreshed by a concurrent call
|
||||
still_valid = (session.gitea_expires_at or 0.0) > self._clock()
|
||||
if session.gitea_refresh_token is None:
|
||||
if still_valid:
|
||||
return session
|
||||
await self._end(session, "Gitea token expired and no refresh token was issued")
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401)
|
||||
try:
|
||||
tokens = await self._upstream.refresh(session.gitea_refresh_token)
|
||||
except UpstreamInvalidGrant:
|
||||
await self._end(session, "Gitea refused the refresh token")
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401) from None
|
||||
except UpstreamError as exc:
|
||||
logger.warning("gitea token refresh failed for %s: %s", session.login, exc)
|
||||
if still_valid:
|
||||
return session
|
||||
raise ToolError(
|
||||
"could not refresh your Gitea sign-in; try again shortly", status_code=503
|
||||
) from None
|
||||
updated = dataclasses.replace(
|
||||
session,
|
||||
gitea_access_token=tokens.access_token,
|
||||
gitea_refresh_token=tokens.refresh_token or session.gitea_refresh_token,
|
||||
gitea_expires_at=tokens.expires_at,
|
||||
)
|
||||
self._store.sessions[session_id] = updated
|
||||
try:
|
||||
await self._store.save()
|
||||
except (OSError, OAuthStoreError) as exc:
|
||||
logger.error("could not save the refreshed Gitea token (%s)", type(exc).__name__)
|
||||
logger.info("gitea token refreshed for %s", session.login)
|
||||
return updated
|
||||
|
||||
async def _end(self, session: SessionRecord, reason: str) -> None:
|
||||
self._store.end_session(session.session_id)
|
||||
self._locks.pop(session.session_id, None)
|
||||
logger.warning("oauth session ended for %s: %s", session.login, reason)
|
||||
try:
|
||||
audit = self._audit_logger or get_audit_logger()
|
||||
audit.log_security_event(
|
||||
"oauth_session_ended",
|
||||
reason,
|
||||
severity="low",
|
||||
metadata={"login": session.login, "client_id": session.client_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("audit log write failed: %s", type(exc).__name__)
|
||||
try:
|
||||
await self._store.save()
|
||||
except (OSError, OAuthStoreError) as exc:
|
||||
logger.error("could not save the OAuth store (%s)", type(exc).__name__)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Encrypted on-disk store for OAuth clients, sessions and token hashes.
|
||||
|
||||
One file, ``DATA_DIR/oauth-store.json``, holds the whole state as a single
|
||||
Fernet-encrypted JSON document. The Fernet key is derived from
|
||||
``TOKEN_ENCRYPTION_KEY`` with HKDF-SHA256, so the setting can be any long random
|
||||
string (the setup command writes a ``secrets.token_urlsafe`` value, not a Fernet
|
||||
key).
|
||||
|
||||
What is stored:
|
||||
|
||||
* registered MCP clients, including their client secrets (the SDK compares the
|
||||
presented secret with the stored one at ``/token``);
|
||||
* sessions: the Gitea login and the user's Gitea access and refresh tokens;
|
||||
* our own access and refresh tokens, only as SHA-256 hashes.
|
||||
|
||||
File safety: the directory is created with mode 0700 and the file is written
|
||||
with mode 0600 through a fresh ``O_EXCL`` temporary file and an atomic
|
||||
``os.replace``. A symlink at the store path is refused. A file that cannot be
|
||||
decrypted or parsed stops startup with a clear message instead of being wiped,
|
||||
because wiping would silently sign every user out and hide a wrong key.
|
||||
|
||||
Mutations happen synchronously in memory (so a check-and-delete is atomic under
|
||||
asyncio) and are persisted with :meth:`OAuthStore.save`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STORE_FILENAME = "oauth-store.json"
|
||||
STORE_VERSION = 1
|
||||
HKDF_INFO = b"aegis-gitea-mcp oauth store"
|
||||
|
||||
#: A session that never received tokens (an abandoned sign-in) is dropped after this long.
|
||||
ABANDONED_SESSION_SECONDS = 600.0
|
||||
|
||||
Clock = Callable[[], float]
|
||||
TokenKind = Literal["access", "refresh"]
|
||||
|
||||
|
||||
class OAuthStoreError(Exception):
|
||||
"""The OAuth store cannot be used (startup error; the message is safe to print)."""
|
||||
|
||||
|
||||
def derive_fernet_key(secret: str) -> bytes:
|
||||
"""Derive the Fernet key for the store from ``TOKEN_ENCRYPTION_KEY`` (HKDF-SHA256)."""
|
||||
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=HKDF_INFO)
|
||||
return base64.urlsafe_b64encode(hkdf.derive(secret.encode("utf-8")))
|
||||
|
||||
|
||||
def token_hash(value: str) -> str:
|
||||
"""SHA-256 hex digest used as the stored form of our tokens, codes and states."""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientRecord:
|
||||
"""A dynamically registered MCP client.
|
||||
|
||||
Attributes:
|
||||
info: ``OAuthClientInformationFull`` as JSON (includes the client secret).
|
||||
registered_at: Registration time (epoch seconds).
|
||||
last_used_at: Last time the client was looked up (epoch seconds).
|
||||
"""
|
||||
|
||||
info: dict[str, Any] = field(repr=False)
|
||||
registered_at: float
|
||||
last_used_at: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionRecord:
|
||||
"""One signed-in grant: a Gitea user, the client it was granted to and the Gitea tokens.
|
||||
|
||||
Attributes:
|
||||
session_id: Random identifier (never sent to clients).
|
||||
client_id: MCP client the grant belongs to.
|
||||
login: Gitea login of the user.
|
||||
is_admin: Whether Gitea reported the user as site admin at sign-in
|
||||
(informational only; Gitea enforces permissions).
|
||||
gitea_access_token: The user's Gitea access token.
|
||||
gitea_refresh_token: The user's Gitea refresh token, if Gitea issued one.
|
||||
gitea_expires_at: Expiry of the Gitea access token (epoch seconds), if known.
|
||||
scopes: Our scopes granted to the client.
|
||||
resource: The resource (MCP endpoint URL) the grant is bound to.
|
||||
created_at: Sign-in time (epoch seconds).
|
||||
previous_refresh_hash: Hash of the last rotated-out refresh token (reuse detection).
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
client_id: str
|
||||
login: str
|
||||
is_admin: bool
|
||||
gitea_access_token: str = field(repr=False)
|
||||
gitea_refresh_token: str | None = field(repr=False)
|
||||
gitea_expires_at: float | None
|
||||
scopes: list[str]
|
||||
resource: str
|
||||
created_at: float
|
||||
previous_refresh_hash: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenRecord:
|
||||
"""One of our tokens, stored under its SHA-256 hash."""
|
||||
|
||||
kind: TokenKind
|
||||
session_id: str
|
||||
client_id: str
|
||||
scopes: list[str]
|
||||
expires_at: float
|
||||
|
||||
|
||||
def _restrict_directory(directory: Path) -> None:
|
||||
"""Create the state directory with mode 0700, or tighten an existing one we own."""
|
||||
if not directory.exists():
|
||||
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return
|
||||
mode = stat.S_IMODE(directory.stat().st_mode)
|
||||
if mode & 0o077:
|
||||
try:
|
||||
directory.chmod(0o700)
|
||||
except OSError:
|
||||
logger.warning("could not restrict the permissions of the state directory to 0700")
|
||||
|
||||
|
||||
def _parse_document(document: Any) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
if not isinstance(document, dict) or document.get("version") != STORE_VERSION:
|
||||
raise ValueError("unsupported store version")
|
||||
clients_raw = document.get("clients", {})
|
||||
sessions_raw = document.get("sessions", {})
|
||||
tokens_raw = document.get("tokens", {})
|
||||
if not all(isinstance(part, dict) for part in (clients_raw, sessions_raw, tokens_raw)):
|
||||
raise ValueError("malformed store sections")
|
||||
clients = {str(key): ClientRecord(**value) for key, value in clients_raw.items()}
|
||||
sessions = {str(key): SessionRecord(**value) for key, value in sessions_raw.items()}
|
||||
tokens = {str(key): TokenRecord(**value) for key, value in tokens_raw.items()}
|
||||
for record in clients.values():
|
||||
if not isinstance(record.info, dict):
|
||||
raise ValueError("malformed client record")
|
||||
for token in tokens.values():
|
||||
if token.kind not in ("access", "refresh"):
|
||||
raise ValueError("malformed token record")
|
||||
return clients, sessions, tokens
|
||||
|
||||
|
||||
class OAuthStore:
|
||||
"""In-memory OAuth state backed by one encrypted file.
|
||||
|
||||
Use :meth:`load` to create it. All getters and mutators are synchronous;
|
||||
:meth:`save` persists the current state (pruning expired entries first).
|
||||
|
||||
Args:
|
||||
directory: State directory (``DATA_DIR``).
|
||||
secret: ``TOKEN_ENCRYPTION_KEY``.
|
||||
clock: Wall clock (epoch seconds); tests inject a fake one.
|
||||
"""
|
||||
|
||||
def __init__(self, directory: Path, secret: str, clock: Clock = time.time) -> None:
|
||||
self._directory = directory
|
||||
self._path = directory / STORE_FILENAME
|
||||
self._fernet = Fernet(derive_fernet_key(secret))
|
||||
self._clock = clock
|
||||
self._lock = asyncio.Lock()
|
||||
self.clients: dict[str, ClientRecord] = {}
|
||||
self.sessions: dict[str, SessionRecord] = {}
|
||||
self.tokens: dict[str, TokenRecord] = {}
|
||||
|
||||
@classmethod
|
||||
def load(cls, directory: Path, secret: str, clock: Clock = time.time) -> OAuthStore:
|
||||
"""Open the store, reading and decrypting the file when it exists.
|
||||
|
||||
Raises:
|
||||
OAuthStoreError: The path is a symlink or not a regular file, or the
|
||||
file cannot be decrypted or parsed.
|
||||
"""
|
||||
store = cls(directory, secret, clock)
|
||||
store._read()
|
||||
store.prune()
|
||||
return store
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Location of the store file."""
|
||||
return self._path
|
||||
|
||||
# Reading and writing the file
|
||||
|
||||
def _read(self) -> None:
|
||||
try:
|
||||
info = os.lstat(self._path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
raise OAuthStoreError(
|
||||
f"{self._path} is a symlink; refusing to use it as the OAuth token store"
|
||||
)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise OAuthStoreError(f"{self._path} is not a regular file")
|
||||
fd = os.open(self._path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(fd, "rb") as handle:
|
||||
raw = handle.read()
|
||||
if stat.S_IMODE(info.st_mode) & 0o077:
|
||||
logger.warning("OAuth store file had loose permissions; restricting it to 0600")
|
||||
os.chmod(self._path, 0o600)
|
||||
try:
|
||||
plain = self._fernet.decrypt(raw)
|
||||
except InvalidToken:
|
||||
raise OAuthStoreError(
|
||||
f"cannot decrypt {self._path}: TOKEN_ENCRYPTION_KEY does not match the key the "
|
||||
"store was written with, or the file is damaged. Restore the original key, or "
|
||||
"move the file away to start over (this signs every OAuth user out)."
|
||||
) from None
|
||||
try:
|
||||
self.clients, self.sessions, self.tokens = _parse_document(json.loads(plain))
|
||||
except (ValueError, TypeError, KeyError):
|
||||
raise OAuthStoreError(
|
||||
f"{self._path} is not a valid OAuth store document; move the file away to "
|
||||
"start over (this signs every OAuth user out)"
|
||||
) from None
|
||||
|
||||
def _document(self) -> bytes:
|
||||
document = {
|
||||
"version": STORE_VERSION,
|
||||
"clients": {key: asdict(value) for key, value in self.clients.items()},
|
||||
"sessions": {key: asdict(value) for key, value in self.sessions.items()},
|
||||
"tokens": {key: asdict(value) for key, value in self.tokens.items()},
|
||||
}
|
||||
return json.dumps(document, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
def _write(self, data: bytes) -> None:
|
||||
_restrict_directory(self._directory)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
if stat.S_ISLNK(os.lstat(self._path).st_mode):
|
||||
raise OAuthStoreError(
|
||||
f"{self._path} is a symlink; refusing to write the OAuth token store"
|
||||
)
|
||||
temporary = self._directory / f".{STORE_FILENAME}.{secrets.token_hex(8)}.tmp"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, self._path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
raise
|
||||
|
||||
async def save(self) -> None:
|
||||
"""Prune expired entries and persist the state atomically (encrypted, mode 0600)."""
|
||||
async with self._lock:
|
||||
self.prune()
|
||||
self._write(self._fernet.encrypt(self._document()))
|
||||
|
||||
# Maintenance
|
||||
|
||||
def prune(self) -> None:
|
||||
"""Drop expired tokens, and sessions that have no tokens left."""
|
||||
now = self._clock()
|
||||
for key in [key for key, token in self.tokens.items() if token.expires_at <= now]:
|
||||
del self.tokens[key]
|
||||
live = {token.session_id for token in self.tokens.values()}
|
||||
for session_id, session in list(self.sessions.items()):
|
||||
if session_id in live:
|
||||
continue
|
||||
if session.created_at + ABANDONED_SESSION_SECONDS < now:
|
||||
del self.sessions[session_id]
|
||||
|
||||
# Sessions and tokens
|
||||
|
||||
def session_token_hashes(self, session_id: str) -> list[str]:
|
||||
"""Hashes of every token issued for ``session_id``."""
|
||||
return [key for key, token in self.tokens.items() if token.session_id == session_id]
|
||||
|
||||
def end_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all of its tokens; return whether it existed."""
|
||||
for key in self.session_token_hashes(session_id):
|
||||
del self.tokens[key]
|
||||
return self.sessions.pop(session_id, None) is not None
|
||||
|
||||
def sessions_of(self, login: str) -> Iterator[SessionRecord]:
|
||||
"""Every session of one Gitea login."""
|
||||
return (session for session in self.sessions.values() if session.login == login)
|
||||
|
||||
def session_by_previous_refresh(self, refresh_hash: str) -> SessionRecord | None:
|
||||
"""The session whose last rotated-out refresh token has ``refresh_hash``."""
|
||||
for session in self.sessions.values():
|
||||
if session.previous_refresh_hash is not None and secrets.compare_digest(
|
||||
session.previous_refresh_hash, refresh_hash
|
||||
):
|
||||
return session
|
||||
return None
|
||||
|
||||
def client_has_sessions(self, client_id: str) -> bool:
|
||||
"""Whether any session belongs to ``client_id``."""
|
||||
return any(session.client_id == client_id for session in self.sessions.values())
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Gitea as the upstream OAuth2 provider: code exchange, refresh and the user lookup.
|
||||
|
||||
We are a confidential client of Gitea (``GITEA_OAUTH_CLIENT_ID`` and
|
||||
``GITEA_OAUTH_CLIENT_SECRET``, sent form-encoded). Errors never carry Gitea's
|
||||
response body or any token: callers only learn a coarse kind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from aegis_gitea_mcp import __version__
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
|
||||
#: Callback path on our server that Gitea redirects to (registered on the Gitea app).
|
||||
CALLBACK_PATH = "/oauth/callback"
|
||||
|
||||
_MAX_TOKEN_CHARS = 32_767 # Gitea's MAX_TOKEN_LENGTH
|
||||
# Gitea user names: alphanumerics, dash, underscore and dot (also keeps logs clean).
|
||||
_LOGIN = re.compile(r"^[A-Za-z0-9_.-]{1,255}$")
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""A request to Gitea's OAuth endpoints failed (message is a coarse, safe kind)."""
|
||||
|
||||
|
||||
class UpstreamInvalidGrant(UpstreamError):
|
||||
"""Gitea rejected the code or refresh token (``invalid_grant``): the user must sign in again."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpstreamTokens:
|
||||
"""Tokens returned by Gitea's token endpoint (excluded from ``repr``)."""
|
||||
|
||||
access_token: str = field(repr=False)
|
||||
refresh_token: str | None = field(repr=False)
|
||||
expires_at: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaUser:
|
||||
"""The signed-in Gitea user."""
|
||||
|
||||
login: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
def _default_client(settings: Settings) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(float(settings.request_timeout_seconds)),
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def _valid_token(value: Any) -> bool:
|
||||
return isinstance(value, str) and 0 < len(value) <= _MAX_TOKEN_CHARS and value.isprintable()
|
||||
|
||||
|
||||
class GiteaUpstream:
|
||||
"""Client for Gitea's ``/login/oauth/access_token`` and ``/api/v1/user``.
|
||||
|
||||
Args:
|
||||
settings: Application settings (Gitea URL, OAuth app credentials, timeout).
|
||||
client_factory: Creates the ``httpx.AsyncClient`` for one exchange. A new
|
||||
client per exchange means no cookies or connections are shared.
|
||||
clock: Wall clock used to turn ``expires_in`` into an expiry time.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._client_factory = client_factory or (lambda: _default_client(settings))
|
||||
self._clock = clock
|
||||
self._token_url = f"{settings.gitea_url}/login/oauth/access_token"
|
||||
self._user_url = f"{settings.gitea_api_url}/user"
|
||||
secret = settings.gitea_oauth_client_secret
|
||||
self._client_id = settings.gitea_oauth_client_id or ""
|
||||
self._client_secret = secret.get_secret_value() if secret is not None else ""
|
||||
self._headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"aegis-gitea-mcp/{__version__}",
|
||||
}
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
"""Our callback URL as registered on the Gitea OAuth application."""
|
||||
return f"{self._settings.public_base_url}{CALLBACK_PATH}"
|
||||
|
||||
def authorize_url(self, *, state: str, code_challenge: str) -> str:
|
||||
"""Gitea's authorization URL for one sign-in (PKCE S256, fixed scope list)."""
|
||||
query = {
|
||||
"client_id": self._client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
"scope": " ".join(self._settings.oauth_scopes),
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return f"{self._settings.gitea_url}/login/oauth/authorize?{urlencode(query)}"
|
||||
|
||||
async def exchange_code(self, code: str, code_verifier: str) -> UpstreamTokens:
|
||||
"""Exchange Gitea's authorization code (with our PKCE verifier) for tokens."""
|
||||
return await self._token_request(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
)
|
||||
|
||||
async def refresh(self, refresh_token: str) -> UpstreamTokens:
|
||||
"""Refresh the user's Gitea access token.
|
||||
|
||||
Raises:
|
||||
UpstreamInvalidGrant: Gitea no longer accepts the refresh token.
|
||||
UpstreamError: Any other failure (network, 5xx, malformed answer).
|
||||
"""
|
||||
return await self._token_request(
|
||||
{"grant_type": "refresh_token", "refresh_token": refresh_token}
|
||||
)
|
||||
|
||||
async def fetch_user(self, access_token: str) -> GiteaUser:
|
||||
"""Look up the user that owns ``access_token`` (``GET /api/v1/user``)."""
|
||||
headers = {**self._headers, "Authorization": f"Bearer {access_token}"}
|
||||
try:
|
||||
async with self._client_factory() as client:
|
||||
response = await client.get(self._user_url, headers=headers)
|
||||
except httpx.HTTPError:
|
||||
raise UpstreamError("user lookup failed: Gitea unreachable") from None
|
||||
if response.status_code != 200:
|
||||
raise UpstreamError(f"user lookup failed: HTTP {response.status_code}")
|
||||
body = _json(response) or {}
|
||||
login = body.get("login")
|
||||
if not isinstance(login, str) or not _LOGIN.match(login):
|
||||
raise UpstreamError("user lookup failed: malformed answer")
|
||||
return GiteaUser(login=login, is_admin=body.get("is_admin") is True)
|
||||
|
||||
async def _token_request(self, form: dict[str, str]) -> UpstreamTokens:
|
||||
payload = {**form, "client_id": self._client_id, "client_secret": self._client_secret}
|
||||
try:
|
||||
async with self._client_factory() as client:
|
||||
response = await client.post(self._token_url, data=payload, headers=self._headers)
|
||||
except httpx.HTTPError:
|
||||
raise UpstreamError("token request failed: Gitea unreachable") from None
|
||||
body = _json(response)
|
||||
if response.status_code != 200:
|
||||
if response.status_code in (400, 401) and body and body.get("error") == "invalid_grant":
|
||||
raise UpstreamInvalidGrant("Gitea rejected the grant")
|
||||
raise UpstreamError(f"token request failed: HTTP {response.status_code}")
|
||||
if body is None:
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
access = body.get("access_token")
|
||||
refresh = body.get("refresh_token")
|
||||
token_type = body.get("token_type", "bearer")
|
||||
expires_in = body.get("expires_in")
|
||||
if not isinstance(access, str) or not _valid_token(access):
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
if not isinstance(token_type, str):
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
if token_type.lower() != "bearer":
|
||||
raise UpstreamError("token request failed: unexpected token type")
|
||||
if refresh is not None and not _valid_token(refresh):
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
expires_at: float | None = None
|
||||
if isinstance(expires_in, bool):
|
||||
expires_in = None
|
||||
if isinstance(expires_in, (int, float)) and expires_in > 0:
|
||||
expires_at = self._clock() + float(expires_in)
|
||||
return UpstreamTokens(access_token=access, refresh_token=refresh, expires_at=expires_at)
|
||||
|
||||
|
||||
def _json(response: httpx.Response) -> dict[str, Any] | None:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
@@ -32,8 +32,15 @@ from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from aegis_gitea_mcp import __version__
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.auth import CompositeVerifier, IdentityResolver, StaticBearerVerifier
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.auth import (
|
||||
MCP_SCOPE,
|
||||
CompositeVerifier,
|
||||
IdentityResolver,
|
||||
StaticBearerVerifier,
|
||||
)
|
||||
from aegis_gitea_mcp.config import Settings, is_loopback_host
|
||||
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||
from aegis_gitea_mcp.rate_limit import RequestRateLimiter
|
||||
from aegis_gitea_mcp.server import (
|
||||
SERVER_NAME,
|
||||
@@ -69,6 +76,8 @@ class HttpAuthExtras:
|
||||
extra_routes: Extra unauthenticated routes (for example ``/oauth/callback``).
|
||||
client_registration_options: Dynamic client registration settings.
|
||||
revocation_options: Token revocation settings.
|
||||
executor: Gitea executor shared by the server and the resolver.
|
||||
rate_limited_paths: Unauthenticated paths that get the per-IP rate limit.
|
||||
"""
|
||||
|
||||
token_verifier: TokenVerifier | None = None
|
||||
@@ -77,12 +86,19 @@ class HttpAuthExtras:
|
||||
extra_routes: tuple[Route, ...] = ()
|
||||
client_registration_options: ClientRegistrationOptions | None = None
|
||||
revocation_options: RevocationOptions | None = None
|
||||
executor: GiteaExecutor | None = None
|
||||
rate_limited_paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def load_oauth_components(settings: Settings) -> HttpAuthExtras | None:
|
||||
"""Return the per-user OAuth components for ``OAUTH_ENABLED``, or None when unavailable."""
|
||||
del settings
|
||||
return None
|
||||
def load_oauth_components(settings: Settings) -> HttpAuthExtras:
|
||||
"""Build the per-user OAuth components for ``OAUTH_ENABLED``.
|
||||
|
||||
Raises:
|
||||
OAuthSetupError: Incomplete configuration or an unusable token store.
|
||||
"""
|
||||
from aegis_gitea_mcp.oauth.components import build_oauth_components
|
||||
|
||||
return build_oauth_components(settings)
|
||||
|
||||
|
||||
# Health probe
|
||||
@@ -133,15 +149,24 @@ class GiteaProbe:
|
||||
|
||||
|
||||
class RateLimitMiddleware:
|
||||
"""ASGI middleware applying the per-IP and per-token limits to the MCP path."""
|
||||
"""ASGI middleware applying the per-IP and per-token limits to the MCP path.
|
||||
|
||||
def __init__(self, app: ASGIApp, mcp_path: str, limiter: RequestRateLimiter) -> None:
|
||||
``extra_paths`` (for example the unauthenticated OAuth endpoints) share the limits.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
mcp_path: str,
|
||||
limiter: RequestRateLimiter,
|
||||
extra_paths: Sequence[str] = (),
|
||||
) -> None:
|
||||
self.app = app
|
||||
self._mcp_path = mcp_path
|
||||
self._paths = frozenset({mcp_path, *(path.rstrip("/") for path in extra_paths)})
|
||||
self._limiter = limiter
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope["path"].rstrip("/") != self._mcp_path:
|
||||
if scope["type"] != "http" or scope["path"].rstrip("/") not in self._paths:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
client = scope.get("client")
|
||||
@@ -191,19 +216,27 @@ def transport_security(settings: Settings) -> TransportSecuritySettings:
|
||||
)
|
||||
|
||||
|
||||
def _resource_url(settings: Settings) -> str:
|
||||
return f"{_base_url(settings)}{settings.mcp_path}"
|
||||
|
||||
|
||||
def _auth_settings(
|
||||
settings: Settings,
|
||||
client_registration_options: ClientRegistrationOptions | None,
|
||||
revocation_options: RevocationOptions | None,
|
||||
*,
|
||||
authorization_server: bool,
|
||||
) -> AuthSettings:
|
||||
issuer = _base_url(settings)
|
||||
return AuthSettings(
|
||||
issuer_url=issuer, # type: ignore[arg-type]
|
||||
resource_server_url=f"{issuer}{settings.mcp_path}", # type: ignore[arg-type]
|
||||
required_scopes=None,
|
||||
# The static bearer carries no RFC 8707 resource; a per-user verifier
|
||||
# that binds tokens to this resource checks the audience itself.
|
||||
validate_token_resource=False,
|
||||
resource_server_url=_resource_url(settings), # type: ignore[arg-type]
|
||||
# With our own authorization server every token (OAuth-issued and the
|
||||
# static bearer) names this endpoint as its RFC 8707 resource and carries
|
||||
# the one scope we grant, so the SDK enforces both. Without it, extra
|
||||
# verifiers are trusted to check the audience themselves.
|
||||
required_scopes=[MCP_SCOPE] if authorization_server else None,
|
||||
validate_token_resource=authorization_server,
|
||||
client_registration_options=client_registration_options,
|
||||
revocation_options=revocation_options,
|
||||
)
|
||||
@@ -219,6 +252,7 @@ def build_http_app(
|
||||
client_registration_options: ClientRegistrationOptions | None = None,
|
||||
revocation_options: RevocationOptions | None = None,
|
||||
probe: GiteaProbe | None = None,
|
||||
rate_limited_paths: Sequence[str] = (),
|
||||
) -> Starlette:
|
||||
"""Build the streamable HTTP ASGI app (MCP endpoint, ``/health``, ``/``).
|
||||
|
||||
@@ -236,6 +270,8 @@ def build_http_app(
|
||||
client_registration_options: Dynamic client registration settings.
|
||||
revocation_options: Token revocation settings.
|
||||
probe: Gitea reachability probe for ``/health`` (defaults to a new one).
|
||||
rate_limited_paths: Further paths (besides the MCP endpoint) that get the
|
||||
rate limit, for example the OAuth endpoints.
|
||||
|
||||
Raises:
|
||||
ValueError: ``auth_server_provider`` given without any token verifier.
|
||||
@@ -245,7 +281,11 @@ def build_http_app(
|
||||
|
||||
verifiers: list[TokenVerifier] = []
|
||||
if settings.mcp_bearer_token is not None:
|
||||
verifiers.append(StaticBearerVerifier(settings.mcp_bearer_token.get_secret_value()))
|
||||
verifiers.append(
|
||||
StaticBearerVerifier(
|
||||
settings.mcp_bearer_token.get_secret_value(), resource=_resource_url(settings)
|
||||
)
|
||||
)
|
||||
if token_verifier is not None:
|
||||
verifiers.append(token_verifier)
|
||||
if auth_server_provider is not None and not verifiers:
|
||||
@@ -289,7 +329,12 @@ def build_http_app(
|
||||
transport_security=transport_security(settings),
|
||||
host=settings.mcp_host,
|
||||
auth=(
|
||||
_auth_settings(settings, client_registration_options, revocation_options)
|
||||
_auth_settings(
|
||||
settings,
|
||||
client_registration_options,
|
||||
revocation_options,
|
||||
authorization_server=auth_server_provider is not None,
|
||||
)
|
||||
if verifier is not None
|
||||
else None
|
||||
),
|
||||
@@ -310,7 +355,10 @@ def build_http_app(
|
||||
|
||||
app.router.lifespan_context = lifespan
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware, mcp_path=settings.mcp_path, limiter=RequestRateLimiter(settings)
|
||||
RateLimitMiddleware,
|
||||
mcp_path=settings.mcp_path,
|
||||
limiter=RequestRateLimiter(settings),
|
||||
extra_paths=tuple(rate_limited_paths),
|
||||
)
|
||||
return app
|
||||
|
||||
@@ -319,13 +367,22 @@ def build_http_app(
|
||||
|
||||
|
||||
def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> list[str]:
|
||||
"""List reasons the HTTP server must not start (empty when startup may proceed)."""
|
||||
"""List reasons the HTTP server must not start (empty when startup may proceed).
|
||||
|
||||
An OAuth-only deployment (OAuth on, no ``MCP_BEARER_TOKEN``, no
|
||||
``GITEA_TOKEN``) may start: every user brings their own Gitea token and
|
||||
admin-token mode is simply unavailable. ``GITEA_TOKEN`` stays required
|
||||
whenever admin-token mode is reachable (a static bearer, or no HTTP auth).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
bearer = settings.mcp_bearer_token is not None
|
||||
if settings.oauth_enabled:
|
||||
errors.extend(settings.oauth_config_errors())
|
||||
if not settings.oauth_config_errors() and extras is None and not bearer:
|
||||
errors.append("OAUTH_ENABLED=true but per-user OAuth is not available in this build")
|
||||
if not settings.oauth_config_errors() and extras is None:
|
||||
errors.append("OAUTH_ENABLED=true but per-user OAuth could not be set up")
|
||||
public = urlsplit(settings.public_base_url or "")
|
||||
if public.scheme == "http" and not is_loopback_host(public.hostname or ""):
|
||||
errors.append("PUBLIC_BASE_URL must use https unless the host is loopback")
|
||||
authenticated = bearer or extras is not None
|
||||
if not settings.is_loopback_bind() and not authenticated:
|
||||
errors.append(
|
||||
@@ -333,7 +390,10 @@ def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> li
|
||||
"MCP_BEARER_TOKEN (or enable OAuth), or bind to 127.0.0.1"
|
||||
)
|
||||
if (bearer or extras is None) and settings.gitea_token is None:
|
||||
errors.append("GITEA_TOKEN is required for admin-token mode (static bearer or no auth)")
|
||||
message = "GITEA_TOKEN is required for admin-token mode (static bearer or no auth)"
|
||||
if settings.oauth_enabled and bearer:
|
||||
message += "; for an OAuth-only deployment unset MCP_BEARER_TOKEN"
|
||||
errors.append(message)
|
||||
return errors
|
||||
|
||||
|
||||
@@ -351,11 +411,6 @@ def http_startup_warnings(settings: Settings, extras: HttpAuthExtras | None) ->
|
||||
"bind address are rejected with 421. Set PUBLIC_BASE_URL (the public https URL) "
|
||||
"or ALLOWED_HOSTS (comma separated host names clients use)"
|
||||
)
|
||||
if settings.oauth_enabled and extras is None and settings.mcp_bearer_token is not None:
|
||||
warnings.append(
|
||||
"OAUTH_ENABLED=true but per-user OAuth is not available in this build: only "
|
||||
"MCP_BEARER_TOKEN is accepted and every call runs as the GITEA_TOKEN owner"
|
||||
)
|
||||
if settings.forwarded_allow_ips == "*":
|
||||
warnings.append(
|
||||
"FORWARDED_ALLOW_IPS=*: X-Forwarded-For is trusted from any peer, so clients can "
|
||||
@@ -382,13 +437,35 @@ def _audit_startup_error(settings: Settings) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
class AccessLogQueryFilter(logging.Filter):
|
||||
"""Drops query strings from uvicorn access log lines.
|
||||
|
||||
OAuth redirects carry one-time codes and states in the query string
|
||||
(``/oauth/callback?code=...&state=...``); they must not reach the logs.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Rewrite the path argument of an access log record; never drops the record."""
|
||||
args = record.args
|
||||
if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str):
|
||||
path, separator, _ = args[2].partition("?")
|
||||
if separator:
|
||||
record.args = (*args[:2], f"{path}?[redacted]", *args[3:])
|
||||
return True
|
||||
|
||||
|
||||
def run_http(settings: Settings) -> int:
|
||||
"""Run the streamable HTTP server; return a process exit code.
|
||||
|
||||
Returns 2 without starting when a startup check fails (including an audit
|
||||
log that cannot be opened for append).
|
||||
"""
|
||||
extras = load_oauth_components(settings) if settings.oauth_enabled else None
|
||||
extras: HttpAuthExtras | None = None
|
||||
if settings.oauth_enabled and not settings.oauth_config_errors():
|
||||
try:
|
||||
extras = load_oauth_components(settings)
|
||||
except OAuthSetupError as exc:
|
||||
return _fail(f"per-user OAuth cannot start: {exc}")
|
||||
errors = http_startup_errors(settings, extras)
|
||||
if errors:
|
||||
for message in errors:
|
||||
@@ -400,7 +477,11 @@ def run_http(settings: Settings) -> int:
|
||||
for message in http_startup_warnings(settings, extras):
|
||||
print(f"aegis-gitea-mcp: warning: {message}", file=sys.stderr)
|
||||
try:
|
||||
server = build_server(settings, resolver=extras.resolver if extras else None)
|
||||
server = build_server(
|
||||
settings,
|
||||
resolver=extras.resolver if extras else None,
|
||||
executor=extras.executor if extras else None,
|
||||
)
|
||||
except ServerConfigError as exc:
|
||||
return _fail(str(exc))
|
||||
app = build_http_app(
|
||||
@@ -411,7 +492,11 @@ def run_http(settings: Settings) -> int:
|
||||
extra_routes=extras.extra_routes if extras else (),
|
||||
client_registration_options=extras.client_registration_options if extras else None,
|
||||
revocation_options=extras.revocation_options if extras else None,
|
||||
rate_limited_paths=extras.rate_limited_paths if extras else (),
|
||||
)
|
||||
access_logger = logging.getLogger("uvicorn.access")
|
||||
if not any(isinstance(item, AccessLogQueryFilter) for item in access_logger.filters):
|
||||
access_logger.addFilter(AccessLogQueryFilter())
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.mcp_host,
|
||||
|
||||
Reference in new issue
Block a user