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:
- SSO — no IdP integration of any kind.
- Org administration —
organizationis a label on the user row; there is no org entity, no isolation, no admin surface. - Invitations — users are provisioned by us via
scripts/seed/seed_users.py, never self-service. - Roles — every active user has identical capabilities; there is no role column.
- 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 |