Merge pull request 'Sign out all operator sessions from a lost-device safety action' (#288) from timmy/287-sign-out-all-devices into main
All checks were successful
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s

This commit is contained in:
timmy 2026-08-08 09:56:45 +00:00
commit 80417abd5b
9 changed files with 195 additions and 9 deletions

View File

@ -84,12 +84,14 @@ 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. Sign-out revokes only the current session
before clearing browser state, so a copied cookie cannot be replayed afterward;
other signed-in devices remain active. 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. **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.
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.

View File

@ -14,6 +14,7 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
.toolbar { 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; }
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; }
@ -298,6 +299,7 @@ textarea { resize: vertical; min-height: 120px; }
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button id="refresh">Refresh</button>
<button id="sign-out" type="button">Sign out &amp; clear this device</button>
<button id="sign-out-all" type="button">Sign out all devices</button>
<button id="open-palette">Command</button>
<span class="small muted" id="clock"></span>
</div>

View File

@ -14,12 +14,15 @@
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
location: root.location,
confirmAction: message => root.confirm(message),
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
});
root.fetch = boundary.fetch;
const attach = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
boundary.resumeQueuedWork();
};
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
@ -27,7 +30,7 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, confirmAction,
onExpired = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@ -90,6 +93,16 @@
}
}
async function signOutAllDevices() {
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
if (!confirmed) return false;
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
if (!response.ok) throw new Error('Could not sign out all devices');
await clearPrivateDeviceData();
location.assign(base + 'login');
return true;
}
async function resumeQueuedWork() {
try {
const registration = await serviceWorker?.ready;
@ -97,5 +110,5 @@
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
return { fetch: sessionFetch, signOut, signOutAllDevices, clearPrivateDeviceData, resumeQueuedWork };
});

View File

@ -140,6 +140,10 @@ def revoke_session(session: Session) -> None:
_session_store().revoke(session.session_id)
async def revoke_all_sessions() -> None:
await asyncio.to_thread(_session_store().revoke_all)
async def request_session(request: Request) -> Session | None:
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))

View File

@ -663,6 +663,39 @@ async def sign_out(request: Request, response: Response):
return {"authenticated": False, "clear_private_device_data": True}
@app.delete("/api/v1/sessions")
async def sign_out_all_devices(request: Request, response: Response):
try:
await dashboard_auth.revoke_all_sessions()
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
path = dashboard_auth.cookie_path(request)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.delete_cookie(
dashboard_auth.CSRF_COOKIE,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {
"authenticated": False,
"all_sessions_revoked": True,
"clear_private_device_data": True,
}
@app.get("/readyz")
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""

View File

@ -86,3 +86,10 @@ class SessionStore:
)
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:
connection.execute("DELETE FROM active_sessions")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc

View File

@ -276,6 +276,92 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_sign_out_all_devices_revokes_every_existing_session(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"}
)
await laptop.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
response = await phone.delete(
"/api/v1/sessions",
headers={
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
},
)
phone_private = await phone.get("/api/v1/background-identity")
laptop_private = await laptop.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {
"authenticated": False,
"all_sessions_revoked": True,
"clear_private_device_data": True,
}
assert phone_private.status_code == 401
assert laptop_private.status_code == 401
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_sign_out_all_devices_rejects_cross_site_requests_without_revoking(access_control):
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"}
)
response = await client.delete(
"/api/v1/sessions",
headers={
"Origin": "https://evil.example",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
session = await client.get("/api/v1/session")
assert response.status_code == 403
assert response.headers.get_list("set-cookie") == []
assert session.status_code == 200
assert session.json()["authenticated"] is True
@pytest.mark.anyio
async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_control, monkeypatch):
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"}
)
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def revoke_all(self):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.delete(
"/api/v1/sessions",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
calls = 0

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: [], deletedCaches: [], assigned: '', workerMessages: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [], confirmations: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@ -35,6 +35,7 @@ const boundary = createSessionBoundary({{
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
location: {{ assign: value => {{ state.assigned = value; }} }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
@ -73,6 +74,26 @@ process.stdout.write(JSON.stringify(state));
assert result["assigned"] == "/dashboard/login"
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
result = run_session_scenario(
"""
await boundary.signOutAllDevices();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["confirmations"] == [
"Sign out every device? You will need to sign in again everywhere."
]
request = result["requests"][0]
assert request["url"] == "/dashboard/api/v1/sessions"
assert request["method"] == "DELETE"
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert result["remaining"] == ["gitea.preference"]
assert result["assigned"] == "/dashboard/login"
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
result = run_session_scenario(
"""
@ -91,3 +112,5 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert '<script src="static/session.js"></script>' in html
assert html.index('static/session.js') < html.index('static/markdown.js')
assert '<button id="sign-out" type="button">Sign out &amp; clear this device</button>' in html
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
assert '#sign-out-all { min-height:44px; }' in html

View File

@ -22,6 +22,22 @@ def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
assert b"second-session-secret" not in database.read_bytes()
def test_revoke_all_is_durable_and_does_not_block_future_sessions(tmp_path):
now = [1_000.0]
database = tmp_path / "sessions.sqlite3"
first = SessionStore(database, clock=lambda: now[0])
first.activate("phone-session", 2_000)
first.activate("laptop-session", 2_000)
first.revoke_all()
reconstructed = SessionStore(database, clock=lambda: now[0])
assert reconstructed.is_active("phone-session", 2_000) is False
assert reconstructed.is_active("laptop-session", 2_000) is False
reconstructed.activate("new-session", 2_000)
assert reconstructed.is_active("new-session", 2_000) is True
def test_validation_does_not_create_a_missing_registry(tmp_path):
database = tmp_path / "sessions.sqlite3"
store = SessionStore(database, clock=lambda: 1_000.0)