commit
334fe77e84
11
README.md
11
README.md
|
|
@ -59,11 +59,22 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||||
|
# Optional; defaults to STACKCHAIN_STATE_DIR/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
|
||||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each signed cookie includes an opaque session identifier whose hash and expiry are
|
||||||
|
kept in the SQLite session registry. Keep that registry on persistent, writable
|
||||||
|
storage shared by all dashboard workers. Sign-out revokes only the current session
|
||||||
|
before clearing browser state, so a copied cookie cannot be replayed afterward;
|
||||||
|
other signed-in devices remain active. Deploying this version invalidates older
|
||||||
|
cookies that do not contain a registered identifier, so operators must sign in once
|
||||||
|
again. Registry read or write failures return a sanitized HTTP 503 before Gitea is
|
||||||
|
contacted.
|
||||||
|
|
||||||
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
||||||
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
|
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
|
||||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -22,6 +24,7 @@ INSECURE_LOCAL_MODE = "insecure-local"
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Session:
|
class Session:
|
||||||
|
session_id: str
|
||||||
csrf: str
|
csrf: str
|
||||||
expires_at: int
|
expires_at: int
|
||||||
|
|
||||||
|
|
@ -76,17 +79,31 @@ def _decode(value: str) -> bytes:
|
||||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def _session_store(now: int | None = None) -> SessionStore:
|
||||||
|
database = os.getenv(
|
||||||
|
"STACKCHAIN_SESSION_DB",
|
||||||
|
os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
|
||||||
|
)
|
||||||
|
current = time.time if now is None else lambda: now
|
||||||
|
return SessionStore(database, clock=current)
|
||||||
|
|
||||||
|
|
||||||
def issue_session(now: int | None = None) -> tuple[str, Session]:
|
def issue_session(now: int | None = None) -> tuple[str, Session]:
|
||||||
issued_at = int(time.time() if now is None else now)
|
issued_at = int(time.time() if now is None else now)
|
||||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||||
session = Session(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl))
|
session = Session(
|
||||||
|
session_id=secrets.token_urlsafe(32),
|
||||||
|
csrf=secrets.token_urlsafe(24),
|
||||||
|
expires_at=issued_at + max(1, ttl),
|
||||||
|
)
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{"csrf": session.csrf, "exp": session.expires_at},
|
{"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
).encode()
|
).encode()
|
||||||
encoded = _encode(payload)
|
encoded = _encode(payload)
|
||||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||||
|
_session_store(now).activate(session.session_id, session.expires_at)
|
||||||
return f"{encoded}.{signature}", session
|
return f"{encoded}.{signature}", session
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,13 +116,27 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
payload = json.loads(_decode(encoded))
|
payload = json.loads(_decode(encoded))
|
||||||
session = Session(csrf=payload["csrf"], expires_at=int(payload["exp"]))
|
session = Session(
|
||||||
|
session_id=payload["sid"],
|
||||||
|
csrf=payload["csrf"],
|
||||||
|
expires_at=int(payload["exp"]),
|
||||||
|
)
|
||||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||||
return None
|
return None
|
||||||
current = int(time.time() if now is None else now)
|
current = int(time.time() if now is None else now)
|
||||||
if session.expires_at <= current or not isinstance(session.csrf, str) or not session.csrf:
|
if (
|
||||||
|
session.expires_at <= current
|
||||||
|
or not isinstance(session.session_id, str)
|
||||||
|
or not session.session_id
|
||||||
|
or not isinstance(session.csrf, str)
|
||||||
|
or not session.csrf
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
return session
|
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_session(session: Session) -> None:
|
||||||
|
_session_store().revoke(session.session_id)
|
||||||
|
|
||||||
|
|
||||||
def request_session(request: Request) -> Session | None:
|
def request_session(request: Request) -> Session | None:
|
||||||
|
|
|
||||||
27
src/main.py
27
src/main.py
|
|
@ -455,7 +455,14 @@ async def require_operator_session(request: Request, call_next):
|
||||||
or path.startswith("/static/")
|
or path.startswith("/static/")
|
||||||
or (path == "/api/v1/session" and request.method == "POST")
|
or (path == "/api/v1/session" and request.method == "POST")
|
||||||
)
|
)
|
||||||
session = dashboard_auth.request_session(request)
|
try:
|
||||||
|
session = dashboard_auth.request_session(request)
|
||||||
|
except dashboard_auth.SessionStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Session registry is temporarily unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
if not public and session is None:
|
if not public and session is None:
|
||||||
if path.startswith("/api/"):
|
if path.startswith("/api/"):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -519,7 +526,14 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||||
payload.access_token, configured_token
|
payload.access_token, configured_token
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=401, detail="Invalid access token")
|
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||||
signed, session = dashboard_auth.issue_session()
|
try:
|
||||||
|
signed, session = dashboard_auth.issue_session()
|
||||||
|
except dashboard_auth.SessionStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Session registry is temporarily unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
path = dashboard_auth.cookie_path(request)
|
path = dashboard_auth.cookie_path(request)
|
||||||
max_age = max(1, session.expires_at - int(time.time()))
|
max_age = max(1, session.expires_at - int(time.time()))
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
|
|
@ -555,6 +569,15 @@ async def session_status(request: Request):
|
||||||
|
|
||||||
@app.delete("/api/v1/session")
|
@app.delete("/api/v1/session")
|
||||||
async def sign_out(request: Request, response: Response):
|
async def sign_out(request: Request, response: Response):
|
||||||
|
session = request.state.dashboard_session
|
||||||
|
try:
|
||||||
|
dashboard_auth.revoke_session(session)
|
||||||
|
except dashboard_auth.SessionStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Session registry is temporarily unavailable"},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
path = dashboard_auth.cookie_path(request)
|
path = dashboard_auth.cookie_path(request)
|
||||||
response.delete_cookie(
|
response.delete_cookie(
|
||||||
dashboard_auth.SESSION_COOKIE,
|
dashboard_auth.SESSION_COOKIE,
|
||||||
|
|
|
||||||
79
src/session_store.py
Normal file
79
src/session_store.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Durable active-session registry used to revoke signed operator sessions."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStoreError(RuntimeError):
|
||||||
|
"""Raised when session state cannot be read or changed safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], float],
|
||||||
|
lock_timeout_seconds: float = 0.1,
|
||||||
|
) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.clock = clock
|
||||||
|
self.lock_timeout_seconds = lock_timeout_seconds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _digest(session_id: str) -> str:
|
||||||
|
return hashlib.sha256(session_id.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||||
|
session_hash TEXT PRIMARY KEY,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return connection
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
|
|
||||||
|
def activate(self, session_id: str, expires_at: int) -> None:
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)",
|
||||||
|
(self._digest(session_id), expires_at),
|
||||||
|
)
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
|
|
||||||
|
def is_active(self, session_id: str, expires_at: int) -> bool:
|
||||||
|
now = int(self.clock())
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("DELETE FROM active_sessions WHERE expires_at <= ?", (now,))
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT expires_at FROM active_sessions WHERE session_hash = ?",
|
||||||
|
(self._digest(session_id),),
|
||||||
|
).fetchone()
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
|
return row is not None and row[0] == expires_at and expires_at > now
|
||||||
|
|
||||||
|
def revoke(self, session_id: str) -> None:
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM active_sessions WHERE session_hash = ?",
|
||||||
|
(self._digest(session_id),),
|
||||||
|
)
|
||||||
|
except (OSError, sqlite3.Error) as exc:
|
||||||
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
|
|
@ -2,13 +2,15 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import main
|
from src import main
|
||||||
|
from src.session_store import SessionStoreError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def access_control(monkeypatch):
|
def access_control(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -207,6 +209,132 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
captured = client.cookies["stackchain_session"]
|
||||||
|
csrf = client.cookies["stackchain_csrf"]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport,
|
||||||
|
base_url="https://test",
|
||||||
|
headers={"Cookie": f"stackchain_session={captured}"},
|
||||||
|
) as replay:
|
||||||
|
before = await replay.get("/api/v1/background-identity")
|
||||||
|
|
||||||
|
await client.delete(
|
||||||
|
"/api/v1/session",
|
||||||
|
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport,
|
||||||
|
base_url="https://test",
|
||||||
|
headers={"Cookie": f"stackchain_session={captured}"},
|
||||||
|
) as replay:
|
||||||
|
after = await replay.get("/api/v1/background-identity")
|
||||||
|
|
||||||
|
assert before.status_code == 200
|
||||||
|
assert after.status_code == 401
|
||||||
|
assert after.headers["cache-control"] == "no-store"
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||||
|
access_control, monkeypatch
|
||||||
|
):
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
class BrokenStore:
|
||||||
|
def is_active(self, session_id, expires_at):
|
||||||
|
raise SessionStoreError("database path and secret details")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||||
|
response = await client.get("/api/v1/background-identity")
|
||||||
|
health = await client.get("/healthz")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert "database path" not in response.text
|
||||||
|
assert called is False
|
||||||
|
assert health.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sign_in_registry_write_failure_issues_no_cookie(access_control, monkeypatch):
|
||||||
|
class BrokenStore:
|
||||||
|
def activate(self, session_id, expires_at):
|
||||||
|
raise SessionStoreError("database path and secret details")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.headers.get_list("set-cookie") == []
|
||||||
|
assert "database path" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_logout_registry_failure_does_not_claim_revocation(access_control, monkeypatch):
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
csrf = client.cookies["stackchain_csrf"]
|
||||||
|
|
||||||
|
class BrokenStore:
|
||||||
|
def is_active(self, session_id, expires_at):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def revoke(self, session_id):
|
||||||
|
raise SessionStoreError("database path and secret details")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/v1/session",
|
||||||
|
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.headers.get_list("set-cookie") == []
|
||||||
|
assert "database path" not in response.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
|
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
|
||||||
async def user():
|
async def user():
|
||||||
|
|
|
||||||
31
tests/test_session_store.py
Normal file
31
tests/test_session_store.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from src.session_store import SessionStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
|
||||||
|
now = [1_000.0]
|
||||||
|
database = tmp_path / "sessions.sqlite3"
|
||||||
|
first = SessionStore(database, clock=lambda: now[0])
|
||||||
|
first.activate("first-session-secret", 2_000)
|
||||||
|
first.activate("second-session-secret", 2_000)
|
||||||
|
|
||||||
|
reconstructed = SessionStore(database, clock=lambda: now[0])
|
||||||
|
reconstructed.revoke("first-session-secret")
|
||||||
|
|
||||||
|
assert reconstructed.is_active("first-session-secret", 2_000) is False
|
||||||
|
assert reconstructed.is_active("second-session-secret", 2_000) is True
|
||||||
|
assert b"first-session-secret" not in database.read_bytes()
|
||||||
|
assert b"second-session-secret" not in database.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_sessions_are_rejected_and_pruned(tmp_path):
|
||||||
|
now = [1_000.0]
|
||||||
|
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||||
|
store.activate("expiring-session", 1_001)
|
||||||
|
|
||||||
|
now[0] = 1_001.0
|
||||||
|
|
||||||
|
assert store.is_active("expiring-session", 1_001) is False
|
||||||
|
with sqlite3.connect(store.path) as connection:
|
||||||
|
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (0,)
|
||||||
Loading…
Reference in New Issue
Block a user