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