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:
@@ -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;
|
||||
}
|
||||
|
||||
235
project/src/app/tests/identity.itest.ts
Normal file
235
project/src/app/tests/identity.itest.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
125
project/src/modules/identity/api/identity.routes.ts
Normal file
125
project/src/modules/identity/api/identity.routes.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
73
project/src/modules/identity/application/login.ts
Normal file
73
project/src/modules/identity/application/login.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
project/src/modules/identity/application/logout.ts
Normal file
19
project/src/modules/identity/application/logout.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
24
project/src/modules/identity/application/register-user.ts
Normal file
24
project/src/modules/identity/application/register-user.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
project/src/modules/identity/domain/errors.ts
Normal file
25
project/src/modules/identity/domain/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
22
project/src/modules/identity/domain/ports.ts
Normal file
22
project/src/modules/identity/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
19
project/src/modules/identity/domain/session.ts
Normal file
19
project/src/modules/identity/domain/session.ts
Normal 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);
|
||||
}
|
||||
19
project/src/modules/identity/domain/user.ts
Normal file
19
project/src/modules/identity/domain/user.ts
Normal 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();
|
||||
}
|
||||
9
project/src/modules/identity/index.ts
Normal file
9
project/src/modules/identity/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
13
project/src/modules/identity/infrastructure/session-token.ts
Normal file
13
project/src/modules/identity/infrastructure/session-token.ts
Normal 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');
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
31
project/src/modules/identity/tests/session-token.test.ts
Normal file
31
project/src/modules/identity/tests/session-token.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user