feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71

Merged
Latte merged 70 commits from feat/full-api-coverage into dev 2026-10-14 17:25:25 +00:00
2 changed files with 252 additions and 4 deletions
Showing only changes of commit 6f7a3c0e5b - Show all commits

No files matched your search

+31 -4
View File
@@ -13,7 +13,8 @@ What is stored:
* 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
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,
@@ -132,16 +133,40 @@ class TokenRecord:
def _restrict_directory(directory: Path) -> None:
"""Create the state directory with mode 0700, or tighten an existing one we own."""
"""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
mode = stat.S_IMODE(directory.stat().st_mode)
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]]:
@@ -191,10 +216,12 @@ class 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
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
+221
View File
@@ -0,0 +1,221 @@
"""The encrypted OAuth token store: permissions, encryption, key handling and ownership (SEC-13)."""
from __future__ import annotations
import os
import stat
from pathlib import Path
from typing import Any
import pytest
from cryptography.fernet import Fernet
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp import transports
from aegis_gitea_mcp.oauth.store import (
STORE_FILENAME,
OAuthStore,
OAuthStoreError,
SessionRecord,
derive_fernet_key,
token_hash,
)
from aegis_gitea_mcp.transports import run_http
from tests.test_oauth_helpers import (
APP_CLIENT_SECRET,
ENCRYPTION_KEY,
USER_REFRESH,
USER_TOKEN,
browser,
build_oauth_app,
oauth_settings,
sign_in,
)
OTHER_KEY = "another-key-0123456789abcdef0123456789"
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
def a_session() -> SessionRecord:
return SessionRecord(
session_id="s1",
client_id="c1",
login="alice",
is_admin=False,
gitea_access_token=USER_TOKEN,
gitea_refresh_token=USER_REFRESH,
gitea_expires_at=None,
scopes=["gitea"],
resource="https://mcp.example.com/mcp",
created_at=1e12,
)
async def test_file_is_0600_and_a_new_directory_is_0700(tmp_path: Path) -> None:
previous = os.umask(0)
try:
directory = tmp_path / "new" / "state"
store = OAuthStore.load(directory, ENCRYPTION_KEY)
store.sessions["s1"] = a_session()
await store.save()
finally:
os.umask(previous)
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
assert stat.S_IMODE((directory / STORE_FILENAME).stat().st_mode) == 0o600
assert [entry.name for entry in directory.iterdir()] == [STORE_FILENAME]
def test_a_symlinked_store_is_refused_and_its_target_untouched(tmp_path: Path) -> None:
target = tmp_path / "elsewhere"
target.write_bytes(b"keep me")
(tmp_path / STORE_FILENAME).symlink_to(target)
with pytest.raises(OAuthStoreError, match="symlink"):
OAuthStore.load(tmp_path, ENCRYPTION_KEY)
assert target.read_bytes() == b"keep me"
async def test_a_symlink_swapped_in_later_is_not_written_through(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY)
target = tmp_path / "elsewhere"
target.write_bytes(b"keep me")
(tmp_path / STORE_FILENAME).symlink_to(target)
with pytest.raises(OAuthStoreError, match="symlink"):
await store.save()
assert target.read_bytes() == b"keep me"
async def test_the_file_holds_no_plaintext_and_our_tokens_only_as_hashes(
tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
oauth = build_oauth_app(oauth_settings(data_dir=tmp_path))
flow = sign_in(browser(oauth.app), httpx_mock, login="alice-the-user")
raw = (tmp_path / STORE_FILENAME).read_bytes()
for secret in (
USER_TOKEN,
USER_REFRESH,
flow.client_secret,
flow.tokens["access_token"],
flow.tokens["refresh_token"],
"alice-the-user",
APP_CLIENT_SECRET,
ENCRYPTION_KEY,
):
assert secret.encode() not in raw
plain = Fernet(derive_fernet_key(ENCRYPTION_KEY)).decrypt(raw).decode()
assert flow.tokens["access_token"] not in plain
assert flow.tokens["refresh_token"] not in plain
assert token_hash(flow.tokens["access_token"]) in plain
assert token_hash(flow.tokens["refresh_token"]) in plain
async def test_a_wrong_key_stops_startup_without_wiping_the_store(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY)
store.sessions["s1"] = a_session()
await store.save()
before = (tmp_path / STORE_FILENAME).read_bytes()
served: list[Any] = []
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: served.append(a))
code = run_http(oauth_settings(data_dir=tmp_path, token_encryption_key=OTHER_KEY))
assert code == 2 and served == []
err = capsys.readouterr().err
assert "TOKEN_ENCRYPTION_KEY" in err and "move the file away" in err
assert OTHER_KEY not in err and ENCRYPTION_KEY not in err
assert (tmp_path / STORE_FILENAME).read_bytes() == before
def test_a_garbage_store_stops_startup_and_stays_on_disk(tmp_path: Path) -> None:
(tmp_path / STORE_FILENAME).write_bytes(b"not a fernet token")
with pytest.raises(OAuthStoreError):
OAuthStore.load(tmp_path, ENCRYPTION_KEY)
assert (tmp_path / STORE_FILENAME).read_bytes() == b"not a fernet token"
# SEC-13: who owns the directory
def fake_owner(monkeypatch: pytest.MonkeyPatch, directory: Path) -> None:
"""Report ``directory`` as owned by a different uid than the running process."""
real_stat = os.stat
def stat_other(path: Any, *args: Any, **kwargs: Any) -> os.stat_result:
info = real_stat(path, *args, **kwargs)
if Path(path) == directory:
fields = list(info)
fields[stat.ST_UID] = info.st_uid + 1
return os.stat_result(fields)
return info
monkeypatch.setattr(os, "stat", stat_other)
async def test_a_directory_owned_by_another_user_is_refused_on_load_and_save(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
directory = tmp_path / "state"
directory.mkdir(mode=0o700)
store = OAuthStore(directory, ENCRYPTION_KEY)
fake_owner(monkeypatch, directory)
with pytest.raises(OAuthStoreError, match="owned by another user"):
OAuthStore.load(directory, ENCRYPTION_KEY)
with pytest.raises(OAuthStoreError, match="owned by another user"):
await store.save()
assert list(directory.iterdir()) == []
async def test_a_group_or_world_writable_directory_that_cannot_be_tightened_is_refused(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
directory = tmp_path / "state"
directory.mkdir()
directory.chmod(0o777)
def refuse(self: Path, mode: int) -> None:
raise PermissionError("not permitted")
monkeypatch.setattr(Path, "chmod", refuse)
with pytest.raises(OAuthStoreError, match="writable by group or others"):
OAuthStore.load(directory, ENCRYPTION_KEY)
with pytest.raises(OAuthStoreError, match="writable by group or others"):
await OAuthStore(directory, ENCRYPTION_KEY).save()
assert list(directory.iterdir()) == []
async def test_a_loose_directory_we_own_is_tightened_not_refused(tmp_path: Path) -> None:
directory = tmp_path / "state"
directory.mkdir()
directory.chmod(0o777)
await OAuthStore.load(directory, ENCRYPTION_KEY).save()
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
def test_startup_exits_2_for_a_directory_owned_by_someone_else(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
directory = tmp_path / "state"
directory.mkdir(mode=0o700)
fake_owner(monkeypatch, directory)
served: list[Any] = []
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: served.append(a))
assert run_http(oauth_settings(data_dir=directory)) == 2
assert served == []
assert "owned by another user" in capsys.readouterr().err