diff --git a/README.md b/README.md index 81446b3..c8424f9 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,14 @@ exposes only independent management IDs and bounded labels—never cookie values session hashes, CSRF proofs, or source addresses. Existing two-column registries are migrated in place and their live sessions remain valid. -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 +If an active session expires, 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. Expiry recovery does not clear offline +state. A selectively or globally revoked device instead receives a bounded revocation +reason: on its next server contact, Stackchain clears its owned local/session storage, +private outbox database, and dashboard caches before enabling sign-in. Revocation +blocks server access immediately, but no web application can erase a device that +remains offline forever. **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 revokes every existing diff --git a/frontend/login.js b/frontend/login.js index b88ced7..5c430a5 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -22,6 +22,7 @@ const button = options.button; const fetchImpl = options.fetchImpl; const location = options.location; + const clearPrivateDeviceData = options.clearPrivateDeviceData; const continuation = validShareContinuation(options.continuation); const setIntervalImpl = options.setIntervalImpl || setInterval; const clearIntervalImpl = options.clearIntervalImpl || clearInterval; @@ -29,9 +30,22 @@ if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.'; - function showReason(reason) { - if (reason !== 'session-expired') return; - status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; + async function showReason(reason) { + if (reason === 'session-expired') { + status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; + return; + } + if (reason !== 'session-revoked') return; + button.disabled = true; + status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…'; + try { + if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.'); + await clearPrivateDeviceData(); + status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.'; + button.disabled = false; + } catch (_error) { + status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.'; + } } function showRetryCountdown(seconds) { @@ -92,6 +106,7 @@ if (typeof document !== 'undefined') { fetchImpl: fetch.bind(window), location: window.location, continuation: loginParams.get('continue'), + clearPrivateDeviceData: window.stackchainPrivateDeviceData, }); controller.showReason(loginParams.get('reason')); form.addEventListener('submit', event => { diff --git a/frontend/private-device-data.js b/frontend/private-device-data.js new file mode 100644 index 0000000..1d6d631 --- /dev/null +++ b/frontend/private-device-data.js @@ -0,0 +1,61 @@ +(function (root, factory) { + if (typeof module !== 'undefined' && module.exports) module.exports = factory; + else root.stackchainPrivateDeviceData = factory({ + localStorage: root.localStorage, + sessionStorage: root.sessionStorage, + indexedDB: root.indexedDB, + caches: root.caches, + serviceWorker: root.navigator?.serviceWorker, + MessageChannel: root.MessageChannel, + }); +})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({ + localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, +}) { + function removeOwnedStorage(storage) { + if (!storage) return; + const keys = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith('stackchain.')) keys.push(key); + } + keys.forEach(key => storage.removeItem(key)); + } + + async function stopWorkerOutbox() { + const registration = await serviceWorker?.ready; + if (!registration?.active || !MessageChannel) return; + await new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000); + channel.port1.onmessage = event => { + clearTimeout(timeout); + if (event.data?.ok) resolve(); + else reject(new Error(event.data?.error || 'Background outbox purge failed.')); + }; + registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]); + }); + } + + function deletePrivateOutbox() { + if (!indexedDB) return Promise.resolve(); + return new Promise((resolve, reject) => { + let request; + try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); } + catch (error) { reject(error); return; } + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); + request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.')); + }); + } + + return async function clearPrivateDeviceData() { + removeOwnedStorage(localStorage); + if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage); + await stopWorkerOutbox(); + await deletePrivateOutbox(); + const keys = await caches?.keys?.() || []; + await Promise.all( + keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) + ); + }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 19a50be..fdf1ebc 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -66,6 +66,16 @@ async function withSessionCsrf(work) { } } +async function purgeRevokedSessionData() { + await issueSync.purge(); + const keys = await caches.keys(); + await Promise.all( + keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) + ); + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' })); +} + async function fetchJson(url, options = {}) { const requestOptions = { ...options }; const method = String(options.method || 'GET').toUpperCase(); @@ -89,9 +99,13 @@ async function fetchJson(url, options = {}) { const response = await fetch(new URL(url, self.location.origin), requestOptions); const payload = await response.json().catch(() => ({})); if (!response.ok) { + const code = payload.code || payload.detail?.code; + if (response.status === 401 && code === 'session_revoked') { + await purgeRevokedSessionData(); + } const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.'); error.status = response.status; - error.code = payload.detail?.code; + error.code = code; throw error; } return payload; diff --git a/frontend/session.js b/frontend/session.js index a9aa4de..40de582 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -80,6 +80,7 @@ }); boundary.resumeQueuedWork(); }; + root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event)); if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); else attach(); root.stackchainSession = boundary; @@ -92,6 +93,19 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); let expirationStarted = false; + async function handleRemoteRevocation() { + if (expirationStarted) return; + expirationStarted = true; + await clearPrivateDeviceData(); + location.replace(base + 'login?reason=session-revoked'); + } + + async function handleServiceWorkerMessage(event) { + if (event?.data?.type === 'stackchain-session-revoked') { + await handleRemoteRevocation(); + } + } + function csrfToken() { const entry = String(cookie?.() || '').split(';') .map(value => value.trim()) @@ -116,8 +130,14 @@ const response = await fetchImpl(input, requestOptions); if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { expirationStarted = true; - onExpired(); - location.replace(base + 'login?reason=session-expired'); + const payload = await response.clone().json().catch(() => ({})); + if (payload.code === 'session_revoked') { + expirationStarted = false; + await handleRemoteRevocation(); + } else { + onExpired(); + location.replace(base + 'login?reason=session-expired'); + } } return response; } @@ -230,6 +250,7 @@ listActiveDevices, revokeActiveDevice, clearPrivateDeviceData, + handleServiceWorkerMessage, resumeQueuedWork, }; }); diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index 429f173..e7ba0b0 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -30,6 +30,12 @@ class Session: expires_at: int +@dataclass(frozen=True) +class SessionVerification: + session: Session | None + reason: str | None = None + + def access_token() -> str: return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "") @@ -112,13 +118,15 @@ def issue_session( return f"{encoded}.{signature}", session -def verify_session(value: str | None, now: int | None = None) -> Session | None: +def verify_session_with_reason( + value: str | None, now: int | None = None +) -> SessionVerification: if not value or "." not in value or not enabled(): - return None + return SessionVerification(None) encoded, supplied_signature = value.rsplit(".", 1) expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) if not hmac.compare_digest(supplied_signature, expected): - return None + return SessionVerification(None) try: payload = json.loads(_decode(encoded)) session = Session( @@ -127,7 +135,7 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None: expires_at=int(payload["exp"]), ) except (ValueError, TypeError, KeyError, json.JSONDecodeError): - return None + return SessionVerification(None) current = int(time.time() if now is None else now) if ( session.expires_at <= current @@ -136,8 +144,14 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None: or not isinstance(session.csrf, str) or not session.csrf ): - return None - return session if _session_store(now).is_active(session.session_id, session.expires_at) else None + return SessionVerification(None) + if not _session_store(now).is_active(session.session_id, session.expires_at): + return SessionVerification(None, "session_revoked") + return SessionVerification(session) + + +def verify_session(value: str | None, now: int | None = None) -> Session | None: + return verify_session_with_reason(value, now).session def revoke_session(session: Session) -> None: @@ -160,6 +174,12 @@ async def request_session(request: Request) -> Session | None: return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE)) +async def request_session_verification(request: Request) -> SessionVerification: + return await asyncio.to_thread( + verify_session_with_reason, request.cookies.get(SESSION_COOKIE) + ) + + def cookie_path(request: Request) -> str: root_path = request.scope.get("root_path", "").rstrip("/") return root_path or "/" diff --git a/src/main.py b/src/main.py index 68bd21a..cfa86c6 100644 --- a/src/main.py +++ b/src/main.py @@ -524,9 +524,12 @@ async def require_operator_session(request: Request, call_next): or (path == "/api/v1/session" and request.method == "POST") ) session = None + session_reason = None if not public: try: - session = await dashboard_auth.request_session(request) + verification = await dashboard_auth.request_session_verification(request) + session = verification.session + session_reason = verification.reason except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, @@ -535,13 +538,21 @@ async def require_operator_session(request: Request, call_next): ) if not public and session is None: if path.startswith("/api/"): + payload = {"detail": "Authentication required"} + if session_reason == "session_revoked": + payload["code"] = session_reason return JSONResponse( - {"detail": "Authentication required"}, + payload, status_code=401, headers={"Cache-Control": "no-store"}, ) + login_redirect = ( + "login?reason=session-revoked" + if session_reason == "session_revoked" + else _share_target_login_redirect(request) + ) return RedirectResponse( - _share_target_login_redirect(request), + login_redirect, status_code=303, headers={"Cache-Control": "no-store"}, ) diff --git a/src/views.py b/src/views.py index a5fe5b6..687dc64 100644 --- a/src/views.py +++ b/src/views.py @@ -14,7 +14,7 @@ LOGIN_HTML = """

Operator sign in

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

-""" +""" class RevalidatingHTMLResponse(HTMLResponse): diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 15f01ab..8b2f1b1 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -295,6 +295,7 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control): }, ) phone_status = await phone.get("/api/v1/session") + phone_page = await phone.get("/", follow_redirects=False) laptop_status = await laptop.get("/api/v1/session") assert listed.status_code == 200 @@ -303,6 +304,12 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control): assert missing_csrf.status_code == 403 assert revoked.json() == {"revoked": True, "current_session": False} assert phone_status.status_code == 401 + assert phone_status.json() == { + "detail": "Authentication required", + "code": "session_revoked", + } + assert phone_page.status_code == 303 + assert phone_page.headers["location"] == "login?reason=session-revoked" assert laptop_status.status_code == 200 assert "session_hash" not in listed.text assert "csrf" not in listed.text diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index ee25983..6d921eb 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -92,6 +92,39 @@ process.stdout.write(JSON.stringify(state)); assert result["deletedCaches"] == [] +def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard(): + result = run_session_scenario( + """ +state.responseStatus = 401; +state.responsePayload = {detail:'Authentication required', code:'session_revoked'}; +await boundary.fetch('/dashboard/api/v1/live'); +state.remaining = Array.from(storage.values.keys()); +state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1; +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["remaining"] == ["gitea.preference"] + assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] + assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] + assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] + assert result["replaced"] == ["/dashboard/login?reason=session-revoked"] + assert result["replacedAfterDeletion"] is True + + +def test_worker_revocation_message_clears_window_storage_before_login(): + result = run_session_scenario( + """ +await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}}); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["remaining"] == ["gitea.preference"] + assert result["replaced"] == ["/dashboard/login?reason=session-revoked"] + + def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session(): result = run_session_scenario( """ diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index 01ea862..8f8f757 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -37,6 +37,75 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }})) assert state["ignored"] == state["expired"] +def test_revoked_session_reason_clears_private_data_before_enabling_sign_in(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +const state = {{cleared:false, snapshots:[]}}; +const status = {{textContent:''}}; +const button = {{disabled:false}}; +const controller = createLoginController({{ + form:{{reset:()=>{{}}}}, status, button, + fetchImpl:async()=>new Response('{{}}',{{status:200}}), + location:{{replace:()=>{{}}}}, + clearPrivateDeviceData:async()=>{{ + state.snapshots.push({{status:status.textContent,disabled:button.disabled}}); + await new Promise(resolve=>setTimeout(resolve,10)); + state.cleared=true; + }}, +}}); +(async()=>{{ + const pending=controller.showReason('session-revoked'); + state.snapshots.push({{status:status.textContent,disabled:button.disabled}}); + await pending; + state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}}; + process.stdout.write(JSON.stringify(state)); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + state = json.loads(result.stdout) + + assert all(snapshot["disabled"] for snapshot in state["snapshots"]) + assert state["final"] == { + "status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.", + "disabled": False, + "cleared": True, + } + + +@pytest.mark.anyio +async def test_login_loads_private_data_purger_before_controller(): + html = await login() + + assert '' in html + assert html.index('static/private-device-data.js') < html.index('static/login.js') + + +def test_revoked_session_does_not_claim_success_when_purger_is_unavailable(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +const status={{textContent:''}}; +const button={{disabled:false}}; +const controller=createLoginController({{ + form:{{reset:()=>{{}}}},status,button, + fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}}, +}}); +(async()=>{{ + await controller.showReason('session-revoked'); + process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}})); +}})(); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + + assert json.loads(result.stdout) == { + "status": "This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.", + "disabled": True, + } + + def test_rate_limited_login_disables_submit_and_counts_down(): harness = f""" const createLoginController = require({json.dumps(str(LOGIN_JS))}); diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py new file mode 100644 index 0000000..da597a6 --- /dev/null +++ b/tests/test_private_device_data.py @@ -0,0 +1,49 @@ +import json +import subprocess +from pathlib import Path + + +PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-device-data.js" + + +def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion(): + harness = f""" +const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))}); +const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}}; +const storage = {{ + values:new Map([['stackchain.draft','private'],['other.preference','keep']]), + get length(){{return this.values.size;}}, + key(index){{return Array.from(this.values.keys())[index] || null;}}, + removeItem(key){{state.removed.push(key);this.values.delete(key);}}, +}}; +const clear = createPrivateDeviceDataPurger({{ + localStorage:storage, sessionStorage:storage, + indexedDB:{{deleteDatabase:name=>{{ + state.databases.push(name); + const request={{}}; + setTimeout(()=>{{state.complete=true;request.onsuccess?.();}},10); + return request; + }}}}, + caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}}, + serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}}, + MessageChannel:class{{constructor(){{ + const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}}; + const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}}; + this.port1=first;this.port2=second; + }}}}, +}}); +(async()=>{{ + await clear(); + process.stdout.write(JSON.stringify({{...state,remaining:Array.from(storage.values.keys())}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + state = json.loads(result.stdout) + + assert state["remaining"] == ["other.preference"] + assert state["databases"] == ["stackchain-background-outbox-v1"] + assert state["caches"] == ["stackchain-dashboard-shell-v37"] + assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] + assert state["complete"] is True diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 5fa47e6..8e65a53 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -146,6 +146,26 @@ def test_background_delivery_preserves_structured_uncertain_error(): } +def test_revoked_background_session_purges_worker_data_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_revoked'}),{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_revoked"} + assert result["state"]["outboxPurges"] == 1 + assert result["state"]["deleted"] == ["stackchain-dashboard-old"] + assert result["state"]["clientMessages"] == [ + {"type": "stackchain-session-revoked"} + ] + + def test_authenticated_page_message_resumes_queued_background_delivery(): result = run_worker_scenario( """