Files

279 lines
9.0 KiB
Python

"""Tests for the encrypted OAuth store: file safety, encryption at rest, refusal modes, restart."""
from __future__ import annotations
import base64
import os
import stat
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.oauth.store import (
STORE_FILENAME,
OAuthStore,
OAuthStoreError,
SessionRecord,
TokenRecord,
derive_fernet_key,
token_hash,
)
from tests.test_oauth_helpers import (
APP_CLIENT_SECRET,
ENCRYPTION_KEY,
USER_REFRESH,
USER_TOKEN,
FakeClock,
browser,
build_oauth_app,
mcp_status_lifespan,
oauth_settings,
sign_in,
)
KEY = ENCRYPTION_KEY
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
def session(session_id: str = "s1", *, created_at: float = 0.0) -> SessionRecord:
return SessionRecord(
session_id=session_id,
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=created_at,
)
def token(session_id: str = "s1", expires_at: float = 10_000.0) -> TokenRecord:
return TokenRecord(
kind="access",
session_id=session_id,
client_id="c1",
scopes=["gitea"],
expires_at=expires_at,
)
def test_key_derivation_is_hkdf_sha256_with_the_documented_info() -> None:
expected = HKDF(
algorithm=hashes.SHA256(), length=32, salt=None, info=b"aegis-gitea-mcp oauth store"
).derive(KEY.encode())
assert derive_fernet_key(KEY) == base64.urlsafe_b64encode(expected)
assert derive_fernet_key(KEY) != derive_fernet_key(KEY + "x")
async def test_file_and_directory_permissions(tmp_path: Path) -> None:
directory = tmp_path / "state" / "nested"
store = OAuthStore.load(directory, KEY)
store.sessions["s1"] = session(created_at=1e12)
await store.save()
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
assert stat.S_IMODE((directory / STORE_FILENAME).stat().st_mode) == 0o600
assert [p.name for p in directory.iterdir()] == [STORE_FILENAME] # no temp file left
async def test_existing_loose_directory_is_tightened(tmp_path: Path) -> None:
directory = tmp_path / "state"
directory.mkdir(mode=0o755)
directory.chmod(0o755)
await OAuthStore.load(directory, KEY).save()
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
async def test_loose_file_permissions_are_restricted_on_load(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
await store.save()
path = tmp_path / STORE_FILENAME
path.chmod(0o644)
OAuthStore.load(tmp_path, KEY)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
async def test_contents_are_encrypted_at_rest(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.client_id,
flow.tokens["access_token"],
flow.tokens["refresh_token"],
"alice-the-user",
APP_CLIENT_SECRET,
KEY,
):
assert secret.encode() not in raw
plain = Fernet(derive_fernet_key(KEY)).decrypt(raw).decode()
assert USER_TOKEN in plain and flow.client_secret in plain
# Our own tokens are stored only as hashes.
assert flow.tokens["access_token"] not in plain
assert token_hash(flow.tokens["access_token"]) in plain
async def test_state_survives_a_restart(tmp_path: Path, httpx_mock: HTTPXMock) -> None:
settings = oauth_settings(data_dir=tmp_path)
first = build_oauth_app(settings)
flow = sign_in(browser(first.app), httpx_mock)
second = build_oauth_app(settings)
assert mcp_status_lifespan(second.app, flow.tokens["access_token"]) != 401
assert flow.client_id in second.store.clients
def test_symlinked_store_is_refused(tmp_path: Path) -> None:
target = tmp_path / "elsewhere.json"
target.write_bytes(b"x")
os.symlink(target, tmp_path / STORE_FILENAME)
with pytest.raises(OAuthStoreError, match="symlink"):
OAuthStore.load(tmp_path, KEY)
async def test_symlink_swapped_in_at_runtime_is_refused_on_write(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
os.symlink(tmp_path / "elsewhere.json", tmp_path / STORE_FILENAME)
with pytest.raises(OAuthStoreError, match="symlink"):
await store.save()
assert not (tmp_path / "elsewhere.json").exists()
def test_non_regular_store_path_is_refused(tmp_path: Path) -> None:
(tmp_path / STORE_FILENAME).mkdir()
with pytest.raises(OAuthStoreError, match="not a regular file"):
OAuthStore.load(tmp_path, KEY)
async def test_wrong_key_refuses_to_start_and_keeps_the_file(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
store.sessions["s1"] = session(created_at=1e12)
await store.save()
before = (tmp_path / STORE_FILENAME).read_bytes()
with pytest.raises(OAuthStoreError, match="TOKEN_ENCRYPTION_KEY") as caught:
OAuthStore.load(tmp_path, "another-key-0123456789abcdef0123456789")
assert KEY not in str(caught.value)
assert (tmp_path / STORE_FILENAME).read_bytes() == before
@pytest.mark.parametrize(
"document",
[
b"not json",
b"[]",
b'{"version": 2}',
b'{"version": 1, "clients": []}',
b'{"version": 1, "sessions": {"s": {"unexpected": 1}}}',
b'{"version": 1, "clients": {"c": {"info": [], "registered_at": 0, "last_used_at": 0}}}',
b'{"version": 1, "tokens": {"h": {"kind": "other", "session_id": "s", "client_id": "c",'
b' "scopes": [], "expires_at": 1}}}',
],
)
def test_corrupt_document_refuses_to_start(tmp_path: Path, document: bytes) -> None:
(tmp_path / STORE_FILENAME).write_bytes(Fernet(derive_fernet_key(KEY)).encrypt(document))
with pytest.raises(OAuthStoreError, match="not a valid OAuth store"):
OAuthStore.load(tmp_path, KEY)
def test_garbage_file_refuses_to_start(tmp_path: Path) -> None:
(tmp_path / STORE_FILENAME).write_bytes(b"garbage")
with pytest.raises(OAuthStoreError, match="cannot decrypt"):
OAuthStore.load(tmp_path, KEY)
async def test_prune_drops_expired_tokens_and_dead_sessions(tmp_path: Path) -> None:
clock = FakeClock()
clock.now = 5_000.0
store = OAuthStore.load(tmp_path, KEY, clock)
store.sessions["live"] = session("live", created_at=0.0)
store.sessions["expired"] = session("expired", created_at=0.0)
store.sessions["fresh"] = session("fresh", created_at=4_900.0) # sign-in in progress
store.sessions["abandoned"] = session("abandoned", created_at=0.0)
store.tokens["a"] = token("live", expires_at=6_000.0)
store.tokens["b"] = token("expired", expires_at=4_000.0)
await store.save()
reloaded = OAuthStore.load(tmp_path, KEY, clock)
assert set(reloaded.tokens) == {"a"}
assert set(reloaded.sessions) == {"live", "fresh"}
def test_store_helpers(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
store.sessions["s1"] = session("s1")
store.sessions["s2"] = session("s2")
store.sessions["s2"].previous_refresh_hash = "abc"
store.tokens["t1"] = token("s1")
assert store.path == tmp_path / STORE_FILENAME
assert [item.session_id for item in store.sessions_of("alice")] == ["s1", "s2"]
assert store.session_by_previous_refresh("abc") is store.sessions["s2"]
assert store.session_by_previous_refresh("nope") is None
assert store.client_has_sessions("c1") and not store.client_has_sessions("c2")
assert store.end_session("s1") is True
assert store.tokens == {} and store.end_session("s1") is False
assert USER_TOKEN not in repr(store.sessions["s2"])
assert USER_REFRESH not in repr(store.sessions["s2"])
async def test_failed_write_leaves_no_temporary_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
store = OAuthStore.load(tmp_path, KEY)
def boom(src: object, dst: object) -> None:
raise OSError("disk full")
monkeypatch.setattr(os, "replace", boom)
with pytest.raises(OSError, match="disk full"):
await store.save()
assert list(tmp_path.iterdir()) == []
async def test_directory_tightening_failure_only_warns(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
directory = tmp_path / "state"
directory.mkdir()
directory.chmod(0o755)
def refuse(self: Path, mode: int) -> None:
raise PermissionError("not owner")
monkeypatch.setattr(Path, "chmod", refuse)
await OAuthStore.load(directory, KEY).save()
assert "could not restrict" in caplog.text