From 7034ec55ed9c83dc3cdfe61358f607ef4bf23824 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 17:59:17 +0000 Subject: [PATCH] feat: review and revoke active devices (#325) --- README.md | 27 ++++--- frontend/dashboard.css | 10 +++ frontend/index.html | 12 +++ frontend/login.js | 10 ++- frontend/service-worker.js | 2 +- frontend/session.js | 79 +++++++++++++++++- src/dashboard_auth.py | 16 +++- src/main.py | 62 +++++++++++++- src/session_store.py | 99 +++++++++++++++++++++-- src/views.py | 2 +- tests/test_dashboard_auth.py | 70 +++++++++++++++- tests/test_dashboard_session_frontend.py | 31 ++++++- tests/test_login_frontend.py | 26 ++++++ tests/test_mobile_composer_integration.py | 2 +- tests/test_service_worker.py | 6 +- tests/test_session_store.py | 44 ++++++++++ 16 files changed, 464 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3d6afd4..90aec5c 100644 --- a/README.md +++ b/README.md @@ -92,17 +92,22 @@ 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. 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 -revokes every existing operator session before clearing the current browser and -returning to sign-in. Deploying this version invalidates older cookies that do not -contain a registered identifier, so operators must sign in once again. Registry -read or write failures return a sanitized HTTP 503 before Gitea is contacted. +storage shared by all dashboard workers. Operators name a device at sign-in and can +open **Active devices** to review creation/expiry times, identify the current device, +and revoke one remote session without interrupting other trusted devices. The API +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 +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 +operator session before clearing the current browser and returning to sign-in. +Registry read or write failures return a sanitized HTTP 503 before Gitea is contacted. Terminate TLS at the trusted reverse proxy: session cookies are deliberately `Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath. diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 315f17a..3991860 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -11,6 +11,16 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex .app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; } #sign-out-all { min-height:44px; } +.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.active-devices-sheet[hidden] { display:none; } +.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } +.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.active-devices-header h2, .active-devices-header p { margin-top:0; } +.active-devices-header button, .active-device button { min-height:44px; } +.active-devices-list { display:grid; gap:10px; margin-top:16px; } +.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; } +.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; } +.active-device-current { color:#55d6be; font-weight:700; } button:hover { filter: brightness(1.15); } .panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); } .panel > summary { cursor: pointer; list-style-position: inside; } diff --git a/frontend/index.html b/frontend/index.html index 8e02503..fc0f420 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,6 +18,7 @@
+ @@ -28,6 +29,17 @@ Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
+ +
diff --git a/frontend/login.js b/frontend/login.js index 2cd600a..b88ced7 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -51,14 +51,14 @@ }, 1000); } - async function submit(accessToken) { + async function submit(accessToken, deviceLabel = 'This device') { status.textContent = 'Signing in…'; let response; try { response = await fetchImpl('api/v1/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ access_token: accessToken }), + body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }), }); } catch (_error) { form.reset(); @@ -96,7 +96,9 @@ if (typeof document !== 'undefined') { controller.showReason(loginParams.get('reason')); form.addEventListener('submit', event => { event.preventDefault(); - const accessToken = new FormData(form).get('access_token'); - controller.submit(accessToken); + const data = new FormData(form); + const accessToken = data.get('access_token'); + const deviceLabel = data.get('device_label'); + controller.submit(accessToken, deviceLabel); }); } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 7b27518..19a50be 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-v36'; +const CACHE = 'stackchain-dashboard-shell-v37'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/frontend/session.js b/frontend/session.js index 318cee2..a9aa4de 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -28,6 +28,56 @@ if (button) button.addEventListener('click', () => boundary.signOut()); const allDevicesButton = root.document.getElementById('sign-out-all'); if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices()); + const devicesButton = root.document.getElementById('active-devices'); + const devicesSheet = root.document.getElementById('active-devices-sheet'); + const devicesList = root.document.getElementById('active-devices-list'); + const devicesStatus = root.document.getElementById('active-devices-status'); + const closeDevices = root.document.getElementById('close-active-devices'); + const renderDevices = async () => { + devicesStatus.textContent = 'Loading active devices…'; + devicesList.replaceChildren(); + try { + const devices = await boundary.listActiveDevices(); + devices.forEach(device => { + const row = root.document.createElement('article'); + row.className = 'active-device'; + const details = root.document.createElement('div'); + const label = root.document.createElement('strong'); + label.textContent = device.device_label; + const timing = root.document.createElement('span'); + timing.className = 'small muted'; + timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`; + details.append(label, timing); + if (device.current) { + const current = root.document.createElement('span'); + current.className = 'active-device-current'; + current.textContent = 'This device'; + details.append(current); + } + const revoke = root.document.createElement('button'); + revoke.type = 'button'; + revoke.textContent = device.current ? 'Sign out' : 'Revoke'; + revoke.addEventListener('click', async () => { + if (device.current) await boundary.signOut(); + else if (await boundary.revokeActiveDevice(device)) await renderDevices(); + }); + row.append(details, revoke); + devicesList.append(row); + }); + devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.'; + } catch (_error) { + devicesStatus.textContent = 'Active devices could not be loaded. Try again.'; + } + }; + if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => { + devicesSheet.hidden = false; + closeDevices?.focus(); + renderDevices(); + }); + if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { + devicesSheet.hidden = true; + devicesButton?.focus(); + }); boundary.resumeQueuedWork(); }; if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); @@ -128,6 +178,25 @@ } catch (_error) { /* A later service-worker activation can clear stale caches. */ } } + async function listActiveDevices() { + const response = await sessionFetch(base + 'api/v1/sessions'); + if (!response.ok) throw new Error('Could not load active devices'); + const payload = await response.json(); + return Array.isArray(payload.devices) ? payload.devices : []; + } + + async function revokeActiveDevice(device) { + if (!device?.management_id || device.current) return false; + const confirmed = confirmAction?.(`Sign out ${device.device_label}?`); + if (!confirmed) return false; + const response = await sessionFetch( + base + 'api/v1/sessions/' + encodeURIComponent(device.management_id), + { method: 'DELETE' }, + ); + if (!response.ok) throw new Error('Could not revoke active device'); + return true; + } + async function signOut() { try { await sessionFetch(base + 'api/v1/session', { method: 'DELETE' }); @@ -154,5 +223,13 @@ } catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ } } - return { fetch: sessionFetch, signOut, signOutAllDevices, clearPrivateDeviceData, resumeQueuedWork }; + return { + fetch: sessionFetch, + signOut, + signOutAllDevices, + listActiveDevices, + revokeActiveDevice, + clearPrivateDeviceData, + resumeQueuedWork, + }; }); diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index 48aa698..429f173 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -89,7 +89,9 @@ def _session_store(now: int | None = None) -> SessionStore: return SessionStore(database, clock=current) -def issue_session(now: int | None = None) -> tuple[str, Session]: +def issue_session( + now: int | None = None, *, device_label: str = "This device" +) -> tuple[str, Session]: issued_at = int(time.time() if now is None else now) ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS))) session = Session( @@ -104,7 +106,9 @@ def issue_session(now: int | None = None) -> tuple[str, Session]: ).encode() encoded = _encode(payload) signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) - _session_store(now).activate(session.session_id, session.expires_at) + _session_store(now).activate( + session.session_id, session.expires_at, device_label=device_label + ) return f"{encoded}.{signature}", session @@ -144,6 +148,14 @@ async def revoke_all_sessions() -> None: await asyncio.to_thread(_session_store().revoke_all) +async def active_devices(session: Session): + return await asyncio.to_thread(_session_store().list_active, session.session_id) + + +async def revoke_managed_session(management_id: str) -> bool: + return await asyncio.to_thread(_session_store().revoke_managed, management_id) + + async def request_session(request: Request) -> Session | None: return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE)) diff --git a/src/main.py b/src/main.py index 0cd2a0f..1fca8af 100644 --- a/src/main.py +++ b/src/main.py @@ -132,6 +132,15 @@ class ReadinessPayloadError(ValueError): class DashboardSignIn(BaseModel): access_token: str = Field(min_length=1, max_length=1_024) + device_label: str = Field(default="This device", min_length=1, max_length=64) + + @field_validator("device_label") + @classmethod + def normalize_device_label(cls, value: str) -> str: + normalized = " ".join(value.split()) + if not normalized: + raise ValueError("device label cannot be blank") + return normalized def _login_attempt_store() -> LoginAttemptStore: @@ -660,7 +669,9 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response headers={"Cache-Control": "no-store"}, ) try: - signed, session = await asyncio.to_thread(dashboard_auth.issue_session) + signed, session = await asyncio.to_thread( + dashboard_auth.issue_session, device_label=payload.device_label + ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, @@ -730,6 +741,55 @@ async def sign_out(request: Request, response: Response): return {"authenticated": False, "clear_private_device_data": True} +@app.get("/api/v1/sessions") +async def list_active_devices(request: Request, response: Response): + try: + devices = await dashboard_auth.active_devices(request.state.dashboard_session) + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + response.headers["Cache-Control"] = "no-store" + return { + "devices": [ + { + "management_id": device.management_id, + "device_label": device.device_label, + "created_at": device.created_at, + "expires_at": device.expires_at, + "current": device.current, + } + for device in devices + ] + } + + +@app.delete("/api/v1/sessions/{management_id}") +async def revoke_active_device( + request: Request, + management_id: str = PathParam( + min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$" + ), +): + try: + devices = await dashboard_auth.active_devices(request.state.dashboard_session) + target = next( + (device for device in devices if device.management_id == management_id), None + ) + revoked = await dashboard_auth.revoke_managed_session(management_id) + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if target is None or not revoked: + raise HTTPException(status_code=404, detail="Active device not found") + return {"revoked": True, "current_session": target.current} + + @app.delete("/api/v1/sessions") async def sign_out_all_devices(request: Request, response: Response): try: diff --git a/src/session_store.py b/src/session_store.py index d5e1a50..6a501f3 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -1,7 +1,9 @@ """Durable active-session registry used to revoke signed operator sessions.""" import hashlib +import secrets import sqlite3 +from dataclasses import dataclass from pathlib import Path from typing import Callable @@ -10,6 +12,15 @@ class SessionStoreError(RuntimeError): """Raised when session state cannot be read or changed safely.""" +@dataclass(frozen=True) +class ActiveDevice: + management_id: str + device_label: str + created_at: int + expires_at: int + current: bool + + class SessionStore: def __init__( self, @@ -44,23 +55,67 @@ class SessionStore: """ CREATE TABLE IF NOT EXISTS active_sessions ( session_hash TEXT PRIMARY KEY, - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + management_id TEXT, + device_label TEXT, + created_at INTEGER ) """ ) + columns = { + row[1] for row in connection.execute("PRAGMA table_info(active_sessions)") + } + additions = { + "management_id": "TEXT", + "device_label": "TEXT", + "created_at": "INTEGER", + } + for name, column_type in additions.items(): + if name not in columns: + connection.execute( + f"ALTER TABLE active_sessions ADD COLUMN {name} {column_type}" + ) + connection.execute( + "UPDATE active_sessions SET management_id = lower(hex(randomblob(16))) " + "WHERE management_id IS NULL" + ) + connection.execute( + "UPDATE active_sessions SET device_label = 'Existing device' " + "WHERE device_label IS NULL" + ) + connection.execute( + "UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL", + (int(self.clock()),), + ) + connection.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id " + "ON active_sessions(management_id)" + ) return connection except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Session registry is temporarily unavailable") from exc - def activate(self, session_id: str, expires_at: int) -> None: + def activate( + self, session_id: str, expires_at: int, *, device_label: str = "This device" + ) -> None: + label = " ".join(str(device_label).split())[:64] or "This device" + now = int(self.clock()) try: with self._connect(initialize=True) as connection: connection.execute( - "DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),) + "DELETE FROM active_sessions WHERE expires_at <= ?", (now,) ) connection.execute( - "INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)", - (self._digest(session_id), expires_at), + "INSERT INTO active_sessions(" + "session_hash, expires_at, management_id, device_label, created_at" + ") VALUES (?, ?, ?, ?, ?)", + ( + self._digest(session_id), + expires_at, + secrets.token_urlsafe(18), + label, + now, + ), ) except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Session registry is temporarily unavailable") from exc @@ -87,6 +142,40 @@ class SessionStore: except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Session registry is temporarily unavailable") from exc + def list_active(self, current_session_id: str) -> list[ActiveDevice]: + now = int(self.clock()) + current_hash = self._digest(current_session_id) + try: + with self._connect() as connection: + rows = connection.execute( + "SELECT management_id, device_label, created_at, expires_at, session_hash " + "FROM active_sessions WHERE expires_at > ? " + "ORDER BY expires_at DESC, created_at DESC", + (now,), + ).fetchall() + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + return [ + ActiveDevice( + management_id=row[0], + device_label=row[1], + created_at=row[2], + expires_at=row[3], + current=secrets.compare_digest(row[4], current_hash), + ) + for row in rows + ] + + def revoke_managed(self, management_id: str) -> bool: + try: + with self._connect() as connection: + cursor = connection.execute( + "DELETE FROM active_sessions WHERE management_id = ?", (management_id,) + ) + return cursor.rowcount == 1 + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + def revoke_all(self) -> None: try: with self._connect() as connection: diff --git a/src/views.py b/src/views.py index 3b55077..a5fe5b6 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_auth.py b/tests/test_dashboard_auth.py index aca4497..15f01ab 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -257,6 +257,72 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr assert "correct horse battery staple" not in response.text +@pytest.mark.anyio +async def test_operator_can_review_and_revoke_one_remote_device(access_control): + transport = httpx.ASGITransport(app=main.app) + async with ( + httpx.AsyncClient(transport=transport, base_url="https://test") as phone, + httpx.AsyncClient(transport=transport, base_url="https://test") as laptop, + ): + await phone.post( + "/api/v1/session", + json={ + "access_token": "correct horse battery staple", + "device_label": "Pixel ' in html assert "main{box-sizing:border-box" in html assert '

' in html + assert 'name="device_label"' in html + assert 'maxlength="64"' in html diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 7d0c365..b83243c 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v36" in worker + assert "stackchain-dashboard-shell-v37" in worker diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2c3b5d1..5fa47e6 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v36" in source + assert "stackchain-dashboard-shell-v37" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v36" in source + assert "stackchain-dashboard-shell-v37" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v36" in source + assert "stackchain-dashboard-shell-v37" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_session_store.py b/tests/test_session_store.py index e3c29ae..b4240d3 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -75,3 +75,47 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat assert store.is_active("expiring-session", 1_001) is False with sqlite3.connect(store.path) as connection: assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,) + + +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") + store.activate("laptop-session-secret", 3_000, device_label="Work laptop") + + devices = store.list_active("phone-session-secret") + + assert [device.device_label for device in devices] == ["Work laptop", "Pixel 9"] + assert [device.current for device in devices] == [False, True] + assert all(device.management_id for device in devices) + assert all(device.created_at == 1_000 for device in devices) + assert "phone-session-secret" not in repr(devices) + + +def test_revoke_managed_device_removes_only_the_selected_session(tmp_path): + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0) + store.activate("phone", 2_000, device_label="Phone") + store.activate("laptop", 2_000, device_label="Laptop") + phone = next(device for device in store.list_active("laptop") if device.device_label == "Phone") + + assert store.revoke_managed(phone.management_id) is True + assert store.is_active("phone", 2_000) is False + assert store.is_active("laptop", 2_000) is True + assert store.revoke_managed(phone.management_id) is False + + +def test_existing_session_registry_migrates_without_invalidating_sessions(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) + store.activate("new-session", 3_000, device_label="New phone") + + assert store.is_active("existing-session", 2_000) is True + devices = store.list_active("existing-session") + assert len(devices) == 2 + assert next(device for device in devices if device.current).device_label == "Existing device"