diff --git a/README.md b/README.md index f3d088a..3d6afd4 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,10 @@ that setting, a reverse proxy is safely treated as one shared source. Each signed cookie includes an opaque session identifier whose hash and expiry are kept in the SQLite session registry. Keep that registry on persistent, writable -storage shared by all dashboard workers. **Sign out & clear this device** revokes +storage shared by all dashboard workers. If an active session expires or is revoked, +the first authenticated API rejection replaces the dashboard with sign-in and explains +that private drafts remain on the device; signing in again resumes account-bound queued +delivery. This recovery does not clear offline state. **Sign out & clear this device** revokes only the current session before clearing browser state, so a copied cookie cannot be replayed afterward; other signed-in devices remain active. **Sign out all devices** is a separately confirmed lost-device safety action that atomically diff --git a/frontend/login.js b/frontend/login.js index 594d73f..0c5c70b 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -11,6 +11,11 @@ const clearIntervalImpl = options.clearIntervalImpl || clearInterval; let timer = null; + function showReason(reason) { + if (reason !== 'session-expired') return; + status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; + } + function showRetryCountdown(seconds) { let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1); button.disabled = true; @@ -54,7 +59,7 @@ status.textContent = 'Sign-in failed. Check the token and try again.'; } - return { submit }; + return { submit, showReason }; })); if (typeof document !== 'undefined') { @@ -68,6 +73,7 @@ if (typeof document !== 'undefined') { fetchImpl: fetch.bind(window), location: window.location, }); + controller.showReason(new URLSearchParams(window.location.search).get('reason')); form.addEventListener('submit', event => { event.preventDefault(); const accessToken = new FormData(form).get('access_token'); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 1a0a299..efbfb68 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v30'; +const CACHE = 'stackchain-dashboard-shell-v31'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/frontend/session.js b/frontend/session.js index 85211cc..318cee2 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -40,6 +40,7 @@ onClearError = () => {}, }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + let expirationStarted = false; function csrfToken() { const entry = String(cookie?.() || '').split(';') @@ -63,7 +64,11 @@ requestOptions.headers = headers; } const response = await fetchImpl(input, requestOptions); - if (response.status === 401) onExpired(); + if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { + expirationStarted = true; + onExpired(); + location.replace(base + 'login?reason=session-expired'); + } return response; } diff --git a/src/views.py b/src/views.py index 41b3373..3b55077 100644 --- a/src/views.py +++ b/src/views.py @@ -13,7 +13,7 @@ LOGIN_HTML = """ Sign in ยท Stackchain Dashboard

Operator sign in

Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.

-

+

""" diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index c683b98..437672d 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js" def run_session_scenario(scenario: str) -> dict: harness = f""" const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); -const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [] }}; +const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [], responseStatus: 200 }}; const storage = {{ values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), get length() {{ return this.values.size; }}, @@ -27,7 +27,7 @@ const boundary = createSessionBoundary({{ base: '/dashboard/', fetchImpl: async (url, options = {{}}) => {{ state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }}); - return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }}); + return new Response('{{}}', {{ status: state.responseStatus, headers: {{ 'Content-Type': 'application/json' }} }}); }}, localStorage: storage, sessionStorage: storage, @@ -47,7 +47,10 @@ const boundary = createSessionBoundary({{ const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }}; this.port1 = first; this.port2 = second; }} }}, - location: {{ assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }} }}, + location: {{ + assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }}, + replace: value => state.replaced.push(value), + }}, onClearError: error => state.clearErrors.push(error.message), confirmAction: message => {{ state.confirmations.push(message); return true; }}, }}); @@ -68,6 +71,39 @@ process.stdout.write(JSON.stringify(state)); assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof" +def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work(): + result = run_session_scenario( + """ +state.responseStatus = 401; +await Promise.all([ + boundary.fetch('/dashboard/api/v1/live'), + boundary.fetch('https://forge.example/dashboard/api/v1/context'), +]); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["replaced"] == [ + "/dashboard/login?reason=session-expired" + ] + assert result["remaining"] == ["stackchain.private", "gitea.preference"] + assert result["deletedDatabases"] == [] + assert result["deletedCaches"] == [] + + +def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session(): + result = run_session_scenario( + """ +state.responseStatus = 401; +await boundary.fetch('https://untrusted.example/api/private'); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["replaced"] == [] + + def test_sign_out_clears_only_dashboard_private_device_state(): result = run_session_scenario( """ diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index e213be0..113aca8 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -11,6 +11,32 @@ ROOT = Path(__file__).resolve().parents[1] LOGIN_JS = ROOT / "frontend" / "login.js" +def test_expired_session_reason_explains_preserved_private_work(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +const status = {{ textContent: '' }}; +const controller = createLoginController({{ + form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }}, + fetchImpl: async () => new Response('{{}}', {{ status: 200 }}), + location: {{ replace: () => {{}} }}, +}}); +controller.showReason('session-expired'); +const expired = status.textContent; +controller.showReason('https://evil.example/redirect'); +process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }})); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + state = json.loads(result.stdout) + + assert state["expired"] == ( + "Your session expired. Private drafts remain on this device. " + "Sign in to continue." + ) + assert state["ignored"] == state["expired"] + + def test_rate_limited_login_disables_submit_and_counts_down(): harness = f""" const createLoginController = require({json.dumps(str(LOGIN_JS))}); @@ -58,3 +84,4 @@ async def test_login_page_loads_rate_limit_controller(): assert '' in html assert "main{box-sizing:border-box" in html + assert '

' in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 9863307..d6bc7ef 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -92,10 +92,10 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_strict_browser_assets_ship_in_a_new_shell_cache(): +def test_session_expiry_recovery_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v30" in source + assert "stackchain-dashboard-shell-v31" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source