feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71
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