# backend-auth.md — AUTH worker handoff (auth.rs + admin.rs) Status: both modules compile clean (`cargo check` shows zero diagnostics for auth.rs/admin.rs, lib and test profiles). 9 security unit tests exist inside the two modules (they compile; running the full `cargo test` target is still blocked by compile errors in CORE/DOMAIN modules: store.rs:179, jobs.rs, main.rs, ai.rs, datasets.rs — not AUTH-owned. Parent final compile gate will unblock them.) ## What is implemented ### auth.rs - `pub const COOKIE_NAME = "sl_session"`. - `AuthUser` extractor: reads cookie, hashes the token with sha256, resolves `sessions JOIN users` requiring unexpired session and `users.active=1`. Rejected tokens return 401; disabled accounts lose the session immediately. - Argon2id password hash/verify (`hash_password`, `verify_password`), fresh random salt per call. - Sessions: random 256-bit token; **only `sha256(token)` is stored** (sessions.id). Cookie is HttpOnly + SameSite=Strict + Path=/ + Max-Age, plus `Secure` when `cfg.secure_cookies` (set from https canonical origin). - `make_admin_token(cx, table, user_id, hours, email)` — shared factory for invitations and password resets: raw token shown once, only the sha256 is inserted; invitations are always stored as role 'member'. - `purge_expired` — housekeeping for expired sessions/invitations/resets and 2-hour-old login failures. - Handlers: - `POST /auth/login` — trims/lowercases email, per-email failure throttle (>20 failures in 1h ⇒ 429), verifies Argon2 hash, rejects `active=0` accounts with 403, issues session, writes audit. - `POST /auth/register` — **single-use atomically consumed invitation with email binding**: inside ONE closure transaction it checks the token exists, role is 'member', bound email matches (case-insensitive) when set, expiry; inserts the user with role='member' (the struct has no role field, so client-supplied `"role":"admin"` is ignored by serde en bloc) and marks the invite used via guarded `UPDATE ... WHERE used_by IS NULL`, rejecting the second concurrent attempt with 409. Duplicate emails → 409 `email_taken`. - `POST /auth/logout` — deletes the server-side session row, clears cookie. - `GET /auth/me`, `PATCH /auth/profile` — owner-scoped profile read/update, no password hash/secret in payload. - `POST /auth/password` — verifies current password; updates hash and revokes ALL other sessions in one transaction, keeping current session. - `GET /auth/sessions` — sanitized own-session listing (sha256-derived ids, no raw tokens, expired rows never listed). - `DELETE /auth/sessions/:id` — revokes an OWN session only; foreign id ⇒ 404. - `POST /auth/reset-password` — single-use token, expiry check, marks `used=1`, updates password hash and deletes ALL sessions of the user inside ONE transaction (replay of the token loses the update race). ### admin.rs - `assert_admin(&Cx, &AuthUser)` guards every admin handler (member ⇒ 403). - `GET /admin/users` — full user table (no password hashes). - `PATCH /admin/users/:id` — accepts {active?, role?, daily_run_limit?, ai_enabled?}; validates role admin|member and 1..=2000 limit; **prevents demotion/disable of the LAST active admin atomically** (count check inside the same transaction as the update, code `last_admin` 409); disabling revokes all sessions and cancels that user's queued/running runs in the same transaction. - `POST /admin/invitations` — POC invites mint member only (admin role via invitation rejected 400); token returned once, sha256 stored. - `GET /admin/invitations` — sanitized list, no token hashes. - `DELETE /admin/invitations/:id` — revoke; 404 when absent. - `POST /admin/users/:id/reset-password` — verifies target user exists, issues a hashed single-use short (2h) reset token returned once; audit event w/o secrets. Honest UI contract: admin-issued recovery token, no fake email. - `GET /admin/audit` — sanitized rendering (ts/actor/action/target/status); `auth::audit` refuses oversized/multiline targets (stored as `redacted-oversized`), so no password/key/code content can be persisted. - `bootstrap_admin(&Cx)` — idempotent first-admin bootstrap from env, Argon2 hashed, email normalized; no-op when any admin exists. ## Unit tests (inside the modules, RED→GREEN verified by compile-gate) auth: argon2 hash/verify; register member-only + role input ignored + payload-no-hash; invitation single-use; invitation email binding (and token not consumed by the failed attempt); reset single-use + session revocation + password actually rotated; change-password revokes others/keeps current + wrong current rejected; audit never records secrets; cookie sessions never store plaintext tokens + cookie flags; login throttle; logout invalidates server-side session. admin: member 403 on users/audit/invitations; last-admin disable/demote blocked then allowed with a second admin; disable revokes sessions and cancels runs; invitations only mint members, token hashed in DB and sanitized listing; bootstrap idempotent + Argon2 hash. ## Coordination notes for other workers / parent 1. db.rs schema is untouched (AUTH owns no db changes) and is fully sufficient. 2. qa_api.py gates covered by AUTH handlers: register ignores supplied admin role, invitation single-use, admin-role members cannot enumerate users, last admin cannot be disabled, admin reset flow + single-use + session revocation, disabled account login/session denial, invite email binding. CSRF substring/foreign-origin gates live in main.rs (CORE); private-object gates (admin cannot read private project, cross-member denial) in projects.rs — verify DOMAIN parts at the parent gate. 3. `AuthUser: FromRequestParts>` matches the existing router `.with_state(Arc)`; handlers across the codebase may keep either `cx: Cx` (State extractor) or the bare Arc — both remain valid for this router. 4. No cross-module edits made by AUTH; `make_admin_token` async signature is coherent for all admin.rs call sites.