"""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, redirect_matches_allowlist from aegis_gitea_mcp.oauth.store import ( ClientRecord, OAuthStore, OAuthStoreError, SessionRecord, TokenRecord, token_hash, ) from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError from aegis_gitea_mcp.request_context import get_client_address 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 #: Per client address: sign-ins waiting for consent or Gitea, and registrations per hour. MAX_PENDING_PER_SOURCE = 20 MAX_REGISTRATIONS_PER_SOURCE = 20 REGISTRATION_WINDOW = 3600 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"}) _NOT_ALLOWED = "redirect URI is not on this server's allowlist (OAUTH_REDIRECT_ALLOWLIST)" _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, allowlist: list[str] | None = None) -> str | None: """Return why ``uri`` is not an acceptable redirect URI, or None when it is. With a non-empty ``allowlist`` an https URI must also match it; http loopback URIs (native clients on the user's own machine) stay allowed. """ 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 == "http" and host in _LOOPBACK_REDIRECT_HOSTS: return None if parts.scheme == "https": return None if redirect_matches_allowlist(uri, allowlist or []) else _NOT_ALLOWED 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 flow_tag(state: str) -> str: """Eight hex characters derived from a Gitea ``state`` (names the browser-binding cookie). Concurrent sign-ins in one browser each get their own cookie, so a second flow cannot overwrite the binding of the first. The tag is public (it is a hash prefix of a value that is in the callback URL anyway) and grants nothing. """ return token_hash(state)[:8] 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 count_where(self, predicate: Callable[[V], bool]) -> int: """Number of live entries whose value satisfies ``predicate``.""" self._purge() return sum(1 for _, value in self._entries.values() if predicate(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) #: Rate-limit bucket of the client address that started the request (quota key). source: str | None = None 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) #: Short public tag of the Gitea state, used to name this flow's binding cookie. flow_tag: str | None = None 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) # Registration times per client address (in memory), for the registration quota. self._registrations: dict[str, list[float]] = {} # 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, and match ``OAUTH_REDIRECT_ALLOWLIST`` when that is set. 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, self._settings.oauth_redirect_allowlist) 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") self._check_registration_quota() 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 _check_registration_quota(self) -> None: """Refuse a registration when this address registered too many clients recently.""" source = get_client_address() if source is None: return now = self._clock() recent = [t for t in self._registrations.get(source, []) if t > now - REGISTRATION_WINDOW] if len(recent) >= MAX_REGISTRATIONS_PER_SOURCE: self._registrations[source] = recent logger.warning("oauth client registration refused: quota of one address reached") raise RegistrationError( "invalid_client_metadata", "too many registrations from this address; try later" ) recent.append(now) self._registrations[source] = recent if len(self._registrations) > MAX_CLIENTS * 4: for key in [ k for k, times in self._registrations.items() if times[-1] <= now - REGISTRATION_WINDOW ]: del self._registrations[key] def _evict_one_client(self) -> bool: """Evict the least recently used client without sessions, never a young one. A client younger than :data:`PENDING_TTL` may still be on its way to its first sign-in; evicting it would let a registration flood cancel those sign-ins. """ now = self._clock() 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) and record.registered_at + PENDING_TTL <= now ] 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") # A client registered before the allowlist was set must not slip through it. if ( redirect_uri_problem(str(params.redirect_uri), self._settings.oauth_redirect_allowlist) is not None ): raise AuthorizeError("invalid_request", "redirect URI is not allowed") source = get_client_address() if ( source is not None and self._requests.count_where(lambda item: item.source == source) >= MAX_PENDING_PER_SOURCE ): logger.warning("oauth authorize refused: pending sign-in quota of one address reached") raise AuthorizeError("temporarily_unavailable", "too many pending sign-ins") 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), source=source, ) 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, flow_tag=flow_tag(state)) 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)