R-Same M1 Identity Backend Core (Wave 1 pass 1)

Decision

M1 Identity backend core shipped in commit 6488d37 (2026-04-17). 16 files: 8 SQLAlchemy models (User, UserRoleAssignment, Workspace, WorkspaceMember, Session, PasswordResetToken, AuditEvent + UserRole/AuditResult enums), Alembic migration 20260417_0700_m1_identity with pgcrypto extension + all indices/constraints, 5 service classes (PasswordService argon2id OWASP-2024 params + zxcvbn ≥3 with Runwal blacklist, MFAService TOTP RFC 6238 via pyotp, SessionService JWT access + opaque hashed refresh with rotation + MFA challenge bridge, AuditService same-transaction writer, UserService lockout 10→15min), FastAPI deps (get_db async session, get_current_session/user, require_roles gate, client_ip, user_agent), 5 auth routes (POST /api/v1/auth/login, /mfa/verify, /refresh, /logout, GET /me) with HttpOnly+Secure+SameSite=Lax cookies + constant-time failure paths + generic error messages + slowapi rate-limit 5/min + audit_events write on every attempt, 26 unit-test assertions across password/MFA/session JWT (no DB required). main.py wires identity router at /api/v1. All 14 Python files parse-clean. Wave 1 pass 2 (admin provisioning, password reset, MFA enrollment, frontend, integration tests, seed CLI) follows.

Rationale

Security-critical module — auth correctness has catastrophic blast radius. Built to Law 11 Security by Default: argon2id (2024 OWASP guidance over bcrypt), mandatory MFA (v1 policy), rate-limit + lockout, constant-time credential verification (dummy hash on unknown email to burn timing signal), generic error messages that never reveal account existence, JWT short-lived (15min) with opaque server-tracked refresh enabling instant revocation, refresh-token rotation (old revoked + new issued in same tx), every mutation writes audit_events in same transaction. zxcvbn strength policy includes Runwal-context blacklist tokens (runwal/r-same/rsame/vantage) to prevent predictable passwords. Migration uses pgcrypto for server-side UUID generation (cryptographically secure). Session model stores SHA256 of refresh token only — raw token never persisted. Workspace membership role is a CHECK-constrained subset (owner/editor/viewer only — not all 7 roles) per L2 RBAC matrix. All models follow Postgres naming convention from packages/db/db/base.py for clean migrations.

Alternatives Rejected

bcrypt instead of argon2id — rejected: OWASP 2024 favors argon2id for memory-hardness; bcrypt acceptable but inferior.

JWT-only auth without server-tracked refresh — rejected: JWT revocation requires server-side state; opaque refresh token enables instant logout + password-change revocation.

Store password in JWT — rejected (never): credential hashes stay in DB only.

Magic-link passwordless v1 — rejected: email delivery becomes critical login path; password+MFA is more reliable for enterprise.

Allow login without MFA (toggle per user) — rejected: defeats the MFA-mandatory policy; M1 enforces “MFA required OR deny login”; pass 2 adds enrollment flow that runs after first-login-before-access.

Recovery codes in Session model — rejected: separate UserRecoveryCode table (pass 2) for correctness.

Global session cookie covering refresh path too — rejected: separate refresh cookie scoped to /api/v1/auth prevents leakage to module handlers.

Store user_id in JWT only (no session id) — rejected: can’t revoke individual sessions, only rotate passwords. sid claim in JWT + server-tracked Session row enables per-session revocation.

valid_window=0 on TOTP — rejected: one-step clock drift tolerance is standard (30-60s window).

argon2 params time=4 memory=128MB — rejected: OWASP 2024 minimum is time=3 memory=64MB; higher params slow login unnecessarily for R-Same’s scale.

Auto-create first platform-admin on startup — rejected: security anti-pattern; Wave 1 pass 2 ships make seed-admin CLI that requires operator input.

Skip rate-limiter on mfa/verify — rejected: protects against TOTP brute-force (even 1e6 combinations + valid_window=1 = 3M attempt space; rate-limit essential).

Single auth route combining login + MFA — rejected: 2-step with mfa_challenge_token lets UI render MFA prompt before asking for password again, better UX + cleaner state machine.

Outcome

Pending