feat: lock idle dashboard sessions (#463)
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 07:45:00 +00:00
1 parent 9904f17e3d
commit 0b992543f1
12 files changed
+402 -27

No files matched your search

+63 -12
View File
@@ -1,4 +1,5 @@
import asyncio
import sqlite3
import time
from urllib.parse import parse_qs, urlsplit
@@ -245,10 +246,14 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
lookups = 0
class CountingStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
nonlocal lookups
lookups += 1
return original_store.is_active(session_id, expires_at)
return original_store.status(
session_id,
expires_at,
idle_timeout_seconds=idle_timeout_seconds,
)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
response = await client.get("/api/v1/session")
@@ -291,6 +296,52 @@ async def test_anonymous_session_status_discloses_no_offline_lease(access_contro
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(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"}
)
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
connection.execute(
"UPDATE active_sessions SET last_active_at = ?", (int(time.time()) - 900,)
)
api_response = await client.get("/api/v1/session")
page_response = await client.get("/")
assert api_response.status_code == 401
assert api_response.json() == {
"detail": "Authentication required",
"code": "session_idle",
}
assert page_response.status_code == 303
assert page_response.headers["location"] == "login?reason=session-idle"
@pytest.mark.anyio
async def test_activity_heartbeat_extends_the_server_idle_deadline(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"}
)
heartbeat = await client.post(
"/api/v1/session/activity",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert heartbeat.status_code == 200
assert heartbeat.json()["active"] is True
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
@@ -615,8 +666,8 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
grant = await fresh_grant(client, "revoke_all_sessions", "all")
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
def consume_step_up(self, grant, session_id, *, action, target):
return True
@@ -693,9 +744,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
)
class SlowStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.15)
return True
return "active"
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
@@ -847,9 +898,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
csrf = client.cookies["stackchain_csrf"]
class SlowStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.02)
return True
return "active"
def revoke(self, session_id):
time.sleep(0.15)
@@ -893,7 +944,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
)
class BrokenStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
@@ -938,8 +989,8 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")
@@ -966,7 +1017,7 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
)
class BrokenStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
raise SessionStoreError("public routes must not read the registry")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
+57 -1
View File
@@ -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: [], 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: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@@ -57,6 +57,8 @@ const boundary = createSessionBoundary({{
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
now: () => state.now,
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
@@ -232,6 +234,29 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
result = run_session_scenario(
"""
boundary.startActivityHeartbeat();
await state.activityListeners.pointerdown();
await state.activityListeners.keydown();
state.now += 60000;
await state.activityListeners.touchstart();
process.stdout.write(JSON.stringify(state));
"""
)
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/session/activity",
"/dashboard/api/v1/session/activity",
]
assert all(request["method"] == "POST" for request in result["requests"])
assert all(
request["headers"]["x-csrf-token"] == "csrf-proof"
for request in result["requests"]
)
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
@@ -350,6 +375,24 @@ process.stdout.write(JSON.stringify(state));
assert result["replacedAfterDeletion"] is True
def test_idle_response_locks_without_clearing_private_queued_work():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["workerMessages"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_revocation_message_clears_window_storage_before_login():
result = run_session_scenario(
"""
@@ -363,6 +406,19 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_idle_message_locks_without_clearing_window_storage():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""
+22
View File
@@ -37,6 +37,28 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
assert state["ignored"] == state["expired"]
def test_idle_session_reason_explains_that_saved_work_will_resume():
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-idle');
process.stdout.write(JSON.stringify({{ status: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["status"] == (
"Stackchain locked after inactivity. Your drafts and queued work are still "
"on this device. Sign in to resume."
)
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
+20
View File
@@ -237,6 +237,26 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
]
def test_idle_background_session_preserves_outbox_and_notifies_dashboard_clients():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_idle'}),{status:401,headers:{'Content-Type':'application/json'}});
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
process.stdout.write(JSON.stringify({state,outcome}));
"""
)
assert result["outcome"] == {"status": 401, "code": "session_idle"}
assert result["state"]["outboxPurges"] == 0
assert result["state"]["deleted"] == []
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-idle"}
]
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""
+56
View File
@@ -77,6 +77,43 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
def test_session_status_reports_idle_without_background_validation_extending_activity(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
now[0] = 1_899.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
now[0] = 1_900.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
def test_touch_extends_only_the_matching_live_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
store.activate("laptop-session", 3_000)
now[0] = 1_800.0
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is True
now[0] = 2_000.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
assert store.status("laptop-session", 3_000, idle_timeout_seconds=900) == "idle"
assert store.touch("missing-session", 3_000, idle_timeout_seconds=900) is False
def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
now[0] = 1_900.0
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is False
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
@@ -121,6 +158,25 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
assert next(device for device in devices if device.current).device_label == "Existing device"
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
)
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
store = SessionStore(database, clock=lambda: 1_000.0)
assert store.status("existing-session", 2_000, idle_timeout_seconds=900) == "active"
with sqlite3.connect(database) as connection:
last_active_at = connection.execute(
"SELECT last_active_at FROM active_sessions WHERE session_hash = ?", (digest,)
).fetchone()[0]
assert last_active_at == 1_000
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")