Skip to content

feat(multiuser): Phase 2 backend auth core — sessions, guards, invite flow, resource bounds - #157

Open
wpfleger96 wants to merge 9 commits into
mainfrom
will/multiuser-phase2
Open

feat(multiuser): Phase 2 backend auth core — sessions, guards, invite flow, resource bounds#157
wpfleger96 wants to merge 9 commits into
mainfrom
will/multiuser-phase2

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What this branch changes vs main

Phase 2 of the SNORE multiuser migration (Phase 1 merged at `22a5b13`). Implements the complete backend auth core: configuration, middleware, session cookies, auth router, invite flow, upload resource bounds, and CI tooling.

Configuration (`src/snore/api/config.py`)

  • `SNORE_AUTH_MODE`: `multiuser` (fail-closed default) or `local`. Startup refuses local mode on a non-loopback bind address.
  • `SNORE_SESSION_SECRET`: Required in multiuser; minimum 32 characters.
  • `SNORE_PUBLIC_BASE_URL`: Required in multiuser; must be loopback HTTP or HTTPS only. Validated at startup to reject userinfo, path, query, fragment, and invalid/out-of-range ports. Parsed once to a canonical `(scheme, host, effective_port)` tuple stored as `AppConfig.public_origin`; all consumers (`secure_cookie`, CSRF, dev-origin comparison) use this stored value.
  • `SNORE_BIND_HOST`: Exported by `snore serve --host` before socket creation so the app lifespan validates the actual bind address.
  • `SNORE_DEV_ORIGINS`: Optional comma-separated extra CSRF-allowed origins for development. Every entry is validated at startup; malformed entries are `ConfigError`. Empty in production.
  • `SNORE_TRUSTED_PROXIES`: Comma-separated trusted proxy IPs; enables `cf-connecting-ip` forwarding when the immediate peer is trusted. Forwarded value validated as a well-formed IP before use (shared with the canonical `get_client_ip()` helper).
  • Upload and job resource bounds: `SNORE_MAX_UPLOAD_BYTES`, `SNORE_MAX_FILE_BYTES`, `SNORE_MAX_JOBS_PER_USER`, `SNORE_MAX_JOBS_GLOBAL`.

Canonical IP helper (`src/snore/api/client_ip.py`)

Single `get_client_ip()` implementation used by both `RateLimitMiddleware` and the auth router. Validates `cf-connecting-ip` as a well-formed IP before using it as a lockout key; malformed or missing forwarded values fall back to the peer address.

Middleware (`src/snore/api/middleware.py`)

`AuthMiddleware`: resolves `request.state.actor` on every request. Local mode idempotently bootstraps the single admin user + profile. Multiuser mode validates the signed session cookie: loads the user, checks `disabled_at`, checks `session_version`, resolves the active profile with ownership revalidation.

