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/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 = """
Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.