Skip to content

OGUR-64 — Minimal named-user identity + session mechanism

Linear: OGUR-64 · parent OGUR-58 · blocks OGUR-65 Status: DELIVERED — shipped in PR #241 (login page/guard #256), released in v1.1.0 Milestone: MVP-1 release gate (docs/product/mvp-scope.md §2 item 1: "Authenticated link for named Genfit / Polygon users")

Chosen mechanism

Per-user password + opaque server-side session token in an HttpOnly cookie. Zero new dependencies — stdlib hashlib.scrypt, secrets, hmac only.

Piece Where Behavior
Password hash ogur/security.py hash_password scrypt at the OWASP minimum (N=2^17, r=8, p=1, explicit maxmem — 128 MiB per hash), self-describing string scrypt$131072$8$1$<salt_b64url>$<dk_b64url>, per-user 16-byte random salt, 32-byte key. Parameters can be raised later; old hashes keep verifying because verify_password parses them from the string.
Session token ogur/security.py secrets.token_urlsafe(32); only its SHA-256 hex digest is stored (UserSession.token_hash). The raw token exists in the client cookie and nowhere else. No signing key exists — there is no SECRET_KEY to manage or rotate.
Identity ogur/models/user.py User email (unique, stored lowercased), display_name, organization (plain attribution label — carries no isolation semantics), password_hash, is_active, created_at. No role field.
Session ogur/models/user.py UserSession user_id, token_hash (unique), created_at, expires_at. The row's expires_at is authoritative; the cookie max_age is hygiene. Expired rows are deleted lazily on lookup.
Cookie POST /api/auth/login ogur_session, HttpOnly, SameSite=Lax, Secure from SESSION_COOKIE_SECURE (default false — dev is http), max_age = TTL.
TTL ogur/config.py SESSION_TTL_HOURS (default 336 = 14 days).
Resolution ogur/api/deps.py get_current_user cookie → token hash → unexpired session row → active user, else 401. Deactivating a user invalidates their live sessions at resolution time.
Login failures ogur/api/routes/auth.py Unknown email, wrong password, and inactive user all return the identical 401 body; the unknown-email path burns a verify against a dummy hash so the failures cost the same.

Endpoints: POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me (see docs/api-reference.md). /auth/me is the only endpoint gated in this ticket — enforcement across report endpoints and the per-identity LLM spend ceiling are OGUR-65.

Explicitly excluded

Out of scope for this mechanism, by decision, per the OGUR-64 acceptance text:

  1. SSO — no IdP integration of any kind.
  2. Org administrationorganization is a label on the user row; there is no org entity, no isolation, no admin surface.
  3. Invitations — users are provisioned by us via scripts/seed/seed_users.py, never self-service.
  4. Roles — every active user has identical capabilities; there is no role column.
  5. Password recovery — a forgotten password is reset by us re-provisioning the user; there is no reset flow.

Provisioning and the provisional→real swap

Real Genfit/Polygon emails had not arrived when this shipped, so scripts/seed/seed_users.py --provisional creates two placeholder identities (genfit.provisional@ogur.local, polygon.provisional@ogur.local) with passwords generated at runtime and printed exactly once. Re-runs never rotate an existing password. When the real emails arrive:

uv run python scripts/seed/seed_users.py --deactivate genfit.provisional@ogur.local
uv run python scripts/seed/seed_users.py --email FIRST.LAST@CLIENT.COM --name "FIRST LAST" --org CLIENT

Deactivation keeps the user row (attribution of past reads survives) and deletes their sessions.

Acceptance → test map

Acceptance criterion Test
Authenticated request resolves a stable user identity tests/unit/api/test_auth.py::test_me_stable_identity_across_requests, tests/unit/store/test_users.py::test_resolve_session_stable_identity
Session expiry is tested tests/unit/api/test_auth.py::test_me_expired_session_401, tests/unit/store/test_users.py::test_resolve_session_expired_returns_none
Secret handling is tested tests/unit/store/test_users.py::test_create_user_no_plaintext_at_rest, ::test_create_session_stores_only_hash, tests/unit/scripts/test_seed_users.py::test_provisional_prints_passwords_once_never_stores_plaintext, tests/unit/test_security.py