Recover safely when an active dashboard session expires #310
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user