Lock inactive dashboard sessions without losing drafts #464
15
README.md
15
README.md
|
|
@ -133,6 +133,9 @@ export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||||
# Optional; defaults to eight hours.
|
# Optional; defaults to eight hours.
|
||||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||||
|
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
|
||||||
|
# Background polling and queued delivery do not. Defaults to 15 minutes.
|
||||||
|
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
||||||
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
||||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||||
|
|
@ -166,8 +169,9 @@ storage shared by all dashboard workers. Operators name a device at sign-in and
|
||||||
open **Active devices** to review creation/expiry times, identify the current device,
|
open **Active devices** to review creation/expiry times, identify the current device,
|
||||||
and revoke one remote session without interrupting other trusted devices. The API
|
and revoke one remote session without interrupting other trusted devices. The API
|
||||||
exposes only independent management IDs and bounded labels—never cookie values,
|
exposes only independent management IDs and bounded labels—never cookie values,
|
||||||
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
|
session hashes, CSRF proofs, or source addresses. The registry also stores each
|
||||||
migrated in place and their live sessions remain valid.
|
session's last explicit activity. Existing two-column registries are migrated in
|
||||||
|
place, their live sessions remain valid, and their idle clock starts at migration.
|
||||||
|
|
||||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||||
remote device, or signing out every device—require the operator access token again.
|
remote device, or signing out every device—require the operator access token again.
|
||||||
|
|
@ -177,7 +181,12 @@ first use. Expired, replayed, cross-session, and target-substituted grants fail
|
||||||
Gitea or session state is changed. The browser preserves the pending request and
|
Gitea or session state is changed. The browser preserves the pending request and
|
||||||
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
||||||
|
|
||||||
If an active session expires, the first authenticated API rejection replaces the
|
After 15 minutes without pointer, keyboard, or touch activity (configurable through
|
||||||
|
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
|
||||||
|
polling or background delivery continued. The dashboard locks and background outbox
|
||||||
|
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
|
||||||
|
the device; signing in resumes the existing account-bound work. 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
|
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
|
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
|
state. A selectively or globally revoked device instead receives a bounded revocation
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@
|
||||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (reason === 'session-idle') {
|
||||||
|
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (reason !== 'session-revoked') return;
|
if (reason !== 'session-revoked') return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,11 @@ async function purgeRevokedSessionData() {
|
||||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyIdleSession() {
|
||||||
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
|
||||||
|
}
|
||||||
|
|
||||||
async function storeOfflineLease(expiresAt) {
|
async function storeOfflineLease(expiresAt) {
|
||||||
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
|
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
|
||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
|
|
@ -177,6 +182,9 @@ async function fetchJson(url, options = {}) {
|
||||||
if (response.status === 401 && code === 'session_revoked') {
|
if (response.status === 401 && code === 'session_revoked') {
|
||||||
await purgeRevokedSessionData();
|
await purgeRevokedSessionData();
|
||||||
}
|
}
|
||||||
|
if (response.status === 401 && code === 'session_idle') {
|
||||||
|
await notifyIdleSession();
|
||||||
|
}
|
||||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
error.code = code;
|
error.code = code;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
serviceWorker: root.navigator?.serviceWorker,
|
serviceWorker: root.navigator?.serviceWorker,
|
||||||
MessageChannel: root.MessageChannel,
|
MessageChannel: root.MessageChannel,
|
||||||
location: root.location,
|
location: root.location,
|
||||||
|
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||||
confirmAction: message => root.confirm(message),
|
confirmAction: message => root.confirm(message),
|
||||||
promptAuthorization: details => root.prompt(
|
promptAuthorization: details => root.prompt(
|
||||||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
});
|
});
|
||||||
root.fetch = boundary.fetch;
|
root.fetch = boundary.fetch;
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
|
boundary.startActivityHeartbeat();
|
||||||
const button = root.document.getElementById('sign-out');
|
const button = root.document.getElementById('sign-out');
|
||||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||||
|
|
@ -91,16 +93,19 @@
|
||||||
root.stackchainSession = boundary;
|
root.stackchainSession = boundary;
|
||||||
}
|
}
|
||||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, addActivityListener,
|
||||||
promptAuthorization = () => null,
|
promptAuthorization = () => null,
|
||||||
onExpired = () => {},
|
onExpired = () => {},
|
||||||
onClearError = () => {},
|
onClearError = () => {},
|
||||||
requestTimeoutMs = 15000,
|
requestTimeoutMs = 15000,
|
||||||
|
activityHeartbeatIntervalMs = 60000,
|
||||||
now = () => Date.now(),
|
now = () => Date.now(),
|
||||||
setTimer = (callback, delay) => setTimeout(callback, delay),
|
setTimer = (callback, delay) => setTimeout(callback, delay),
|
||||||
}) {
|
}) {
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
let expirationStarted = false;
|
let expirationStarted = false;
|
||||||
|
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
|
||||||
|
let activityHeartbeat = null;
|
||||||
|
|
||||||
async function handleRemoteRevocation() {
|
async function handleRemoteRevocation() {
|
||||||
if (expirationStarted) return;
|
if (expirationStarted) return;
|
||||||
|
|
@ -117,10 +122,19 @@
|
||||||
location.replace(base + 'login?reason=session-expired');
|
location.replace(base + 'login?reason=session-expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSessionIdle() {
|
||||||
|
if (expirationStarted) return;
|
||||||
|
expirationStarted = true;
|
||||||
|
location.replace(base + 'login?reason=session-idle');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleServiceWorkerMessage(event) {
|
async function handleServiceWorkerMessage(event) {
|
||||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||||
await handleRemoteRevocation();
|
await handleRemoteRevocation();
|
||||||
}
|
}
|
||||||
|
if (event?.data?.type === 'stackchain-session-idle') {
|
||||||
|
handleSessionIdle();
|
||||||
|
}
|
||||||
if (event?.data?.type === 'stackchain-session-expired') {
|
if (event?.data?.type === 'stackchain-session-expired') {
|
||||||
await handleSessionExpiry();
|
await handleSessionExpiry();
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +267,9 @@
|
||||||
if (payload.code === 'session_revoked') {
|
if (payload.code === 'session_revoked') {
|
||||||
expirationStarted = false;
|
expirationStarted = false;
|
||||||
await handleRemoteRevocation();
|
await handleRemoteRevocation();
|
||||||
|
} else if (payload.code === 'session_idle') {
|
||||||
|
expirationStarted = false;
|
||||||
|
handleSessionIdle();
|
||||||
} else {
|
} else {
|
||||||
expirationStarted = false;
|
expirationStarted = false;
|
||||||
await handleSessionExpiry();
|
await handleSessionExpiry();
|
||||||
|
|
@ -261,6 +278,27 @@
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordActivity() {
|
||||||
|
const current = now();
|
||||||
|
if (
|
||||||
|
expirationStarted
|
||||||
|
|| activityHeartbeat
|
||||||
|
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
|
||||||
|
) return Promise.resolve(false);
|
||||||
|
lastActivityHeartbeatAt = current;
|
||||||
|
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
|
||||||
|
.then(response => response.ok)
|
||||||
|
.catch(() => false)
|
||||||
|
.finally(() => { activityHeartbeat = null; });
|
||||||
|
return activityHeartbeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startActivityHeartbeat() {
|
||||||
|
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
|
||||||
|
addActivityListener?.(type, recordActivity, { passive: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshOfflineLease() {
|
async function refreshOfflineLease() {
|
||||||
try {
|
try {
|
||||||
const response = await sessionFetch(base + 'api/v1/session');
|
const response = await sessionFetch(base + 'api/v1/session');
|
||||||
|
|
@ -391,5 +429,7 @@
|
||||||
handleServiceWorkerMessage,
|
handleServiceWorkerMessage,
|
||||||
refreshOfflineLease,
|
refreshOfflineLease,
|
||||||
resumeQueuedWork,
|
resumeQueuedWork,
|
||||||
|
recordActivity,
|
||||||
|
startActivityHeartbeat,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from src.session_store import SessionStore, SessionStoreError
|
||||||
SESSION_COOKIE = "stackchain_session"
|
SESSION_COOKIE = "stackchain_session"
|
||||||
CSRF_COOKIE = "stackchain_csrf"
|
CSRF_COOKIE = "stackchain_csrf"
|
||||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||||
|
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
|
||||||
STEP_UP_TTL_SECONDS = 90
|
STEP_UP_TTL_SECONDS = 90
|
||||||
MIN_SECRET_LENGTH = 24
|
MIN_SECRET_LENGTH = 24
|
||||||
OPERATOR_MODE = "operator"
|
OPERATOR_MODE = "operator"
|
||||||
|
|
@ -96,6 +97,18 @@ def _session_store(now: int | None = None) -> SessionStore:
|
||||||
return SessionStore(database, clock=current)
|
return SessionStore(database, clock=current)
|
||||||
|
|
||||||
|
|
||||||
|
def idle_timeout_seconds() -> int:
|
||||||
|
return max(
|
||||||
|
1,
|
||||||
|
int(
|
||||||
|
os.getenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
|
||||||
|
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def issue_session(
|
def issue_session(
|
||||||
now: int | None = None, *, device_label: str = "This device"
|
now: int | None = None, *, device_label: str = "This device"
|
||||||
) -> tuple[str, Session]:
|
) -> tuple[str, Session]:
|
||||||
|
|
@ -146,7 +159,14 @@ def verify_session_with_reason(
|
||||||
or not session.csrf
|
or not session.csrf
|
||||||
):
|
):
|
||||||
return SessionVerification(None)
|
return SessionVerification(None)
|
||||||
if not _session_store(now).is_active(session.session_id, session.expires_at):
|
status = _session_store(now).status(
|
||||||
|
session.session_id,
|
||||||
|
session.expires_at,
|
||||||
|
idle_timeout_seconds=idle_timeout_seconds(),
|
||||||
|
)
|
||||||
|
if status == "idle":
|
||||||
|
return SessionVerification(None, "session_idle")
|
||||||
|
if status != "active":
|
||||||
return SessionVerification(None, "session_revoked")
|
return SessionVerification(None, "session_revoked")
|
||||||
return SessionVerification(session)
|
return SessionVerification(session)
|
||||||
|
|
||||||
|
|
@ -167,6 +187,15 @@ async def active_devices(session: Session):
|
||||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def touch_session(session: Session) -> bool:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_session_store().touch,
|
||||||
|
session.session_id,
|
||||||
|
session.expires_at,
|
||||||
|
idle_timeout_seconds=idle_timeout_seconds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def revoke_managed_session(management_id: str) -> bool:
|
async def revoke_managed_session(management_id: str) -> bool:
|
||||||
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
||||||
|
|
||||||
|
|
|
||||||
36
src/main.py
36
src/main.py
|
|
@ -670,18 +670,19 @@ async def require_operator_session(request: Request, call_next):
|
||||||
if not public and session is None:
|
if not public and session is None:
|
||||||
if path.startswith("/api/"):
|
if path.startswith("/api/"):
|
||||||
payload = {"detail": "Authentication required"}
|
payload = {"detail": "Authentication required"}
|
||||||
if session_reason == "session_revoked":
|
if session_reason in {"session_revoked", "session_idle"}:
|
||||||
payload["code"] = session_reason
|
payload["code"] = session_reason
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
payload,
|
payload,
|
||||||
status_code=401,
|
status_code=401,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
login_redirect = (
|
if session_reason == "session_revoked":
|
||||||
"login?reason=session-revoked"
|
login_redirect = "login?reason=session-revoked"
|
||||||
if session_reason == "session_revoked"
|
elif session_reason == "session_idle":
|
||||||
else _share_target_login_redirect(request)
|
login_redirect = "login?reason=session-idle"
|
||||||
)
|
else:
|
||||||
|
login_redirect = _share_target_login_redirect(request)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
login_redirect,
|
login_redirect,
|
||||||
status_code=303,
|
status_code=303,
|
||||||
|
|
@ -937,6 +938,29 @@ async def session_status(request: Request):
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/session/activity")
|
||||||
|
async def record_session_activity(request: Request):
|
||||||
|
session = request.state.dashboard_session
|
||||||
|
try:
|
||||||
|
active = await dashboard_auth.touch_session(session)
|
||||||
|
except dashboard_auth.SessionStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Session registry is temporarily unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
if not active:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Authentication required"},
|
||||||
|
status_code=401,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _today_store() -> TodayStore:
|
def _today_store() -> TodayStore:
|
||||||
return TodayStore(
|
return TodayStore(
|
||||||
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
|
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ class SessionStore:
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
management_id TEXT,
|
management_id TEXT,
|
||||||
device_label TEXT,
|
device_label TEXT,
|
||||||
created_at INTEGER
|
created_at INTEGER,
|
||||||
|
last_active_at INTEGER
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -84,6 +85,7 @@ class SessionStore:
|
||||||
"management_id": "TEXT",
|
"management_id": "TEXT",
|
||||||
"device_label": "TEXT",
|
"device_label": "TEXT",
|
||||||
"created_at": "INTEGER",
|
"created_at": "INTEGER",
|
||||||
|
"last_active_at": "INTEGER",
|
||||||
}
|
}
|
||||||
for name, column_type in additions.items():
|
for name, column_type in additions.items():
|
||||||
if name not in columns:
|
if name not in columns:
|
||||||
|
|
@ -102,6 +104,10 @@ class SessionStore:
|
||||||
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
||||||
(int(self.clock()),),
|
(int(self.clock()),),
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
|
||||||
|
(int(self.clock()),),
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
||||||
"ON active_sessions(management_id)"
|
"ON active_sessions(management_id)"
|
||||||
|
|
@ -122,14 +128,15 @@ class SessionStore:
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO active_sessions("
|
"INSERT INTO active_sessions("
|
||||||
"session_hash, expires_at, management_id, device_label, created_at"
|
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
|
||||||
") VALUES (?, ?, ?, ?, ?)",
|
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
self._digest(session_id),
|
self._digest(session_id),
|
||||||
expires_at,
|
expires_at,
|
||||||
secrets.token_urlsafe(18),
|
secrets.token_urlsafe(18),
|
||||||
label,
|
label,
|
||||||
now,
|
now,
|
||||||
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
|
@ -147,6 +154,55 @@ class SessionStore:
|
||||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
return row is not None and row[0] == expires_at and expires_at > now
|
return row is not None and row[0] == expires_at and expires_at > now
|
||||||
|
|
||||||
|
def status(
|
||||||
|
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||||
|
) -> str:
|
||||||
|
now = int(self.clock())
|
||||||
|
query = (
|
||||||
|
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||||
|
"WHERE session_hash = ?"
|
||||||
|
)
|
||||||
|
parameters = (self._digest(session_id),)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(query, parameters).fetchone()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
if "no such column: last_active_at" not in str(exc):
|
||||||
|
raise
|
||||||
|
with self._connect(initialize=True) as connection:
|
||||||
|
row = connection.execute(query, parameters).fetchone()
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
|
if row is None or row[0] != expires_at or expires_at <= now:
|
||||||
|
return "revoked"
|
||||||
|
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||||
|
return "idle"
|
||||||
|
return "active"
|
||||||
|
|
||||||
|
def touch(
|
||||||
|
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||||
|
) -> bool:
|
||||||
|
now = int(self.clock())
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE active_sessions SET last_active_at = ? "
|
||||||
|
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
|
||||||
|
"AND last_active_at + ? > ?",
|
||||||
|
(
|
||||||
|
now,
|
||||||
|
self._digest(session_id),
|
||||||
|
expires_at,
|
||||||
|
now,
|
||||||
|
max(1, idle_timeout_seconds),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cursor.rowcount == 1
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
|
|
||||||
def revoke(self, session_id: str) -> None:
|
def revoke(self, session_id: str) -> None:
|
||||||
try:
|
try:
|
||||||
with self._connect(initialize=True) as connection:
|
with self._connect(initialize=True) as connection:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
@ -245,10 +246,14 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
|
||||||
lookups = 0
|
lookups = 0
|
||||||
|
|
||||||
class CountingStore:
|
class CountingStore:
|
||||||
def is_active(self, session_id, expires_at):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
nonlocal lookups
|
nonlocal lookups
|
||||||
lookups += 1
|
lookups += 1
|
||||||
return original_store.is_active(session_id, expires_at)
|
return original_store.status(
|
||||||
|
session_id,
|
||||||
|
expires_at,
|
||||||
|
idle_timeout_seconds=idle_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
|
||||||
response = await client.get("/api/v1/session")
|
response = await client.get("/api/v1/session")
|
||||||
|
|
@ -291,6 +296,52 @@ async def test_anonymous_session_status_discloses_no_offline_lease(access_contro
|
||||||
assert response.headers["cache-control"] == "no-store"
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(access_control, monkeypatch):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE active_sessions SET last_active_at = ?", (int(time.time()) - 900,)
|
||||||
|
)
|
||||||
|
|
||||||
|
api_response = await client.get("/api/v1/session")
|
||||||
|
page_response = await client.get("/")
|
||||||
|
|
||||||
|
assert api_response.status_code == 401
|
||||||
|
assert api_response.json() == {
|
||||||
|
"detail": "Authentication required",
|
||||||
|
"code": "session_idle",
|
||||||
|
}
|
||||||
|
assert page_response.status_code == 303
|
||||||
|
assert page_response.headers["location"] == "login?reason=session-idle"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
heartbeat = await client.post(
|
||||||
|
"/api/v1/session/activity",
|
||||||
|
headers={
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert heartbeat.status_code == 200
|
||||||
|
assert heartbeat.json()["active"] is True
|
||||||
|
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
|
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
|
||||||
access_control, monkeypatch
|
access_control, monkeypatch
|
||||||
|
|
@ -615,8 +666,8 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
||||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||||
|
|
||||||
class BrokenStore:
|
class BrokenStore:
|
||||||
def is_active(self, session_id, expires_at):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
return True
|
return "active"
|
||||||
|
|
||||||
def consume_step_up(self, grant, session_id, *, action, target):
|
def consume_step_up(self, grant, session_id, *, action, target):
|
||||||
return True
|
return True
|
||||||
|
|
@ -693,9 +744,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
||||||
)
|
)
|
||||||
|
|
||||||
class SlowStore:
|
class SlowStore:
|
||||||
def is_active(self, session_id, expires_at):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
return True
|
return "active"
|
||||||
|
|
||||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
||||||
private_request = asyncio.create_task(client.get("/api/v1/session"))
|
private_request = asyncio.create_task(client.get("/api/v1/session"))
|
||||||
|
|
@ -847,9 +898,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
|
||||||
csrf = client.cookies["stackchain_csrf"]
|
csrf = client.cookies["stackchain_csrf"]
|
||||||
|
|
||||||
class SlowStore:
|
class SlowStore:
|
||||||
def is_active(self, session_id, expires_at):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
return True
|
return "active"
|
||||||
|
|
||||||
def revoke(self, session_id):
|
def revoke(self, session_id):
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
|
|
@ -893,7 +944,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||||
)
|
)
|
||||||
|
|
||||||
class BrokenStore:
|
class BrokenStore:
|
||||||
def is_active(self, session_id, expires_at):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
raise SessionStoreError("database path and secret details")
|
raise SessionStoreError("database path and secret details")
|
||||||
|
|
||||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||||
|
|
@ -938,8 +989,8 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
|
||||||
csrf = client.cookies["stackchain_csrf"]
|
csrf = client.cookies["stackchain_csrf"]
|
||||||
|
|
||||||
class BrokenStore:
|
class BrokenStore:
|
||||||
def is_active(self, session_id, expires_at):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
return True
|
return "active"
|
||||||
|
|
||||||
def revoke(self, session_id):
|
def revoke(self, session_id):
|
||||||
raise SessionStoreError("database path and secret details")
|
raise SessionStoreError("database path and secret details")
|
||||||
|
|
@ -966,7 +1017,7 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
|
||||||
)
|
)
|
||||||
|
|
||||||
class BrokenStore:
|
class BrokenStore:
|
||||||
def is_active(self, session_id, expires_at):
|
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||||
raise SessionStoreError("public routes must not read the registry")
|
raise SessionStoreError("public routes must not read the registry")
|
||||||
|
|
||||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||||
|
|
|
||||||
|
|
@ -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: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||||
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; }},
|
||||||
|
|
@ -57,6 +57,8 @@ const boundary = createSessionBoundary({{
|
||||||
}},
|
}},
|
||||||
onClearError: error => state.clearErrors.push(error.message),
|
onClearError: error => state.clearErrors.push(error.message),
|
||||||
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
||||||
|
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
|
||||||
|
now: () => state.now,
|
||||||
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||||
|
|
@ -232,6 +234,29 @@ 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_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
|
||||||
|
result = run_session_scenario(
|
||||||
|
"""
|
||||||
|
boundary.startActivityHeartbeat();
|
||||||
|
await state.activityListeners.pointerdown();
|
||||||
|
await state.activityListeners.keydown();
|
||||||
|
state.now += 60000;
|
||||||
|
await state.activityListeners.touchstart();
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [request["url"] for request in result["requests"]] == [
|
||||||
|
"/dashboard/api/v1/session/activity",
|
||||||
|
"/dashboard/api/v1/session/activity",
|
||||||
|
]
|
||||||
|
assert all(request["method"] == "POST" for request in result["requests"])
|
||||||
|
assert all(
|
||||||
|
request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||||
|
for request in result["requests"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
||||||
result = run_session_scenario(
|
result = run_session_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -350,6 +375,24 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["replacedAfterDeletion"] is True
|
assert result["replacedAfterDeletion"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_response_locks_without_clearing_private_queued_work():
|
||||||
|
result = run_session_scenario(
|
||||||
|
"""
|
||||||
|
state.responseStatus = 401;
|
||||||
|
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
|
||||||
|
await boundary.fetch('/dashboard/api/v1/live');
|
||||||
|
state.remaining = Array.from(storage.values.keys());
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||||
|
assert result["deletedDatabases"] == []
|
||||||
|
assert result["deletedCaches"] == []
|
||||||
|
assert result["workerMessages"] == []
|
||||||
|
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||||
|
|
||||||
|
|
||||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||||
result = run_session_scenario(
|
result = run_session_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -363,6 +406,19 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_idle_message_locks_without_clearing_window_storage():
|
||||||
|
result = run_session_scenario(
|
||||||
|
"""
|
||||||
|
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
|
||||||
|
state.remaining = Array.from(storage.values.keys());
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||||
|
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||||
|
|
||||||
|
|
||||||
def test_worker_expiry_message_clears_window_storage_before_expired_login():
|
def test_worker_expiry_message_clears_window_storage_before_expired_login():
|
||||||
result = run_session_scenario(
|
result = run_session_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,28 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
|
||||||
assert state["ignored"] == state["expired"]
|
assert state["ignored"] == state["expired"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_session_reason_explains_that_saved_work_will_resume():
|
||||||
|
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-idle');
|
||||||
|
process.stdout.write(JSON.stringify({{ status: status.textContent }}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout)["status"] == (
|
||||||
|
"Stackchain locked after inactivity. Your drafts and queued work are still "
|
||||||
|
"on this device. Sign in to resume."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
|
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,26 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_background_session_preserves_outbox_and_notifies_dashboard_clients():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
state.clientMessages=[];
|
||||||
|
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
|
||||||
|
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_idle'}),{status:401,headers:{'Content-Type':'application/json'}});
|
||||||
|
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
||||||
|
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
|
||||||
|
process.stdout.write(JSON.stringify({state,outcome}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["outcome"] == {"status": 401, "code": "session_idle"}
|
||||||
|
assert result["state"]["outboxPurges"] == 0
|
||||||
|
assert result["state"]["deleted"] == []
|
||||||
|
assert result["state"]["clientMessages"] == [
|
||||||
|
{"type": "stackchain-session-idle"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,43 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat
|
||||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_status_reports_idle_without_background_validation_extending_activity(tmp_path):
|
||||||
|
now = [1_000.0]
|
||||||
|
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||||
|
store.activate("phone-session", 3_000)
|
||||||
|
|
||||||
|
now[0] = 1_899.0
|
||||||
|
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
|
||||||
|
now[0] = 1_900.0
|
||||||
|
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_touch_extends_only_the_matching_live_session(tmp_path):
|
||||||
|
now = [1_000.0]
|
||||||
|
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||||
|
store.activate("phone-session", 3_000)
|
||||||
|
store.activate("laptop-session", 3_000)
|
||||||
|
|
||||||
|
now[0] = 1_800.0
|
||||||
|
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is True
|
||||||
|
now[0] = 2_000.0
|
||||||
|
|
||||||
|
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
|
||||||
|
assert store.status("laptop-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||||
|
assert store.touch("missing-session", 3_000, idle_timeout_seconds=900) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
|
||||||
|
now = [1_000.0]
|
||||||
|
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||||
|
store.activate("phone-session", 3_000)
|
||||||
|
|
||||||
|
now[0] = 1_900.0
|
||||||
|
|
||||||
|
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is False
|
||||||
|
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||||
|
|
||||||
|
|
||||||
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
||||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||||
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
|
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
|
||||||
|
|
@ -121,6 +158,25 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
|
||||||
assert next(device for device in devices if device.current).device_label == "Existing device"
|
assert next(device for device in devices if device.current).device_label == "Existing device"
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(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)
|
||||||
|
|
||||||
|
assert store.status("existing-session", 2_000, idle_timeout_seconds=900) == "active"
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
last_active_at = connection.execute(
|
||||||
|
"SELECT last_active_at FROM active_sessions WHERE session_hash = ?", (digest,)
|
||||||
|
).fetchone()[0]
|
||||||
|
assert last_active_at == 1_000
|
||||||
|
|
||||||
|
|
||||||
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
|
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
|
||||||
database = tmp_path / "sessions.sqlite3"
|
database = tmp_path / "sessions.sqlite3"
|
||||||
digest = SessionStore._digest("existing-session")
|
digest = SessionStore._digest("existing-session")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user