feat: session mode parsing, memory-only cookies, single-user mode

- COOKIE_SECURE env now accepts: 'true' (secure), 'false' (memory-only), 'single_user' (no cookie)
- config.parse_cookie_mode() returns (COOKIE_SECURE, SINGLE_USER) tuple for routing
- single_user_owner() returns oldest Owner for test/local deployments
- session cookie respects mode: Max-Age only in secure mode, dropped for memory-only
- base.html renders ephemeral-session and single-user banners per mode
- Tests: comprehensive coverage for all three modes with monkeypatch configs
- CLAUDE.md + README docs updated

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:21:18 +02:00
co-authored by Claude Haiku 4.5
parent a8bcf7cdeb
commit f70ba93e7a
9 changed files with 231 additions and 12 deletions
+16 -2
View File
@@ -115,7 +115,7 @@ on narrow screens or in the add-printer sidebar (`.form-aside`).
|-----|---------|---------| |-----|---------|---------|
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) | | `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
| `PORT` | `8000` | Server port | | `PORT` | `8000` | Server port |
| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for plain-HTTP serving or the browser drops the cookie and a new Owner is created on every request. `false` also makes the cookie **memory-only** (no `Max-Age`) — see below. | | `COOKIE_SECURE` | `true` | Three-way session mode, parsed by `config.parse_cookie_mode()` into `(COOKIE_SECURE, SINGLE_USER)`: `true` = Secure + 10-year cookie; `false` = plain-HTTP serving (browser drops a Secure cookie new Owner per request), cookie becomes **memory-only** (no `Max-Age`); `single_user` (or `single-user`/`single`) = no cookie at all, one shared Owner — see below. |
`COOKIE_SECURE=false` degrades the session instead of weakening the credential: `COOKIE_SECURE=false` degrades the session instead of weakening the credential:
`services/session.cookie_kwargs()` drops `max_age`, so the browser holds the `services/session.cookie_kwargs()` drops `max_age`, so the browser holds the
@@ -125,4 +125,18 @@ cookie-setting call sites (the middleware and `POST /session/restore`) must go
through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag, through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag,
and `base.html` renders the `#ephemeral-session-warning` banner plus an extra and `base.html` renders the `#ephemeral-session-warning` banner plus an extra
paragraph in the onboarding modal off it. Changing this touches paragraph in the onboarding modal off it. Changing this touches
`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`. `tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`.
`COOKIE_SECURE=single_user` (`cfg.SINGLE_USER`) removes sessions for test boxes
and single-person local prod: the middleware never reads or sets a cookie and
returns `services/session.single_user_owner()` — the **oldest** `Owner` row,
created on demand — so a deployment switched over from cookie mode keeps the
printers it already had and no second row is ever minted. `request.state.owner`
is still what every route filters on, so per-owner query code is unchanged.
`request.state.single_user` gates the sidebar "This session" menu and the
`#single-user-notice` banner in `base.html`; `is_new_owner`/`ephemeral_session`
are forced `False` (no onboarding modal, no memory-only warning). All three
`/session/*` routes 404 via `api/session._require_cookie_sessions()` — a key
can't re-point a cookie that isn't read, and downloading one would leak the
shared owner's bearer credential for a later switch back to cookie mode. Tests:
`test_session.py::test_single_user_*` + `test_parse_cookie_mode_*`.
+32 -1
View File
@@ -52,7 +52,7 @@ Set these under `environment:` in `docker-compose.yml`.
|------------------|---------|--------------------------------------------------| |------------------|---------|--------------------------------------------------|
| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. | | `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
| `PORT` | `8000` | Port the server listens on inside the container. | | `PORT` | `8000` | Port the server listens on inside the container. |
| `COOKIE_SECURE` | `true` | `Secure` flag on the session cookie. Set to `false` when the app is reached over plain HTTP — see below. | | `COOKIE_SECURE` | `true` | Session mode: `true` (Secure cookie), `false` (plain-HTTP cookie, memory-only) or `single_user` (no cookie, one shared store) — see below. |
### `COOKIE_SECURE` and HTTPS ### `COOKIE_SECURE` and HTTPS
@@ -91,3 +91,34 @@ export DATA_DIR=/tmp/imptune_data
export COOKIE_SECURE=false export COOKIE_SECURE=false
uvicorn imptune.main:app --reload --port 8000 uvicorn imptune.main:app --reload --port 8000
``` ```
### `COOKIE_SECURE=single_user` — no sessions at all
For a test box or a local deployment used by one person, sessions are pure
friction. `COOKIE_SECURE=single_user` (also accepted: `single-user`, `single`)
drops them:
- No cookie is read or set. Every request — every browser, every device, curl —
resolves to **one shared owner**, so all printers, print defaults and clients
are simply "the server's".
- The onboarding modal, the memory-only warning, the "This session" sidebar menu
and both `/session/*` key routes disappear (the routes return `404`). There is
no backup key to lose, and none to hand out.
- Every page shows a banner stating that whoever reaches the app sees the same
data.
**There is no isolation left in this mode**, so put it only where reaching the
app is already the permission — localhost, or a network you trust. Switching an
existing deployment over adopts the oldest existing owner, so printers saved
under a cookie stay visible; switching back re-enables cookie scoping and hands
new browsers a fresh empty session (that same data is then reachable only with
its key, which single-user mode never printed — download a backup key *before*
switching if you may switch back).
```yaml
services:
imptune:
environment:
- DATA_DIR=/data
- COOKIE_SECURE=single_user
```
+3
View File
@@ -17,6 +17,9 @@ services:
# drops it and every request mints a new empty Owner. With `false` the # drops it and every request mints a new empty Owner. With `false` the
# session works but lives only until the browser closes, and the UI warns. # session works but lives only until the browser closes, and the UI warns.
# - COOKIE_SECURE=false # - COOKIE_SECURE=false
# Or drop sessions entirely — no cookie, one shared store for everyone who
# can reach the app. Test boxes / single-person local prod only.
# - COOKIE_SECURE=single_user
volumes: volumes:
imptune_data: imptune_data:
+16 -1
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import imptune.config as cfg
from imptune.db.models import Owner from imptune.db.models import Owner
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
@@ -17,9 +18,21 @@ templates = Jinja2Templates(
) )
def _require_cookie_sessions() -> None:
"""404 these routes in single-user mode — keys have no meaning without a cookie.
Restoring one could not re-point anything, and downloading one would hand
out the shared owner's bearer key, which turns into a live credential the
moment the deployment is switched back to a cookie-scoped mode.
"""
if cfg.SINGLE_USER:
raise HTTPException(status_code=404, detail="Session keys are disabled in single-user mode")
@router.get("/key/download") @router.get("/key/download")
def download_key(request: Request) -> PlainTextResponse: def download_key(request: Request) -> PlainTextResponse:
"""Mark the current owner permanent and hand back its key as a backup file.""" """Mark the current owner permanent and hand back its key as a backup file."""
_require_cookie_sessions()
owner: Owner = request.state.owner owner: Owner = request.state.owner
if not owner.is_permanent: if not owner.is_permanent:
owner.is_permanent = True owner.is_permanent = True
@@ -33,6 +46,7 @@ def download_key(request: Request) -> PlainTextResponse:
@router.get("/restore", response_class=HTMLResponse) @router.get("/restore", response_class=HTMLResponse)
def restore_page(request: Request, error: str = "") -> HTMLResponse: def restore_page(request: Request, error: str = "") -> HTMLResponse:
_require_cookie_sessions()
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="session_restore.html", name="session_restore.html",
@@ -43,6 +57,7 @@ def restore_page(request: Request, error: str = "") -> HTMLResponse:
@router.post("/restore") @router.post("/restore")
def restore_session(request: Request, key: str = Form(...)): def restore_session(request: Request, key: str = Form(...)):
"""Re-associate this browser with a previously downloaded backup key.""" """Re-associate this browser with a previously downloaded backup key."""
_require_cookie_sessions()
if not is_same_origin(request): if not is_same_origin(request):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
+22 -2
View File
@@ -7,8 +7,28 @@ load_dotenv()
DATA_DIR = os.environ.get("DATA_DIR", "/data") DATA_DIR = os.environ.get("DATA_DIR", "/data")
PORT = int(os.environ.get("PORT", "8000")) PORT = int(os.environ.get("PORT", "8000"))
# Set to "false" for local plain-HTTP dev — browsers drop Secure cookies over HTTP.
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false" _SINGLE_USER_VALUES = frozenset({"single_user", "single-user", "singleuser", "single"})
def parse_cookie_mode(raw: str) -> tuple[bool, bool]:
"""Read `COOKIE_SECURE` as a three-way mode → `(cookie_secure, single_user)`.
- `true` (default): cookie-scoped Owners, `Secure` + persistent cookie.
- `false`: cookie-scoped Owners over plain HTTP, memory-only cookie —
browsers drop `Secure` cookies on HTTP, so the flag has to come off.
- `single_user`: no cookie at all. Every request shares one Owner, so a
test box or a local single-person deployment needs no session plumbing.
Anyone who can reach the app gets that data — there is no separation
left to enforce, which is the point.
"""
value = raw.strip().lower()
if value in _SINGLE_USER_VALUES:
return False, True
return value != "false", False
COOKIE_SECURE, SINGLE_USER = parse_cookie_mode(os.environ.get("COOKIE_SECURE", "true"))
DB_PATH = str(Path(DATA_DIR) / "imptune.db") DB_PATH = str(Path(DATA_DIR) / "imptune.db")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers") DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
+26
View File
@@ -45,6 +45,21 @@ def cookie_kwargs() -> dict:
return kwargs return kwargs
def single_user_owner() -> Owner:
"""The one Owner every request shares under `COOKIE_SECURE=single_user`.
No cookie is read, so the owner has to be findable from the DB alone: the
oldest row wins. That way a deployment that used to run cookie-scoped keeps
the printers it already had instead of waking up empty, and repeat requests
never mint a second row. It is marked permanent so nothing treats it as a
throwaway first-visit session.
"""
owner = Owner.select().order_by(Owner.id).first()
if owner is None:
owner = Owner.create(key=generate_key(), is_permanent=True)
return owner
def _request_hosts(request: Request) -> set[str]: def _request_hosts(request: Request) -> set[str]:
"""Every host spelling that legitimately identifies this deployment.""" """Every host spelling that legitimately identifies this deployment."""
hosts = {request.url.netloc} hosts = {request.url.netloc}
@@ -98,6 +113,16 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES): if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES):
return await call_next(request) return await call_next(request)
if cfg.SINGLE_USER:
# No cookie is read and none is set: identity comes from the DB, so
# onboarding ("keep your printers?") and the ephemeral-cookie
# warning are both meaningless here.
request.state.owner = single_user_owner()
request.state.is_new_owner = False
request.state.ephemeral_session = False
request.state.single_user = True
return await call_next(request)
key = request.cookies.get(COOKIE_NAME) key = request.cookies.get(COOKIE_NAME)
owner = Owner.get_or_none(Owner.key == key) if key else None owner = Owner.get_or_none(Owner.key == key) if key else None
is_new = owner is None is_new = owner is None
@@ -106,6 +131,7 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
request.state.owner = owner request.state.owner = owner
request.state.is_new_owner = is_new request.state.is_new_owner = is_new
request.state.single_user = False
# Templates warn about it; see cookie_kwargs(). # Templates warn about it; see cookie_kwargs().
request.state.ephemeral_session = not cfg.COOKIE_SECURE request.state.ephemeral_session = not cfg.COOKIE_SECURE
+11 -6
View File
@@ -1203,16 +1203,20 @@ div.error > p {
.error p, .notice p { margin: 0; } .error p, .notice p { margin: 0; }
/* Memory-only-session banner (COOKIE_SECURE=false): icon, message, and the /* Session-mode banners — memory-only (COOKIE_SECURE=false) and single-user
escape hatch on one line, stacking on narrow screens. */ (COOKIE_SECURE=single_user): icon, message, and the escape hatch on one
.notice.session-ephemeral { line, stacking on narrow screens. */
.notice.session-ephemeral,
.notice.session-single-user {
display: flex; display: flex;
align-items: center; align-items: center;
gap: .6rem; gap: .6rem;
} }
.notice.session-ephemeral p { flex: 1; min-width: 12rem; } .notice.session-ephemeral p,
.notice.session-ephemeral .ico { flex: none; } .notice.session-single-user p { flex: 1; min-width: 12rem; }
.notice.session-ephemeral .ico,
.notice.session-single-user .ico { flex: none; }
.notice.session-ephemeral .btn { .notice.session-ephemeral .btn {
flex: none; flex: none;
@@ -1226,7 +1230,8 @@ div.error > p {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.notice.session-ephemeral { flex-wrap: wrap; } .notice.session-ephemeral,
.notice.session-single-user { flex-wrap: wrap; }
} }
.notice details { margin: .4rem 0 0; } .notice details { margin: .4rem 0 0; }
+12
View File
@@ -92,6 +92,7 @@
session_restore_key: 'Restaurer depuis une clé', session_restore_key: 'Restaurer depuis une clé',
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.', session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.', ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
single_user_notice: 'Mode mono-utilisateur : tout est enregistré sur le serveur sans cookie de session, et toute personne qui accède à cette application voit les mêmes imprimantes.',
open_menu: 'Ouvrir le menu', open_menu: 'Ouvrir le menu',
// Dashboard // Dashboard
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.', dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
@@ -277,6 +278,7 @@
session_restore_key: 'Restore from a key', session_restore_key: 'Restore from a key',
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.', session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.', ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
single_user_notice: 'Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.',
open_menu: 'Open menu', open_menu: 'Open menu',
// Dashboard // Dashboard
dashboard_intro: 'Three steps from driver ZIP to deployable package.', dashboard_intro: 'Three steps from driver ZIP to deployable package.',
@@ -459,6 +461,8 @@
</ul> </ul>
</div> </div>
{# Single-user mode has no cookie and no keys — the whole menu would 404. #}
{% if not request.state.single_user %}
<div class="sidebar-foot"> <div class="sidebar-foot">
<details {% if request.url.path == "/session/restore" %}open{% endif %}> <details {% if request.url.path == "/session/restore" %}open{% endif %}>
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary> <summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
@@ -469,6 +473,7 @@
</div> </div>
</details> </details>
</div> </div>
{% endif %}
</nav> </nav>
<div class="main-wrapper"> <div class="main-wrapper">
@@ -497,6 +502,13 @@
</header> </header>
<main class="main-content"> <main class="main-content">
{% if request.state.single_user %}
{# COOKIE_SECURE=single_user: one shared store, no cookie, no isolation. #}
<div class="notice session-single-user" id="single-user-notice">
{{ ico.i('key', 16) }}
<p x-data x-text="$store.i18n.t('single_user_notice')">Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.</p>
</div>
{% endif %}
{% if request.state.ephemeral_session %} {% if request.state.ephemeral_session %}
{# COOKIE_SECURE=false: the owner key is memory-only, so the session {# COOKIE_SECURE=false: the owner key is memory-only, so the session
dies with the browser window. Say it before work is lost. #} dies with the browser window. Say it before work is lost. #}
+93
View File
@@ -268,3 +268,96 @@ def test_restore_also_issues_a_session_cookie_when_insecure(tmp_data_dir, monkey
assert resp.status_code == 303 assert resp.status_code == 303
header = _set_cookie_header(resp) header = _set_cookie_header(resp)
assert "Max-Age" not in header and "Expires" not in header assert "Max-Age" not in header and "Expires" not in header
# ---------------------------------------------------------------------------
# COOKIE_SECURE=single_user — no cookie at all, one shared Owner
# ---------------------------------------------------------------------------
def test_parse_cookie_mode_reads_three_modes():
from imptune.config import parse_cookie_mode
assert parse_cookie_mode("true") == (True, False)
assert parse_cookie_mode("") == (True, False) # unset-ish → secure default
assert parse_cookie_mode("False") == (False, False)
for spelling in ("single_user", "single-user", "SingleUser", " single "):
assert parse_cookie_mode(spelling) == (False, True)
def _single_user(monkeypatch):
import imptune.config as cfg
monkeypatch.setattr(cfg, "SINGLE_USER", True)
monkeypatch.setattr(cfg, "COOKIE_SECURE", False)
def test_single_user_mode_sets_no_cookie_and_skips_onboarding(tmp_data_dir, monkeypatch):
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as fresh:
resp = fresh.get("/")
assert resp.status_code == 200
assert COOKIE_NAME not in fresh.cookies
assert not [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"]
assert "session-choice-modal" not in resp.text
assert "ephemeral-session-warning" not in resp.text
assert "single-user-notice" in resp.text
def test_single_user_mode_shares_data_across_browsers(tmp_data_dir, monkeypatch):
"""The point of the mode: a cookie-less client sees the same printers."""
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as client_a, TestClient(app) as client_b:
client_a.post(
"/printers",
data={"name": "Shared Printer", "ip_address": "10.0.7.1", "port_name": "IP_S"},
follow_redirects=False,
)
assert "Shared Printer" in client_b.get("/printers").text
def test_single_user_mode_creates_exactly_one_owner(tmp_data_dir, monkeypatch):
from imptune.db.models import Owner
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as fresh:
for _ in range(4):
assert fresh.get("/printers").status_code == 200
assert Owner.select().count() == 1
def test_single_user_mode_adopts_the_existing_owner(tmp_data_dir, monkeypatch):
"""Switching a cookie-scoped deployment over must not hide its printers."""
from imptune.main import app
with TestClient(app) as cookie_client:
cookie_client.post(
"/printers",
data={"name": "Pre-switch Printer", "ip_address": "10.0.7.2", "port_name": "IP_T"},
follow_redirects=False,
)
_single_user(monkeypatch)
with TestClient(app) as after_switch:
assert "Pre-switch Printer" in after_switch.get("/printers").text
def test_single_user_mode_disables_session_key_routes(tmp_data_dir, monkeypatch):
"""No cookie to re-point, and the shared owner's key must not leak."""
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as fresh:
assert fresh.get("/session/key/download").status_code == 404
assert fresh.get("/session/restore").status_code == 404
assert fresh.post(
"/session/restore",
data={"key": "anything"},
headers={"origin": "http://testserver"},
).status_code == 404
assert "/session/key/download" not in fresh.get("/printers").text