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

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