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
This commit is contained in:
rikrdo
2026-08-14 22:58:32 +02:00
parent 4851692031
commit 75293f39bc
46 changed files with 1734 additions and 54 deletions

View File

@@ -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
}
},
{

View File

@@ -7,3 +7,7 @@ REDIS_URL=redis://localhost:6379
# Feature flags: FLAG_<NAME>=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

View File

@@ -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_<NAME>=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

View File

@@ -0,0 +1,34 @@
/**
* Identity module tables. Module-owned naming: identity_<table>.
* 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');
};

View File

@@ -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"

View File

@@ -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",

View File

@@ -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<FastifyInstance
await registerHealthRoutes(instance);
});
if (deps.pool) {
await app.register(async (instance) => {
await registerIdentityRoutes(instance, {
pool: deps.pool as pg.Pool,
cookieSecure: deps.cookieSecure,
});
});
}
return app;
}

View File

@@ -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<ReturnType<typeof buildApp>>;
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();
});
});

View File

@@ -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<Record<string, boolean>>;
}
@@ -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<string, boolean> = {};
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,
};
}

View File

@@ -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',

View File

@@ -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 });
}

View File

@@ -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<void> {
/** 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<void> {
await runner({
databaseUrl,
dir: 'migrations',
direction,
...(count === undefined ? {} : { count }),
migrationsTable: 'pgmigrations',
verbose: false,
});

View File

@@ -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);
});
});

View File

@@ -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/);
});
});

View File

@@ -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);
}

View File

@@ -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<void> {
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,
});
}

View File

@@ -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<string, Entry>();
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);
}
}

View File

@@ -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<string> | undefined;
constructor(private readonly deps: LoginDeps) {}
async execute(input: LoginInput): Promise<LoginResult> {
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<string> {
this.dummyHashPromise ??= this.deps.hasher.hash('identity-dummy-password');
return this.dummyHashPromise;
}
}

View File

@@ -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<void> {
if (!rawToken) {
return;
}
await this.sessions.revokeByTokenHash(this.hashToken(rawToken));
}
}

View File

@@ -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<User> {
const email = normalizeEmail(input.email);
const passwordHash = await this.hasher.hash(input.password);
return this.users.create({ email, passwordHash });
}
}

View File

@@ -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';
}
}

View File

@@ -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<string>;
verify(hash: string, plain: string): Promise<boolean>;
}
export interface UserRepository {
create(user: NewUser): Promise<User>;
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<Session>;
/** Revokes by token hash. Returns true when a live session was revoked. */
revokeByTokenHash(tokenHash: string): Promise<boolean>;
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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';

View File

@@ -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<string> {
return argon2.hash(plain, OPTIONS);
}
async verify(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
// Malformed hash or verification failure: never throw upward.
return false;
}
}
}

View File

@@ -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<Session> {
const result = await this.pool.query<SessionRow>(
`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<boolean> {
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,
};
}

View File

@@ -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<User> {
try {
const result = await this.pool.query<UserRow>(
`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<UserRow>(
`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;
}

View File

@@ -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');
}

View File

@@ -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 });
});
});

View File

@@ -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<string>();
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));
});
});

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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

View File

@@ -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)

View File

@@ -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).

View File

@@ -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

View File

@@ -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`.

View File

@@ -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"
}

View File

@@ -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."
}

View File

@@ -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 <module>_<table>",
"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."
}

View File

@@ -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."
]
}

View File

@@ -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.

View File

@@ -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/

View File

@@ -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"
}
]
}