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

+1
View File
@@ -8,6 +8,7 @@ license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastmcp>=2.3.0", "fastmcp>=2.3.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"cryptography>=42.0.0",
] ]
[project.scripts] [project.scripts]
+41 -3
View File
@@ -1,15 +1,53 @@
"""Entry point: `python -m familiar` or the `familiar` console script. """Entry point: `python -m familiar` or the `familiar` console script.
Runs the MCP server over stdio, which is how Claude Desktop / Claude Code / With no arguments it runs the MCP server over stdio (how Claude Desktop /
Cowork launch and talk to it. 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 __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: 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() mcp.run()
+128
View File
@@ -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)
+277
View File
@@ -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 &mdash; 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"),
}
+67
View File
@@ -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
View File
@@ -49,16 +49,38 @@ class EsiClient:
}, },
) )
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: async def get(
return await self._request("GET", path, params=params) 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( async def post(
self, self,
path: str, path: str,
json: Any | None = None, json: Any | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
token: str | None = None,
) -> Any: ) -> 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( async def _request(
self, self,
@@ -67,10 +89,15 @@ class EsiClient:
*, *,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
json: Any | None = None, json: Any | None = None,
token: str | None = None,
with_headers: bool = False,
) -> Any: ) -> Any:
merged = {"datasource": DATASOURCE, **(params or {})} merged = {"datasource": DATASOURCE, **(params or {})}
headers = {"Authorization": f"Bearer {token}"} if token else None
try: 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 except httpx.HTTPError as exc: # network/timeout
raise EsiError(f"ESI request to {path} failed: {exc}") from exc 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}", f"ESI {method} {path} returned {resp.status_code}: {detail}",
status=resp.status_code, status=resp.status_code,
) )
if not resp.content: body = None if not resp.content else resp.json()
return None if with_headers:
return resp.json() return body, resp.headers
return body
async def aclose(self) -> None: async def aclose(self) -> None:
await self._client.aclose() await self._client.aclose()
+4 -1
View File
@@ -11,7 +11,7 @@ import datetime as _dt
from fastmcp import FastMCP from fastmcp import FastMCP
from . import __version__, market, universe from . import __version__, account, market, universe
mcp: FastMCP = FastMCP( mcp: FastMCP = FastMCP(
name="familiar", name="familiar",
@@ -43,3 +43,6 @@ def ping() -> dict[str, str]:
# Phase 1: public market + universe lookups (no auth). # Phase 1: public market + universe lookups (no auth).
universe.register(mcp) universe.register(mcp)
market.register(mcp) market.register(mcp)
# Phase 2: authenticated, read-only account lookups (requires `familiar login`).
account.register(mcp)
+13
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import base64
import json
from collections.abc import Callable from collections.abc import Callable
import httpx import httpx
@@ -9,6 +11,17 @@ import pytest
from familiar import esi 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] 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
Generated
+2
View File
@@ -424,6 +424,7 @@ name = "familiar"
version = "0.0.1" version = "0.0.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cryptography" },
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "httpx" }, { name = "httpx" },
] ]
@@ -437,6 +438,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "cryptography", specifier = ">=42.0.0" },
{ name = "fastmcp", specifier = ">=2.3.0" }, { name = "fastmcp", specifier = ">=2.3.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
] ]