Files
AegisGitea-MCP/src/aegis_gitea_mcp/oauth/store.py
T

350 lines
14 KiB
Python

"""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 (an existing one must be owned by the
running user and is tightened to 0700, otherwise startup stops) 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
import anyio.to_thread
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 check and tighten an existing one.
Whoever controls the directory can swap the store for an older copy (which
resurrects revoked grants) or delete it, so an existing directory must belong
to the effective user and must not stay writable by group or others.
Raises:
OAuthStoreError: The directory is owned by another user or remains writable
by group or others after an attempt to tighten it.
"""
if not directory.exists():
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
return
info = os.stat(directory)
if not stat.S_ISDIR(info.st_mode):
raise OAuthStoreError(f"{directory} is not a directory")
owner = getattr(os, "geteuid", lambda: info.st_uid)()
if info.st_uid != owner:
raise OAuthStoreError(
f"{directory} is owned by another user (uid {info.st_uid}, this process runs as "
f"uid {owner}); refusing to keep the OAuth token store there. Use a DATA_DIR "
"owned by the service user"
)
mode = stat.S_IMODE(info.st_mode)
if mode & 0o077:
try:
directory.chmod(0o700)
except OSError:
logger.warning("could not restrict the permissions of the state directory to 0700")
if stat.S_IMODE(os.stat(directory).st_mode) & 0o022:
raise OAuthStoreError(
f"{directory} is writable by group or others and could not be restricted; "
"refusing to keep the OAuth token store there. Run 'chmod 700' on it"
)
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 directory belongs to another user or stays group or
world writable, the path is a symlink or not a regular file, or the
file cannot be decrypted or parsed.
"""
store = cls(directory, secret, clock)
_restrict_directory(directory)
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()
data = self._fernet.encrypt(self._document())
# The write fsyncs: keep that off the event loop (the bytes are already built,
# so the thread touches no shared state).
await anyio.to_thread.run_sync(self._write, data)
# 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())