Compare commits
No commits in common. "7639a42d7b37c52a8d5f7a2d0bb54914fdff78bc" and "9904f17e3d201b8acc383bf4ded8f684d7733b0d" have entirely different histories.
7639a42d7b
...
9904f17e3d
15
README.md
15
README.md
|
|
@ -133,9 +133,6 @@ 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
|
||||||
|
|
@ -169,9 +166,8 @@ 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. The registry also stores each
|
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
|
||||||
session's last explicit activity. Existing two-column registries are migrated in
|
migrated in place and their live sessions remain valid.
|
||||||
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.
|
||||||
|
|
@ -181,12 +177,7 @@ 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.
|
||||||
|
|
||||||
After 15 minutes without pointer, keyboard, or touch activity (configurable through
|
If an active session expires, the first authenticated API rejection replaces the
|
||||||
`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,10 +35,6 @@
|
||||||
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,11 +115,6 @@ 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);
|
||||||
|
|
@ -182,9 +177,6 @@ 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,7 +15,6 @@
|
||||||
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.`,
|
||||||
|
|
@ -28,7 +27,6 @@
|
||||||
});
|
});
|
||||||
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');
|
||||||
|
|
@ -93,19 +91,16 @@
|
||||||
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, addActivityListener,
|
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||||
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;
|
||||||
|
|
@ -122,19 +117,10 @@
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
@ -267,9 +253,6 @@
|
||||||
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();
|
||||||
|
|
@ -278,27 +261,6 @@
|
||||||
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');
|
||||||
|
|
@ -429,7 +391,5 @@
|
||||||
handleServiceWorkerMessage,
|
handleServiceWorkerMessage,
|
||||||
refreshOfflineLease,
|
refreshOfflineLease,
|
||||||
resumeQueuedWork,
|
resumeQueuedWork,
|
||||||
recordActivity,
|
|
||||||
startActivityHeartbeat,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ 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"
|
||||||
|
|
@ -97,18 +96,6 @@ 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]:
|
||||||
|
|
@ -159,14 +146,7 @@ def verify_session_with_reason(
|
||||||
or not session.csrf
|
or not session.csrf
|
||||||
):
|
):
|
||||||
return SessionVerification(None)
|
return SessionVerification(None)
|
||||||
status = _session_store(now).status(
|
if not _session_store(now).is_active(session.session_id, session.expires_at):
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -187,15 +167,6 @@ 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,19 +670,18 @@ 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 in {"session_revoked", "session_idle"}:
|
if session_reason == "session_revoked":
|
||||||
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"},
|
||||||
)
|
)
|
||||||
if session_reason == "session_revoked":
|
login_redirect = (
|
||||||
login_redirect = "login?reason=session-revoked"
|
"login?reason=session-revoked"
|
||||||
elif session_reason == "session_idle":
|
if session_reason == "session_revoked"
|
||||||
login_redirect = "login?reason=session-idle"
|
else _share_target_login_redirect(request)
|
||||||
else:
|
)
|
||||||
login_redirect = _share_target_login_redirect(request)
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
login_redirect,
|
login_redirect,
|
||||||
status_code=303,
|
status_code=303,
|
||||||
|
|
@ -938,29 +937,6 @@ 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,8 +58,7 @@ 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
|
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -85,7 +84,6 @@ 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:
|
||||||
|
|
@ -104,10 +102,6 @@ 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)"
|
||||||
|
|
@ -128,15 +122,14 @@ class SessionStore:
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO active_sessions("
|
"INSERT INTO active_sessions("
|
||||||
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
|
"session_hash, expires_at, management_id, device_label, created_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:
|
||||||
|
|
@ -154,55 +147,6 @@ 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,5 +1,4 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlite3
|
|
||||||
import time
|
import time
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
@ -246,14 +245,10 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
|
||||||
lookups = 0
|
lookups = 0
|
||||||
|
|
||||||
class CountingStore:
|
class CountingStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def is_active(self, session_id, expires_at):
|
||||||
nonlocal lookups
|
nonlocal lookups
|
||||||
lookups += 1
|
lookups += 1
|
||||||
return original_store.status(
|
return original_store.is_active(session_id, expires_at)
|
||||||
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")
|
||||||
|
|
@ -296,52 +291,6 @@ 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
|
||||||
|
|
@ -666,8 +615,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 status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def is_active(self, session_id, expires_at):
|
||||||
return "active"
|
return True
|
||||||
|
|
||||||
def consume_step_up(self, grant, session_id, *, action, target):
|
def consume_step_up(self, grant, session_id, *, action, target):
|
||||||
return True
|
return True
|
||||||
|
|
@ -744,9 +693,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
||||||
)
|
)
|
||||||
|
|
||||||
class SlowStore:
|
class SlowStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def is_active(self, session_id, expires_at):
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
return "active"
|
return True
|
||||||
|
|
||||||
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"))
|
||||||
|
|
@ -898,9 +847,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 status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def is_active(self, session_id, expires_at):
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
return "active"
|
return True
|
||||||
|
|
||||||
def revoke(self, session_id):
|
def revoke(self, session_id):
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
|
|
@ -944,7 +893,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||||
)
|
)
|
||||||
|
|
||||||
class BrokenStore:
|
class BrokenStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def is_active(self, session_id, expires_at):
|
||||||
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())
|
||||||
|
|
@ -989,8 +938,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 status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def is_active(self, session_id, expires_at):
|
||||||
return "active"
|
return True
|
||||||
|
|
||||||
def revoke(self, session_id):
|
def revoke(self, session_id):
|
||||||
raise SessionStoreError("database path and secret details")
|
raise SessionStoreError("database path and secret details")
|
||||||
|
|
@ -1017,7 +966,7 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
|
||||||
)
|
)
|
||||||
|
|
||||||
class BrokenStore:
|
class BrokenStore:
|
||||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
def is_active(self, session_id, expires_at):
|
||||||
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: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
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 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,8 +57,6 @@ 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'; }},
|
||||||
|
|
@ -234,29 +232,6 @@ 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(
|
||||||
"""
|
"""
|
||||||
|
|
@ -375,24 +350,6 @@ 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(
|
||||||
"""
|
"""
|
||||||
|
|
@ -406,19 +363,6 @@ 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,28 +37,6 @@ 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,26 +237,6 @@ 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,43 +77,6 @@ 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")
|
||||||
|
|
@ -158,25 +121,6 @@ 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