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
@@ -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__)
|
||||
Reference in new issue
Block a user