AZ-005 — Users and session authentication API
Status
Done
Context
Read README, architecture, Docker, testing and CI before implementation. No user/auth
tables/endpoints exist. backend/src/server.ts wires concrete repositories; app.ts accepts
dependencies. packages/db owns Drizzle schema/migrations. AZ-003/004 provide safe codes/logging.
The existing integration helper bypasses Drizzle's journal and naïvely splits SQL; auth migrations
require exercising the actual migration mechanism. This task delivers backend login, not its UI.
Goal
Deliver persistent users and revocable cookie sessions with safe provisioning and coherent login/logout/current-user API behavior across host and packaged local environments.
Requirements
Identity, persistence and provisioning
- Add users with opaque stable ID, normalized email/login address with uniqueness, display name, password hash, enabled/disabled state, timestamps and preferences needed later (or add those preferences in AZ-007). Enforce length/null/unique constraints and map conflicts safely. Document case-insensitive email normalization (trim/lowercase for this application's login), without claiming all mail providers treat addresses identically. Do not attach users to the message, static brand or implicit sole business; no tenant/membership/role columns.
- Hash passwords using maintained Argon2id, with OWASP-recommended parameters at least 19 MiB, two iterations, parallelism one; benchmark sensible compatible settings on Node 24 Alpine. Library owns salts/verification. Permit long passphrases, set a bounded maximum of at least 128 characters, minimum 15 without MFA, no composition rules, trimming or silent truncation. Use the same policy in provisioning, password change and reset. Verify unknown accounts against a valid dummy hash to reduce obvious timing differences; never log plaintext/hashes.
- Add narrow user/password/session persistence contracts and concrete PostgreSQL implementations
under existing backend application/repository responsibilities. Avoid generic repositories or
moving credential hashing into
packages/dbjust to make seeding easy. - Introduce root
pnpm user:create, delegating to a backend workspace tooling command explicitly introduced here. Prompt safely for address/name/password (hidden terminal input), or accept password via stdin in controlled automation, never command-line args or logs. Use the real policy/hasher/repository. Create multiple users without a singleton flag; existing address fails clearly and never silently resets credentials. Exit nonzero on validation/DB failure. - No automatic staging/production user seed or embedded default password. Existing
db:seedkeeps message behavior and does not overwrite users. Provide separate rootpnpm user:seed:test(introduced here) for deterministic synthetic local/test fixtures only, requiringDEPLOYMENT_ENV=local|test, explicit test credential input, and a guarded dedicated test target. Wire E2E setup/CI fixture creation without production defaults. Do not infer safety only from a substring in DATABASE_URL; document explicit target validation/allowed test names.
Session and HTTP boundary
- Use compatible maintained
@fastify/cookie,@fastify/session,@fastify/csrf-protection, and@fastify/rate-limit; use plugin-generated IDs/signing/CSRF mechanisms, not custom crypto. Implement the session store API using PostgreSQL, not the plugin's default memory store. Fastify session documentation defines the plugin store contract and proxy/cookie requirements. Store only needed session data, principally user ID, CSRF state and expiry; never a full user/credential payload. - Hash lookup session identifiers at rest where compatible with the plugin store adapter; keep the hash transformation inside that adapter. Enforce expiry server-side independently of cookie expiry. Baseline absolute lifetime eight hours, no indefinite rolling; clean expired rows using bounded opportunistic cleanup or a documented maintenance operation, no scheduler infrastructure. Disabled/deleted user must not authenticate through an old session.
- Set HttpOnly, SameSite=Lax, Path=/, host-only cookie; Secure in staging/production. Use one documented cookie name, session signing secret minimum plugin requirement, no default secret in hosted mode. Regenerate on login and explicitly invalidate the old identifier; prove the old session is unusable, since regeneration alone must not be assumed to destroy store data. Logout invalidates stored session and expires matching cookie attributes; session storage failures fail closed and do not create authenticated success.
- Introduce validated
DEPLOYMENT_ENV=local|test|staging|production. For production Node builds, absent deployment classification means production; for development/test use matching safe defaults. Compose explicitly sets local classification despiteNODE_ENV=productionand permits HTTP cookies only there. Hosted staging/production require secure cookies and secret. Inject/generated local signing secret may support quickstart; do not commit a reusable secret. Store persistence survives restart; sessions survive only when a stable configured signing secret is intentionally retained. Document local generated-secret restart logout behavior. - Configure trusted proxies explicitly and narrowly for secure cookies/rate-limit IPs. Local Compose
gateway and Vite change host/origin context: validate against configured public browser origin,
not internal Docker Host alone. Do not enable blanket
trustProxy: truefor directly accessible hosted endpoints without a documented trusted-proxy boundary. - Add
GET /api/auth/csrf(200 token),POST /api/auth/login(200 minimal user DTO),POST /api/auth/logout(204), andGET /api/auth/me(200 DTO or 401). User DTO exposes only ID/email/display name/preferences, never hash/session data. Use no-store on auth responses. Logout is idempotent for already unauthenticated clients but still validates CSRF/origin. Current-user endpoint and any account operation authenticate server-side;/healthand/api/messageretain public existing behavior. - Require CSRF token header and allowlisted exact Origin for browser mutations, including login and logout, using the plugin's maintained token validation. Bootstrap CSRF with a bounded anonymous session; rate-limit bootstrap and ensure expired anonymous sessions are cleaned. Define missing Origin policy: reject browser-style mutations without it in this baseline; CLI provisioning uses services directly, not bypassing HTTP checks. SameSite alone is not CSRF.
- Login validates JSON, returns generic 401 for unknown/disabled/wrong credentials, 400 for invalid shape, 429 with Retry-After on exceeded configured throttle, generic 500 on internal failure. Apply reasonable IP + normalized-account limits, including nonexistent accounts; initial default 10 login attempts per 15 minutes per IP/account dimension, configurable and testable. Do not expose address in log/key output; avoid permanent lockout abuse. Document in-process limiter's single-instance/restart limitation without adding Redis now.
- Extend optional exact-origin CORS to required methods/headers, credentials and exposed safe response headers. Preserve empty-disabled mode and legacy anonymous requests. Never combine wildcard and credentials. Scope preflight changes without declaring plugin-generated OPTIONS as public OpenAPI operations. Same-origin is canonical; same-site separate origins need exact frontend origin configuration. Unrelated cross-site cookie origins are outside this epic.
Migration and testing lifecycle
- Generate/review committed auth migrations and journal with
pnpm db:generate. Fix backendtest-support/test-database.tsto run the real journaled migration mechanism for dedicated databases, without arbitrary SQL splitting or developer-data reset. Add targeted empty schema, upgrade from message-only schema with preserved id 1, and migration-rerun tests. - Update dev E2E setup and Docker fixture preparation for users without changing production seed requirements. Docker E2E currently has no state-changing global setup: add an explicit guarded test provisioning step/lifecycle owned by test tooling, not backend startup or normal seed. Fast tests require no real DB/secret; controlled collaborators/config cover HTTP contracts.
- Verify real HTTP cookie jar login/me/logout, expiry, disabled account, old-ID rejection, signature tampering, CSRF rejection and store failure. Use real PostgreSQL for identity/session persistence, cleanup and uniqueness, not mocked storage as integration evidence.
Non-goals
No frontend login yet, registration/invitations, admin UI/roles, OIDC provider, token refresh JWTs, email verification, recovery/change-password (later tasks), tenancy, or additional infrastructure.
Implementation guidance
Keep hashing/use-case policy in backend application code, HTTP/plugin adaptation in API code and
PostgreSQL store queries behind repositories. server.ts composes them. Thin tooling wrappers
belong within backend, not a new top-level package. Include .js import extensions per backend
convention and TypeDoc comments for added intentional public API. Native Argon2 dependencies must
work in the deployed pnpm --prod deploy --legacy Alpine image, proven by clean Docker build.
Extend the route-surface contract test with controlled auth collaborators and await plugin readiness
where necessary; conditional developer route surfaces are handled later, not hidden from OpenAPI.
Acceptance criteria
- Multiple users can be provisioned safely; normalization/uniqueness/password policy and disabled status are enforced without account credential leaks.
- Login/me/logout, invalid credentials, expiry/tampering/revocation and storage failures meet documented status/header/body behavior; old IDs cannot survive login rotation.
- CSRF/origin/throttle protections apply to login/logout, with no hidden public auth bypass.
- HTTP local Compose works; hosted secure/secret/proxy configuration fails safely when invalid.
- Real journaled migrations prove fresh/upgrade/rerun behavior and preserve the message.
- Provisioning/fixtures cannot write hosted users automatically; no credentials reach images, logs, screenshots, OpenAPI examples, public config or committed test artifacts.
Verification
pnpm db:generate
pnpm quality
pnpm test
pnpm build
pnpm test:coverage
pnpm test:ci
pnpm validate:docker
Run generation only after schema changes; review generated SQL/journal without reformatting.
Coverage needs local/test PostgreSQL. Docker validation includes integration once; upgrade/rerun
tests must join that suite. Test introduced pnpm user:create and pnpm user:seed:test with
dedicated synthetic targets and negative hosted-config cases. Manually inspect cookie flags and safe
logs in local HTTP and a controlled secure-proxy test harness, not unauthorized live staging.
Documentation
Update OpenAPI, architecture (identity/session/CSRF boundaries), techstack, development (provisioning, secrets, exact origins, fixture flow), Docker (local versus hosted classification), testing (journaled migrations/session integration), CI (fixture inputs). Record an auth ADR with internal versus OIDC rationale and deployment limitations. Update current-state/handover/evidence.
Dependencies
AZ-004 (including AZ-001–003); existing Drizzle migration tooling, Fastify composition and local database/Compose/integration profiles.
Future considerations
Global user identity is not business ownership. A later multi-business epic must add scoped data access/membership decisions before admitting another business; no implicit global admin role now.