From de326995397661110df22beed6632cf3505ef524 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 15 Aug 2026 16:43:37 -0500 Subject: [PATCH] fix(hub): recover from expired sessions and preserve logout --- CHANGELOG.md | 7 +++ app/sovran_systemsos_web/server.py | 26 ++++++-- app/sovran_systemsos_web/static/js/helpers.js | 11 ++++ tests/test_security.py | 59 +++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0c1a1..d24f720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed + - The Hub now redirects to the login page when an API request finds an + expired browser session, instead of leaving the dashboard tile grid in a + loading state while `/api/services` continues returning 401 every five + seconds. + - Explicitly logging out now suppresses the desktop launcher's local + auto-login after the Hub window is closed and reopened. A successful + password login clears that preference. - Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the browser login survives the Hub service restart that `nixos-rebuild switch` performs during activation. Previously the in-memory session store was diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 2a1ef0a..cc980bb 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -142,9 +142,13 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web" MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass" -HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret" -SESSION_COOKIE_NAME = "hub_session" -SESSION_MAX_AGE = 86400 # 24 hours +HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret" +SESSION_COOKIE_NAME = "hub_session" +MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout" +SESSION_MAX_AGE = 86400 # 24 hours +# Chromium limits persistent cookies to roughly 400 days. This marker only +# suppresses desktop auto-login until the next successful password login. +MANUAL_LOGOUT_MAX_AGE = 400 * 86400 # Sessions are persisted here so logins survive a restart of the Hub service. # nixos-rebuild switch restarts sovran-hub-web.service during activation (its @@ -2517,6 +2521,10 @@ async def auto_login_redirect(request: Request): client_ip = request.client.host if request.client else "unknown" if client_ip not in ("127.0.0.1", "::1"): raise HTTPException(status_code=403, detail="Forbidden") + # An explicit logout must take precedence over the desktop launcher's + # localhost auto-login, including after the Hub window is closed/reopened. + if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1": + return RedirectResponse(url="/login", status_code=303) token = _create_session() response = RedirectResponse(url="/", status_code=303) response.set_cookie( @@ -2553,17 +2561,27 @@ async def api_login(req: LoginRequest, request: Request): samesite="lax", secure=False, # LAN-only appliance; no TLS on the Hub port ) + # A successful password login explicitly reverses a prior manual logout. + response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME) return response @app.post("/api/logout") async def api_logout(request: Request): - """Clear the session cookie and destroy the server-side session.""" + """Destroy the session and prevent desktop auto-login until password login.""" token = request.cookies.get(SESSION_COOKIE_NAME) if token: _destroy_session(token) response = JSONResponse({"ok": True}) response.delete_cookie(key=SESSION_COOKIE_NAME) + response.set_cookie( + key=MANUAL_LOGOUT_COOKIE_NAME, + value="1", + max_age=MANUAL_LOGOUT_MAX_AGE, + httponly=True, + samesite="lax", + secure=False, # LAN-only appliance; no TLS on the Hub port + ) return response diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js index de1b17d..69ddb59 100644 --- a/app/sovran_systemsos_web/static/js/helpers.js +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -111,8 +111,19 @@ function formatDuration(seconds) { // ── Fetch wrappers ──────────────────────────────────────────────── +// Avoid issuing multiple redirects when several startup requests discover an +// expired session at the same time. +let _authRedirectInProgress = false; + async function apiFetch(path, options) { const res = await fetch(path, options || {}); + if (res.status === 401) { + if (!_authRedirectInProgress) { + _authRedirectInProgress = true; + window.location.replace("/login"); + } + throw new Error("Unauthenticated"); + } if (!res.ok) { let detail = res.status + " " + res.statusText; try { diff --git a/tests/test_security.py b/tests/test_security.py index daa262b..69ea47b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -365,6 +365,65 @@ class TestAuthExemptPaths(unittest.TestCase): self.assertIn("/api/ping", self._get_exempt_paths()) +class TestFrontendAuthRecovery(unittest.TestCase): + """Expired browser sessions must not leave the Hub polling forever.""" + + @classmethod + def setUpClass(cls): + path = os.path.join( + _REPO_ROOT, "app", "sovran_systemsos_web", "static", "js", "helpers.js" + ) + with open(path, encoding="utf-8") as f: + cls.helpers = f.read() + + def test_api_fetch_redirects_unauthorized_response_to_login(self): + self.assertRegex(self.helpers, r"res\.status\s*===\s*401") + self.assertIn('window.location.replace("/login")', self.helpers) + + def test_unauthorized_response_does_not_use_local_auto_login(self): + # Remote clients must still authenticate with the Hub password. + self.assertNotIn('window.location.replace("/auto-login")', self.helpers) + + +class TestManualLogoutPersistence(unittest.TestCase): + """Explicit logout must take precedence over desktop auto-login.""" + + @classmethod + def setUpClass(cls): + path = os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py") + with open(path, encoding="utf-8") as f: + cls.server = f.read() + + def _between(self, start, end): + return self.server.split(start, 1)[1].split(end, 1)[0] + + def test_auto_login_honors_manual_logout_cookie(self): + route = self._between( + '@app.get("/auto-login")', + "class LoginRequest", + ) + self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route) + self.assertIn('RedirectResponse(url="/login"', route) + + def test_logout_sets_persistent_manual_logout_cookie(self): + route = self._between( + '@app.post("/api/logout")', + "def _get_sovran_version", + ) + self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route) + self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route) + self.assertIn("httponly=True", route) + + def test_password_login_clears_manual_logout_cookie(self): + route = self._between( + '@app.post("/api/login")', + '@app.post("/api/logout")', + ) + self.assertIn( + "response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route + ) + + # --------------------------------------------------------------------------- # Persistent session store # ---------------------------------------------------------------------------