Phase 2: EVE SSO auth + read-only account tools
CI / lint (pull_request) Successful in 51s
CI / lint (push) Successful in 50s
CI / test (pull_request) Successful in 57s
CI / publish (pull_request) Has been skipped
CI / test (push) Successful in 57s
CI / publish (push) Successful in 50s

Add PKCE OAuth2 login against EVE SSO with an encrypted, auto-refreshing
token store, driven by `familiar login` / `status` / `logout` CLI commands.
The MCP server gains authenticated account tools:

- account_status, get_wallet, get_skills, get_skill_queue
- get_location, get_clones, get_assets (paginated)

ESI client extended with Bearer auth and X-Pages pagination. 51 tests
(auth + account mocked), coverage 88%. Client id/callback/scopes are
env-overridable; refresh token Fernet-encrypted on disk.

Closes #7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
LatteandClaude Opus 4.8 committed 2026-06-27 18:32:47 +02:00
1 parent edf2298e20
commit eb6440db40
11 files changed
+796 -11

No files matched your search

+13
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import base64
import json
from collections.abc import Callable
import httpx
@@ -9,6 +11,17 @@ import pytest
from familiar import esi
def make_jwt(claims: dict) -> str:
"""Build a fake JWT (header.payload.signature) whose payload is `claims`.
Only the payload segment is ever decoded by familiar, so the header and
signature can be arbitrary.
"""
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode()
return f"header.{payload}.signature"
Handler = Callable[[httpx.Request], httpx.Response]
+102
View File
@@ -0,0 +1,102 @@
"""Authenticated account tools (auth mocked, ESI mocked)."""
import asyncio
import httpx
import pytest
from familiar import account, auth
from familiar.server import mcp
from conftest import route
@pytest.fixture
def fake_auth(monkeypatch):
async def _stub():
return "fake-token", 99
monkeypatch.setattr(auth, "get_auth", _stub)
return 99
def test_get_wallet(mock_esi, fake_auth):
mock_esi(lambda req: httpx.Response(200, json=1234567.89))
result = asyncio.run(account.get_wallet())
assert result["character_id"] == 99
assert result["balance"] == 1234567.89
def test_get_skills_summarizes(mock_esi, fake_auth):
body = {"total_sp": 5_000_000, "unallocated_sp": 10_000, "skills": [{}, {}, {}]}
mock_esi(lambda req: route(req, {"/skills/": body}))
result = asyncio.run(account.get_skills())
assert result["total_sp"] == 5_000_000
assert result["skills_trained"] == 3
def test_get_skill_queue_limits(mock_esi, fake_auth):
queue = [
{"skill_id": i, "finished_level": 5, "queue_position": i, "finish_date": "2026"}
for i in range(5)
]
mock_esi(lambda req: route(req, {"/skillqueue/": queue}))
result = asyncio.run(account.get_skill_queue(limit=2))
assert result["in_queue"] == 5
assert len(result["upcoming"]) == 2
def test_get_location_merges_endpoints(mock_esi, fake_auth):
fixtures = {
"/location/": {"solar_system_id": 30000142, "station_id": 60003760},
"/ship/": {"ship_type_id": 670, "ship_name": "My Capsule"},
"/online/": {"online": True, "last_login": "2026-06-27T00:00:00Z"},
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(account.get_location())
assert result["solar_system_id"] == 30000142
assert result["ship_name"] == "My Capsule"
assert result["online"] is True
def test_get_clones(mock_esi, fake_auth):
fixtures = {
"/clones/": {"home_location": {"location_id": 60003760}, "jump_clones": [{}, {}]},
"/implants/": [10000, 10001],
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(account.get_clones())
assert result["jump_clones"] == 2
assert result["active_implants"] == [10000, 10001]
def test_get_assets_paginates(mock_esi, fake_auth):
# Two pages of assets; the client should follow X-Pages and concatenate.
def handler(req):
page = req.url.params.get("page", "1")
if page == "1":
return httpx.Response(
200,
json=[{"type_id": 34, "quantity": 1, "location_id": 1}],
headers={"X-Pages": "2"},
)
return httpx.Response(
200,
json=[{"type_id": 35, "quantity": 2, "location_id": 1}],
headers={"X-Pages": "2"},
)
mock_esi(handler)
result = asyncio.run(account.get_assets(limit=20))
assert result["total_stacks"] == 2
assert {a["type_id"] for a in result["sample"]} == {34, 35}
def test_account_status_delegates(monkeypatch):
monkeypatch.setattr(auth, "status", lambda: {"authenticated": True, "character_id": 1})
result = asyncio.run(account.account_status())
assert result["authenticated"] is True
def test_account_tools_registered():
tools = {t.name for t in asyncio.run(mcp.list_tools())}
assert {"get_wallet", "get_skills", "get_location", "get_assets", "account_status"} <= tools
+126
View File
@@ -0,0 +1,126 @@
"""EVE SSO auth: PKCE, JWT claims, token store, exchange/refresh, get_auth."""
import asyncio
import base64
import hashlib
import httpx
import pytest
from conftest import make_jwt
from familiar import auth
@pytest.fixture
def tmp_store(tmp_path, monkeypatch):
"""Point the token/key files at a temp dir."""
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
return tmp_path
def test_generate_pkce_challenge_matches_verifier():
verifier, challenge = auth.generate_pkce()
expected = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
)
assert challenge == expected
assert "=" not in challenge
def test_build_authorize_url_has_required_params():
url = auth.build_authorize_url("the-state", "the-challenge")
assert "response_type=code" in url
assert "code_challenge=the-challenge" in url
assert "code_challenge_method=S256" in url
assert "state=the-state" in url
assert "client_id=" in url
def test_decode_jwt_and_character_id():
token = make_jwt({"sub": "CHARACTER:EVE:2112000001", "name": "Latte", "scp": ["a"]})
claims = auth.decode_jwt_claims(token)
assert claims["name"] == "Latte"
assert auth.character_id_from_claims(claims) == 2112000001
def test_character_id_from_bad_sub_raises():
with pytest.raises(auth.AuthError):
auth.character_id_from_claims({"sub": "nonsense"})
def test_decode_malformed_token_raises():
with pytest.raises(auth.AuthError):
auth.decode_jwt_claims("not-a-jwt")
def test_token_store_round_trip(tmp_store):
bundle = {"refresh_token": "r", "character_id": 7, "character_name": "X"}
assert auth.load_tokens() is None
auth.save_tokens(bundle)
assert auth.load_tokens() == bundle
assert (tmp_store / "token.enc").exists()
assert auth.clear_tokens() is True
assert auth.load_tokens() is None
assert auth.clear_tokens() is False
def test_stored_token_is_encrypted_not_plaintext(tmp_store):
auth.save_tokens({"refresh_token": "super-secret-value", "character_id": 1})
raw = (tmp_store / "token.enc").read_bytes()
assert b"super-secret-value" not in raw
def _token_transport(payload):
return httpx.MockTransport(lambda req: httpx.Response(200, json=payload))
def test_exchange_code_posts_and_parses():
payload = {"access_token": "a", "refresh_token": "r", "expires_in": 1200}
out = asyncio.run(auth.exchange_code("code", "verifier", transport=_token_transport(payload)))
assert out["refresh_token"] == "r"
def test_token_request_raises_on_error():
transport = httpx.MockTransport(lambda req: httpx.Response(400, text="bad_request"))
with pytest.raises(auth.AuthError):
asyncio.run(auth.refresh_tokens("r", transport=transport))
def test_get_auth_without_login_raises(tmp_store):
with pytest.raises(auth.NotAuthenticated):
asyncio.run(auth.get_auth())
def test_get_auth_uses_unexpired_token(tmp_store):
auth.save_tokens(
{
"access_token": "still-good",
"refresh_token": "r",
"character_id": 9,
"expires_at": 10_000,
}
)
token, cid = asyncio.run(auth.get_auth(now=5_000))
assert token == "still-good"
assert cid == 9
def test_get_auth_refreshes_when_expired(tmp_store):
auth.save_tokens(
{"access_token": "old", "refresh_token": "r0", "character_id": 9, "expires_at": 1_000}
)
new_access = make_jwt({"sub": "CHARACTER:EVE:9", "name": "Latte"})
payload = {"access_token": new_access, "refresh_token": "r1", "expires_in": 1200}
token, cid = asyncio.run(auth.get_auth(now=2_000, transport=_token_transport(payload)))
assert token == new_access
assert cid == 9
# The rotated refresh token is persisted.
assert auth.load_tokens()["refresh_token"] == "r1"
def test_status_reflects_store(tmp_store):
assert auth.status() == {"authenticated": False}
auth.save_tokens({"refresh_token": "r", "character_id": 3, "character_name": "Y", "scopes": []})
info = auth.status()
assert info["authenticated"] is True
assert info["character_id"] == 3