diff --git a/pyproject.toml b/pyproject.toml index f4691bd..b840f40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ license = { file = "LICENSE" } dependencies = [ "fastmcp>=2.3.0", "httpx>=0.27.0", + "cryptography>=42.0.0", ] [project.scripts] diff --git a/src/familiar/__main__.py b/src/familiar/__main__.py index 7dc1a3b..78ab902 100644 --- a/src/familiar/__main__.py +++ b/src/familiar/__main__.py @@ -1,15 +1,53 @@ """Entry point: `python -m familiar` or the `familiar` console script. -Runs the MCP server over stdio, which is how Claude Desktop / Claude Code / -Cowork launch and talk to it. +With no arguments it runs the MCP server over stdio (how Claude Desktop / +Claude Code / Cowork launch it). The auth subcommands are meant to be run by +hand in a terminal: + + familiar login # browser-based EVE SSO login (run once) + familiar status # show whether you're logged in + familiar logout # forget stored credentials """ from __future__ import annotations -from .server import mcp +import sys + +from . import auth + + +def _login() -> int: + bundle = auth.interactive_login() + print(f"Logged in as {bundle.get('character_name')} (id {bundle.get('character_id')}).") + return 0 + + +def _status() -> int: + info = auth.status() + if info["authenticated"]: + print(f"Logged in as {info.get('character_name')} (id {info.get('character_id')}).") + else: + print("Not logged in. Run `familiar login`.") + return 0 + + +def _logout() -> int: + print("Logged out." if auth.clear_tokens() else "Was not logged in.") + return 0 def main() -> None: + command = sys.argv[1] if len(sys.argv) > 1 else None + handlers = {"login": _login, "status": _status, "logout": _logout} + if command in handlers: + raise SystemExit(handlers[command]()) + if command in {"-h", "--help", "help"}: + print(__doc__) + raise SystemExit(0) + + # Default: run the MCP server over stdio. + from .server import mcp + mcp.run() diff --git a/src/familiar/account.py b/src/familiar/account.py new file mode 100644 index 0000000..aa911b6 --- /dev/null +++ b/src/familiar/account.py @@ -0,0 +1,128 @@ +"""Authenticated, read-only tools for the logged-in character. + +Each tool resolves a valid access token via :mod:`familiar.auth` (refreshing +transparently) and then calls the character ESI endpoints. If the user has not +logged in, the tools surface a clear "run `familiar login`" error. +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from . import auth +from .esi import client + + +async def _auth() -> tuple[str, int]: + return await auth.get_auth() + + +async def account_status() -> dict[str, Any]: + """Whether familiar is logged in, and as which character (no network call).""" + return auth.status() + + +async def get_wallet() -> dict[str, Any]: + """The character's wallet balance in ISK.""" + token, cid = await _auth() + balance = await client().get(f"/characters/{cid}/wallet/", token=token) + return {"character_id": cid, "balance": balance} + + +async def get_skills() -> dict[str, Any]: + """Total skill points, unallocated SP, and how many skills are trained.""" + token, cid = await _auth() + data = await client().get(f"/characters/{cid}/skills/", token=token) + return { + "character_id": cid, + "total_sp": data.get("total_sp"), + "unallocated_sp": data.get("unallocated_sp"), + "skills_trained": len(data.get("skills") or []), + } + + +async def get_skill_queue(limit: int = 10) -> dict[str, Any]: + """The next entries in the skill training queue.""" + token, cid = await _auth() + queue = await client().get(f"/characters/{cid}/skillqueue/", token=token) or [] + upcoming = [ + { + "skill_id": q.get("skill_id"), + "finished_level": q.get("finished_level"), + "position": q.get("queue_position"), + "finish_date": q.get("finish_date"), + } + for q in queue[: max(1, limit)] + ] + return {"character_id": cid, "in_queue": len(queue), "upcoming": upcoming} + + +async def get_location() -> dict[str, Any]: + """Current system/station, active ship, and online status.""" + token, cid = await _auth() + loc = await client().get(f"/characters/{cid}/location/", token=token) or {} + ship = await client().get(f"/characters/{cid}/ship/", token=token) or {} + online = await client().get(f"/characters/{cid}/online/", token=token) or {} + return { + "character_id": cid, + "solar_system_id": loc.get("solar_system_id"), + "station_id": loc.get("station_id"), + "structure_id": loc.get("structure_id"), + "ship_type_id": ship.get("ship_type_id"), + "ship_name": ship.get("ship_name"), + "online": online.get("online"), + "last_login": online.get("last_login"), + } + + +async def get_clones() -> dict[str, Any]: + """Home station, jump clones, and currently plugged-in implants.""" + token, cid = await _auth() + clones = await client().get(f"/characters/{cid}/clones/", token=token) or {} + implants = await client().get(f"/characters/{cid}/implants/", token=token) or [] + return { + "character_id": cid, + "home_location": clones.get("home_location"), + "jump_clones": len(clones.get("jump_clones") or []), + "active_implants": implants, + } + + +async def get_assets(limit: int = 20) -> dict[str, Any]: + """Asset summary: total item stacks and a sample of them. + + The full asset list can be thousands of stacks, so only the first ``limit`` + are returned alongside the total count. + """ + token, cid = await _auth() + assets = await client().get_all_pages(f"/characters/{cid}/assets/", token=token) + sample = [ + { + "type_id": a.get("type_id"), + "quantity": a.get("quantity"), + "location_id": a.get("location_id"), + "location_flag": a.get("location_flag"), + "is_singleton": a.get("is_singleton"), + } + for a in assets[: max(1, limit)] + ] + return {"character_id": cid, "total_stacks": len(assets), "sample": sample} + + +TOOLS = [ + account_status, + get_wallet, + get_skills, + get_skill_queue, + get_location, + get_clones, + get_assets, +] + + +def register(mcp: FastMCP) -> None: + """Register the authenticated account tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/src/familiar/auth.py b/src/familiar/auth.py new file mode 100644 index 0000000..8c7b57f --- /dev/null +++ b/src/familiar/auth.py @@ -0,0 +1,277 @@ +"""EVE SSO authentication (PKCE) and encrypted token storage. + +The interactive ``login()`` is meant to be run once from a terminal: it opens +the browser, captures the callback on localhost, exchanges the code, and stores +the (rotating) refresh token encrypted on disk. The MCP server then only calls +:func:`get_auth`, which transparently refreshes the short-lived access token. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import os +import secrets +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx +from cryptography.fernet import Fernet + +from . import __version__, config + +_USER_AGENT = f"familiar/{__version__} (read-only MCP)" + + +class NotAuthenticated(RuntimeError): + """No stored credentials. The user must run `familiar login` first.""" + + +class AuthError(RuntimeError): + """An SSO token request failed.""" + + +# --------------------------------------------------------------------------- # +# PKCE + authorize URL (pure) +# --------------------------------------------------------------------------- # +def generate_pkce() -> tuple[str, str]: + """Return ``(code_verifier, code_challenge)`` for the S256 PKCE method.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + digest = hashlib.sha256(verifier.encode()).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return verifier, challenge + + +def build_authorize_url(state: str, code_challenge: str) -> str: + """Build the EVE SSO authorize URL for the configured app + scopes.""" + params = { + "response_type": "code", + "redirect_uri": config.REDIRECT_URI, + "client_id": config.CLIENT_ID, + "scope": " ".join(config.SCOPES), + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + return f"{config.AUTHORIZE_URL}?{urlencode(params)}" + + +# --------------------------------------------------------------------------- # +# JWT claims (decoded, not signature-verified — token came straight from CCP) +# --------------------------------------------------------------------------- # +def decode_jwt_claims(access_token: str) -> dict[str, Any]: + """Decode the payload of an EVE SSO access token (no signature check).""" + try: + payload = access_token.split(".")[1] + except IndexError as exc: + raise AuthError("Malformed access token.") from exc + padded = payload + "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(padded)) + + +def character_id_from_claims(claims: dict[str, Any]) -> int: + """Pull the numeric character id out of the ``sub`` claim.""" + sub = str(claims.get("sub", "")) + # Format: "CHARACTER:EVE:2112000000" + if ":" in sub: + return int(sub.rsplit(":", 1)[1]) + raise AuthError(f"Unexpected sub claim: {sub!r}") + + +# --------------------------------------------------------------------------- # +# SSO token requests +# --------------------------------------------------------------------------- # +async def _post_token( + data: dict[str, str], *, transport: httpx.AsyncBaseTransport | None = None +) -> dict[str, Any]: + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": _USER_AGENT, + "Accept": "application/json", + } + async with httpx.AsyncClient(transport=transport, timeout=30.0) as client: + resp = await client.post(config.TOKEN_URL, data=data, headers=headers) + if resp.status_code != 200: + raise AuthError(f"SSO token request failed ({resp.status_code}): {resp.text[:200]}") + return resp.json() + + +async def exchange_code( + code: str, verifier: str, *, transport: httpx.AsyncBaseTransport | None = None +) -> dict[str, Any]: + """Exchange an authorization code for tokens (PKCE, no client secret).""" + return await _post_token( + { + "grant_type": "authorization_code", + "code": code, + "client_id": config.CLIENT_ID, + "code_verifier": verifier, + }, + transport=transport, + ) + + +async def refresh_tokens( + refresh_token: str, *, transport: httpx.AsyncBaseTransport | None = None +) -> dict[str, Any]: + """Get a fresh access token (and a rotated refresh token) from a refresh token.""" + return await _post_token( + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": config.CLIENT_ID, + }, + transport=transport, + ) + + +# --------------------------------------------------------------------------- # +# Encrypted token store +# --------------------------------------------------------------------------- # +def _load_key() -> bytes: + kf = config.key_file() + if kf.exists(): + return kf.read_bytes() + key = Fernet.generate_key() + kf.write_bytes(key) + try: + os.chmod(kf, 0o600) + except OSError: + pass + return key + + +def save_tokens(store: dict[str, Any]) -> None: + """Encrypt and persist the token bundle.""" + blob = Fernet(_load_key()).encrypt(json.dumps(store).encode()) + tf = config.token_file() + tf.write_bytes(blob) + try: + os.chmod(tf, 0o600) + except OSError: + pass + + +def load_tokens() -> dict[str, Any] | None: + """Load and decrypt the token bundle, or None if not logged in.""" + tf = config.token_file() + if not tf.exists(): + return None + return json.loads(Fernet(_load_key()).decrypt(tf.read_bytes())) + + +def clear_tokens() -> bool: + """Remove stored credentials. Returns True if something was removed.""" + tf = config.token_file() + if tf.exists(): + tf.unlink() + return True + return False + + +def _bundle_from_response(resp: dict[str, Any], *, now: float) -> dict[str, Any]: + claims = decode_jwt_claims(resp["access_token"]) + return { + "refresh_token": resp["refresh_token"], + "access_token": resp["access_token"], + "expires_at": now + int(resp.get("expires_in", 1200)), + "character_id": character_id_from_claims(claims), + "character_name": claims.get("name"), + "scopes": claims.get("scp"), + } + + +async def get_auth( + *, now: float | None = None, transport: httpx.AsyncBaseTransport | None = None +) -> tuple[str, int]: + """Return ``(access_token, character_id)``, refreshing if near expiry. + + Raises :class:`NotAuthenticated` if the user has not logged in. + """ + store = load_tokens() + if not store: + raise NotAuthenticated("Not logged in. Run `familiar login` in a terminal first.") + now = now if now is not None else time.time() + if store.get("access_token") and store.get("expires_at", 0) - now > 60: + return store["access_token"], store["character_id"] + + resp = await refresh_tokens(store["refresh_token"], transport=transport) + bundle = _bundle_from_response(resp, now=now) + # Preserve the known character even if the new token omits a field. + bundle["character_id"] = store.get("character_id", bundle["character_id"]) + bundle["character_name"] = bundle["character_name"] or store.get("character_name") + save_tokens(bundle) + return bundle["access_token"], bundle["character_id"] + + +_SUCCESS_PAGE = ( + b"
" + b"You can close this tab and return to your terminal.
" + b"" +) + + +def interactive_login(*, open_browser: bool = True) -> dict[str, Any]: + """Run the full browser-based PKCE login and persist the result. + + Blocks until the SSO callback arrives on the local port. Returns the stored + bundle (without the secret tokens echoed back to callers that print it). + """ + verifier, challenge = generate_pkce() + state = secrets.token_urlsafe(16) + url = build_authorize_url(state, challenge) + captured: dict[str, str | None] = {} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 (stdlib naming) + parsed = urlparse(self.path) + if parsed.path != config.CALLBACK_PATH: + self.send_response(404) + self.end_headers() + return + qs = parse_qs(parsed.query) + captured["code"] = qs.get("code", [None])[0] + captured["state"] = qs.get("state", [None])[0] + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_SUCCESS_PAGE) + + def log_message(self, *args: Any) -> None: # silence the default logging + pass + + httpd = HTTPServer((config.CALLBACK_HOST, config.CALLBACK_PORT), Handler) + print(f"Opening browser for EVE SSO login...\nIf it doesn't open, visit:\n{url}\n") + if open_browser: + webbrowser.open(url) + try: + while not captured.get("code"): + httpd.handle_request() + finally: + httpd.server_close() + + if captured.get("state") != state: + raise AuthError("State mismatch — possible CSRF; aborting login.") + resp = asyncio.run(exchange_code(captured["code"], verifier)) + bundle = _bundle_from_response(resp, now=time.time()) + save_tokens(bundle) + return bundle + + +def status() -> dict[str, Any]: + """Report whether we're logged in and as whom (no network).""" + store = load_tokens() + if not store: + return {"authenticated": False} + return { + "authenticated": True, + "character_id": store.get("character_id"), + "character_name": store.get("character_name"), + "scopes": store.get("scopes"), + } diff --git a/src/familiar/config.py b/src/familiar/config.py new file mode 100644 index 0000000..67b8040 --- /dev/null +++ b/src/familiar/config.py @@ -0,0 +1,67 @@ +"""Configuration for EVE SSO auth and on-disk storage. + +Everything is overridable via environment variables so the defaults (which +match the registered EVE application) can be changed without code edits. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# EVE SSO endpoints. +SSO_BASE = os.environ.get("FAMILIAR_SSO_BASE", "https://login.eveonline.com") +AUTHORIZE_URL = f"{SSO_BASE}/v2/oauth/authorize" +TOKEN_URL = f"{SSO_BASE}/v2/oauth/token" + +# Registered application. The client id is not a secret for a PKCE client. +CLIENT_ID = os.environ.get("FAMILIAR_CLIENT_ID", "f51414677af644f0a66c2f36603c01af") +CALLBACK_HOST = os.environ.get("FAMILIAR_CALLBACK_HOST", "localhost") +CALLBACK_PORT = int(os.environ.get("FAMILIAR_CALLBACK_PORT", "8765")) +CALLBACK_PATH = "/callback" +REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}" + +# Read-only scopes requested at login. These must be enabled on the EVE app. +SCOPES = os.environ.get( + "FAMILIAR_SCOPES", + " ".join( + [ + "esi-wallet.read_character_wallet.v1", + "esi-assets.read_assets.v1", + "esi-skills.read_skills.v1", + "esi-skills.read_skillqueue.v1", + "esi-location.read_location.v1", + "esi-location.read_ship_type.v1", + "esi-location.read_online.v1", + "esi-clones.read_clones.v1", + "esi-clones.read_implants.v1", + ] + ), +).split() + + +def data_dir() -> Path: + """Where the encrypted token and key live. Created with restricted perms.""" + override = os.environ.get("FAMILIAR_DATA_DIR") + if override: + path = Path(override) + elif os.name == "nt": + base = os.environ.get("LOCALAPPDATA") or str(Path.home()) + path = Path(base) / "familiar" + else: + base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share") + path = Path(base) / "familiar" + path.mkdir(parents=True, exist_ok=True) + try: + os.chmod(path, 0o700) + except OSError: + pass # best-effort on platforms that don't support it + return path + + +def token_file() -> Path: + return data_dir() / "token.enc" + + +def key_file() -> Path: + return data_dir() / "key" diff --git a/src/familiar/esi.py b/src/familiar/esi.py index 11010fe..699391a 100644 --- a/src/familiar/esi.py +++ b/src/familiar/esi.py @@ -49,16 +49,38 @@ class EsiClient: }, ) - async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: - return await self._request("GET", path, params=params) + async def get( + self, path: str, params: dict[str, Any] | None = None, token: str | None = None + ) -> Any: + return await self._request("GET", path, params=params, token=token) async def post( self, path: str, json: Any | None = None, params: dict[str, Any] | None = None, + token: str | None = None, ) -> Any: - return await self._request("POST", path, params=params, json=json) + return await self._request("POST", path, params=params, json=json, token=token) + + async def get_all_pages( + self, path: str, params: dict[str, Any] | None = None, token: str | None = None + ) -> list[Any]: + """GET every page of a paginated endpoint, concatenating the JSON lists. + + ESI exposes the page count in the ``X-Pages`` header. + """ + first, headers = await self._request( + "GET", path, params={**(params or {}), "page": 1}, token=token, with_headers=True + ) + items = list(first or []) + pages = int(headers.get("x-pages", "1")) + for page in range(2, pages + 1): + more = await self._request( + "GET", path, params={**(params or {}), "page": page}, token=token + ) + items.extend(more or []) + return items async def _request( self, @@ -67,10 +89,15 @@ class EsiClient: *, params: dict[str, Any] | None = None, json: Any | None = None, + token: str | None = None, + with_headers: bool = False, ) -> Any: merged = {"datasource": DATASOURCE, **(params or {})} + headers = {"Authorization": f"Bearer {token}"} if token else None try: - resp = await self._client.request(method, path, params=merged, json=json) + resp = await self._client.request( + method, path, params=merged, json=json, headers=headers + ) except httpx.HTTPError as exc: # network/timeout raise EsiError(f"ESI request to {path} failed: {exc}") from exc @@ -80,9 +107,10 @@ class EsiClient: f"ESI {method} {path} returned {resp.status_code}: {detail}", status=resp.status_code, ) - if not resp.content: - return None - return resp.json() + body = None if not resp.content else resp.json() + if with_headers: + return body, resp.headers + return body async def aclose(self) -> None: await self._client.aclose() diff --git a/src/familiar/server.py b/src/familiar/server.py index 58bf5c2..291b822 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -11,7 +11,7 @@ import datetime as _dt from fastmcp import FastMCP -from . import __version__, market, universe +from . import __version__, account, market, universe mcp: FastMCP = FastMCP( name="familiar", @@ -43,3 +43,6 @@ def ping() -> dict[str, str]: # Phase 1: public market + universe lookups (no auth). universe.register(mcp) market.register(mcp) + +# Phase 2: authenticated, read-only account lookups (requires `familiar login`). +account.register(mcp) diff --git a/tests/conftest.py b/tests/conftest.py index f96c7e8..d021c66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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] diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 0000000..2ac2c85 --- /dev/null +++ b/tests/test_account.py @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..32c55c8 --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/uv.lock b/uv.lock index e20a97b..95f2a9c 100644 --- a/uv.lock +++ b/uv.lock @@ -424,6 +424,7 @@ name = "familiar" version = "0.0.1" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "fastmcp" }, { name = "httpx" }, ] @@ -437,6 +438,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", specifier = ">=42.0.0" }, { name = "fastmcp", specifier = ">=2.3.0" }, { name = "httpx", specifier = ">=0.27.0" }, ]