Merge pull request #432 from naturallaw777/fix/hub-session-auth-recovery

fix(hub): recover from expired sessions and preserve logout
This commit is contained in:
Sovran Systems
2026-08-15 16:45:24 -05:00
committed by GitHub
4 changed files with 99 additions and 4 deletions
+7
View File
@@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed ### 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 - Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
browser login survives the Hub service restart that `nixos-rebuild switch` browser login survives the Hub service restart that `nixos-rebuild switch`
performs during activation. Previously the in-memory session store was performs during activation. Previously the in-memory session store was
+22 -4
View File
@@ -142,9 +142,13 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web" FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web"
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass" MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret" HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
SESSION_COOKIE_NAME = "hub_session" SESSION_COOKIE_NAME = "hub_session"
SESSION_MAX_AGE = 86400 # 24 hours 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. # Sessions are persisted here so logins survive a restart of the Hub service.
# nixos-rebuild switch restarts sovran-hub-web.service during activation (its # 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" client_ip = request.client.host if request.client else "unknown"
if client_ip not in ("127.0.0.1", "::1"): if client_ip not in ("127.0.0.1", "::1"):
raise HTTPException(status_code=403, detail="Forbidden") 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() token = _create_session()
response = RedirectResponse(url="/", status_code=303) response = RedirectResponse(url="/", status_code=303)
response.set_cookie( response.set_cookie(
@@ -2553,17 +2561,27 @@ async def api_login(req: LoginRequest, request: Request):
samesite="lax", samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port 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 return response
@app.post("/api/logout") @app.post("/api/logout")
async def api_logout(request: Request): 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) token = request.cookies.get(SESSION_COOKIE_NAME)
if token: if token:
_destroy_session(token) _destroy_session(token)
response = JSONResponse({"ok": True}) response = JSONResponse({"ok": True})
response.delete_cookie(key=SESSION_COOKIE_NAME) 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 return response
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
// ── Fetch wrappers ──────────────────────────────────────────────── // ── 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) { async function apiFetch(path, options) {
const res = await fetch(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) { if (!res.ok) {
let detail = res.status + " " + res.statusText; let detail = res.status + " " + res.statusText;
try { try {
+59
View File
@@ -365,6 +365,65 @@ class TestAuthExemptPaths(unittest.TestCase):
self.assertIn("/api/ping", self._get_exempt_paths()) 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 # Persistent session store
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------