Recover safely when an active dashboard session expires #310

Merged
timmy merged 1 commits from timmy/309-session-expiry-recovery into main 2026-08-08 14:33:47 +00:00
8 changed files with 87 additions and 10 deletions

View File

@ -92,7 +92,10 @@ 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 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 kept in the SQLite session registry. Keep that registry on persistent, writable
storage shared by all dashboard workers. **Sign out & clear this device** revokes 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 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 be replayed afterward; other signed-in devices remain active. **Sign out all
devices** is a separately confirmed lost-device safety action that atomically devices** is a separately confirmed lost-device safety action that atomically

View File

@ -11,6 +11,11 @@
const clearIntervalImpl = options.clearIntervalImpl || clearInterval; const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
let timer = null; let timer = null;
function showReason(reason) {
if (reason !== 'session-expired') return;
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
}
function showRetryCountdown(seconds) { function showRetryCountdown(seconds) {
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1); let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
button.disabled = true; button.disabled = true;
@ -54,7 +59,7 @@
status.textContent = 'Sign-in failed. Check the token and try again.'; status.textContent = 'Sign-in failed. Check the token and try again.';
} }
return { submit }; return { submit, showReason };
})); }));
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@ -68,6 +73,7 @@ if (typeof document !== 'undefined') {
fetchImpl: fetch.bind(window), fetchImpl: fetch.bind(window),
location: window.location, location: window.location,
}); });
controller.showReason(new URLSearchParams(window.location.search).get('reason'));
form.addEventListener('submit', event => { form.addEventListener('submit', event => {
event.preventDefault(); event.preventDefault();
const accessToken = new FormData(form).get('access_token'); const accessToken = new FormData(form).get('access_token');

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v30'; const CACHE = 'stackchain-dashboard-shell-v31';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,

View File

@ -40,6 +40,7 @@
onClearError = () => {}, onClearError = () => {},
}) { }) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
function csrfToken() { function csrfToken() {
const entry = String(cookie?.() || '').split(';') const entry = String(cookie?.() || '').split(';')
@ -63,7 +64,11 @@
requestOptions.headers = headers; requestOptions.headers = headers;
} }
const response = await fetchImpl(input, requestOptions); const response = await fetchImpl(input, requestOptions);
if (response.status === 401) onExpired(); if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
expirationStarted = true;
onExpired();
location.replace(base + 'login?reason=session-expired');
}
return response; return response;
} }

View File

@ -13,7 +13,7 @@ LOGIN_HTML = """<!doctype html>
<title>Sign in · Stackchain Dashboard</title> <title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head> <style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p> <body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main> <form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/login.js"></script></body></html>""" <script src="static/login.js"></script></body></html>"""

View File

@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict: def run_session_scenario(scenario: str) -> dict:
harness = f""" harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [] }}; const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [], responseStatus: 200 }};
const storage = {{ const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }}, get length() {{ return this.values.size; }},
@ -27,7 +27,7 @@ const boundary = createSessionBoundary({{
base: '/dashboard/', base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{ fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }}); state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }}); return new Response('{{}}', {{ status: state.responseStatus, headers: {{ 'Content-Type': 'application/json' }} }});
}}, }},
localStorage: storage, localStorage: storage,
sessionStorage: storage, sessionStorage: storage,
@ -47,7 +47,10 @@ const boundary = createSessionBoundary({{
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }}; const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
this.port1 = first; this.port2 = second; this.port1 = first; this.port2 = second;
}} }}, }} }},
location: {{ assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }} }}, location: {{
assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }},
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message), onClearError: error => state.clearErrors.push(error.message),
confirmAction: message => {{ state.confirmations.push(message); return true; }}, confirmAction: message => {{ state.confirmations.push(message); return true; }},
}}); }});
@ -68,6 +71,39 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof" assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
result = run_session_scenario(
"""
state.responseStatus = 401;
await Promise.all([
boundary.fetch('/dashboard/api/v1/live'),
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
state.responseStatus = 401;
await boundary.fetch('https://untrusted.example/api/private');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == []
def test_sign_out_clears_only_dashboard_private_device_state(): def test_sign_out_clears_only_dashboard_private_device_state():
result = run_session_scenario( result = run_session_scenario(
""" """

View File

@ -11,6 +11,32 @@ ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = ROOT / "frontend" / "login.js" LOGIN_JS = ROOT / "frontend" / "login.js"
def test_expired_session_reason_explains_preserved_private_work():
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-expired');
const expired = status.textContent;
controller.showReason('https://evil.example/redirect');
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["expired"] == (
"Your session expired. Private drafts remain on this device. "
"Sign in to continue."
)
assert state["ignored"] == state["expired"]
def test_rate_limited_login_disables_submit_and_counts_down(): def test_rate_limited_login_disables_submit_and_counts_down():
harness = f""" harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))}); const createLoginController = require({json.dumps(str(LOGIN_JS))});
@ -58,3 +84,4 @@ async def test_login_page_loads_rate_limit_controller():
assert '<script src="static/login.js"></script>' in html assert '<script src="static/login.js"></script>' in html
assert "main{box-sizing:border-box" in html assert "main{box-sizing:border-box" in html
assert '<p id="status" role="status" aria-live="polite"></p>' in html

View File

@ -92,10 +92,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout) return json.loads(completed.stdout)
def test_strict_browser_assets_ship_in_a_new_shell_cache(): def test_session_expiry_recovery_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v30" in source assert "stackchain-dashboard-shell-v31" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source