`CsrfMiddleware` (new): applies to all unsafe methods (POST/PUT/PATCH/DELETE) in multiuser mode. Compares incoming `Origin` (or parsed `Referer`) against `AppConfig.public_origin` and pre-parsed `dev_origins` tuples using canonical `(scheme, host, effective_port)` comparison — no `startswith`. A literal `"null"` origin (file:// / sandboxed iframe) is always rejected. Fails closed when `public_origin` is `None`. Also applies a 16 KiB body ceiling to all `/api/v1/auth/` requests (first resource boundary before Pydantic materialization) and adds `Cache-Control: no-store` to all `/api/v1/auth/` responses.

`RateLimitMiddleware` (implemented): per-IP sliding-window rate limiter (30 requests per 60-second window) on all `/api/v1/auth/` paths in multiuser mode. Uses `get_client_ip()` so the key matches credential lockout.

`_ByteCeilingReceive`: ASGI receive wrapper that enforces byte ceilings before multipart spooling; used for both upload ingress and the auth-endpoint body limit.

Auth router (`src/snore/api/routers/auth.py`)

Routes: `POST /login`, `POST /logout`, `GET /status`, `POST /active-profile`, `POST /invites/lookup`, `POST /invites/redeem`.

Invite tokens are in the request body, not the URL. The emailed invite URL carries the token in a fragment (`/invite#`) so it never appears in URL paths or access logs. The CLI prints the fragment URL; the future UI extracts it client-side and POSTs to the lookup/redeem endpoints. `SNORE_MULTIUSER_PLAN.md:233`.

  • Generic 401 for wrong email vs. wrong password (timing-equalized via dummy Argon2id).
  • Lockout: per `(canonical_email, trusted_client_ip)` exponential backoff via shared `get_client_ip()`. Invite lookup/redeem keyed per `(token_hash, ip)`.
  • Disabled users rejected on all paths.
  • CSRF handled globally by `CsrfMiddleware`.
  • Model bounds: `LoginRequest` email `max_length=254`, password `max_length=4096`; `InviteLookupRequest`/`InviteRedeemRequest` token `max_length=256`, password `max_length=4096`.
  • Argon2 operations run via `loop.run_in_executor(_KDF_EXECUTOR)` where `_KDF_EXECUTOR = ThreadPoolExecutor(max_workers=4)`. The executor slot is owned by the thread, not the awaiting coroutine — cancelling a request does not release the slot, so at most 4 native Argon2 ops run concurrently regardless of cancellation.
  • Password byte validation via shared `validate_password_bytes()` helper (1–1024 bytes; empty rejected; used by login and invite redemption).
  • Invite redemption uses `run_txn` for atomic consume + create.
  • Session cookies: host-only (no `Domain` attribute), `HttpOnly`, `SameSite=Lax`, `Path=/`, 14-day `Max-Age`, `Secure` derived from transport.

Passwords (`src/snore/auth/passwords.py`)

  • `validate_password_bytes()`: shared byte-based validator enforcing 1–1024 bytes.
  • `_KDF_EXECUTOR = ThreadPoolExecutor(max_workers=4)`: dedicated executor whose slot is owned by the running thread; cancelling awaiters cannot admit a 5th native op.
  • `hash_password_async()`, `verify_password_async()`, `dummy_verify_async()`: async wrappers using `run_in_executor`.

Lockout and rate limiting (`src/snore/auth/lockout.py`)

  • `LockoutStore`: per `(key, ip)` exponential backoff. `record_failure` drops new keys when at `MAX_ENTRIES` with no expired slots (no active-lockout eviction).
  • `RateLimitStore`: per-IP sliding window. `check_and_record` now purges up to 64 stale entries before the capacity decision so the table recovers when old windows close (no permanent fail-open after saturation).

Upload resource bounds (`src/snore/api/routers/import_data.py`)

  • `_ByteCeilingReceive` (imported from `middleware.py`): ingress byte ceiling before spooling.
  • `_copy_chunked()`: synchronous 64 KiB chunk copy running via `asyncio.to_thread`. Server-side byte counting; removes dest on over-limit.
  • One upload lifecycle guard (`try/finally`) from `mkdtemp()` through every exit: success, `HTTPException`, `Exception`, and `CancelledError`. On cancellation, `asyncio.shield()` waits for the copy thread to terminate before cleanup runs. `SNORE_MULTIUSER_PLAN.md:194-198`.

CLI (`src/snore/cli/commands/serve.py`, `src/snore/cli/groups/user.py`)

  • `snore serve`: exports `SNORE_BIND_HOST` = `--host` value, then calls `load_config()` before `uvicorn.run`. Local mode + non-loopback bind exits with `ConfigError` before any socket is opened.
  • `snore user invite`: invite URL printed in fragment format (`{base_url}/invite#{token}`) built from `SNORE_PUBLIC_BASE_URL`.

Justfile (`local.just`)

  • `dev`: local mode via `snore serve`.
  • `dev-auth`: multiuser mode with throwaway secret; includes `SNORE_DEV_ORIGINS` for Vite dev server; runs `snore serve`.
  • `_dev-auth-seed`: wired to `snore user invite dev@localhost --role admin`.

README (`README.md`)

Server-launch section updated: `snore serve` documented as the supported network launcher; local vs multiuser binding rules stated; stale "auth not yet implemented" wide-binding example removed.

TypeScript regeneration (`ui/src/types/generated.ts`)

Mechanical regeneration reflecting the invite endpoint shape change: `GET /invites/{token}` and `POST /invites/{token}/redeem` replaced by `POST /invites/lookup` and `POST /invites/redeem` with request bodies.

Tests

1323 tests pass (1276 original + 47 adversarial tests in `test_security_adversarial.py`). Pass-1 adversarial tests (TestCritical1–TestAuthNoStore) cover: process-level `snore serve --host 0.0.0.0` refusal; CSRF prefix/userinfo/null origin rejection; cross-origin multipart import rejection; cookie no-`Domain`; chunked-copy strict-read and cleanup-on-over-limit; lockout at-cap; multibyte 1024-char password boundary; `Cache-Control: no-store` on representative 2xx/4xx.

Pass-2 adversarial tests (TestP2*) cover: KDF executor slot count under cancellation; `snore-upload-*` snapshot before/after mid-copy 413 (parent dir gone); mid-copy slot release; port validation (`:bad`/`:0`/`:70000`); canonical IP with malformed/IPv6 forwarded values; stale-at-cap rate-limit recovery; overlong email/password at real endpoints; invite token absent from log records; invite URL fragment format; password validator byte boundaries (0/1/1024/1025/multibyte); CSRF fails closed on `None` `public_origin`.

npub17xpz0p704l6vlapga6nahzevr9h0kd9ggfzw640d9yevhmcgst2ql280uq and others added 4 commits August 3, 2026 15:04
… router, guards

Add SNORE_AUTH_MODE (default multiuser, fail-closed), SNORE_SESSION_SECRET
(required in multiuser), SNORE_PUBLIC_BASE_URL (validated, drives Secure
cookie flag), and SNORE_TRUSTED_PROXIES config via AppConfig/load_config.
Startup refuses local mode on non-loopback bind.

Real AuthMiddleware: local mode idempotently bootstraps local admin via
ActorContextFactory.make_local; multiuser mode validates signed session
cookie, DB-validates user + session_version, falls back on stale profile.

Full auth router: POST /auth/login (Argon2id verify, dummy-verify unknown
emails, lockout by email+trusted-IP, disabled-user rejection, generic
errors), POST /auth/logout (clears cookie), GET /auth/status, POST
/auth/active-profile (ownership re-validated), GET /auth/invites/{token}
(Cache-Control: no-store), POST /auth/invites/{token}/redeem (atomic
user+profile creation via run_txn, session issued).

require_auth / require_writable / require_admin guards wired.
/import/detect and /import/path not registered in multiuser mode.
/db/reset not registered in multiuser mode.

Justfile local.just: dev (local mode) and dev-auth (multiuser, throwaway
secret, seeded invite) targets.

Test conftest: SNORE_AUTH_MODE defaults to local; autouse reset_auth_config
fixture clears cached AppConfig between tests so Phase 2 tests can override
mode per-test via monkeypatch. All 1244 existing tests pass.

Update ui/openapi.json and regenerate ui/src/types/generated.ts to include
the six new auth routes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…guards

The api_client fixture's override_get_actor declared Depends(override_get_db)
directly instead of Depends(get_db). FastAPI caches deps by callable identity,
so override_get_db appeared as a separate cache node and called begin() a second
time on the same session, raising 'A transaction is already begun on this Session'.

Fix: declare Depends(get_db) in override_get_actor so FastAPI resolves it through
the override table to the cached override_get_db node. begin() is called exactly
once per request.

Also wires the route auth guards staged from the prior session:
- sessions.py: PATCH/DELETE get RequireWritable
- analysis.py: POST run/batch/DELETE get RequireWritable
- profiles.py: GET gets RequireAuth, POST/PATCH get RequireWritable
- db.py: /stats gets RequireAuth, /vacuum gets RequireAdmin
- guards.py: require_auth now chains through get_actor (public dep) so test
  overrides cascade through all guards without a private _get_actor indirection

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add three resource-bound config fields to AppConfig (SNORE_MAX_UPLOAD_BYTES,
SNORE_MAX_JOBS_PER_USER, SNORE_MAX_JOBS_GLOBAL) with full env-var parsing and
ConfigError validation for zero/negative values.

Replace module-level MAX_ACTIVE_PER_USER/MAX_ACTIVE_GLOBAL constants in
import_jobs.py with _get_caps() that reads live from config, enabling test
override via env without module reload.

Add _ByteCeilingReceive ASGI receive wrapper in import_data.py that counts
bytes in-stream and raises 413 before Starlette spools to temp files —
enforces the ingress ceiling before any disk I/O. Post-spool size check kept
as defense-in-depth only.

Wire RequireWritable on import_files, import_from_path, and cancel_import,
replacing the manual can_write guard. Add PENDING_UPLOAD admission slot
reservation before body read in import_files.

Update test_upload_size_limit_exceeded to patch _get_upload_limits instead of
the removed MAX_UPLOAD_BYTES constant. Update test_import_jobs_admission.py to
read caps from _DEFAULT_MAX_ACTIVE_PER_USER/_DEFAULT_MAX_ACTIVE_GLOBAL instead
of importing the removed module-level names.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add 28 integration tests covering all Phase 2 named test requirements:
- TestLocalModeNonLoopbackStartupRefusal: 0.0.0.0/LAN/loopback/localhost
- TestDevAuthCookieOverLoopbackHTTP: secure_cookie=False for loopback HTTP
- TestLockout: exponential back-off, generic 401, cross-IP independence
- TestSessionVersionInvalidation: bumped version rejects old cookie
- TestStaleProfileCookie: tombstoned and foreign profile_id fallback to default
- TestCSRFOriginCheck: wrong/absent/correct origin on login/logout/redeem
- TestPathImportAbsenceInMultiuser: /import/detect + /import/path absent in
  multiuser (incl. loopback proxy peer); present in local mode
- TestInviteLifecycle: expiry, revoke, replay, valid success, IS NULL guard

All 1272 tests pass (1244 pre-existing + 28 new).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 marked this pull request as ready for review August 3, 2026 19:47
- guards.py: correct docstring — service-dep routes authenticate
  implicitly via ActorDep → get_actor, not via an explicit guard
  parameter; document all three auth patterns (explicit, implicit,
  public) and local-only routes
- validation.py: upgrade POST /validate/ from RequireAuth to
  RequireWritable — BatchValidator._validate_session calls
  facade.run_analysis(store_results=True) by default, making this a
  write-capable endpoint
- config.py: add SNORE_MAX_FILE_BYTES (per-file size limit, default
  256 MiB); parse and pass through to AppConfig
- import_data.py: enforce per-file cap post-spool (413 if any
  individual file exceeds max_file_bytes); prefix mkdtemp dirs
  snore-upload- for startup identification
- app.py: add _cleanup_stale_upload_tempdirs — scans tempdir at startup
  and removes snore-upload-* dirs older than 2 h to reclaim disk after
  a crash
- auth.py: add _opportunistic_purge_oauth_attempts and call it on the
  POST /auth/login and POST /auth/invites/{token}/redeem paths so
  expired/consumed rows are purged between restarts, not only at startup
- db.py: remove stale WARNING comment (vacuum/stats are now
  authenticated via RequireAuth/RequireAdmin)
- test_import.py: add test_per_file_limit_exceeded_returns_413 and
  TestStaleTempDirCleanup (removed / kept / non-snore unaffected);
  update _get_upload_limits patch from 2-tuple to 3-tuple

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 marked this pull request as draft August 3, 2026 20:08
@wpfleger96
wpfleger96 marked this pull request as ready for review August 3, 2026 20:23
Closes 3 CRITICAL, 4 IMPORTANT, and 1 MINOR finding from Thufir's pass-1
review of PR #157 (Phase 2 backend auth core).

Unified-boundary architecture per Paul's ruling:

CRITICAL 1 — serve.py exports SNORE_BIND_HOST before uvicorn and calls
load_config() for early validation; local-mode + non-loopback bind is
refused before the socket opens.

CRITICAL 2 — CsrfMiddleware added to the global middleware stack: applies
to all unsafe methods in multiuser mode, compares canonical
(scheme, host, effective_port) tuples (never startswith), covers all
mutations including multipart import, adds Cache-Control: no-store to all
/api/v1/auth/ responses. Hardcoded localhost:5173 origins removed; dev
origins go through SNORE_DEV_ORIGINS. SNORE_PUBLIC_BASE_URL now rejects
userinfo/path/query/fragment at config load time.

CRITICAL 3 — cookie_domain property removed; set_session_cookie and
clear_session_cookie no longer accept a domain parameter; all session
cookies are host-only (no Domain attribute).

IMPORTANT 1 — Upload copy replaced with _copy_chunked() running in
asyncio.to_thread: reads in 64 KiB chunks, counts bytes server-side,
removes dest and raises on over-limit.

IMPORTANT 2 — LockoutStore.record_failure fixed to not exceed MAX_ENTRIES
when all entries are active (drops rather than evicting a live lockout).
RateLimitMiddleware implemented: per-IP sliding window (30 req/60s) on all
/api/v1/auth/ paths in multiuser mode. Invite lookup/redeem keyed through
the lockout store per (token_hash, IP). Forwarded IP validated before use.

IMPORTANT 3 — validate_password_bytes() shared helper enforces the
1024-byte limit consistently on login and invite redemption. Invite
redemption previously checked character count, allowing 1024 multibyte
chars to pass and raise ValueError from hash_password. Argon2 calls
offloaded via asyncio.to_thread behind asyncio.Semaphore(4).

IMPORTANT 4 — _dev-auth-seed recipe wired to real snore user invite
(was calling nonexistent snore invite create behind || true). Invite URL
built from SNORE_PUBLIC_BASE_URL env var, not hardcoded localhost:8000.

MINOR — Cache-Control: no-store applied by CsrfMiddleware to all auth-path
responses (2xx and 4xx alike), covering framework 422 and HTTPException
errors that bypassed the per-handler _NO_STORE dict.

25 adversarial tests added in test_security_adversarial.py covering all
8 findings. 1301 tests pass at this head.
Closes all 8 IMPORTANT and 3 MINOR findings from Thufir pass-2 of PR #157.

Four ownership boundaries (Paul ruling 1):

(a) KDF executor — ThreadPoolExecutor(max_workers=4) replaces the asyncio
Semaphore.  The executor slot is owned by the thread, not the awaiting
coroutine.  Cancelling a request leaves the Argon2 thread running and the
slot occupied; a 5th submission queues in the executor, never starts a 5th
native op.

(b) Canonical trusted-client IP — new src/snore/api/client_ip.py is the
single get_client_ip() implementation; both RateLimitMiddleware and the auth
router import from it.  The duplicate unvalidated auth.py:_client_ip is
deleted; the IP-address-only forwarded-header validation is now enforced
everywhere.

(c) Upload lifecycle guard — single try/finally from mkdtemp() through all
exits: success, HTTPException, Exception, and CancelledError.  On
cancellation: asyncio.shield() waits for the copy thread to terminate before
cleanup runs.  HTTPException path now cleans up tmp alongside the job.

(d) Parsed-origin single source — public_origin stored as a field computed
once in load_config(); _validate_public_base_url() validates port (rejects
text, 0, >65535 with ConfigError); secure_cookie and CsrfMiddleware both
consume public_origin.  dev_origins pre-parsed to tuples at startup;
malformed SNORE_DEV_ORIGINS is ConfigError.  CSRF fails closed when
public_origin is None in multiuser.

Invite tokens out of URLs (ruling 2): GET/POST /invites/{token} replaced by
POST /invites/lookup + POST /invites/redeem with token in request body.
Invite URL printed by CLI uses fragment format (/invite#<token>) so the raw
token never appears in URL paths or access logs.
SNORE_MULTIUSER_PLAN.md:233.

Auth model bounds (ruling 3): email max_length=254, password max_length=4096,
token max_length=256 in Pydantic models.  16 KiB auth-body ceiling in
CsrfMiddleware.  _ByteCeilingReceive moved to middleware.py (single impl).

Rate-limit saturation recovery (ruling 4): check_and_record purges up to 64
stale entries before the capacity decision so the table recovers when old
windows close.

MINORs: validate_password_bytes enforces 1-1024-byte invariant (empty
rejected); CSRF fails closed on None public_origin; SNORE_DEV_ORIGINS
malformed = ConfigError; README documents snore serve as supported launcher.

OpenAPI schema regenerated; generated TypeScript updated for new invite shape.

22 new adversarial tests in TestP2* classes.  1323 tests pass at this head.
All in test evidence and one code edge (Paul's acceptance check,
rework round 2).

Code fix: double-cancellation gap in import_files upload guard.
The shielded wait inside ``except CancelledError`` was itself
cancellable — a second cancel during the wait would absorb it
and let finally-cleanup run while the copy thread was still live.
Replaced the single ``await asyncio.shield(copy_task)`` + suppress
with a ``while not copy_task.done()`` loop so cleanup never races a
running write regardless of how many cancellations arrive.

Test fixes (6 blockers):

1. Upload cancellation test (blocker 1): route-level cancellation test
   added to TestP2UploadLifecycle.  Exercises the actual lifecycle guard
   code (job reservation, asyncio.shield loop, finally cleanup) with a
   blocking copy function, cancels the outer task, verifies temp dir and
   slot are released.  SNORE_MULTIUSER_PLAN.md:194-198.

2. test_invite_url_fragment_format (blocker 2): replaced OR assertion
   with affirmative assert + exit-code-0 check. Now fails if snore
   errors out, prints nothing, or uses the old path-based URL.

3. test_invite_token_absent_from_server_access_log (blocker 3): new
   subprocess test that boots a real snore serve, sends a POST to
   /auth/invites/lookup with a known token in the body, then asserts
   the raw token is absent from all server output (stdout+stderr).
   Requires --db flag to point to a fresh temp DB.

4. KDF test uses real wrappers (blocker 4): test_kdf_async_wrapper_
   runs_on_snore_kdf_thread and test_kdf_slot_held_after_awaiter_cancel
   now drive hash_password_async / verify_password_async with the
   production _KDF_EXECUTOR (size patched to 1 for isolation).  Patches
   passwords.hash_password (the module function submitted to the executor)
   rather than the read-only C-extension method.

5. Double-cancellation gap fixed in import_data.py (blocker 5, code).

6. auth.py Routes docstring updated to reflect the new invite endpoint
   names (blocker 6).

1326 tests pass at this head.
…test

Closes Paul's remaining acceptance blocker (IMPORTANT 3 test shape).

Replaces test_upload_cancel_cleans_up_and_releases_slot (which duplicated
the production guard inline) with test_upload_cancel_drives_real_handler.

The new test drives the actual import_files handler through
httpx.AsyncClient + ASGITransport.  _copy_chunked is monkeypatched to a
double that signals copy_started_event via call_soon_threadsafe and then
blocks on a threading.Event gate.  The test polls copy_started_event,
cancels the upload task, releases the gate so the copy thread finishes,
then asserts: no new snore-upload-* dirs, _global_count back to baseline,
and reserve_slot succeeds at the cap.

If the real import_files guard regresses (shield loop deleted, finally
reordered, ownership transfer moved), this test fails because the real
handler runs.  The URL is /api/v1/import/ (trailing slash) to avoid
Starlette's 307 slash-redirect.  SNORE_MULTIUSER_PLAN.md:194-198.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant