279 lines
9.0 KiB
Python
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
|