diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c1f2d3b..e91f0eb 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -122,20 +122,25 @@ async function notifyIdleSession() { clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' })); } -async function storeOfflineLease(expiresAt) { - if (!Number.isInteger(expiresAt) || expiresAt <= 0) return; +async function storeOfflineLease(expiresAt, idleExpiresAt) { + if ( + !Number.isInteger(expiresAt) || expiresAt <= 0 + || !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0 + ) return; const cache = await caches.open(CACHE); await cache.put(OFFLINE_LEASE_URL, new Response( - JSON.stringify({ expires_at: expiresAt }), + JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }, )); } -async function validOfflineLease(cache) { +async function offlineLeaseState(cache) { const response = await cache.match(OFFLINE_LEASE_URL); const payload = await response?.json?.().catch(() => ({})) || {}; - return Number.isInteger(payload.expires_at) - && payload.expires_at > Math.floor(Date.now() / 1000); + const current = Math.floor(Date.now() / 1000); + if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired'; + if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle'; + return 'valid'; } async function expiredOfflineResponse() { @@ -152,8 +157,18 @@ async function expiredOfflineResponse() { ); } +async function idleOfflineResponse() { + await notifyIdleSession(); + return new Response( + 'Your Stackchain session is locked. Reconnect and sign in.', + { status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } }, + ); +} + async function cachedShellWithValidLease(cache) { - if (!await validOfflineLease(cache)) return expiredOfflineResponse(); + const state = await offlineLeaseState(cache); + if (state === 'expired') return expiredOfflineResponse(); + if (state === 'idle') return idleOfflineResponse(); return cache.match(BASE); } @@ -239,7 +254,7 @@ self.addEventListener('message', event => { await flushAndNotify(); })()); if (event.data?.type === 'stackchain-session-lease') { - event.waitUntil(storeOfflineLease(event.data.expiresAt)); + event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt)); } if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => { try { diff --git a/frontend/session.js b/frontend/session.js index 059b1a0..7b9e967 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -228,6 +228,7 @@ }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); let expirationStarted = false; + let idleLockStarted = false; let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY; let activityHeartbeat = null; @@ -247,8 +248,8 @@ } function handleSessionIdle() { - if (expirationStarted) return; - expirationStarted = true; + if (expirationStarted || idleLockStarted) return; + idleLockStarted = true; location.replace(base + 'login?reason=session-idle'); } @@ -269,16 +270,32 @@ return Number.isInteger(value) && value > 0 ? value : 0; } + function storedOfflineIdleLease() { + const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at')); + return Number.isInteger(value) && value > 0 ? value : 0; + } + function scheduleOfflineExpiry(expiresAt) { const delay = Math.max(0, expiresAt * 1000 - now()); setTimer(() => handleSessionExpiry(), delay); } - async function publishOfflineLease(expiresAt) { + function scheduleOfflineIdle(idleExpiresAt) { + const delay = Math.max(0, idleExpiresAt * 1000 - now()); + setTimer(() => { + if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle(); + }, delay); + } + + async function publishOfflineLease(expiresAt, idleExpiresAt) { localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt)); + localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt)); scheduleOfflineExpiry(expiresAt); + scheduleOfflineIdle(idleExpiresAt); const registration = await serviceWorker?.ready; - registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt }); + registration?.active?.postMessage({ + type: 'stackchain-session-lease', expiresAt, idleExpiresAt, + }); } function csrfToken() { @@ -449,7 +466,10 @@ return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false); } } - if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { + if ( + response.status === 401 && isSameOrigin(input) + && !expirationStarted && !idleLockStarted + ) { expirationStarted = true; const payload = await response.clone().json().catch(() => ({})); if (payload.code === 'session_revoked') { @@ -470,12 +490,21 @@ const current = now(); if ( expirationStarted + || idleLockStarted || activityHeartbeat || current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs ) return Promise.resolve(false); lastActivityHeartbeatAt = current; activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' }) - .then(response => response.ok) + .then(async response => { + if (!response.ok) return false; + const payload = await response.json().catch(() => ({})); + const expiresAt = storedOfflineLease(); + if (expiresAt && Number.isInteger(payload.idle_expires_at)) { + await publishOfflineLease(expiresAt, payload.idle_expires_at); + } + return true; + }) .catch(() => false) .finally(() => { activityHeartbeat = null; }); return activityHeartbeat; @@ -492,17 +521,27 @@ const response = await sessionFetch(base + 'api/v1/session'); if (!response.ok) return false; const payload = await response.json().catch(() => ({})); - if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false; - await publishOfflineLease(payload.expires_at); + if ( + !payload.authenticated + || !Number.isInteger(payload.expires_at) + || !Number.isInteger(payload.idle_expires_at) + ) return false; + await publishOfflineLease(payload.expires_at, payload.idle_expires_at); return true; } catch (_error) { const expiresAt = storedOfflineLease(); - if (expiresAt * 1000 > now()) { - scheduleOfflineExpiry(expiresAt); - return true; + if (expiresAt * 1000 <= now()) { + if (expiresAt) await handleSessionExpiry(); + return false; } - if (expiresAt) await handleSessionExpiry(); - return false; + const idleExpiresAt = storedOfflineIdleLease(); + if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) { + handleSessionIdle(); + return false; + } + scheduleOfflineExpiry(expiresAt); + scheduleOfflineIdle(idleExpiresAt); + return true; } } diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index 17c336d..c6cbd88 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -30,6 +30,7 @@ class Session: session_id: str csrf: str expires_at: int + idle_expires_at: int | None = None @dataclass(frozen=True) @@ -174,7 +175,14 @@ def verify_session_with_reason( return SessionVerification(None, "session_idle") if status != "active": return SessionVerification(None, "session_revoked") - return SessionVerification(session) + return SessionVerification( + Session( + session_id=session.session_id, + csrf=session.csrf, + expires_at=session.expires_at, + idle_expires_at=getattr(status, "idle_expires_at", None), + ) + ) def verify_session(value: str | None, now: int | None = None) -> Session | None: diff --git a/src/main.py b/src/main.py index e63c0e2..ccb2898 100644 --- a/src/main.py +++ b/src/main.py @@ -1512,6 +1512,7 @@ async def session_status(request: Request): } if session is not None: payload["expires_at"] = session.expires_at + payload["idle_expires_at"] = session.idle_expires_at return payload @@ -1534,7 +1535,10 @@ async def record_session_activity(request: Request): ) return { "active": True, - "idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(), + "idle_expires_at": min( + session.expires_at, + int(time.time()) + dashboard_auth.idle_timeout_seconds(), + ), } diff --git a/src/session_store.py b/src/session_store.py index 65c451d..8a351d2 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -12,6 +12,17 @@ class SessionStoreError(RuntimeError): """Raised when session state cannot be read or changed safely.""" +class SessionStatus(str): + """String-compatible status carrying the server-confirmed idle deadline.""" + + idle_expires_at: int | None + + def __new__(cls, value: str, idle_expires_at: int | None = None): + instance = super().__new__(cls, value) + instance.idle_expires_at = idle_expires_at + return instance + + @dataclass(frozen=True) class ActiveDevice: management_id: str @@ -180,10 +191,11 @@ class SessionStore: except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Session registry is temporarily unavailable") from exc if row is None or row[0] != expires_at or expires_at <= now: - return "revoked" - if row[1] + max(1, idle_timeout_seconds) <= now: - return "idle" - return "active" + return SessionStatus("revoked") + idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds)) + if idle_expires_at <= now: + return SessionStatus("idle", idle_expires_at) + return SessionStatus("active", idle_expires_at) def touch( self, session_id: str, expires_at: int, *, idle_timeout_seconds: int diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index bb949fc..7f6a538 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -791,9 +791,39 @@ async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease assert payload["authenticated"] is True assert payload["csrf_token"] == client.cookies["stackchain_csrf"] assert payload["expires_at"] > int(time.time()) + assert payload["idle_expires_at"] > int(time.time()) assert "correct horse battery staple" not in response.text +@pytest.mark.anyio +async def test_session_status_reports_server_idle_deadline_without_extending_it( + access_control, monkeypatch +): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900") + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + expected_last_active = int(time.time()) - 120 + with sqlite3.connect(main.dashboard_auth._session_store().path) as connection: + connection.execute( + "UPDATE active_sessions SET last_active_at = ?", (expected_last_active,) + ) + + first = await client.get("/api/v1/session") + second = await client.get("/api/v1/session") + with sqlite3.connect(main.dashboard_auth._session_store().path) as connection: + stored_last_active = connection.execute( + "SELECT last_active_at FROM active_sessions" + ).fetchone()[0] + + assert first.status_code == 200 + assert first.json()["idle_expires_at"] == expected_last_active + 900 + assert second.json()["idle_expires_at"] == expected_last_active + 900 + assert stored_last_active == expected_last_active + + @pytest.mark.anyio async def test_anonymous_session_status_discloses_no_offline_lease(access_control): transport = httpx.ASGITransport(app=main.app) @@ -834,6 +864,7 @@ async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(acce @pytest.mark.anyio async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch): monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", "100") transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: await client.post( @@ -846,10 +877,11 @@ async def test_activity_heartbeat_extends_the_server_idle_deadline(access_contro "X-CSRF-Token": client.cookies["stackchain_csrf"], }, ) + status = await client.get("/api/v1/session") assert heartbeat.status_code == 200 assert heartbeat.json()["active"] is True - assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899 + assert heartbeat.json()["idle_expires_at"] == status.json()["expires_at"] @pytest.mark.anyio diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 63b726e..d0792b5 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: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }}; +const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, timers: [], now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }}; const storage = {{ values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), get length() {{ return this.values.size; }}, @@ -59,7 +59,7 @@ const boundary = createSessionBoundary({{ onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }}, addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }}, now: () => state.now, - setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }}, + setTimer: (callback, delay) => {{ state.leaseDelay = delay; state.timers.push({{callback, delay}}); return state.timers.length; }}, confirmAction: message => {{ state.confirmations.push(message); return true; }}, promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }}, }}); @@ -260,22 +260,100 @@ process.stdout.write(JSON.stringify(state)); def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer(): result = run_session_scenario( """ -state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800}; +state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800, idle_expires_at:4102441200}; const valid = await boundary.refreshOfflineLease(); state.valid = valid; state.savedExpiry = storage.getItem('stackchain.session-expires-at'); +state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at'); process.stdout.write(JSON.stringify(state)); """ ) assert result["valid"] is True assert result["savedExpiry"] == "4102444800" + assert result["savedIdleExpiry"] == "4102441200" assert result["workerMessages"] == [ - {"type": "stackchain-session-lease", "expiresAt": 4102444800} + { + "type": "stackchain-session-lease", + "expiresAt": 4102444800, + "idleExpiresAt": 4102441200, + } ] assert result["leaseDelay"] > 0 +def test_expired_idle_lease_locks_offline_without_purging_private_work(): + result = run_session_scenario( + """ +storage.setItem('stackchain.session-expires-at', '200'); +storage.setItem('stackchain.session-idle-expires-at', '99'); +state.failFetch = true; +const valid = await boundary.refreshOfflineLease(); +state.valid = valid; +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["valid"] is False + assert result["remaining"] == [ + "stackchain.private", + "gitea.preference", + "stackchain.session-expires-at", + "stackchain.session-idle-expires-at", + ] + assert result["deletedDatabases"] == [] + assert result["deletedCaches"] == [] + assert result["replaced"] == ["/dashboard/login?reason=session-idle"] + + +def test_successful_activity_heartbeat_republishes_only_confirmed_idle_deadline(): + result = run_session_scenario( + """ +storage.setItem('stackchain.session-expires-at', '4102444800'); +state.responsePayload = {active:true, idle_expires_at:4102441300}; +const recorded = await boundary.recordActivity(); +state.recorded = recorded; +state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at'); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["recorded"] is True + assert result["savedIdleExpiry"] == "4102441300" + assert result["workerMessages"] == [ + { + "type": "stackchain-session-lease", + "expiresAt": 4102444800, + "idleExpiresAt": 4102441300, + } + ] + + +def test_absolute_expiry_still_purges_after_an_idle_lock(): + result = run_session_scenario( + """ +state.responsePayload = {authenticated:true, expires_at:200, idle_expires_at:150}; +await boundary.refreshOfflineLease(); +state.now = 150000; +await state.timers[1].callback(); +state.deletedAtIdle = [...state.deletedDatabases]; +state.now = 200000; +await state.timers[0].callback(); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["deletedAtIdle"] == [] + assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] + assert result["remaining"] == ["gitea.preference"] + assert result["replaced"] == [ + "/dashboard/login?reason=session-idle", + "/dashboard/login?reason=session-expired", + ] + + def test_expired_stored_lease_purges_private_work_when_session_status_is_offline(): result = run_session_scenario( """ diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 52d7055..261d119 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -17,7 +17,10 @@ const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', - new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})), + new Response(JSON.stringify({{ + expires_at: Math.floor(Date.now() / 1000) + 3600, + idle_expires_at: Math.floor(Date.now() / 1000) + 900, + }})), ); const cache = {{ addAll: async urls => {{ state.added = urls; }}, @@ -503,7 +506,7 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url(): def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell(): result = run_worker_scenario( """ - await dispatchMessage({type:'stackchain-session-lease', expiresAt:1}); + await dispatchMessage({type:'stackchain-session-lease', expiresAt:1, idleExpiresAt:1}); state.failFetch = true; state.cachedBody = 'private cached dashboard'; const response = await dispatch('fetch', { @@ -524,6 +527,36 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she assert "private cached dashboard" not in result["body"] +def test_expired_idle_lease_locks_cached_shell_without_purging_recoverable_work(): + result = run_worker_scenario( + """ + state.clientList = [{postMessage: message => { state.clientMessage = message; }}]; + await dispatchMessage({ + type:'stackchain-session-lease', + expiresAt:Math.floor(Date.now() / 1000) + 3600, + idleExpiresAt:1, + }); + state.failFetch = true; + state.cachedBody = 'private cached dashboard'; + const response = await dispatch('fetch', { + method:'GET', mode:'navigate', url:'https://forge.example/dashboard/', + }); + process.stdout.write(JSON.stringify({ + state, status:response.status, body:await response.text(), + cacheControl:response.headers.get('Cache-Control'), + })); +""" + ) + + assert result["status"] == 401 + assert result["body"] == "Your Stackchain session is locked. Reconnect and sign in." + assert result["cacheControl"] == "no-store" + assert result["state"]["outboxPurges"] == 0 + assert result["state"]["deleted"] == [] + assert result["state"]["clientMessage"] == {"type": "stackchain-session-idle"} + assert "private cached dashboard" not in result["body"] + + def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline(): result = run_worker_scenario( """