From c0b6c44c72254b2ecead54757ffb7fafd2fd01ca Mon Sep 17 00:00:00 2001 From: scottcorbin Date: Sun, 8 Nov 2026 13:16:56 -0800 Subject: [PATCH 1/2] Discover EVE SSO endpoints via OAuth metadata instead of hardcoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CCP recommends resolving the SSO authorization and token endpoints from the OAuth metadata document (/.well-known/oauth-authorization-server) rather than hardcoding the /v2/oauth/* URLs. They have a track record of retiring auth routes: the March 2026 spring cleaning removed /verify and the implicit flow, which broke every deployed Short Circuit build at once. Had those builds followed the metadata document, the switch to new routes would have been invisible. The discovery result is cached in-process for an hour, and any failure (network, bad JSON, missing keys) falls back to the existing hardcoded ENDPOINT_EVE_* constants, so login in the worst case behaves exactly as before this change. All three SSO round trips — the browser authorize URL, the code exchange, and the token refresh — now use the discovered endpoints. Co-Authored-By: Claude Fable 5 --- src/shortcircuit/model/esi/esi.py | 69 ++++++++++++++- src/shortcircuit/model/esi/test_esi.py | 111 +++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 src/shortcircuit/model/esi/test_esi.py diff --git a/src/shortcircuit/model/esi/esi.py b/src/shortcircuit/model/esi/esi.py index 89762a3..dab96c0 100644 --- a/src/shortcircuit/model/esi/esi.py +++ b/src/shortcircuit/model/esi/esi.py @@ -16,6 +16,64 @@ from .server import AuthHandler, StoppableHTTPServer +# CCP recommends discovering SSO endpoints from the OAuth metadata document +# rather than hardcoding them. When they retire or move a route (as with the +# 24 March 2026 spring cleaning that removed /verify and the implicit flow) a +# deployed client that follows the metadata keeps working without a rebuild. +# The hardcoded ESI.ENDPOINT_EVE_* values below are only used if the metadata +# fetch fails. +# https://developers.eveonline.com/docs/services/sso/ +ENDPOINT_EVE_METADATA = 'https://login.eveonline.com/.well-known/oauth-authorization-server' +_METADATA_CACHE_TTL_SECONDS = 3600 + +_metadata_cache_lock = threading.Lock() +_metadata_cache = {'fetched_at': 0.0, 'endpoints': None} + + +def discover_sso_endpoints(force_refresh=False): + """ + Fetch and cache the EVE SSO OAuth metadata document. + + Returns a dict with at least ``authorization_endpoint`` and + ``token_endpoint``. On network failure, returns the hardcoded fallback + values so login still works (at least until CCP retires those routes). + The cache is per-process, in-memory, and refreshes hourly. + """ + now = time.monotonic() + with _metadata_cache_lock: + cached = _metadata_cache['endpoints'] + if ( + not force_refresh and cached is not None + and now - _metadata_cache['fetched_at'] < _METADATA_CACHE_TTL_SECONDS + ): + return cached + + try: + r = requests.get( + ENDPOINT_EVE_METADATA, + headers={'User-Agent': USER_AGENT}, + timeout=10.0, + ) + r.raise_for_status() + data = r.json() + endpoints = { + 'authorization_endpoint': data['authorization_endpoint'], + 'token_endpoint': data['token_endpoint'], + } + except (requests.RequestException, KeyError, ValueError) as e: + Logger.warning( + 'SSO metadata discovery failed ({}); falling back to hardcoded endpoints'.format(e) + ) + endpoints = { + 'authorization_endpoint': ESI.ENDPOINT_EVE_AUTH, + 'token_endpoint': ESI.ENDPOINT_EVE_TOKEN, + } + + with _metadata_cache_lock: + _metadata_cache['fetched_at'] = now + _metadata_cache['endpoints'] = endpoints + return endpoints + class ESI: @@ -110,7 +168,8 @@ def start_server(self): 'code_challenge': code_challenge, 'code_challenge_method': 'S256', } - endpoint_auth = ESI.ENDPOINT_EVE_AUTH + '?' + urllib.parse.urlencode(params) + endpoints = discover_sso_endpoints() + endpoint_auth = endpoints['authorization_endpoint'] + '?' + urllib.parse.urlencode(params) return webbrowser.open(endpoint_auth) def timeout_server(self): @@ -135,8 +194,11 @@ def handle_login(self, message): return code = message['code'][0] + # Reuse the metadata cache warmed by start_server() so we post to the + # same token endpoint CCP currently advertises. + endpoints = discover_sso_endpoints() r = requests.post( - ESI.ENDPOINT_EVE_TOKEN, + endpoints['token_endpoint'], data={ 'grant_type': 'authorization_code', 'code': code, @@ -184,8 +246,9 @@ def _refresh_access_token(self): self._logout() return + endpoints = discover_sso_endpoints() r = requests.post( - ESI.ENDPOINT_EVE_TOKEN, + endpoints['token_endpoint'], data={ 'grant_type': 'refresh_token', 'refresh_token': self.refresh_token, diff --git a/src/shortcircuit/model/esi/test_esi.py b/src/shortcircuit/model/esi/test_esi.py new file mode 100644 index 0000000..1bc196d --- /dev/null +++ b/src/shortcircuit/model/esi/test_esi.py @@ -0,0 +1,111 @@ +# test_esi.py + +import unittest +from unittest import mock + +import requests + +from shortcircuit.model.esi import esi as esi_module +from shortcircuit.model.esi.esi import ESI, discover_sso_endpoints + + +def _reset_metadata_cache(): + with esi_module._metadata_cache_lock: + esi_module._metadata_cache['fetched_at'] = 0.0 + esi_module._metadata_cache['endpoints'] = None + + +class _FakeResponse: + + def __init__(self, payload=None, error=None): + self._payload = payload + self._error = error + + def raise_for_status(self): + if self._error: + raise self._error + + def json(self): + return self._payload + + +class TestDiscoverSsoEndpoints(unittest.TestCase): + + def setUp(self): + _reset_metadata_cache() + + def tearDown(self): + _reset_metadata_cache() + + def test_success_returns_advertised_endpoints(self): + payload = { + 'authorization_endpoint': 'https://login.eveonline.com/v3/oauth/authorize', + 'token_endpoint': 'https://login.eveonline.com/v3/oauth/token', + 'issuer': 'https://login.eveonline.com', + } + with mock.patch.object( + esi_module.requests, 'get', return_value=_FakeResponse(payload) + ) as fake_get: + endpoints = discover_sso_endpoints() + + fake_get.assert_called_once() + self.assertEqual( + endpoints['authorization_endpoint'], + 'https://login.eveonline.com/v3/oauth/authorize', + ) + self.assertEqual( + endpoints['token_endpoint'], + 'https://login.eveonline.com/v3/oauth/token', + ) + + def test_result_is_cached(self): + payload = { + 'authorization_endpoint': 'https://a.example', + 'token_endpoint': 'https://t.example', + } + with mock.patch.object( + esi_module.requests, 'get', return_value=_FakeResponse(payload) + ) as fake_get: + discover_sso_endpoints() + discover_sso_endpoints() + + self.assertEqual(fake_get.call_count, 1) + + def test_force_refresh_bypasses_cache(self): + payload = { + 'authorization_endpoint': 'https://a.example', + 'token_endpoint': 'https://t.example', + } + with mock.patch.object( + esi_module.requests, 'get', return_value=_FakeResponse(payload) + ) as fake_get: + discover_sso_endpoints() + discover_sso_endpoints(force_refresh=True) + + self.assertEqual(fake_get.call_count, 2) + + def test_network_failure_falls_back_to_hardcoded(self): + with mock.patch.object( + esi_module.requests, + 'get', + return_value=_FakeResponse(error=requests.ConnectionError('boom')), + ): + endpoints = discover_sso_endpoints() + + self.assertEqual(endpoints['authorization_endpoint'], ESI.ENDPOINT_EVE_AUTH) + self.assertEqual(endpoints['token_endpoint'], ESI.ENDPOINT_EVE_TOKEN) + + def test_missing_keys_fall_back_to_hardcoded(self): + with mock.patch.object( + esi_module.requests, + 'get', + return_value=_FakeResponse({'issuer': 'https://login.eveonline.com'}), + ): + endpoints = discover_sso_endpoints() + + self.assertEqual(endpoints['authorization_endpoint'], ESI.ENDPOINT_EVE_AUTH) + self.assertEqual(endpoints['token_endpoint'], ESI.ENDPOINT_EVE_TOKEN) + + +if __name__ == '__main__': + unittest.main() From 2b58f5cfe6cca971bbb48d4c87f0cca82a6f287f Mon Sep 17 00:00:00 2001 From: scottcorbin Date: Sun, 8 Nov 2026 13:21:32 -0800 Subject: [PATCH 2/2] Persist ESI refresh tokens so users don't re-auth every session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PKCE login keeps the refresh token in memory only, so every launch of Short Circuit requires a full browser round trip to EVE SSO even though CCP hands us a long-lived refresh token precisely so clients can resume sessions. For a tool people open at the start of every play session, that is the single most repeated piece of friction in the app. The refresh token is functionally a password — anyone holding it can act as the character within the granted scopes — so it goes in the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) via the keyring library, never a plaintext file. The only thing written to disk is the last-used character_id, which is public information (killboards, ESI URLs), stored under appdirs user_data_dir without version scoping so the session survives app upgrades. If no keyring backend exists (headless Linux without Secret Service), we degrade to exactly the old behaviour: re-auth each session, nothing secret ever on disk in the clear. Startup now calls try_silent_login() in a background thread: it loads the persisted token, exchanges it for a fresh access token, decodes the character from the JWT, and fires login_response exactly like an interactive login, so the UI needs no new states. Two behaviours worth calling out: - CCP rotates the refresh token on every successful refresh and the old value is single-use, so the rotated token is persisted on every refresh, not just at login. Skipping this bricks the next silent login with invalid_grant. - Any refresh failure (rejected token, network error, undecodable JWT) logs out AND purges the persisted session. Purging on network blips is deliberately conservative: it trades an occasional extra re-auth for never looping on a dead token at startup and never showing a "connected" UI backed by an expired access token. The network-error handling also fixes a pre-existing crash: an offline refresh previously raised ConnectionError and silently killed the timer thread, leaving the UI claiming to be logged in. Explicit Logout also purges, so logging out now means staying logged out across restarts. Tests fake the keyring module and the char_id meta path — they never touch the real OS keychain, which can prompt interactively on macOS. Co-Authored-By: Claude Fable 5 --- Pipfile | 1 + Pipfile.lock | 43 ++++- src/shortcircuit/app.py | 3 + src/shortcircuit/model/esi/esi.py | 213 +++++++++++++++++++++--- src/shortcircuit/model/esi/test_esi.py | 163 ++++++++++++++++++ src/shortcircuit/model/esi_processor.py | 10 ++ 6 files changed, 412 insertions(+), 21 deletions(-) diff --git a/Pipfile b/Pipfile index 1897a50..33b8918 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ python-dateutil = "*" pyside6 = ">=6.8.0.2,<6.10" typing-extensions = "*" appdirs = "*" +keyring = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index 5187239..259f252 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "033b8d5d2d6197253c00bfe8d3f9f97319375aa5751096be5e7d1f563de6fc0f" + "sha256": "3587d2e7976a3a10e283dd93e6538e5ee94200b0c6857f2e52cc3b48bdf278ac" }, "pipfile-spec": 6, "requires": { @@ -218,6 +218,47 @@ "markers": "python_version >= '3.9'", "version": "==3.20" }, + "jaraco.classes": { + "hashes": [ + "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + ], + "markers": "python_version >= '3.8'", + "version": "==3.4.0" + }, + "jaraco.context": { + "hashes": [ + "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", + "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3" + ], + "markers": "python_version >= '3.10'", + "version": "==6.1.2" + }, + "jaraco.functools": { + "hashes": [ + "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", + "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30" + ], + "markers": "python_version >= '3.10'", + "version": "==4.6.0" + }, + "keyring": { + "hashes": [ + "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", + "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==25.7.0" + }, + "more-itertools": { + "hashes": [ + "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0", + "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63" + ], + "markers": "python_version >= '3.11'", + "version": "==11.2.1" + }, "pyside6": { "hashes": [ "sha256:33fb70addd02e1adaa45573485c431bca43cf12bb7b2596535b824ff169138ce", diff --git a/src/shortcircuit/app.py b/src/shortcircuit/app.py index 7fa37ca..950416e 100644 --- a/src/shortcircuit/app.py +++ b/src/shortcircuit/app.py @@ -395,6 +395,9 @@ def __init__(self, parent=None): self.esip.logout_response.connect(self.logout_handler) self.esip.location_response.connect(self.location_handler) self.esip.destination_response.connect(self.destination_handler) + # Resume the previous EVE session from the persisted refresh token, if + # any; fires login_response exactly like an interactive login. + self.esip.try_silent_login() # Start version check self.version_thread.start() diff --git a/src/shortcircuit/model/esi/esi.py b/src/shortcircuit/model/esi/esi.py index dab96c0..1532403 100644 --- a/src/shortcircuit/model/esi/esi.py +++ b/src/shortcircuit/model/esi/esi.py @@ -11,11 +11,20 @@ import webbrowser import requests +from appdirs import AppDirs + from shortcircuit.model.logger import Logger -from shortcircuit import USER_AGENT +from shortcircuit import USER_AGENT, __appslug__ from .server import AuthHandler, StoppableHTTPServer +try: + import keyring + import keyring.errors as keyring_errors +except Exception: # pragma: no cover - keyring is an optional backend + keyring = None + keyring_errors = None + # CCP recommends discovering SSO endpoints from the OAuth metadata document # rather than hardcoding them. When they retire or move a route (as with the # 24 March 2026 spring cleaning that removed /verify and the implicit flow) a @@ -90,6 +99,17 @@ class ESI: 'esi-ui.write_waypoint.v1', ] + # Keyring service name for the persisted refresh token. Username is the + # EVE character_id as a string, so a future multi-character feature can + # store one entry per character without changing the schema. + KEYRING_SERVICE = 'shortcircuit-esi' + # Filename under appdirs.user_data_dir holding the most-recently-used + # character_id. The id itself is public (it appears in killboards, zKill, + # ESI URLs); the secret is the refresh token, which lives in the keyring. + # Deliberately NOT version-scoped like the log dir: the persisted session + # should survive app upgrades. + CHAR_ID_META_FILENAME = 'esi_char_id' + def __init__(self, login_callback, logout_callback): self.login_callback = login_callback self.logout_callback = logout_callback @@ -226,6 +246,9 @@ def handle_login(self, message): sub = claims.get('sub', '') self.char_id = int(sub.split(':')[-1]) if sub.startswith('CHARACTER:EVE:') else None self.char_name = claims.get('name') + if self.char_id is not None and self.refresh_token: + ESI._store_refresh_token(self.char_id, self.refresh_token) + ESI._save_persisted_char_id(self.char_id) self.login_callback(True, self.char_name) else: self._clear_tokens() @@ -242,32 +265,176 @@ def _schedule_refresh(self, expires_in): self.sso_timer.start() def _refresh_access_token(self): + """ + Exchange the stored refresh token for a fresh access token. Called at + startup via try_silent_login() and from the background sso_timer before + the current access token expires. + + CCP rotates the refresh token on every successful refresh, so the + response carries a NEW refresh_token superseding the old one. Persisting + the rotated value is mandatory — re-using the old token a second time + fails with invalid_grant. + + Returns True iff the refresh succeeded and self.token is now valid; on + failure _logout() has already run, which also purges the persisted + session so a dead token is not retried on every startup. + """ if not self.refresh_token: self._logout() - return + return False endpoints = discover_sso_endpoints() - r = requests.post( - endpoints['token_endpoint'], - data={ - 'grant_type': 'refresh_token', - 'refresh_token': self.refresh_token, - 'client_id': ESI.CLIENT_ID, - 'code_verifier': self._code_verifier, - }, - headers={'User-Agent': USER_AGENT}, - ) + try: + r = requests.post( + endpoints['token_endpoint'], + data={ + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + 'client_id': ESI.CLIENT_ID, + 'code_verifier': self._code_verifier, + }, + headers={'User-Agent': USER_AGENT}, + ) + except requests.RequestException as e: + # A network blip, not necessarily a dead token. Log out so the user + # sees an honest disconnected state; they can click Login to retry. + # Without this, an offline refresh kills the timer thread and leaves + # the UI claiming to be connected with an expired token. + Logger.warning('Token refresh request failed: {}'.format(e)) + self._logout() + return False - if r.status_code == requests.codes.ok: - token_data = r.json() - self.token = token_data['access_token'] - self.refresh_token = token_data.get('refresh_token', self.refresh_token) - expires_in = token_data.get('expires_in', 1200) - self._schedule_refresh(expires_in) - Logger.debug('ESI token refreshed for {}'.format(self.char_name)) - else: + if r.status_code != requests.codes.ok: Logger.warning('Token refresh failed: {} {}'.format(r.status_code, r.text)) self._logout() + return False + + token_data = r.json() + self.token = token_data['access_token'] + self.refresh_token = token_data.get('refresh_token', self.refresh_token) + expires_in = token_data.get('expires_in', 1200) + self._schedule_refresh(expires_in) + + if self.char_id is None or self.char_name is None: + # Silent-login path: character identity comes from the fresh JWT. + claims = self._decode_jwt(self.token) + if not claims: + self._logout() + return False + sub = claims.get('sub', '') + self.char_id = int(sub.split(':')[-1]) if sub.startswith('CHARACTER:EVE:') else None + self.char_name = claims.get('name') + + if self.char_id is not None and self.refresh_token: + ESI._store_refresh_token(self.char_id, self.refresh_token) + ESI._save_persisted_char_id(self.char_id) + + Logger.debug('ESI token refreshed for {}'.format(self.char_name)) + return True + + def try_silent_login(self): + """ + Resume a previous session using a persisted refresh token, with no + browser interaction. Intended to be called once at app startup. Fires + login_callback on success so the UI can flip to its logged-in state. + Returns True iff a session was resumed. + """ + char_id = ESI._load_persisted_char_id() + if char_id is None: + return False + refresh_token = ESI._load_refresh_token(char_id) + if not refresh_token: + # Stale meta file pointing at a character with no keyring entry — + # clean it up so we don't keep checking on every startup. + ESI._clear_persisted_char_id() + return False + + self.refresh_token = refresh_token + self.char_id = char_id + + if not self._refresh_access_token(): + return False + + self.login_callback(True, self.char_name) + return True + + # The refresh token is functionally equivalent to a password — anyone + # holding it can act as the user against ESI within the granted scopes + # until it is revoked. It is therefore stored in the OS keychain (Keychain + # on macOS, Credential Manager on Windows, Secret Service on Linux) via + # the keyring library, never in a plaintext file. If no keyring backend is + # available (eg. a headless Linux session without Secret Service) we + # silently degrade to the old behaviour: the user re-auths each session, + # but the secret is never written to disk in the clear. + + @staticmethod + def _char_id_meta_path(): + app_dirs = AppDirs(__appslug__, 'secondfry') + os.makedirs(app_dirs.user_data_dir, exist_ok=True) + return os.path.join(app_dirs.user_data_dir, ESI.CHAR_ID_META_FILENAME) + + @staticmethod + def _load_persisted_char_id(): + try: + with open(ESI._char_id_meta_path(), 'r') as f: + return int(f.read().strip()) + except (FileNotFoundError, ValueError, OSError): + return None + + @staticmethod + def _save_persisted_char_id(char_id): + try: + with open(ESI._char_id_meta_path(), 'w') as f: + f.write(str(char_id)) + except OSError as e: + Logger.warning('Failed to persist char_id: {}'.format(e)) + + @staticmethod + def _clear_persisted_char_id(): + try: + os.remove(ESI._char_id_meta_path()) + except FileNotFoundError: + pass + except OSError as e: + Logger.warning('Failed to clear persisted char_id: {}'.format(e)) + + @staticmethod + def _store_refresh_token(char_id, refresh_token): + if keyring is None: + Logger.warning( + 'keyring unavailable; skipping refresh-token persistence ' + '(user will need to re-auth next session)' + ) + return False + try: + keyring.set_password(ESI.KEYRING_SERVICE, str(char_id), refresh_token) + return True + except Exception as e: + Logger.warning('Could not store refresh token in keyring: {}'.format(e)) + return False + + @staticmethod + def _load_refresh_token(char_id): + if keyring is None: + return None + try: + return keyring.get_password(ESI.KEYRING_SERVICE, str(char_id)) + except Exception as e: + Logger.warning('Could not load refresh token from keyring: {}'.format(e)) + return None + + @staticmethod + def _delete_refresh_token(char_id): + if keyring is None: + return + try: + keyring.delete_password(ESI.KEYRING_SERVICE, str(char_id)) + except Exception as e: + # PasswordDeleteError when the entry doesn't exist is benign; other + # errors are just logged — there is nothing the user can do. + if keyring_errors and isinstance(e, keyring_errors.PasswordDeleteError): + return + Logger.warning('Could not delete refresh token from keyring: {}'.format(e)) def _clear_tokens(self): self.token = None @@ -323,6 +490,12 @@ def logout(self): self._logout() def _logout(self): + # Purge persisted credentials so a restart doesn't silently resume a + # session the user (or the server) just ended. Both operations are + # no-ops when the entries don't exist. + if self.char_id is not None: + ESI._delete_refresh_token(self.char_id) + ESI._clear_persisted_char_id() self._clear_tokens() self._code_verifier = None self.logout_callback() diff --git a/src/shortcircuit/model/esi/test_esi.py b/src/shortcircuit/model/esi/test_esi.py index 1bc196d..39ebffc 100644 --- a/src/shortcircuit/model/esi/test_esi.py +++ b/src/shortcircuit/model/esi/test_esi.py @@ -1,5 +1,10 @@ # test_esi.py +import base64 +import json +import os +import tempfile +import time import unittest from unittest import mock @@ -107,5 +112,163 @@ def test_missing_keys_fall_back_to_hardcoded(self): self.assertEqual(endpoints['token_endpoint'], ESI.ENDPOINT_EVE_TOKEN) +class _FakeKeyring: + """In-memory stand-in for the keyring module; tests must never touch the + real OS keychain (it can prompt interactively on macOS).""" + + def __init__(self): + self.store = {} + + def set_password(self, service, username, password): + self.store[(service, username)] = password + + def get_password(self, service, username): + return self.store.get((service, username)) + + def delete_password(self, service, username): + try: + del self.store[(service, username)] + except KeyError: + raise Exception('no such entry') + + +def _make_jwt(char_id=92000001, name='Test Pilot'): + payload = { + 'iss': 'https://login.eveonline.com', + 'aud': [ESI.CLIENT_ID, 'EVE Online'], + 'exp': int(time.time()) + 1200, + 'sub': 'CHARACTER:EVE:{}'.format(char_id), + 'name': name, + } + body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode() + return 'header.{}.signature'.format(body) + + +class _PersistenceCase(unittest.TestCase): + """Common fixture: fake keyring, temp char_id meta file, no real timers.""" + + def setUp(self): + _reset_metadata_cache() + self.fake_keyring = _FakeKeyring() + self.meta_path = os.path.join(tempfile.mkdtemp(), 'esi_char_id') + + patchers = [ + mock.patch.object(esi_module, 'keyring', self.fake_keyring), + mock.patch.object(esi_module, 'keyring_errors', None), + mock.patch.object(ESI, '_char_id_meta_path', staticmethod(lambda: self.meta_path)), + mock.patch.object(ESI, '_schedule_refresh', lambda self_, expires_in: None), + ] + for p in patchers: + p.start() + self.addCleanup(p.stop) + + self.login_calls = [] + self.logout_calls = [] + self.esi = ESI( + lambda is_ok, char_name: self.login_calls.append((is_ok, char_name)), + lambda: self.logout_calls.append(True), + ) + + def _persist_session(self, char_id=92000001, refresh_token='rt-old'): + self.fake_keyring.set_password(ESI.KEYRING_SERVICE, str(char_id), refresh_token) + with open(self.meta_path, 'w') as f: + f.write(str(char_id)) + + +class TestRefreshTokenStorage(_PersistenceCase): + + def test_store_load_delete_roundtrip(self): + self.assertTrue(ESI._store_refresh_token(92000001, 'rt-secret')) + self.assertEqual(ESI._load_refresh_token(92000001), 'rt-secret') + ESI._delete_refresh_token(92000001) + self.assertIsNone(ESI._load_refresh_token(92000001)) + + def test_char_id_meta_roundtrip(self): + ESI._save_persisted_char_id(92000001) + self.assertEqual(ESI._load_persisted_char_id(), 92000001) + ESI._clear_persisted_char_id() + self.assertIsNone(ESI._load_persisted_char_id()) + + def test_keyring_unavailable_degrades_gracefully(self): + with mock.patch.object(esi_module, 'keyring', None): + self.assertFalse(ESI._store_refresh_token(92000001, 'rt-secret')) + self.assertIsNone(ESI._load_refresh_token(92000001)) + ESI._delete_refresh_token(92000001) # must not raise + + +class TestSilentLogin(_PersistenceCase): + + def test_no_persisted_session_returns_false(self): + with mock.patch.object(esi_module.requests, 'post') as fake_post: + self.assertFalse(self.esi.try_silent_login()) + fake_post.assert_not_called() + self.assertEqual(self.login_calls, []) + + def test_stale_meta_without_keyring_entry_is_cleaned_up(self): + with open(self.meta_path, 'w') as f: + f.write('92000001') + with mock.patch.object(esi_module.requests, 'post') as fake_post: + self.assertFalse(self.esi.try_silent_login()) + fake_post.assert_not_called() + self.assertFalse(os.path.exists(self.meta_path)) + + def test_resumes_session_and_persists_rotated_token(self): + self._persist_session(refresh_token='rt-old') + response = mock.Mock( + status_code=200, + json=lambda: { + 'access_token': _make_jwt(), + 'refresh_token': 'rt-rotated', + 'expires_in': 1200, + }, + ) + with mock.patch.object(esi_module.requests, 'post', return_value=response) as fake_post: + self.assertTrue(self.esi.try_silent_login()) + + sent = fake_post.call_args.kwargs['data'] + self.assertEqual(sent['grant_type'], 'refresh_token') + self.assertEqual(sent['refresh_token'], 'rt-old') + self.assertEqual(self.login_calls, [(True, 'Test Pilot')]) + self.assertEqual(self.esi.char_id, 92000001) + # CCP rotates the refresh token; the rotated value must be persisted or + # the NEXT silent login would present a single-use token twice. + self.assertEqual(ESI._load_refresh_token(92000001), 'rt-rotated') + self.assertEqual(ESI._load_persisted_char_id(), 92000001) + + def test_dead_token_is_purged_and_logs_out(self): + self._persist_session() + response = mock.Mock(status_code=400, text='invalid_grant') + with mock.patch.object(esi_module.requests, 'post', return_value=response): + self.assertFalse(self.esi.try_silent_login()) + + self.assertEqual(self.login_calls, []) + self.assertEqual(self.logout_calls, [True]) + self.assertIsNone(ESI._load_refresh_token(92000001)) + self.assertIsNone(ESI._load_persisted_char_id()) + + def test_network_failure_logs_out_without_crashing(self): + self._persist_session() + with mock.patch.object( + esi_module.requests, 'post', side_effect=requests.ConnectionError('offline') + ): + self.assertFalse(self.esi.try_silent_login()) + self.assertEqual(self.logout_calls, [True]) + + +class TestLogoutPurgesSession(_PersistenceCase): + + def test_logout_removes_keyring_entry_and_meta(self): + self._persist_session() + self.esi.char_id = 92000001 + self.esi.refresh_token = 'rt-old' + self.esi.token = 'at' + + self.esi.logout() + + self.assertEqual(self.logout_calls, [True]) + self.assertIsNone(ESI._load_refresh_token(92000001)) + self.assertIsNone(ESI._load_persisted_char_id()) + + if __name__ == '__main__': unittest.main() diff --git a/src/shortcircuit/model/esi_processor.py b/src/shortcircuit/model/esi_processor.py index 8d5d8f1..cc33a77 100644 --- a/src/shortcircuit/model/esi_processor.py +++ b/src/shortcircuit/model/esi_processor.py @@ -23,6 +23,16 @@ def __init__(self, parent=None): def login(self): return self.esi.start_server() + def try_silent_login(self): + """ + Resume the previous session from a persisted refresh token, if any. + Runs in a background thread because it does a token-endpoint round + trip; emits login_response on success just like interactive login. + """ + server_thread = threading.Thread(target=self.esi.try_silent_login) + server_thread.setDaemon(True) + server_thread.start() + def logout(self): self.esi.logout()