Phase 2: EVE SSO auth + read-only account tools
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:
11 files changed
+796
-11
No files matched your search
@@ -8,6 +8,7 @@ license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"fastmcp>=2.3.0",
|
||||
"httpx>=0.27.0",
|
||||
"cryptography>=42.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"<html><body style='font-family:sans-serif'>"
|
||||
b"<h2>familiar — login complete</h2>"
|
||||
b"<p>You can close this tab and return to your terminal.</p>"
|
||||
b"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
@@ -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"
|
||||
+35
-7
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user