# DESIGN — F-005 Identity: register, login, sessions ## Module layout (hexagonal, screaming) ``` src/modules/identity/ index.ts # public API only: registerIdentityRoutes + types domain/ user.ts # User, NewUser types; email normalization session.ts # Session type, token TTL constants errors.ts # InvalidCredentials, EmailAlreadyRegistered, RateLimited ports.ts # PasswordHasher, UserRepository, SessionRepository application/ register-user.ts # RegisterUser use case login.ts # Login use case (rate limit + timing equalization) logout.ts # Logout use case (revoke by token hash) login-rate-limiter.ts # LoginRateLimiter interface + InMemoryLoginRateLimiter infrastructure/ argon2-password-hasher.ts # argon2id, OWASP params pg-user-repository.ts # identity_users CRUD (pg parameterized) pg-session-repository.ts # identity_sessions create/revoke-by-token-hash session-token.ts # random token + sha256 hash helpers api/ identity.routes.ts # POST /auth/register | /auth/login | /auth/logout tests/ login-rate-limiter.test.ts # unit session-token.test.ts # unit ``` Rules respected: domain imports nothing from infra/frameworks; application depends on domain ports only; infrastructure implements ports; api is a thin adapter using `parseJson` (F-003) and maps domain errors to AppError. ## Data model — migration 002_identity ```sql identity_users( id uuid PK DEFAULT gen_random_uuid(), email citext NOT NULL UNIQUE, password_hash text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ) identity_sessions( id uuid PK DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, -- sha256 of the opaque cookie token created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, revoked_at timestamptz ) + index on user_id ``` - citext for case-insensitive unique email (extension from baseline). - Only the token HASH is stored: a DB leak does not yield usable sessions. - Down drops both tables (reverse FK order). ## Password hashing - argon2id, memoryCost 19456 KiB, timeCost 2, parallelism 1 (OWASP 2024 baseline). - Output PHC string (`$argon2id$...`) stored as-is; verify via argon2.verify. - Timing equalization: unknown email still runs one verify against a module-level dummy hash so 401 timing does not enumerate users. ## Sessions and cookie - Token: `crypto.randomBytes(64).toString('base64url')` (opaque, 512 bits). - TTL: 7 days, stamped in `expires_at`; expired/revoked sessions are invalid. - Cookie `mdv_session`: HttpOnly, SameSite=Lax, Path=/, Secure per config (`COOKIE_SECURE`, default true; `.env.example` sets false for local http dev), Max-Age = TTL. - Logout revokes by token hash (`revoked_at`), always answers 204 and clears the cookie (idempotent). ## Rate limiting ```ts interface LoginRateLimiter { consume(key: string): { allowed: true } | { allowed: false; retryAfterMs: number }; recordFailure(key: string): void; reset(key: string): void; } ``` - InMemoryLoginRateLimiter: per normalized email, threshold 10 consecutive failures, 15-minute cooldown once reached, reset on success. Lazy sweep of expired entries. - In-memory per instance (documented limitation; interface allows Redis-backed swap). - 429 response includes `Retry-After` seconds. ## Endpoints | Route | Success | Errors | |---|---|---| | POST /auth/register | 201 `{ id, email, createdAt }` | 400 VALIDATION_ERROR, 409 EMAIL_ALREADY_REGISTERED | | POST /auth/login | 200 `{ id, email }` + Set-Cookie | 401 INVALID_CREDENTIALS (generic), 429 TOO_MANY_ATTEMPTS | | POST /auth/logout | 204 + clear cookie | — (idempotent) | Request bodies validated with zod via `parseJson` (F-003 hook): email + password (min 8, max 128 chars). ## Wiring - `pool.ts`: `createPool(connectionString)` replaces `createPoolFromEnv` (config owns env). - `config.ts`: adds optional `COOKIE_SECURE` (default true). - `buildApp(deps)`: new optional `pool` + `cookieSecure`; when pool present it registers identity routes. No pool = foundation-only app (keeps F-001..F-004 tests DB-free). - `server.ts`: creates pool from config, passes into buildApp. ## Test strategy - Unit (module-scoped): rate limiter thresholds/reset/cooldown; session token randomness/hashing; domain error mapping. - Composition (app-scoped itest, real PostgreSQL): register/login/logout happy paths; argon2 PHC hash in DB + no plaintext; duplicate email 409; identical 401 bodies for wrong password vs unknown email; cookie attributes; 10 failures -> 429 + Retry-After; logout revokes (second logout idempotent, session unusable). ## Risks / mitigations - Native argon2 build: toolchain present; if install fails, switch impl behind the PasswordHasher port to @node-rs/argon2 (no design change). - Enumeration: generic 401 + dummy verify; register 409 accepted as standard contract (login is the enumeration-sensitive path per acceptance). - In-memory rate limiter bypass via multi-instance: documented; interface ready for Redis.