diff --git a/nanobot/config/oauth_store.py b/nanobot/config/oauth_store.py new file mode 100644 index 0000000..eb4ceff --- /dev/null +++ b/nanobot/config/oauth_store.py @@ -0,0 +1,59 @@ +"""OAuth credential storage.""" + +import json +from pathlib import Path +from typing import Any + +from nanobot.config.schema import OAuthCredentials + + +class OAuthStore: + """Stores OAuth credentials in a JSON file.""" + + FILENAME = "oauth-credentials.json" + + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self.file_path = config_dir / self.FILENAME + + def _load_all(self) -> dict[str, Any]: + """Load all credentials from file.""" + if not self.file_path.exists(): + return {} + + with open(self.file_path, "r") as f: + return json.load(f) + + def _save_all(self, data: dict[str, Any]) -> None: + """Save all credentials to file.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + + with open(self.file_path, "w") as f: + json.dump(data, f, indent=2) + + # Secure permissions + self.file_path.chmod(0o600) + + def save(self, provider: str, credentials: OAuthCredentials) -> None: + """Save credentials for a provider.""" + data = self._load_all() + data[provider] = credentials.model_dump() + self._save_all(data) + + def load(self, provider: str) -> OAuthCredentials | None: + """Load credentials for a provider.""" + data = self._load_all() + if provider not in data: + return None + + return OAuthCredentials(**data[provider]) + + def delete(self, provider: str) -> bool: + """Delete credentials for a provider.""" + data = self._load_all() + if provider not in data: + return False + + del data[provider] + self._save_all(data) + return True diff --git a/tests/test_oauth_store.py b/tests/test_oauth_store.py new file mode 100644 index 0000000..93282b6 --- /dev/null +++ b/tests/test_oauth_store.py @@ -0,0 +1,55 @@ +"""Test OAuth credential storage.""" +import pytest +import tempfile +from pathlib import Path +from nanobot.config.oauth_store import OAuthStore +from nanobot.config.schema import OAuthCredentials + + +@pytest.fixture +def temp_store(): + """Create store with temp directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield OAuthStore(Path(tmpdir) / ".nanobot") + + +def test_save_and_load_credentials(temp_store): + """Should save and load OAuth credentials.""" + creds = OAuthCredentials( + access_token="sk-ant-oat01-xxx", + refresh_token="rt_xxx", + expires_at=1234567890 + ) + + temp_store.save("anthropic", creds) + loaded = temp_store.load("anthropic") + + assert loaded is not None + assert loaded.access_token == creds.access_token + assert loaded.refresh_token == creds.refresh_token + + +def test_load_nonexistent_returns_none(temp_store): + """Should return None for missing credentials.""" + assert temp_store.load("nonexistent") is None + + +def test_delete_credentials(temp_store): + """Should delete saved credentials.""" + creds = OAuthCredentials(access_token="sk-ant-oat01-xxx") + temp_store.save("anthropic", creds) + assert temp_store.delete("anthropic") is True + assert temp_store.load("anthropic") is None + + +def test_delete_nonexistent_returns_false(temp_store): + """Should return False when deleting missing credentials.""" + assert temp_store.delete("nonexistent") is False + + +def test_file_permissions(temp_store): + """Credentials file should have restricted permissions.""" + creds = OAuthCredentials(access_token="sk-ant-oat01-xxx") + temp_store.save("anthropic", creds) + perms = oct(temp_store.file_path.stat().st_mode)[-3:] + assert perms == "600"