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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user