Rework the token store to hold many characters keyed by character_id,
migrating the previous single-character file transparently. `familiar login`
now adds a character; `familiar status` lists all; `familiar logout [name|id]`
removes one or all.
Every account tool gains an optional `character` (name or id): omitted means
all logged-in characters, given means just that one. Results are uniform
{"characters": [...]}. get_auth refreshes per character.
58 tests, coverage 89%. Closes #9
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.4 KiB
Python
159 lines
5.4 KiB
Python
"""EVE SSO auth: PKCE, JWT claims, multi-character store, exchange/refresh."""
|
|
|
|
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 _char(cid, name, **extra):
|
|
return {
|
|
"refresh_token": f"r-{cid}",
|
|
"access_token": f"a-{cid}",
|
|
"expires_at": 10_000,
|
|
"character_id": cid,
|
|
"character_name": name,
|
|
"scopes": [],
|
|
**extra,
|
|
}
|
|
|
|
|
|
# --- pure helpers ---------------------------------------------------------- #
|
|
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
|
|
|
|
|
|
def test_decode_jwt_and_character_id():
|
|
token = make_jwt({"sub": "CHARACTER:EVE:2112000001", "name": "Latte"})
|
|
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")
|
|
|
|
|
|
# --- multi-character store ------------------------------------------------- #
|
|
def test_store_starts_empty(tmp_store):
|
|
assert auth.load_store() == {"characters": {}}
|
|
assert auth.list_characters() == []
|
|
|
|
|
|
def test_save_and_list_multiple_characters(tmp_store):
|
|
auth._save_character(_char(1, "Beta"))
|
|
auth._save_character(_char(2, "Alpha"))
|
|
chars = auth.list_characters()
|
|
assert [c["character_name"] for c in chars] == ["Alpha", "Beta"] # sorted by name
|
|
|
|
|
|
def test_legacy_single_bundle_is_migrated(tmp_store):
|
|
# Write a pre-multi-character file (single bundle at top level).
|
|
auth._write_store({"refresh_token": "r", "character_id": 42, "character_name": "Old"})
|
|
store = auth.load_store()
|
|
assert "42" in store["characters"]
|
|
assert auth.list_characters()[0]["character_name"] == "Old"
|
|
|
|
|
|
def test_remove_character_by_name_and_all(tmp_store):
|
|
auth._save_character(_char(1, "Beta"))
|
|
auth._save_character(_char(2, "Alpha"))
|
|
assert auth.remove_character("Alpha") is True
|
|
assert {c["character_id"] for c in auth.list_characters()} == {1}
|
|
assert auth.remove_character("nobody") is False
|
|
assert auth.remove_character() is True # removes all
|
|
assert auth.list_characters() == []
|
|
assert auth.remove_character() is False
|
|
|
|
|
|
def test_resolve_character_by_id_and_name(tmp_store):
|
|
auth._save_character(_char(7, "Gamma"))
|
|
assert auth.resolve_character(7) == 7
|
|
assert auth.resolve_character("gamma") == 7
|
|
with pytest.raises(auth.NotAuthenticated):
|
|
auth.resolve_character("ghost")
|
|
|
|
|
|
def test_stored_tokens_are_encrypted(tmp_store):
|
|
auth._save_character(_char(1, "X", refresh_token="super-secret-value"))
|
|
raw = (tmp_store / "token.enc").read_bytes()
|
|
assert b"super-secret-value" not in raw
|
|
|
|
|
|
# --- token requests + get_auth -------------------------------------------- #
|
|
def _token_transport(payload):
|
|
return httpx.MockTransport(lambda req: httpx.Response(200, json=payload))
|
|
|
|
|
|
def test_exchange_code_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_unknown_character_raises(tmp_store):
|
|
with pytest.raises(auth.NotAuthenticated):
|
|
asyncio.run(auth.get_auth(123))
|
|
|
|
|
|
def test_get_auth_uses_unexpired_token(tmp_store):
|
|
auth._save_character(_char(9, "Z", access_token="still-good", expires_at=10_000))
|
|
token, cid = asyncio.run(auth.get_auth(9, now=5_000))
|
|
assert token == "still-good"
|
|
assert cid == 9
|
|
|
|
|
|
def test_get_auth_refreshes_when_expired(tmp_store):
|
|
auth._save_character(_char(9, "Z", refresh_token="r0", expires_at=1_000))
|
|
new_access = make_jwt({"sub": "CHARACTER:EVE:9", "name": "Z"})
|
|
payload = {"access_token": new_access, "refresh_token": "r1", "expires_in": 1200}
|
|
token, cid = asyncio.run(auth.get_auth(9, now=2_000, transport=_token_transport(payload)))
|
|
assert token == new_access
|
|
assert cid == 9
|
|
assert auth.load_store()["characters"]["9"]["refresh_token"] == "r1"
|
|
|
|
|
|
def test_status_lists_characters(tmp_store):
|
|
assert auth.status() == {"authenticated": False, "characters": []}
|
|
auth._save_character(_char(3, "Y"))
|
|
info = auth.status()
|
|
assert info["authenticated"] is True
|
|
assert info["characters"][0]["character_id"] == 3
|