"""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