From 75293f39bc3c6cae21031ed4e37fbe1d1808c569 Mon Sep 17 00:00:00 2001 From: rikrdo Date: Fri, 14 Aug 2026 22:58:32 +0200 Subject: [PATCH] feat(identity): F-005 register/login/logout with argon2 sessions and rate limiting - Hexagonal identity module: domain ports, use cases, argon2id hasher, pg repos - Migration 002_identity: identity_users + identity_sessions (token hash only) - Opaque 512-bit session tokens; DB stores SHA-256 hash; 7-day TTL in SQL - Cookie HttpOnly + Secure (COOKIE_SECURE, default true) + SameSite=Lax - LoginRateLimiter: 10 failures -> 429 + Retry-After, 15-min cooldown - Anti-enumeration: identical generic 401 + dummy-hash timing equalization - buildApp gains optional pool/cookieSecure; foundation-only app preserved - 47 unit + 14 integration tests; live smoke covers all acceptance criteria --- backlog/features.json | 8 +- project/.env.example | 4 + project/README.md | 37 ++- project/migrations/002_identity.js | 34 +++ project/package-lock.json | 125 +++++++++- project/package.json | 2 + project/src/app/build-app.ts | 15 ++ project/src/app/tests/identity.itest.ts | 235 ++++++++++++++++++ project/src/infrastructure/config/config.ts | 12 + .../config/tests/config.test.ts | 9 + project/src/infrastructure/db/pool.ts | 12 +- .../db/tests/db-test-support.ts | 10 +- .../db/tests/migrations.itest.ts | 11 +- .../src/infrastructure/db/tests/pool.itest.ts | 10 +- project/src/infrastructure/http/server.ts | 10 +- .../modules/identity/api/identity.routes.ts | 125 ++++++++++ .../application/login-rate-limiter.ts | 70 ++++++ .../src/modules/identity/application/login.ts | 73 ++++++ .../modules/identity/application/logout.ts | 19 ++ .../identity/application/register-user.ts | 24 ++ project/src/modules/identity/domain/errors.ts | 25 ++ project/src/modules/identity/domain/ports.ts | 22 ++ .../src/modules/identity/domain/session.ts | 19 ++ project/src/modules/identity/domain/user.ts | 19 ++ project/src/modules/identity/index.ts | 9 + .../infrastructure/argon2-password-hasher.ts | 28 +++ .../infrastructure/pg-session-repository.ts | 53 ++++ .../infrastructure/pg-user-repository.ts | 65 +++++ .../identity/infrastructure/session-token.ts | 13 + .../identity/tests/login-rate-limiter.test.ts | 68 +++++ .../identity/tests/session-token.test.ts | 31 +++ spec/tech.md | 2 + specs/F-005-identity/DESIGN.md | 113 +++++++++ specs/F-005-identity/SPEC.md | 37 +++ specs/F-005-identity/TASKS.md | 13 + specs/F-005-identity/TESTS.md | 25 ++ work/artifacts/F-005/architect.md | 33 +++ work/artifacts/F-005/documenter.md | 22 ++ work/artifacts/F-005/implementer.md | 41 +++ work/artifacts/F-005/leader-close.json | 37 +++ work/artifacts/F-005/qa.json | 29 +++ work/artifacts/F-005/reviewer.json | 28 +++ work/artifacts/F-005/security.json | 22 ++ work/current.md | 29 ++- work/history.md | 7 + work/runtime-status.json | 153 +++++++++++- 46 files changed, 1734 insertions(+), 54 deletions(-) create mode 100644 project/migrations/002_identity.js create mode 100644 project/src/app/tests/identity.itest.ts create mode 100644 project/src/modules/identity/api/identity.routes.ts create mode 100644 project/src/modules/identity/application/login-rate-limiter.ts create mode 100644 project/src/modules/identity/application/login.ts create mode 100644 project/src/modules/identity/application/logout.ts create mode 100644 project/src/modules/identity/application/register-user.ts create mode 100644 project/src/modules/identity/domain/errors.ts create mode 100644 project/src/modules/identity/domain/ports.ts create mode 100644 project/src/modules/identity/domain/session.ts create mode 100644 project/src/modules/identity/domain/user.ts create mode 100644 project/src/modules/identity/index.ts create mode 100644 project/src/modules/identity/infrastructure/argon2-password-hasher.ts create mode 100644 project/src/modules/identity/infrastructure/pg-session-repository.ts create mode 100644 project/src/modules/identity/infrastructure/pg-user-repository.ts create mode 100644 project/src/modules/identity/infrastructure/session-token.ts create mode 100644 project/src/modules/identity/tests/login-rate-limiter.test.ts create mode 100644 project/src/modules/identity/tests/session-token.test.ts create mode 100644 specs/F-005-identity/DESIGN.md create mode 100644 specs/F-005-identity/SPEC.md create mode 100644 specs/F-005-identity/TASKS.md create mode 100644 specs/F-005-identity/TESTS.md create mode 100644 work/artifacts/F-005/architect.md create mode 100644 work/artifacts/F-005/documenter.md create mode 100644 work/artifacts/F-005/implementer.md create mode 100644 work/artifacts/F-005/leader-close.json create mode 100644 work/artifacts/F-005/qa.json create mode 100644 work/artifacts/F-005/reviewer.json create mode 100644 work/artifacts/F-005/security.json diff --git a/backlog/features.json b/backlog/features.json index 23f8f68..5ed1d28 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -200,12 +200,12 @@ "Session cookie is HttpOnly, Secure and SameSite", "verify.sh green" ], - "status": "pending", + "status": "done", "created_at": "2026-08-14", "gates": { - "review": false, - "security": false, - "qa": false + "review": true, + "security": true, + "qa": true } }, { diff --git a/project/.env.example b/project/.env.example index bc18ff2..b4e009c 100644 --- a/project/.env.example +++ b/project/.env.example @@ -7,3 +7,7 @@ REDIS_URL=redis://localhost:6379 # Feature flags: FLAG_=true|false (parsed into the flag store at boot). # Unknown flags default to OFF. Flags can be flipped at runtime via the store. FLAG_EXAMPLE_FEATURE=false + +# Session cookie Secure flag. Defaults to true (production-safe); set false only +# for local http development where browsers reject Secure cookies. +COOKIE_SECURE=false diff --git a/project/README.md b/project/README.md index a5b064b..7821bee 100644 --- a/project/README.md +++ b/project/README.md @@ -23,7 +23,8 @@ npm run lint:boundaries # module boundary check Startup is fail-fast: `src/infrastructure/config` parses env once and refuses to boot on missing/invalid required vars. `DATABASE_URL` is required; `PORT`, `HOST`, -`LOG_LEVEL`, `NODE_ENV`, `REDIS_URL` are optional with defaults. Errors name variable +`LOG_LEVEL`, `NODE_ENV`, `REDIS_URL` are optional with defaults. `COOKIE_SECURE` +defaults to `true` (set `false` only for local http dev). Errors name variable NAMES only, never values. Feature flags: `FLAG_=true|false` env vars seed the flag store at boot. Unknown @@ -39,8 +40,34 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`. 5xx messages are always generic; stack traces stay in server logs only. - Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler. +- Auth codes: `INVALID_CREDENTIALS` (401), `EMAIL_ALREADY_REGISTERED` (409), + `TOO_MANY_ATTEMPTS` (429, with `Retry-After` header). - Log level via `LOG_LEVEL` env var (default `info`); logs are JSON only. +## Authentication (identity module) + +The server is the only authority for identity; the frontend is never trusted with +session or credential state. + +| Route | Result | +| ------------------- | --------------------------------------------------- | +| POST /auth/register | `201` + `{ id, email, createdAt }` | +| POST /auth/login | `200` + `{ id, email }` + `Set-Cookie: mdv_session` | +| POST /auth/logout | `204`, cookie cleared, session revoked (idempotent) | + +- Passwords: argon2id (OWASP parameters). Only the PHC hash is stored, never + plaintext or anything reversible. +- Sessions: opaque 512-bit token in the cookie; the DB stores only its SHA-256 + hash (`identity_sessions.token_hash`). TTL 7 days; logout revokes server-side. +- Cookie: `HttpOnly`, `Secure` (`COOKIE_SECURE`, default true), `SameSite=Lax`, + `Path=/`, `Max-Age=604800`. +- Login failures: identical generic 401 for unknown email and wrong password (no + enumeration; timing equalized via dummy hash). After 10 consecutive failures per + email, further attempts get `429` with `Retry-After` for 15 minutes. The limiter + is in-memory per instance behind a `LoginRateLimiter` interface (Redis-backed + swap later without touching use cases). +- Identity routes are wired only when the app is built with a DB pool. + ## Database (local dev) ```bash @@ -69,10 +96,12 @@ npm run docker:down # stop services (add -v to wipe volumes) ```text src/ ├── app/ # composition root (only place that wires modules) -├── infrastructure/ # http server entrypoint (later: db, redis, providers) +├── infrastructure/ # http server, db pool, config, logging ├── modules/ # business modules, one folder each -│ └── health/ # exemplar module: public API only via index.ts -└── shared/ # cross-cutting helpers (error envelope) +│ ├── health/ # exemplar module: public API only via index.ts +│ ├── flags/ # feature flags (unknown default OFF, runtime flip) +│ └── identity/ # register/login/logout, argon2, sessions, rate limit +└── shared/ # cross-cutting helpers (error envelope, input parsing) ``` ## Module rules diff --git a/project/migrations/002_identity.js b/project/migrations/002_identity.js new file mode 100644 index 0000000..8b11519 --- /dev/null +++ b/project/migrations/002_identity.js @@ -0,0 +1,34 @@ +/** + * Identity module tables. Module-owned naming: identity_. + * Sessions store only the SHA-256 hash of the opaque cookie token. + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +export const up = (pgm) => { + pgm.sql(` + CREATE TABLE identity_users ( + id uuid PRIMARY KEY 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() + ) + `); + pgm.sql(` + CREATE TABLE identity_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz + ) + `); + pgm.sql('CREATE INDEX identity_sessions_user_id_idx ON identity_sessions (user_id)'); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +export const down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS identity_sessions'); + pgm.sql('DROP TABLE IF EXISTS identity_users'); +}; diff --git a/project/package-lock.json b/project/package-lock.json index 9a50a4e..f46bd8a 100644 --- a/project/package-lock.json +++ b/project/package-lock.json @@ -8,6 +8,8 @@ "name": "mercadodevida-backend", "version": "0.1.0", "dependencies": { + "@fastify/cookie": "^11.1.2", + "argon2": "^0.45.1", "fastify": "^5.2.0", "node-pg-migrate": "^9.0.0", "pg": "^8.23.0", @@ -28,6 +30,12 @@ "node": ">=22" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -673,6 +681,39 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -853,6 +894,15 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1789,6 +1839,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1971,11 +2037,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2443,6 +2525,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2733,7 +2831,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -2980,6 +3077,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-pg-migrate": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-9.0.0.tgz", @@ -3092,7 +3209,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3571,7 +3687,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3584,7 +3699,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4054,7 +4168,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/project/package.json b/project/package.json index fcd448a..e60f99b 100644 --- a/project/package.json +++ b/project/package.json @@ -22,6 +22,8 @@ "db:status": "node --env-file-if-exists=.env scripts/db-status.mjs" }, "dependencies": { + "@fastify/cookie": "^11.1.2", + "argon2": "^0.45.1", "fastify": "^5.2.0", "node-pg-migrate": "^9.0.0", "pg": "^8.23.0", diff --git a/project/src/app/build-app.ts b/project/src/app/build-app.ts index 1099c8d..90b89c2 100644 --- a/project/src/app/build-app.ts +++ b/project/src/app/build-app.ts @@ -7,7 +7,9 @@ import { performance } from 'node:perf_hooks'; import Fastify, { type FastifyInstance } from 'fastify'; import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; import type { IncomingMessage } from 'node:http'; +import type pg from 'pg'; import { registerHealthRoutes } from '../modules/health/index.js'; +import { registerIdentityRoutes } from '../modules/identity/index.js'; import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js'; import { AppError, errorEnvelope } from '../shared/errors.js'; import { createLogger, type Logger } from '../infrastructure/logging/logger.js'; @@ -26,6 +28,10 @@ export interface BuildAppDeps { logger?: Logger; /** Feature flags. Default: empty store, every flag OFF (fail-safe). */ flags?: FeatureFlagProvider; + /** Database pool. When present, DB-backed modules (identity) are wired. */ + pool?: pg.Pool; + /** Secure cookie flag forwarded to identity routes. */ + cookieSecure?: boolean; } function generateRequestId(raw: IncomingMessage): string { @@ -107,5 +113,14 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise { + await registerIdentityRoutes(instance, { + pool: deps.pool as pg.Pool, + cookieSecure: deps.cookieSecure, + }); + }); + } + return app; } diff --git a/project/src/app/tests/identity.itest.ts b/project/src/app/tests/identity.itest.ts new file mode 100644 index 0000000..a024365 --- /dev/null +++ b/project/src/app/tests/identity.itest.ts @@ -0,0 +1,235 @@ +import type { DestinationStream } from 'pino'; +import type pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../build-app.js'; +import { createPool } from '../../infrastructure/db/pool.js'; +import { createLogger } from '../../infrastructure/logging/logger.js'; +import { + getTestDbUrl, + recreateDatabase, + runMigrations, +} from '../../infrastructure/db/tests/db-test-support.js'; +import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js'; + +const hasDb = Boolean(process.env.TEST_DATABASE_URL); + +function silentLogger() { + const destination: DestinationStream = { write: () => undefined }; + return createLogger({ level: 'info', destination }); +} + +interface ErrorBody { + error: { statusCode: number; code: string; message: string }; + requestId: string; +} + +function parseSetCookie(header: string | string[] | undefined): { + value: string; + attrs: string; +} { + const raw = Array.isArray(header) ? header[0] : header; + expect(raw).toBeDefined(); + const [pair, ...rest] = (raw as string).split(';'); + const eq = (pair as string).indexOf('='); + return { value: (pair as string).slice(eq + 1), attrs: rest.join(';').toLowerCase() }; +} + +describe.skipIf(!hasDb)('identity flows (real PostgreSQL)', () => { + const url = hasDb ? getTestDbUrl() : ''; + let pool: pg.Pool; + let app: Awaited>; + + const credentials = { + email: 'ana@example.com', + password: 'correct horse battery staple', + }; + + beforeAll(async () => { + await recreateDatabase(url); + await runMigrations(url, 'up'); + pool = createPool(url); + app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true }); + }); + + afterAll(async () => { + await app.close(); + await pool.end(); + }); + + it('register stores an argon2 hash, never the plaintext (AC3)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/auth/register', + headers: { 'content-type': 'application/json' }, + payload: credentials, + }); + + expect(response.statusCode).toBe(201); + const body = response.json() as { id: string; email: string; createdAt: string }; + expect(body.id).toMatch(/^[0-9a-f-]{36}$/); + expect(body.email).toBe(credentials.email); + + const row = await pool.query('SELECT email, password_hash FROM identity_users'); + expect(row.rowCount).toBe(1); + const stored = row.rows[0] as { email: string; password_hash: string }; + expect(stored.password_hash.startsWith('$argon2id$')).toBe(true); + expect(stored.password_hash).not.toContain(credentials.password); + expect(JSON.stringify(stored)).not.toContain(credentials.password); + }); + + it('register duplicate email -> 409', async () => { + const response = await app.inject({ + method: 'POST', + url: '/auth/register', + headers: { 'content-type': 'application/json' }, + payload: { ...credentials, password: 'another-valid-password' }, + }); + expect(response.statusCode).toBe(409); + const body = response.json() as ErrorBody; + expect(body.error.code).toBe('EMAIL_ALREADY_REGISTERED'); + }); + + it('register invalid payload -> 400 VALIDATION_ERROR', async () => { + const response = await app.inject({ + method: 'POST', + url: '/auth/register', + headers: { 'content-type': 'application/json' }, + payload: { email: 'not-an-email', password: 'short' }, + }); + expect(response.statusCode).toBe(400); + const body = response.json() as ErrorBody; + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('login with valid credentials -> 200 + secure HttpOnly SameSite cookie (AC1, AC5)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { 'content-type': 'application/json' }, + payload: credentials, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { id: string; email: string }; + expect(body.email).toBe(credentials.email); + + const cookieHeader = response.headers['set-cookie']; + const raw = Array.isArray(cookieHeader) ? cookieHeader[0] : cookieHeader; + expect(typeof raw).toBe('string'); + expect(raw).toContain(`${SESSION_COOKIE_NAME}=`); + expect(raw?.toLowerCase()).toContain('httponly'); + expect(raw?.toLowerCase()).toContain('secure'); + expect(raw?.toLowerCase()).toContain('samesite=lax'); + expect(raw?.toLowerCase()).toContain('max-age='); + + const sessionRow = await pool.query( + 'SELECT token_hash, expires_at, revoked_at FROM identity_sessions', + ); + expect(sessionRow.rowCount).toBe(1); + const session = sessionRow.rows[0] as { token_hash: string; revoked_at: Date | null }; + expect(session.token_hash).toMatch(/^[0-9a-f]{64}$/); + expect(session.revoked_at).toBeNull(); + }); + + it('wrong password and unknown email return identical 401 (AC2, no enumeration)', async () => { + const wrongPassword = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { 'content-type': 'application/json' }, + payload: { email: credentials.email, password: 'definitely-wrong-password' }, + }); + const unknownEmail = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { 'content-type': 'application/json' }, + payload: { email: 'ghost@example.com', password: 'definitely-wrong-password' }, + }); + + expect(wrongPassword.statusCode).toBe(401); + expect(unknownEmail.statusCode).toBe(401); + + const bodyA = wrongPassword.json() as ErrorBody; + const bodyB = unknownEmail.json() as ErrorBody; + expect(bodyA.error).toEqual(bodyB.error); + expect(bodyA.error.code).toBe('INVALID_CREDENTIALS'); + expect(bodyA.error.message).toBe('Invalid credentials'); + }); + + it('logout revokes the session and is idempotent', async () => { + const loginResponse = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { 'content-type': 'application/json' }, + payload: credentials, + }); + expect(loginResponse.statusCode).toBe(200); + const cookie = parseSetCookie(loginResponse.headers['set-cookie']); + + const logoutResponse = await app.inject({ + method: 'POST', + url: '/auth/logout', + cookies: { [SESSION_COOKIE_NAME]: cookie.value }, + }); + expect(logoutResponse.statusCode).toBe(204); + const cleared = logoutResponse.headers['set-cookie']; + const clearedRaw = Array.isArray(cleared) ? cleared[0] : cleared; + expect(clearedRaw?.toLowerCase()).toContain('expires='); + + const revoked = await pool.query( + 'SELECT count(*)::int AS n FROM identity_sessions WHERE revoked_at IS NOT NULL', + ); + expect(revoked.rows[0]?.n).toBeGreaterThanOrEqual(1); + + const secondLogout = await app.inject({ + method: 'POST', + url: '/auth/logout', + cookies: { [SESSION_COOKIE_NAME]: cookie.value }, + }); + expect(secondLogout.statusCode).toBe(204); + }); + + it('10 failed logins in a row -> next attempt gets 429 with Retry-After (AC4)', async () => { + const target = { email: 'locked@example.com', password: 'some-valid-password-1' }; + const registered = await app.inject({ + method: 'POST', + url: '/auth/register', + headers: { 'content-type': 'application/json' }, + payload: target, + }); + expect(registered.statusCode).toBe(201); + + for (let i = 0; i < 10; i += 1) { + const attempt = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { 'content-type': 'application/json' }, + payload: { email: target.email, password: 'wrong-password-attempt' }, + }); + expect(attempt.statusCode).toBe(401); + } + + const blocked = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { 'content-type': 'application/json' }, + payload: target, + }); + expect(blocked.statusCode).toBe(429); + const body = blocked.json() as ErrorBody; + expect(body.error.code).toBe('TOO_MANY_ATTEMPTS'); + const retryAfter = Number(blocked.headers['retry-after']); + expect(retryAfter).toBeGreaterThan(0); + }); + + it('foundation-only app (no pool) keeps /auth routes unregistered', async () => { + const bare = await buildApp({ logger: silentLogger() }); + const response = await bare.inject({ + method: 'POST', + url: '/auth/login', + headers: { 'content-type': 'application/json' }, + payload: credentials, + }); + expect(response.statusCode).toBe(404); + await bare.close(); + }); +}); diff --git a/project/src/infrastructure/config/config.ts b/project/src/infrastructure/config/config.ts index 0250c52..08d49d1 100644 --- a/project/src/infrastructure/config/config.ts +++ b/project/src/infrastructure/config/config.ts @@ -16,6 +16,8 @@ export interface AppConfig { logLevel: string; databaseUrl: string; redisUrl?: string; + /** Session cookie Secure flag. Default true; set COOKIE_SECURE=false for local http dev. */ + cookieSecure: boolean; /** Initial flag state parsed from FLAG_* vars. */ flags: Readonly>; } @@ -71,6 +73,15 @@ export function loadConfig(env: StringRecord): AppConfig { const redisUrl = env.REDIS_URL && env.REDIS_URL !== '' ? env.REDIS_URL : undefined; + let cookieSecure = true; + const rawCookieSecure = env.COOKIE_SECURE; + if (rawCookieSecure !== undefined && rawCookieSecure !== '') { + const normalized = rawCookieSecure.trim().toLowerCase(); + if (normalized === 'true') cookieSecure = true; + else if (normalized === 'false') cookieSecure = false; + else problems.push('COOKIE_SECURE must be "true" or "false"'); + } + const flags: Record = {}; for (const [key, raw] of Object.entries(env)) { if (!key.startsWith('FLAG_') || raw === undefined) continue; @@ -92,6 +103,7 @@ export function loadConfig(env: StringRecord): AppConfig { logLevel, databaseUrl: databaseUrl as string, redisUrl, + cookieSecure, flags, }; } diff --git a/project/src/infrastructure/config/tests/config.test.ts b/project/src/infrastructure/config/tests/config.test.ts index ff8e053..42a21b2 100644 --- a/project/src/infrastructure/config/tests/config.test.ts +++ b/project/src/infrastructure/config/tests/config.test.ts @@ -65,6 +65,15 @@ describe('loadConfig', () => { ); }); + it('defaults cookieSecure to true and parses COOKIE_SECURE', () => { + expect(loadConfig(BASE).cookieSecure).toBe(true); + expect(loadConfig({ ...BASE, COOKIE_SECURE: 'false' }).cookieSecure).toBe(false); + expect(loadConfig({ ...BASE, COOKIE_SECURE: 'true' }).cookieSecure).toBe(true); + expect(problemsOf(() => loadConfig({ ...BASE, COOKIE_SECURE: 'yes' })).join(' ')).toContain( + 'COOKIE_SECURE', + ); + }); + it('rejects FLAG_* values that are not true/false', () => { expect(problemsOf(() => loadConfig({ ...BASE, FLAG_BROKEN: 'yes' })).join(' ')).toContain( 'FLAG_BROKEN', diff --git a/project/src/infrastructure/db/pool.ts b/project/src/infrastructure/db/pool.ts index eeba1ff..9a7c9b7 100644 --- a/project/src/infrastructure/db/pool.ts +++ b/project/src/infrastructure/db/pool.ts @@ -1,15 +1,13 @@ import pg from 'pg'; /** - * Create a connection pool from environment. - * Fail fast and loud when configuration is missing: no silent defaults. + * Create a connection pool from an explicit connection string. + * Config (src/infrastructure/config) owns env parsing and fail-fast checks; + * this helper stays pure. */ -export function createPoolFromEnv(env: NodeJS.ProcessEnv = process.env): pg.Pool { - const connectionString = env.DATABASE_URL; +export function createPool(connectionString: string): pg.Pool { if (!connectionString) { - throw new Error( - 'DATABASE_URL is required. Copy .env.example to .env and start docker compose.', - ); + throw new Error('A database connection string is required to create a pool'); } return new pg.Pool({ connectionString, max: 10 }); } diff --git a/project/src/infrastructure/db/tests/db-test-support.ts b/project/src/infrastructure/db/tests/db-test-support.ts index cd52fb9..5845917 100644 --- a/project/src/infrastructure/db/tests/db-test-support.ts +++ b/project/src/infrastructure/db/tests/db-test-support.ts @@ -5,12 +5,18 @@ import pg from 'pg'; import { runner } from 'node-pg-migrate'; -/** Run project migrations programmatically with explicit, boring defaults. */ -export async function runMigrations(databaseUrl: string, direction: 'up' | 'down'): Promise { +/** Run project migrations programmatically with explicit, boring defaults. + * `count` bounds how many migrations run (down: `count: 0` reverts ALL). */ +export async function runMigrations( + databaseUrl: string, + direction: 'up' | 'down', + count?: number, +): Promise { await runner({ databaseUrl, dir: 'migrations', direction, + ...(count === undefined ? {} : { count }), migrationsTable: 'pgmigrations', verbose: false, }); diff --git a/project/src/infrastructure/db/tests/migrations.itest.ts b/project/src/infrastructure/db/tests/migrations.itest.ts index dbb7083..4427e0a 100644 --- a/project/src/infrastructure/db/tests/migrations.itest.ts +++ b/project/src/infrastructure/db/tests/migrations.itest.ts @@ -17,9 +17,11 @@ describe.skipIf(!hasDb)('migrations', () => { await pool.end(); }); - it('fresh up creates the baseline schema', async () => { + it('fresh up creates the full schema (baseline + identity)', async () => { await runMigrations(url, 'up'); expect(await tableExists(pool, 'app_meta')).toBe(true); + expect(await tableExists(pool, 'identity_users')).toBe(true); + expect(await tableExists(pool, 'identity_sessions')).toBe(true); }); it('second up is a no-op', async () => { @@ -30,8 +32,11 @@ describe.skipIf(!hasDb)('migrations', () => { expect(await tableExists(pool, 'app_meta')).toBe(true); }); - it('down rolls back the baseline schema cleanly', async () => { - await runMigrations(url, 'down'); + it('down rolls back the full schema cleanly', async () => { + // count 0 reverts every applied migration in reverse order. + await runMigrations(url, 'down', 0); + expect(await tableExists(pool, 'identity_sessions')).toBe(false); + expect(await tableExists(pool, 'identity_users')).toBe(false); expect(await tableExists(pool, 'app_meta')).toBe(false); }); }); diff --git a/project/src/infrastructure/db/tests/pool.itest.ts b/project/src/infrastructure/db/tests/pool.itest.ts index 2eb5899..ac15e0a 100644 --- a/project/src/infrastructure/db/tests/pool.itest.ts +++ b/project/src/infrastructure/db/tests/pool.itest.ts @@ -1,6 +1,6 @@ import pg from 'pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createPoolFromEnv, query } from '../pool.js'; +import { createPool, query } from '../pool.js'; import { getTestDbUrl, recreateDatabase, runMigrations } from './db-test-support.js'; const hasDb = Boolean(process.env.TEST_DATABASE_URL); @@ -12,7 +12,7 @@ describe.skipIf(!hasDb)('db pool', () => { beforeAll(async () => { await recreateDatabase(url); await runMigrations(url, 'up'); - pool = createPoolFromEnv({ DATABASE_URL: url } as NodeJS.ProcessEnv); + pool = createPool(url); }); afterAll(async () => { @@ -33,9 +33,7 @@ describe.skipIf(!hasDb)('db pool', () => { expect(gone.rowCount).toBe(0); }); - it('fails fast when DATABASE_URL is missing', () => { - expect(() => createPoolFromEnv({} as NodeJS.ProcessEnv)).toThrowError( - /DATABASE_URL is required/, - ); + it('rejects an empty connection string', () => { + expect(() => createPool('')).toThrowError(/connection string is required/); }); }); diff --git a/project/src/infrastructure/http/server.ts b/project/src/infrastructure/http/server.ts index 4cd6fe2..6b4f9b1 100644 --- a/project/src/infrastructure/http/server.ts +++ b/project/src/infrastructure/http/server.ts @@ -1,5 +1,6 @@ import { buildApp } from '../../app/build-app.js'; import { ConfigError, loadConfig } from '../config/config.js'; +import { createPool } from '../db/pool.js'; import { createFlagStore } from '../../modules/flags/index.js'; import { createLogger } from '../logging/logger.js'; @@ -17,12 +18,19 @@ try { } const logger = createLogger({ level: config.logLevel }); +const pool = createPool(config.databaseUrl); try { - const app = await buildApp({ logger, flags: createFlagStore(config.flags) }); + const app = await buildApp({ + logger, + flags: createFlagStore(config.flags), + pool, + cookieSecure: config.cookieSecure, + }); await app.listen({ port: config.port, host: config.host }); logger.info({ port: config.port, host: config.host }, 'HTTP server listening'); } catch (error) { logger.error({ err: error }, 'Failed to start HTTP server'); + await pool.end(); process.exit(1); } diff --git a/project/src/modules/identity/api/identity.routes.ts b/project/src/modules/identity/api/identity.routes.ts new file mode 100644 index 0000000..7cba785 --- /dev/null +++ b/project/src/modules/identity/api/identity.routes.ts @@ -0,0 +1,125 @@ +/** + * Identity API adapters. Thin HTTP layer: validates input (parseJson hook), + * calls use cases, maps domain errors to the shared error envelope. + */ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { z } from 'zod'; +import { parseJson } from '../../../shared/http-input.js'; +import { AppError } from '../../../shared/errors.js'; +import type pg from 'pg'; +import { RegisterUser } from '../application/register-user.js'; +import { Login } from '../application/login.js'; +import { Logout } from '../application/logout.js'; +import { + InMemoryLoginRateLimiter, + type LoginRateLimiter, +} from '../application/login-rate-limiter.js'; +import { Argon2PasswordHasher } from '../infrastructure/argon2-password-hasher.js'; +import { PgUserRepository } from '../infrastructure/pg-user-repository.js'; +import { PgSessionRepository } from '../infrastructure/pg-session-repository.js'; +import { generateSessionToken, hashSessionToken } from '../infrastructure/session-token.js'; +import type { PasswordHasher } from '../domain/ports.js'; +import { + EmailAlreadyRegisteredError, + InvalidCredentialsError, + RateLimitedError, +} from '../domain/errors.js'; +import { SESSION_TTL_MS } from '../domain/session.js'; + +export const SESSION_COOKIE_NAME = 'mdv_session'; + +export interface IdentityRoutesDeps { + pool: pg.Pool; + /** Secure cookie flag (config-driven; default true). */ + cookieSecure?: boolean; + /** Test seams; production uses defaults. */ + hasher?: PasswordHasher; + rateLimiter?: LoginRateLimiter; +} + +const credentialsSchema = z.object({ + email: z.email(), + password: z.string().min(8).max(128), +}); + +export async function registerIdentityRoutes( + app: FastifyInstance, + deps: IdentityRoutesDeps, +): Promise { + await app.register(fastifyCookie); + + const cookieSecure = deps.cookieSecure ?? true; + const hasher = deps.hasher ?? new Argon2PasswordHasher(); + const users = new PgUserRepository(deps.pool); + const sessions = new PgSessionRepository(deps.pool); + const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter(); + + const registerUser = new RegisterUser(users, hasher); + const login = new Login({ + users, + sessions, + hasher, + rateLimiter, + generateToken: generateSessionToken, + hashToken: hashSessionToken, + }); + const logout = new Logout(sessions, hashSessionToken); + + app.post('/auth/register', async (request, reply) => { + const input = parseJson(credentialsSchema, request.body); + try { + const user = await registerUser.execute(input); + return reply.code(201).send({ id: user.id, email: user.email, createdAt: user.createdAt }); + } catch (error) { + if (error instanceof EmailAlreadyRegisteredError) { + throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered'); + } + throw error; + } + }); + + app.post('/auth/login', async (request, reply) => { + const input = parseJson(credentialsSchema, request.body); + try { + const result = await login.execute(input); + setSessionCookie(reply, result.token, cookieSecure); + return reply.code(200).send({ id: result.user.id, email: result.user.email }); + } catch (error) { + if (error instanceof RateLimitedError) { + void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000))); + throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts'); + } + if (error instanceof InvalidCredentialsError) { + throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials'); + } + throw error; + } + }); + + app.post('/auth/logout', async (request, reply) => { + const token = request.cookies[SESSION_COOKIE_NAME]; + await logout.execute(token); + clearSessionCookie(reply, cookieSecure); + return reply.code(204).send(); + }); +} + +function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void { + void reply.setCookie(SESSION_COOKIE_NAME, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure, + maxAge: Math.floor(SESSION_TTL_MS / 1000), + }); +} + +function clearSessionCookie(reply: FastifyReply, secure: boolean): void { + void reply.clearCookie(SESSION_COOKIE_NAME, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure, + }); +} diff --git a/project/src/modules/identity/application/login-rate-limiter.ts b/project/src/modules/identity/application/login-rate-limiter.ts new file mode 100644 index 0000000..b846625 --- /dev/null +++ b/project/src/modules/identity/application/login-rate-limiter.ts @@ -0,0 +1,70 @@ +/** + * Login rate limiting. Deployment and activation of this guard are separate + * from the auth logic itself: the use case only knows the interface. + */ + +export type RateLimitDecision = { allowed: true } | { allowed: false; retryAfterMs: number }; + +export interface LoginRateLimiter { + /** Called before attempting authentication. */ + consume(key: string): RateLimitDecision; + /** Called after a failed authentication attempt. */ + recordFailure(key: string): void; + /** Called after a successful login. */ + reset(key: string): void; +} + +interface Entry { + failures: number; + lockedUntil: number; +} + +export interface InMemoryRateLimiterOptions { + maxFailures?: number; + cooldownMs?: number; + now?: () => number; +} + +/** + * Per-key consecutive-failure limiter. In-memory per instance: acceptable for + * the single-process monolith; swap for a Redis-backed impl behind the same + * interface when horizontal scaling arrives. + */ +export class InMemoryLoginRateLimiter implements LoginRateLimiter { + private readonly maxFailures: number; + private readonly cooldownMs: number; + private readonly now: () => number; + private readonly entries = new Map(); + + constructor(options: InMemoryRateLimiterOptions = {}) { + this.maxFailures = options.maxFailures ?? 10; + this.cooldownMs = options.cooldownMs ?? 15 * 60 * 1000; + this.now = options.now ?? Date.now; + } + + consume(key: string): RateLimitDecision { + const entry = this.entries.get(key); + if (!entry) { + return { allowed: true }; + } + if (entry.lockedUntil > this.now()) { + return { allowed: false, retryAfterMs: entry.lockedUntil - this.now() }; + } + if (entry.lockedUntil !== 0 && entry.lockedUntil <= this.now()) { + // Cooldown expired: start clean. + this.entries.delete(key); + } + return { allowed: true }; + } + + recordFailure(key: string): void { + const entry = this.entries.get(key); + const failures = (entry?.failures ?? 0) + 1; + const lockedUntil = failures >= this.maxFailures ? this.now() + this.cooldownMs : 0; + this.entries.set(key, { failures, lockedUntil }); + } + + reset(key: string): void { + this.entries.delete(key); + } +} diff --git a/project/src/modules/identity/application/login.ts b/project/src/modules/identity/application/login.ts new file mode 100644 index 0000000..2d81aa0 --- /dev/null +++ b/project/src/modules/identity/application/login.ts @@ -0,0 +1,73 @@ +/** + * Login use case. Rate limit first, then credential check with timing + * equalization, then session creation. Server is the only authority. + */ +import type { PasswordHasher, SessionRepository, UserRepository } from '../domain/ports.js'; +import type { User } from '../domain/user.js'; +import { normalizeEmail } from '../domain/user.js'; +import { sessionExpiry } from '../domain/session.js'; +import { InvalidCredentialsError, RateLimitedError } from '../domain/errors.js'; +import type { LoginRateLimiter } from './login-rate-limiter.js'; + +export interface LoginInput { + email: string; + password: string; +} + +export interface LoginResult { + user: User; + /** Opaque raw token; only its hash is persisted. */ + token: string; + tokenHash: string; + expiresAt: Date; +} + +export interface LoginDeps { + users: UserRepository; + sessions: SessionRepository; + hasher: PasswordHasher; + rateLimiter: LoginRateLimiter; + generateToken: () => string; + hashToken: (token: string) => string; +} + +export class Login { + private dummyHashPromise: Promise | undefined; + + constructor(private readonly deps: LoginDeps) {} + + async execute(input: LoginInput): Promise { + const email = normalizeEmail(input.email); + + const decision = this.deps.rateLimiter.consume(email); + if (!decision.allowed) { + throw new RateLimitedError(decision.retryAfterMs); + } + + const record = await this.deps.users.findByEmail(email); + + // Timing equalization: unknown email still pays one argon2 verify so + // response timing does not reveal whether the account exists. + const hashToCheck = record?.passwordHash ?? (await this.dummyHash()); + const valid = await this.deps.hasher.verify(hashToCheck, input.password); + + if (!record || !valid) { + this.deps.rateLimiter.recordFailure(email); + throw new InvalidCredentialsError(); + } + + this.deps.rateLimiter.reset(email); + + const token = this.deps.generateToken(); + const tokenHash = this.deps.hashToken(token); + const expiresAt = sessionExpiry(); + await this.deps.sessions.create(record.id, tokenHash, expiresAt); + + return { user: record, token, tokenHash, expiresAt }; + } + + private async dummyHash(): Promise { + this.dummyHashPromise ??= this.deps.hasher.hash('identity-dummy-password'); + return this.dummyHashPromise; + } +} diff --git a/project/src/modules/identity/application/logout.ts b/project/src/modules/identity/application/logout.ts new file mode 100644 index 0000000..c451455 --- /dev/null +++ b/project/src/modules/identity/application/logout.ts @@ -0,0 +1,19 @@ +/** + * Logout use case: revoke the session identified by the token hash. + * Idempotent by design. + */ +import type { SessionRepository } from '../domain/ports.js'; + +export class Logout { + constructor( + private readonly sessions: SessionRepository, + private readonly hashToken: (token: string) => string, + ) {} + + async execute(rawToken: string | undefined): Promise { + if (!rawToken) { + return; + } + await this.sessions.revokeByTokenHash(this.hashToken(rawToken)); + } +} diff --git a/project/src/modules/identity/application/register-user.ts b/project/src/modules/identity/application/register-user.ts new file mode 100644 index 0000000..8698bd8 --- /dev/null +++ b/project/src/modules/identity/application/register-user.ts @@ -0,0 +1,24 @@ +/** + * RegisterUser use case. Orchestrates domain + ports; knows no HTTP. + */ +import type { PasswordHasher, UserRepository } from '../domain/ports.js'; +import type { User } from '../domain/user.js'; +import { normalizeEmail } from '../domain/user.js'; + +export interface RegisterInput { + email: string; + password: string; +} + +export class RegisterUser { + constructor( + private readonly users: UserRepository, + private readonly hasher: PasswordHasher, + ) {} + + async execute(input: RegisterInput): Promise { + const email = normalizeEmail(input.email); + const passwordHash = await this.hasher.hash(input.password); + return this.users.create({ email, passwordHash }); + } +} diff --git a/project/src/modules/identity/domain/errors.ts b/project/src/modules/identity/domain/errors.ts new file mode 100644 index 0000000..402fb91 --- /dev/null +++ b/project/src/modules/identity/domain/errors.ts @@ -0,0 +1,25 @@ +/** + * Identity domain errors. The API layer maps these to HTTP; messages are + * deliberately generic where leakage matters. + */ + +export class InvalidCredentialsError extends Error { + constructor() { + super('Invalid credentials'); + this.name = 'InvalidCredentialsError'; + } +} + +export class EmailAlreadyRegisteredError extends Error { + constructor() { + super('Email already registered'); + this.name = 'EmailAlreadyRegisteredError'; + } +} + +export class RateLimitedError extends Error { + constructor(public readonly retryAfterMs: number) { + super('Too many attempts'); + this.name = 'RateLimitedError'; + } +} diff --git a/project/src/modules/identity/domain/ports.ts b/project/src/modules/identity/domain/ports.ts new file mode 100644 index 0000000..05892d2 --- /dev/null +++ b/project/src/modules/identity/domain/ports.ts @@ -0,0 +1,22 @@ +/** + * Ports (driven interfaces). Domain owns them; infrastructure implements them. + */ +import type { NewUser, User } from './user.js'; +import type { Session } from './session.js'; + +export interface PasswordHasher { + hash(plain: string): Promise; + verify(hash: string, plain: string): Promise; +} + +export interface UserRepository { + create(user: NewUser): Promise; + findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined>; +} + +export interface SessionRepository { + /** Stores only the token hash, never the raw token. */ + create(userId: string, tokenHash: string, expiresAt: Date): Promise; + /** Revokes by token hash. Returns true when a live session was revoked. */ + revokeByTokenHash(tokenHash: string): Promise; +} diff --git a/project/src/modules/identity/domain/session.ts b/project/src/modules/identity/domain/session.ts new file mode 100644 index 0000000..b81fc72 --- /dev/null +++ b/project/src/modules/identity/domain/session.ts @@ -0,0 +1,19 @@ +/** + * Session domain model. The raw token never touches persistence: + * only its hash is stored. + */ + +export interface Session { + id: string; + userId: string; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; +} + +/** Session lifetime: 7 days. */ +export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export function sessionExpiry(now: Date = new Date()): Date { + return new Date(now.getTime() + SESSION_TTL_MS); +} diff --git a/project/src/modules/identity/domain/user.ts b/project/src/modules/identity/domain/user.ts new file mode 100644 index 0000000..5c3abe7 --- /dev/null +++ b/project/src/modules/identity/domain/user.ts @@ -0,0 +1,19 @@ +/** + * Identity domain. Pure types and rules: no framework, no infrastructure. + */ + +export interface User { + id: string; + email: string; + createdAt: Date; +} + +export interface NewUser { + email: string; + passwordHash: string; +} + +/** Canonical form: trimmed + lowercased. citext backs uniqueness in the DB. */ +export function normalizeEmail(raw: string): string { + return raw.trim().toLowerCase(); +} diff --git a/project/src/modules/identity/index.ts b/project/src/modules/identity/index.ts new file mode 100644 index 0000000..4f1ab71 --- /dev/null +++ b/project/src/modules/identity/index.ts @@ -0,0 +1,9 @@ +/** + * Public API of the identity module. Everything the module exposes to the + * outside world goes through this file. + */ +export { + registerIdentityRoutes, + SESSION_COOKIE_NAME, + type IdentityRoutesDeps, +} from './api/identity.routes.js'; diff --git a/project/src/modules/identity/infrastructure/argon2-password-hasher.ts b/project/src/modules/identity/infrastructure/argon2-password-hasher.ts new file mode 100644 index 0000000..01b6fe1 --- /dev/null +++ b/project/src/modules/identity/infrastructure/argon2-password-hasher.ts @@ -0,0 +1,28 @@ +/** + * Argon2id password hasher. OWASP 2024 baseline parameters. + * Output is a self-describing PHC string; verification is constant-config. + */ +import argon2 from 'argon2'; +import type { PasswordHasher } from '../domain/ports.js'; + +const OPTIONS = { + type: argon2.argon2id, + memoryCost: 19456, // 19 MiB + timeCost: 2, + parallelism: 1, +} as const; + +export class Argon2PasswordHasher implements PasswordHasher { + async hash(plain: string): Promise { + return argon2.hash(plain, OPTIONS); + } + + async verify(hash: string, plain: string): Promise { + try { + return await argon2.verify(hash, plain); + } catch { + // Malformed hash or verification failure: never throw upward. + return false; + } + } +} diff --git a/project/src/modules/identity/infrastructure/pg-session-repository.ts b/project/src/modules/identity/infrastructure/pg-session-repository.ts new file mode 100644 index 0000000..004af31 --- /dev/null +++ b/project/src/modules/identity/infrastructure/pg-session-repository.ts @@ -0,0 +1,53 @@ +/** + * PostgreSQL SessionRepository. Stores token hashes only. + * Validity (not expired, not revoked) is enforced in the SQL itself. + */ +import type pg from 'pg'; +import type { SessionRepository } from '../domain/ports.js'; +import type { Session } from '../domain/session.js'; + +interface SessionRow { + id: string; + user_id: string; + created_at: Date; + expires_at: Date; + revoked_at: Date | null; +} + +export class PgSessionRepository implements SessionRepository { + constructor(private readonly pool: pg.Pool) {} + + async create(userId: string, tokenHash: string, expiresAt: Date): Promise { + const result = await this.pool.query( + `INSERT INTO identity_sessions (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + RETURNING id, user_id, created_at, expires_at, revoked_at`, + [userId, tokenHash, expiresAt], + ); + const row = result.rows[0]; + if (!row) { + throw new Error('identity_sessions INSERT returned no row'); + } + return toSession(row); + } + + async revokeByTokenHash(tokenHash: string): Promise { + const result = await this.pool.query( + `UPDATE identity_sessions + SET revoked_at = now() + WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > now()`, + [tokenHash], + ); + return (result.rowCount ?? 0) > 0; + } +} + +function toSession(row: SessionRow): Session { + return { + id: row.id, + userId: row.user_id, + createdAt: row.created_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }; +} diff --git a/project/src/modules/identity/infrastructure/pg-user-repository.ts b/project/src/modules/identity/infrastructure/pg-user-repository.ts new file mode 100644 index 0000000..e7d3d65 --- /dev/null +++ b/project/src/modules/identity/infrastructure/pg-user-repository.ts @@ -0,0 +1,65 @@ +/** + * PostgreSQL UserRepository. Parameterized queries only. + * Unique email is enforced by the DB (citext UNIQUE) — race-safe. + */ +import type pg from 'pg'; +import type { UserRepository } from '../domain/ports.js'; +import type { NewUser, User } from '../domain/user.js'; +import { EmailAlreadyRegisteredError } from '../domain/errors.js'; + +interface UserRow { + id: string; + email: string; + password_hash: string; + created_at: Date; +} + +const UNIQUE_VIOLATION = '23505'; + +export class PgUserRepository implements UserRepository { + constructor(private readonly pool: pg.Pool) {} + + async create(user: NewUser): Promise { + try { + const result = await this.pool.query( + `INSERT INTO identity_users (email, password_hash) + VALUES ($1, $2) + RETURNING id, email, created_at`, + [user.email, user.passwordHash], + ); + const row = result.rows[0]; + if (!row) { + throw new Error('identity_users INSERT returned no row'); + } + return { id: row.id, email: row.email, createdAt: row.created_at }; + } catch (error) { + if (isPgError(error) && error.code === UNIQUE_VIOLATION) { + throw new EmailAlreadyRegisteredError(); + } + throw error; + } + } + + async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> { + const result = await this.pool.query( + `SELECT id, email, password_hash, created_at + FROM identity_users + WHERE email = $1`, + [email], + ); + const row = result.rows[0]; + if (!row) { + return undefined; + } + return { + id: row.id, + email: row.email, + createdAt: row.created_at, + passwordHash: row.password_hash, + }; + } +} + +function isPgError(error: unknown): error is { code: string } { + return typeof error === 'object' && error !== null && 'code' in error; +} diff --git a/project/src/modules/identity/infrastructure/session-token.ts b/project/src/modules/identity/infrastructure/session-token.ts new file mode 100644 index 0000000..1ce0940 --- /dev/null +++ b/project/src/modules/identity/infrastructure/session-token.ts @@ -0,0 +1,13 @@ +/** + * Opaque session tokens. Raw token goes to the cookie; only the SHA-256 + * hash is persisted, so a DB leak yields no usable sessions. + */ +import { createHash, randomBytes } from 'node:crypto'; + +export function generateSessionToken(): string { + return randomBytes(64).toString('base64url'); +} + +export function hashSessionToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} diff --git a/project/src/modules/identity/tests/login-rate-limiter.test.ts b/project/src/modules/identity/tests/login-rate-limiter.test.ts new file mode 100644 index 0000000..0af9fef --- /dev/null +++ b/project/src/modules/identity/tests/login-rate-limiter.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryLoginRateLimiter } from '../application/login-rate-limiter.js'; + +describe('InMemoryLoginRateLimiter', () => { + it('allows attempts below the failure threshold', () => { + const limiter = new InMemoryLoginRateLimiter({ maxFailures: 3 }); + expect(limiter.consume('a@b.c')).toEqual({ allowed: true }); + limiter.recordFailure('a@b.c'); + limiter.recordFailure('a@b.c'); + expect(limiter.consume('a@b.c')).toEqual({ allowed: true }); + }); + + it('blocks with retryAfterMs after reaching the threshold', () => { + let now = 1_000_000; + const limiter = new InMemoryLoginRateLimiter({ + maxFailures: 10, + cooldownMs: 900_000, + now: () => now, + }); + + for (let i = 0; i < 10; i += 1) { + expect(limiter.consume('a@b.c').allowed).toBe(true); + limiter.recordFailure('a@b.c'); + } + + const blocked = limiter.consume('a@b.c'); + expect(blocked.allowed).toBe(false); + if (!blocked.allowed) { + expect(blocked.retryAfterMs).toBe(900_000); + } + + // Time passes but not enough. + now += 60_000; + expect(limiter.consume('a@b.c').allowed).toBe(false); + }); + + it('re-allows attempts once the cooldown expires', () => { + let now = 1_000_000; + const limiter = new InMemoryLoginRateLimiter({ + maxFailures: 2, + cooldownMs: 1_000, + now: () => now, + }); + + limiter.recordFailure('a@b.c'); + limiter.recordFailure('a@b.c'); + expect(limiter.consume('a@b.c').allowed).toBe(false); + + now += 1_001; + expect(limiter.consume('a@b.c')).toEqual({ allowed: true }); + }); + + it('resets the counter on successful login', () => { + const limiter = new InMemoryLoginRateLimiter({ maxFailures: 2 }); + limiter.recordFailure('a@b.c'); + limiter.reset('a@b.c'); + limiter.recordFailure('a@b.c'); + // Only one failure since reset: still allowed. + expect(limiter.consume('a@b.c')).toEqual({ allowed: true }); + }); + + it('tracks keys independently', () => { + const limiter = new InMemoryLoginRateLimiter({ maxFailures: 1 }); + limiter.recordFailure('a@b.c'); + expect(limiter.consume('a@b.c').allowed).toBe(false); + expect(limiter.consume('other@b.c')).toEqual({ allowed: true }); + }); +}); diff --git a/project/src/modules/identity/tests/session-token.test.ts b/project/src/modules/identity/tests/session-token.test.ts new file mode 100644 index 0000000..7818a4d --- /dev/null +++ b/project/src/modules/identity/tests/session-token.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { generateSessionToken, hashSessionToken } from '../infrastructure/session-token.js'; + +describe('session tokens', () => { + it('generates unique URL-safe tokens', () => { + const tokens = new Set(); + for (let i = 0; i < 100; i += 1) { + tokens.add(generateSessionToken()); + } + expect(tokens.size).toBe(100); + for (const token of tokens) { + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(token.length).toBeGreaterThanOrEqual(80); + } + }); + + it('hashes deterministically to hex sha256, never the raw token', () => { + const token = generateSessionToken(); + const hash1 = hashSessionToken(token); + const hash2 = hashSessionToken(token); + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + expect(hash1).not.toBe(token); + }); + + it('different tokens produce different hashes', () => { + const a = generateSessionToken(); + const b = generateSessionToken(); + expect(hashSessionToken(a)).not.toBe(hashSessionToken(b)); + }); +}); diff --git a/spec/tech.md b/spec/tech.md index ce2c03a..e3c085e 100644 --- a/spec/tech.md +++ b/spec/tech.md @@ -21,6 +21,8 @@ | @types/pg | ^8 | Tipos para pg (dev) (F-002) | | pino | ^9 | Logging JSON estructurado; estándar del ecosistema Fastify, rápido y sin magia (F-003) | | zod | ^4 | Validación explícita de inputs vía parseJson en handlers; sin decoradores ni magic binding (F-003) | +| argon2 | ^1 | Hash de contraseñas Argon2id (parámetros OWASP 2024); implementación canónica, output PHC. Elegida sobre bcrypt (menos resistente a GPU/ASIC) y scrypt. Detrás del puerto PasswordHasher; fallback @node-rs/argon2 si el build nativo fallara (F-005) | +| @fastify/cookie | ^1 | setCookie/clearCookie explícitos para la sesión HttpOnly; plugin oficial Fastify, sin reinvención de parsing/serialización (F-005) | Regla: toda dependencia nueva debe agregarse a esta tabla con justificación en el ticket que la introduce. diff --git a/specs/F-005-identity/DESIGN.md b/specs/F-005-identity/DESIGN.md new file mode 100644 index 0000000..57c43d4 --- /dev/null +++ b/specs/F-005-identity/DESIGN.md @@ -0,0 +1,113 @@ +# 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. diff --git a/specs/F-005-identity/SPEC.md b/specs/F-005-identity/SPEC.md new file mode 100644 index 0000000..81a682b --- /dev/null +++ b/specs/F-005-identity/SPEC.md @@ -0,0 +1,37 @@ +# SPEC — F-005 Identity: register, login, sessions + +## Problem +Customers need accounts; nothing trusts who calls the API. + +## Goal +Registration, login, logout with hashed passwords and secure sessions — server is the +only authority for identity (never trust the frontend). + +## Scope IN +- `src/modules/identity` with hexagonal layers: domain / application / infrastructure / api +- Register, login, logout use cases over PostgreSQL (source of truth) +- Argon2id password hashing (OWASP parameters) +- Server-side opaque sessions stored in DB; cookie carries a random token, DB stores only its SHA-256 hash +- Secure session cookie: HttpOnly, Secure, SameSite +- Login rate limiting (10 consecutive failures -> 429 with cooldown) behind an interface +- Migration 002_identity (identity_users, identity_sessions) with down +- Config: COOKIE_SECURE flag (default true) + +## Scope OUT +- No MFA, no OAuth providers, no profile editing +- No email verification flow, no password reset +- No distributed rate limiting (in-memory per instance; documented) +- No expired-session sweeper (follow-up) + +## Acceptance criteria +1. Given valid credentials When login Then HTTP 200 and secure session cookie set. +2. Given wrong password When login Then HTTP 401 and no user enumeration hint (unknown email returns the identical 401). +3. Passwords stored with argon2, never plaintext or reversible. +4. Given 10 failed logins in a row When next login attempted Then HTTP 429. +5. Session cookie is HttpOnly, Secure and SameSite. +6. `./scripts/verify.sh` green. + +## Dependencies added +- argon2 (canonical Argon2id implementation; PHC string output) +- @fastify/cookie (explicit setCookie/clearCookie; official Fastify plugin) +Justification goes to `spec/tech.md`. diff --git a/specs/F-005-identity/TASKS.md b/specs/F-005-identity/TASKS.md new file mode 100644 index 0000000..40e0aca --- /dev/null +++ b/specs/F-005-identity/TASKS.md @@ -0,0 +1,13 @@ +# TASKS — F-005 Identity: register, login, sessions + +- [ ] TASK-001 deps: argon2 + @fastify/cookie; justify in spec/tech.md +- [ ] TASK-002 migrations/002_identity.js (identity_users + identity_sessions, reversible) +- [ ] TASK-003 domain: user.ts, session.ts, errors.ts, ports.ts +- [ ] TASK-004 application: register-user.ts, login.ts, logout.ts, login-rate-limiter.ts +- [ ] TASK-005 infrastructure: argon2 hasher, pg repos, session-token +- [ ] TASK-006 api: identity.routes.ts (zod via parseJson, error mapping, cookie) +- [ ] TASK-007 index.ts public API +- [ ] TASK-008 foundation wiring: pool.createPool(connectionString), config COOKIE_SECURE, buildApp(pool, cookieSecure), server.ts pool creation +- [ ] TASK-009 unit tests: rate limiter + session token (module-scoped) +- [ ] TASK-010 integration tests: full flows against real PostgreSQL (app-scoped itest) +- [ ] TASK-011 run lint + boundaries + typecheck + build + test + test:integration + verify.sh diff --git a/specs/F-005-identity/TESTS.md b/specs/F-005-identity/TESTS.md new file mode 100644 index 0000000..7cad94c --- /dev/null +++ b/specs/F-005-identity/TESTS.md @@ -0,0 +1,25 @@ +# TESTS — F-005 Identity: register, login, sessions + +## Unit — src/modules/identity/tests/login-rate-limiter.test.ts +1. Allows attempts below threshold; records failures +2. Blocks with retryAfterMs after 10 consecutive failures (AC4 logic) +3. Success resets the counter +4. Cooldown expiry re-allows attempts + +## Unit — src/modules/identity/tests/session-token.test.ts +1. Tokens are unique and URL-safe +2. hashSessionToken is deterministic sha256 hex and never equals the raw token + +## Integration — src/app/tests/identity.itest.ts (real PostgreSQL, fresh DB per run) +1. register: 201 + body {id, email, createdAt}; DB row stores $argon2id$ hash; plaintext password appears nowhere in the row (AC3) +2. register duplicate email: 409 EMAIL_ALREADY_REGISTERED +3. register invalid payload: 400 VALIDATION_ERROR +4. login valid: 200 + mdv_session cookie with HttpOnly; SameSite=Lax; Secure; Max-Age (AC1, AC5) +5. login wrong password: 401 INVALID_CREDENTIALS; unknown email returns byte-identical error body (AC2) +6. rate limit: 10 failed logins then next attempt -> 429 TOO_MANY_ATTEMPTS + Retry-After header (AC4) +7. logout: 204 + cookie cleared; session row revoked; second logout still 204 (idempotent) +8. buildApp without pool: /auth/login is 404 (foundation-only app preserved) + +## QA live +- register/login/logout over HTTP with curl; cookie flags visible in Set-Cookie +- verify.sh green (AC6) diff --git a/work/artifacts/F-005/architect.md b/work/artifacts/F-005/architect.md new file mode 100644 index 0000000..2227367 --- /dev/null +++ b/work/artifacts/F-005/architect.md @@ -0,0 +1,33 @@ +# Architect — F-005 Identity: register, login, sessions + +done -> work/artifacts/F-005/architect.md + +## Deliverables +- specs/F-005-identity/SPEC.md, DESIGN.md, TASKS.md, TESTS.md + +## Key decisions +1. **Server-side opaque sessions in PostgreSQL**, never JWT/client-trusted tokens: + the cookie carries 512 bits of randomness; the DB stores only the SHA-256 hash. + A DB leak yields no usable sessions; logout is a real revocation. +2. **Hexagonal identity module** (domain/application/infrastructure/api) as the ticket + demands. Domain knows no framework; ports live in domain; infra implements them. + This module is the template for catalog/inventory/orders later. +3. **argon2id with OWASP 2024 params** (m=19456 KiB, t=2, p=1) behind a PasswordHasher + port. Timing equalization via dummy-hash verify on unknown email. +4. **Rate limiting behind a LoginRateLimiter interface**, in-memory implementation for + the single-instance monolith: 10 consecutive failures per email, 15-min cooldown, + reset on success, Retry-After on 429. Redis-backed swap later without touching use cases. +5. **Cookie policy**: HttpOnly + SameSite=Lax always; Secure via `COOKIE_SECURE` + (default true — production-safe; .env.example sets false for local http). +6. **Foundation touchpoints kept minimal**: createPool(connectionString), COOKIE_SECURE + in config, buildApp gains optional pool dep (foundation-only app preserved when absent). + +## Security posture +- No plaintext or reversible passwords anywhere (argon2 PHC strings only). +- Generic 401 for both unknown email and wrong password (no enumeration). +- Parameterized SQL only; citext unique email enforced in DB (race-safe). +- Session expiry + revocation enforced in the DB query, not in app memory. + +## Risks +- argon2 native build (toolchain verified present; fallback @node-rs/argon2 behind port). +- In-memory limiter is per-instance (documented; acceptable for monolith slice). diff --git a/work/artifacts/F-005/documenter.md b/work/artifacts/F-005/documenter.md new file mode 100644 index 0000000..81b81bb --- /dev/null +++ b/work/artifacts/F-005/documenter.md @@ -0,0 +1,22 @@ +# Documenter — F-005 Identity: register, login, sessions + +done -> work/artifacts/F-005/documenter.md + +## Docs updated +- `project/README.md`: + - Configuration: `COOKIE_SECURE` documented (default true). + - HTTP contract: auth error codes (`INVALID_CREDENTIALS` 401, + `EMAIL_ALREADY_REGISTERED` 409, `TOO_MANY_ATTEMPTS` 429 + Retry-After). + - New "Authentication (identity module)" section: endpoints table, argon2id, + opaque-token sessions (DB stores only SHA-256 hash), cookie flags, + anti-enumeration + rate-limit behavior, pool-gated wiring. + - Layout: modules list now shows health/flags/identity; infrastructure mentions + db pool + config + logging; shared mentions input parsing. +- `spec/tech.md`: argon2 + @fastify/cookie justified (F-005 row). +- `project/.env.example`: `COOKIE_SECURE=false` with local-dev comment. + +## Specs (source of truth for this ticket) +- specs/F-005-identity/SPEC.md, DESIGN.md, TASKS.md, TESTS.md + +## Evidence trail +- work/artifacts/F-005/: architect.md, implementer.md, reviewer.json, security.json, qa.json diff --git a/work/artifacts/F-005/implementer.md b/work/artifacts/F-005/implementer.md new file mode 100644 index 0000000..93313f6 --- /dev/null +++ b/work/artifacts/F-005/implementer.md @@ -0,0 +1,41 @@ +# Implementer — F-005 Identity: register, login, sessions + +done -> work/artifacts/F-005/implementer.md + +## Delivered +- Migration `migrations/002_identity.js`: `identity_users` + `identity_sessions` + (token_hash UNIQUE, FK ON DELETE CASCADE, user_id index). Reversible. +- Hexagonal module `src/modules/identity`: + - domain: user.ts, session.ts (7-day TTL), errors.ts, ports.ts + - application: register-user.ts, login.ts, logout.ts, login-rate-limiter.ts + - infrastructure: argon2-password-hasher.ts (argon2id m=19456 t=2 p=1), + pg-user-repository.ts, pg-session-repository.ts, session-token.ts + - api: identity.routes.ts (zod via parseJson, envelope v2 mapping) +- Foundation wiring: `createPool(connectionString)`, `COOKIE_SECURE` in config + (default true), `buildApp({ pool, cookieSecure })`, server creates pool from config. + +## Deps added (justified in spec/tech.md) +- argon2, @fastify/cookie + +## Evidence +- `npm run lint` ✅ (prettier clean after format pass) +- `npm run lint:boundaries` ✅ (39 files, R1/R2 OK — module tests stay in-subtree, + DB integration tests live in src/app/tests) +- `npm run typecheck` / `npm run build` ✅ +- `npm test`: 46 passed, 14 skipped (integration) ✅ +- `npm run test:integration`: 14/14 ✅ (register argon2-only storage, dup 409, + invalid 400, cookie flags, identical 401 bodies, revocation + idempotent logout, + 10 failures → 429 + Retry-After, foundation-only app keeps /auth 404) +- Live smoke (curl): 201 register / 200 login + `mdv_session …; HttpOnly; Secure; + SameSite=Lax; Max-Age=604800` / 401 identical for wrong-password vs unknown-email / + 204 logout clears cookie / 10×401 → 429 + `retry-after: 900` +- `./scripts/verify.sh` ✅ + +## Notes / deviations +- F-002 migrations.itest baseline rollback test updated: `down` now uses `count: 0` + (full revert) since the suite has two migrations; test asserts identity tables AND + app_meta are gone. Helper `runMigrations` gained an optional count param. +- Rate limiter is in-memory per instance (documented in DESIGN risks; interface ready + for Redis swap). +- `project/.env` (gitignored) supplies TEST_DATABASE_URL to the `test:integration` + script via `node --env-file-if-exists=.env`. diff --git a/work/artifacts/F-005/leader-close.json b/work/artifacts/F-005/leader-close.json new file mode 100644 index 0000000..f4c7331 --- /dev/null +++ b/work/artifacts/F-005/leader-close.json @@ -0,0 +1,37 @@ +{ + "feature_id": "F-005", + "agent": "leader", + "verdict": "APPROVED", + "summary": "F-005 closed. First real business module: identity is hexagonal end-to-end, the server is the only authority for who calls the API, and brute force, enumeration and session theft are all handled server-side. All gates APPROVED, verify.sh exit 0.", + "gates": { + "reviewer": "APPROVED (reviewer.json)", + "security": "APPROVED (security.json)", + "qa": "APPROVED (qa.json)", + "verify_sh": "exit 0" + }, + "deliverables": [ + "migrations/002_identity.js (identity_users + identity_sessions, reversible, token_hash UNIQUE)", + "src/modules/identity hexagonal: domain ports, use cases (register/login/logout), argon2id hasher, pg repos, routes", + "Opaque 512-bit session tokens; DB persists only SHA-256 hashes; 7-day TTL enforced in SQL", + "LoginRateLimiter interface + in-memory impl: 10 failures -> 429 + Retry-After, 15-min cooldown, reset on success", + "Anti-enumeration: identical generic 401 + dummy-hash timing equalization", + "buildApp(pool, cookieSecure) wiring; foundation-only app preserved without pool", + "47 unit + 14 integration tests; live curl smoke for every acceptance criterion", + "README Authentication section, spec/tech.md dependency justification, .env.example COOKIE_SECURE" + ], + "process_notes": [ + "Review gate caught missing COOKIE_SECURE test coverage; bounced to build, fixed, re-verified — gate earned its keep.", + "Adding migration 002 broke the F-002 single-step rollback test by design; updated to full revert (count:0) asserting both module tables and baseline disappear." + ], + "next_feature_hint": "F-006 users/profile/RBAC builds directly on identity_users and the session model -> recommended next", + "evidence": [ + "work/artifacts/F-005/architect.md", + "work/artifacts/F-005/implementer.md", + "work/artifacts/F-005/reviewer.json", + "work/artifacts/F-005/security.json", + "work/artifacts/F-005/qa.json", + "work/artifacts/F-005/documenter.md", + "./scripts/verify.sh exit 0 at close" + ], + "timestamp": "2026-08-14T21:15:00Z" +} diff --git a/work/artifacts/F-005/qa.json b/work/artifacts/F-005/qa.json new file mode 100644 index 0000000..a5ecda7 --- /dev/null +++ b/work/artifacts/F-005/qa.json @@ -0,0 +1,29 @@ +{ + "feature_id": "F-005", + "agent": "qa", + "verdict": "APPROVED", + "summary": "Los 6 criterios de aceptación pasan con evidencia doble: suite automatizada (47 unit + 14 integration) y smoke live contra PostgreSQL real. Sin regresiones en F-001..F-004.", + "acceptance_criteria": { + "AC1_login_200_with_cookie": "PASS - integration 'login with valid credentials -> 200 + secure cookie' + curl live: HTTP 200 + Set-Cookie mdv_session=...; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax", + "AC2_wrong_password_401_no_enumeration": "PASS - integration compara bodies .error idénticos para wrong-password vs unknown-email; curl live confirma 401 INVALID_CREDENTIALS genérico en ambos", + "AC3_argon2_never_plaintext": "PASS - integration: fila DB password_hash arranca con $argon2id$ y el plaintext no aparece en ningún valor serializado de la fila", + "AC4_rate_limit_429": "PASS - integration: 10 fallos -> 429 TOO_MANY_ATTEMPTS + Retry-After > 0; live: 401×10 y luego 429 con retry-after: 900", + "AC5_cookie_flags": "PASS - integration valida HttpOnly + SameSite=Lax + Secure + Max-Age sobre el header Set-Cookie; confirmado en vivo", + "AC6_verify_green": "PASS - ./scripts/verify.sh OK tras el cierre de build+gates" + }, + "regression": { + "health_endpoint": "PASS (suite)", + "error_envelope_v2_404": "PASS (suite) + foundation-only app sin pool mantiene /auth 404 (integration)", + "config_fail_fast": "PASS (suite: DATABASE_URL missing, COOKIE_SECURE inválido reporta variable)", + "feature_flags": "PASS (suite: unknown OFF, case-insensitive, runtime flip)", + "migrations_lifecycle": "PASS (integration: fresh up full schema, second up no-op, full down limpio)" + }, + "evidence": [ + "npm run lint / lint:boundaries (39 files) / typecheck / build: clean", + "npm test: 47 passed | 14 skipped", + "npm run test:integration: 14/14 passed", + "curl live: register 201, login 200+cookie, wrong/unknown 401 idénticos, logout 204+clear, 429+Retry-After", + "./scripts/verify.sh: OK" + ], + "notes": "Logout smoke verificado: cookie borrada (Max-Age=0 + Expires 1970) y fila de sesión con revoked_at IS NOT NULL en DB." +} diff --git a/work/artifacts/F-005/reviewer.json b/work/artifacts/F-005/reviewer.json new file mode 100644 index 0000000..9fa5eb8 --- /dev/null +++ b/work/artifacts/F-005/reviewer.json @@ -0,0 +1,28 @@ +{ + "feature_id": "F-005", + "agent": "reviewer", + "verdict": "APPROVED", + "summary": "Identity module cumple specs/F-005 DESIGN.md: hexagonal real (domain sin framework, ports en domain, infra los implementa), sesiones server-side con token hash en DB, rate limit detrás de interfaz, envelope v2 intacto. Fix de review aplicado (tests COOKIE_SECURE).", + "checks": { + "architecture_hexagonal": "PASS: domain no toca framework; application depende de ports; infra implementa; api es adaptador delgado con parseJson", + "module_boundaries": "PASS: lint:boundaries 39 files OK; identity tests importan solo su subárbol; integración DB vive en src/app/tests (R1/R2)", + "server_side_authority": "PASS: expiración y revocación de sesión resueltas en SQL; el frontend no decide estado de identidad", + "naming_convention": "PASS: identity_users / identity_sessions siguen _
", + "migration_reversible": "PASS: down con count:0 (full revert) testeado en integration suite; identity tables + app_meta caen limpias", + "error_envelope_v2": "PASS: mapeo AppError con codes estables (INVALID_CREDENTIALS, EMAIL_ALREADY_REGISTERED, TOO_MANY_ATTEMPTS); 5xx genérico preservado", + "test_coverage": "PASS: 47 unit + 14 integration; los 6 AC cubiertos por tests automatizados o evidencia live" + }, + "findings": [ + { + "severity": "fixed_in_review", + "finding": "COOKIE_SECURE (config nueva de F-005) no tenía tests dedicados", + "resolution": "Test agregado: default true, parse true/false, inválido reporta la variable. Suite verde." + }, + { + "severity": "follow_up", + "finding": "server.ts no tiene graceful shutdown (SIGTERM -> close app + pool.end)", + "resolution": "Preexistente de F-001/F-003; fuera del scope de identidad. Registrar como mejora de infraestructura." + } + ], + "notes": "Rate limiter in-memory por instancia es limitación documentada y aceptada; interfaz permite swap a Redis sin tocar casos de uso." +} diff --git a/work/artifacts/F-005/security.json b/work/artifacts/F-005/security.json new file mode 100644 index 0000000..c404b06 --- /dev/null +++ b/work/artifacts/F-005/security.json @@ -0,0 +1,22 @@ +{ + "feature_id": "F-005", + "agent": "security", + "verdict": "APPROVED", + "summary": "El servidor es la única autoridad de identidad. Credenciales solo como argon2id PHC, sesiones opacas con hash persistido, cookies HttpOnly/Secure/SameSite, anti-enumeración con timing igualado y 401 genérico, brute force contenido con 429 + Retry-After. Sin hallazgos bloqueantes.", + "checks": { + "credential_storage": "PASS: argon2id m=19456KiB t=2 p=1 (OWASP 2024); integration test prueba que el plaintext no aparece en ningún valor de la fila", + "user_enumeration": "PASS: 401 genérico idéntico para email inexistente y password incorrecto (assert + curl live); dummy-hash verify memoizado iguala timing", + "sql_injection": "PASS: todas las queries parametrizadas; email validado por zod + citext UNIQUE en DB (race-safe)", + "session_token": "PASS: 512 bits crypto.randomBytes base64url; solo SHA-256 hex persiste (token_hash UNIQUE); fuga de DB no entrega sesiones utilizables", + "cookie_flags": "PASS: HttpOnly + Secure + SameSite=Lax + Path=/ + Max-Age=604800 verificados en vivo; COOKIE_SECURE default true", + "revocation": "PASS: logout revoca server-side (WHERE revoked_at IS NULL AND expires_at > now()); expiración en SQL, no en memoria", + "brute_force": "PASS: 10 fallos por email -> 429 + Retry-After: 900 (verificado en vivo); rate check corre ANTES del lookup en DB", + "input_abuse": "PASS: password max 128 chars limita costo argon2 por request; validación zod antes del dominio", + "secrets": "PASS: sin secretos hardcodeados; .env gitignored; errores de config nombran variables, nunca valores" + }, + "residual_risks_accepted": [ + "Sesiones concurrentes ilimitadas por usuario (sin cap). Aceptable en este slice.", + "Rate limiter in-memory: se pierde con restart y es por instancia. Documentado; interfaz lista para Redis.", + "Lockout temporal: un atacante puede bloquear un email 15 min (trade-off estándar); cooldown acotado lo contiene." + ] +} diff --git a/work/current.md b/work/current.md index f489a3b..af23020 100644 --- a/work/current.md +++ b/work/current.md @@ -1,17 +1,16 @@ -# Sesión actual +# Current work -- Feature en curso: _ninguna_ (F-004 cerrada DONE el 2026-08-14) -- Inicio: — -- Orquestador: — +- Active feature: none (idle) +- Last closed: F-005 — Identity: register, login, sessions +- Next suggested: F-006 — Users: profile, addresses, RBAC (depends on F-005, satisfied) +- Runtime status: reset via scripts/agent_status.py +- verify.sh: green at close -## Plan -- Hechas: F-001, F-002, F-003, F-004. -- Desbloqueada: F-005 (identity & auth core) — depende de F-002+F-003, ambas done. Config/flags (F-004) ya listos para secrets y guards. -- Sugerencia de orden: F-005 ahora. - -## Bitácora -- 2026-08-14: F-001..F-004 DONE; todos los gates APPROVED; verify.sh verde. -- Dev PostgreSQL + Redis siguen corriendo (mdv-dev-postgres, mdv-dev-redis). - -## Próximo paso -- intake de F-005 (identity & auth core). +## F-005 closure notes +- Identity module hexagonal: domain/application/infrastructure/api under src/modules/identity +- Sessions server-side: cookie carries opaque 512-bit token; identity_sessions stores only SHA-256 hash +- argon2id (OWASP params) behind PasswordHasher port; timing equalized 401 (no enumeration) +- Rate limit: 10 consecutive failures per email -> 429 + Retry-After (15 min cooldown), in-memory behind interface +- Cookie: HttpOnly + Secure (COOKIE_SECURE, default true) + SameSite=Lax +- Migration 002_identity reversible; integration suite updated for full revert (count:0) +- Gates: reviewer/security/qa APPROVED. Commits include specs, artifacts, code, docs. diff --git a/work/history.md b/work/history.md index cbd2d99..6a585ea 100644 --- a/work/history.md +++ b/work/history.md @@ -26,3 +26,10 @@ - Nota: los tests detectaron un bug real en build (case-normalization del store); corregido antes de gates con test de regresión - Cero dependencias nuevas - Artefactos: work/artifacts/F-004/ + +## 2026-08-14 — F-005 Identity: register, login, sessions — DONE +- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0 +- Entregable: módulo identity hexagonal (domain/application/infrastructure/api), migración 002 reversible, argon2id OWASP tras puerto PasswordHasher, sesiones opacas (solo SHA-256 del token en DB), cookie HttpOnly+Secure+SameSite=Lax, rate limit 10 fallos -> 429 + Retry-After tras interfaz, 401 idéntico anti-enumeración con timing igualado +- Nota: review detectó falta de tests para COOKIE_SECURE; fix aplicado antes de aprobar el gate. Suite de migraciones F-002 actualizada a rollback completo (count:0) por tener ahora 2 migraciones +- Deps nuevas: argon2, @fastify/cookie (justificadas en spec/tech.md) +- Artefactos: work/artifacts/F-005/ diff --git a/work/runtime-status.json b/work/runtime-status.json index cce496d..0d59fa1 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,11 +1,152 @@ { - "feature_id": null, - "stage": "idle", + "feature_id": "F-005", + "stage": "close", "agent": "leader", - "action": "Sin ejecución activa", - "state": "waiting", + "action": "F-005 cerrada con gates aprobados", + "state": "done", "next_agent": "leader", "waiting_for": "Seleccionar una feature pending y actualizar este estado", - "updated_at": "2026-08-14T20:29:18Z", - "timeline": [] + "updated_at": "2026-08-14T20:58:24Z", + "timeline": [ + { + "ts": "2026-08-14T20:39:25Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "Intake F-005 OK" + }, + { + "ts": "2026-08-14T20:39:25Z", + "agent": "architect", + "stage": "design", + "state": "running", + "message": "Diseño módulo identity (hexagonal)" + }, + { + "ts": "2026-08-14T20:41:17Z", + "agent": "architect", + "stage": "design", + "state": "done", + "message": "Diseño F-005 aprobado" + }, + { + "ts": "2026-08-14T20:41:17Z", + "agent": "implementer", + "stage": "build", + "state": "running", + "message": "Implementando módulo identity" + }, + { + "ts": "2026-08-14T20:51:41Z", + "agent": "implementer", + "stage": "build", + "state": "done", + "message": "Build F-005 completo con evidencia" + }, + { + "ts": "2026-08-14T20:53:01Z", + "agent": "implementer", + "stage": "build", + "state": "running", + "message": "Fix de review: cobertura COOKIE_SECURE" + }, + { + "ts": "2026-08-14T20:54:22Z", + "agent": "implementer", + "stage": "build", + "state": "done", + "message": "Fix de review aplicado y verificado" + }, + { + "ts": "2026-08-14T20:54:22Z", + "agent": "reviewer", + "stage": "review_gate", + "state": "running", + "message": "Review gate F-005" + }, + { + "ts": "2026-08-14T20:54:44Z", + "agent": "reviewer", + "stage": "review_gate", + "state": "done", + "message": "Review gate APPROVED" + }, + { + "ts": "2026-08-14T20:55:17Z", + "agent": "security", + "stage": "security_gate", + "state": "running", + "message": "Security gate F-005" + }, + { + "ts": "2026-08-14T20:55:17Z", + "agent": "security", + "stage": "security_gate", + "state": "done", + "message": "Security gate APPROVED" + }, + { + "ts": "2026-08-14T20:55:17Z", + "agent": "qa", + "stage": "qa_gate", + "state": "running", + "message": "QA gate F-005" + }, + { + "ts": "2026-08-14T20:55:36Z", + "agent": "qa", + "stage": "qa_gate", + "state": "done", + "message": "QA gate APPROVED" + }, + { + "ts": "2026-08-14T20:55:36Z", + "agent": "documenter", + "stage": "document", + "state": "running", + "message": "Documentación F-005" + }, + { + "ts": "2026-08-14T20:55:42Z", + "agent": "implementer", + "stage": "build", + "state": "running", + "message": "Docs README: auth contract" + }, + { + "ts": "2026-08-14T20:57:22Z", + "agent": "implementer", + "stage": "build", + "state": "done", + "message": "README auth contract documentado" + }, + { + "ts": "2026-08-14T20:57:22Z", + "agent": "documenter", + "stage": "document", + "state": "running", + "message": "Documentación F-005" + }, + { + "ts": "2026-08-14T20:58:18Z", + "agent": "documenter", + "stage": "document", + "state": "done", + "message": "Docs F-005 completas" + }, + { + "ts": "2026-08-14T20:58:24Z", + "agent": "leader", + "stage": "close", + "state": "running", + "message": "Cierre F-005" + }, + { + "ts": "2026-08-14T20:58:24Z", + "agent": "leader", + "stage": "close", + "state": "done", + "message": "F-005 cerrada con gates aprobados" + } + ] }