← back to Directory Core
src/auth.ts
347 lines
// directory-core / auth
//
// Cookie-session auth + bcrypt password hashing + role-gated middleware.
// Sessions live in PG; the cookie just holds the session id.
//
// 2026-05-04 (tick 24, post-architect-review): refactored from a hard-coded
// lawyer-schema impl into a `createAuth({ ... })` factory. Each vertical
// supplies its own column list, table names, cookie name, role enum, and
// admin check. Backward-compat: a default preset (lawyer-shaped) is exported
// at module level so lawyer-directory's existing imports keep working.
//
// Animals-specific defenses ported per architect rec:
// 1. DUMMY_HASH constant-time-defense in `findUserAndVerifyPassword` —
// runs bcrypt.compare against a known-bad hash if user not found, so
// response time doesn't leak account existence.
// 2. `__Host-` cookie prefix option — when on + NODE_ENV=production, the
// cookie name is forced to `__Host-<name>`, which the browser refuses
// unless Secure + Path=/ + no Domain. Bound to origin.
// 3. Configurable session-table PK + FK column names — animals uses
// `app_sessions.token` + `app_user_id`, lawyer uses `id` + `user_id`.
//
// Audit 2026-05-04: also includes the fail-closed `requireAdminToken`
// middleware — header-only (NOT query param), constant-time compare, throws
// 500 if ADMIN_TOKEN env unset.
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import cookie from 'cookie';
import type { Request, Response, NextFunction } from 'express';
import pg from 'pg';
import { query as defaultQuery } from './db.js';
import { assertSafeIdent, assertSafeIdents } from './_ident.js';
// Bcrypt hash that will never match any real password — used by DUMMY_HASH
// defense to make verifyPassword take the same time whether the user exists
// or not.
//
// IMPORTANT (audit 2026-05-04 tick 24): the DUMMY_HASH animals/lib/auth.js
// uses (`$2b$10$abcdefghijklmnopqrstuu...`) is MALFORMED — bcryptjs rejects
// it in 0ms, defeating the timing defense entirely. Animals is currently
// vulnerable to user-existence enumeration via login response time.
//
// This hash IS valid (generated via bcrypt.hash('canary', 10)) and takes
// ~65ms to compare on this machine — actually constant-time-defending.
// Verified at module load by the test/auth-factory.test.ts DUMMY_HASH suite.
const DUMMY_HASH = '$2a$10$Jqugk5R4m/Rd5KMiBsRjOOsTRNTJk/GqwTxaV0GHwNVdMBETjr8iO';
// ── Standalone primitives (no schema coupling) ────────────────────────────
export async function hashPassword(plain: string, rounds = 10): Promise<string> {
return bcrypt.hash(plain, rounds);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
/**
* Static-token admin gate for service-to-service calls (cron, debugger).
* Reads ADMIN_TOKEN from env at request time; THROWS 500 if not set rather
* than silently allowing requests.
*
* Security note: only accepts the `X-Admin-Token` header, NOT a `?admin_token=`
* query param. Query strings appear in nginx access logs, proxy history, and
* analytics — leaking the token at the perimeter.
*/
export function requireAdminToken(req: Request, res: Response, next: NextFunction) {
const expected = process.env.ADMIN_TOKEN;
if (!expected) {
return res.status(500).send('ADMIN_TOKEN unset on server — refusing request');
}
const got = req.header('X-Admin-Token');
if (!got) return res.status(401).send('Missing X-Admin-Token');
const a = Buffer.from(got);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).send('Forbidden');
}
next();
}
// ── BaseAppUser — minimal contract every vertical must satisfy ────────────
/**
* Architect-mandated minimal contract: just `id` + `status`. Verticals declare
* their own concrete user shape via the AppUser generic on createAuth().
*/
export interface BaseAppUser {
id: number | string;
status?: string | null;
}
declare module 'express-serve-static-core' {
interface Request {
user?: BaseAppUser;
}
}
// ── Config shape ──────────────────────────────────────────────────────────
// QueryFn is defined in match.ts (and re-exported there) — kept private here
// to avoid the duplicate-export collision when index.ts barrel re-exports both.
type QueryFn = <T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params: unknown[]
) => Promise<pg.QueryResult<T>>;
export interface AuthConfig<U extends BaseAppUser = BaseAppUser> {
/**
* Query function. If omitted, uses directory-core/db's `query`.
* Required when the consumer wants to inject its own pool (e.g. test fixtures).
*/
query?: QueryFn;
// Tables
userTable?: string; // default 'app_users'
sessionTable?: string; // default 'app_sessions'
// Session table column names (animals uses `token` + `app_user_id`)
sessionPkColumn?: string; // default 'id'
sessionUserFkColumn?: string; // default 'user_id'
// Active-status filter — set to false if your status column doesn't exist
// or doesn't use the string 'active'.
activeStatusValue?: string | null; // default 'active'; null skips the filter
// Cookie
cookieName?: string; // default 'sid'
cookieHostPrefix?: boolean; // default false; when true + NODE_ENV=production, prefix with `__Host-`
cookieMaxAgeSeconds?: number; // default 30 days
sameSite?: 'lax' | 'strict' | 'none'; // default 'lax'
/**
* Columns to SELECT into the user object on findUserById. Caller-defined
* to handle column drift across verticals (animals lacks `professional_id`,
* doctor lacks `firm_size_band`, etc.). MUST include `id` and `status`.
*/
userColumns?: string[];
/**
* Admin predicate. Default: `u.role === 'admin' || u.tier === 'admin'`.
* Verticals with custom role enums (animals: 'business_owner'|'admin'|'staff')
* can override.
*/
isAdmin?: (user: U) => boolean;
/** Bcrypt cost factor. Default 10. */
bcryptRounds?: number;
}
// ── The factory ───────────────────────────────────────────────────────────
export function createAuth<U extends BaseAppUser = BaseAppUser>(cfg: AuthConfig<U> = {}) {
const query = cfg.query ?? (defaultQuery as QueryFn);
const T_USERS = cfg.userTable ?? 'app_users';
const T_SESSIONS = cfg.sessionTable ?? 'app_sessions';
const SESSION_PK = cfg.sessionPkColumn ?? 'id';
const SESSION_FK = cfg.sessionUserFkColumn ?? 'user_id';
const ACTIVE_STATUS = cfg.activeStatusValue === undefined ? 'active' : cfg.activeStatusValue;
const COOKIE_BASE = cfg.cookieName ?? 'sid';
const COOKIE_MAX_AGE = cfg.cookieMaxAgeSeconds ?? 30 * 86400;
const SAME_SITE = cfg.sameSite ?? 'lax';
const USER_COLUMNS = cfg.userColumns ?? [
'id', 'email', 'full_name', 'role', 'plan', 'status',
'organization_id', 'professional_id',
'created_at', 'last_login_at', 'tier',
];
// Audit P1-c 2026-05-04: validate every identifier that gets templated into
// SQL strings. Today every caller is hard-coded; tomorrow if any vertical
// threads request data into a config field, this stops SQL injection at the
// factory boundary.
assertSafeIdent(T_USERS, 'userTable');
assertSafeIdent(T_SESSIONS, 'sessionTable');
assertSafeIdent(SESSION_PK, 'sessionPkColumn');
assertSafeIdent(SESSION_FK, 'sessionUserFkColumn');
assertSafeIdents(USER_COLUMNS, 'userColumns');
const ROUNDS = cfg.bcryptRounds ?? 10;
const isAdminFn = cfg.isAdmin ?? ((u: U) => {
const r = u as { role?: unknown; tier?: unknown };
return r.role === 'admin' || r.tier === 'admin';
});
/** Cookie name resolved against current NODE_ENV (re-evaluated per call). */
function cookieName(): string {
if (cfg.cookieHostPrefix && process.env.NODE_ENV === 'production') {
return `__Host-${COOKIE_BASE}`;
}
return COOKIE_BASE;
}
async function findUserByEmail(email: string): Promise<{ id: number | string; password_hash: string; status?: string } | null> {
const r = await query<{ id: number | string; password_hash: string; status?: string }>(
`SELECT id, password_hash, status FROM ${T_USERS} WHERE LOWER(email) = LOWER($1) LIMIT 1`,
[email.trim()]
);
return r.rows[0] || null;
}
/**
* Constant-time email+password verify (DUMMY_HASH defense).
* Returns the user row on match, null on mismatch OR no-such-user. Either
* way bcrypt.compare runs once — response time doesn't leak existence.
*/
async function findUserAndVerifyPassword(
email: string,
password: string
): Promise<{ id: number | string; status?: string } | null> {
const u = await findUserByEmail(email);
const hashToCheck = u?.password_hash || DUMMY_HASH;
const ok = await bcrypt.compare(password, hashToCheck);
if (!ok || !u) return null;
return { id: u.id, status: u.status };
}
async function findUserById(id: number | string): Promise<U | null> {
const cols = USER_COLUMNS.join(', ');
const r = await query<U>(
`SELECT ${cols} FROM ${T_USERS} WHERE id = $1 LIMIT 1`,
[id]
);
return r.rows[0] || null;
}
async function createSession(userId: number | string, ip: string | null, ua: string | null): Promise<string> {
const id = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + COOKIE_MAX_AGE * 1000);
await query(
`INSERT INTO ${T_SESSIONS} (${SESSION_PK}, ${SESSION_FK}, expires_at, ip, user_agent)
VALUES ($1, $2, $3, $4, $5)`,
[id, userId, expires, ip, ua ? ua.slice(0, 200) : null]
);
await query(
`UPDATE ${T_USERS} SET last_login_at = NOW() WHERE id = $1`,
[userId]
).catch(() => { /* last_login_at may not exist on every vertical */ });
return id;
}
async function destroySession(sid: string): Promise<void> {
await query(`DELETE FROM ${T_SESSIONS} WHERE ${SESSION_PK} = $1`, [sid]);
}
async function getSessionUser(sid: string | undefined): Promise<U | null> {
if (!sid) return null;
const r = await query<{ user_id: number | string }>(
`SELECT ${SESSION_FK} AS user_id FROM ${T_SESSIONS}
WHERE ${SESSION_PK} = $1 AND expires_at > NOW() LIMIT 1`,
[sid]
);
if (!r.rows.length) return null;
const u = await findUserById(r.rows[0].user_id);
if (!u) return null;
if (ACTIVE_STATUS !== null && u.status && u.status !== ACTIVE_STATUS) return null;
return u;
}
function baseCookieOpts(): cookie.CookieSerializeOptions {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: SAME_SITE,
path: '/',
};
}
function setSessionCookie(res: Response, sid: string): void {
res.setHeader('Set-Cookie', cookie.serialize(cookieName(), sid, {
...baseCookieOpts(),
maxAge: COOKIE_MAX_AGE,
}));
}
function clearSessionCookie(res: Response): void {
res.setHeader('Set-Cookie', cookie.serialize(cookieName(), '', {
...baseCookieOpts(),
maxAge: 0,
}));
}
async function authMiddleware(req: Request, _res: Response, next: NextFunction): Promise<void> {
const cookies = cookie.parse(req.headers.cookie || '');
const sid = cookies[cookieName()];
const user = await getSessionUser(sid);
if (user) req.user = user;
next();
}
function requireUser(req: Request, res: Response, next: NextFunction): void | Response {
if (!req.user) return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
next();
}
function requireAdmin(req: Request, res: Response, next: NextFunction): void | Response {
if (!req.user) return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
if (!isAdminFn(req.user as U)) return res.status(403).send('Forbidden — admin only.');
next();
}
return {
// schema-coupled
findUserByEmail,
findUserAndVerifyPassword,
findUserById,
createSession,
destroySession,
getSessionUser,
// cookie
setSessionCookie,
clearSessionCookie,
cookieName,
// middleware
authMiddleware,
requireUser,
requireAdmin,
// config introspection
config: {
userTable: T_USERS,
sessionTable: T_SESSIONS,
sessionPkColumn: SESSION_PK,
sessionUserFkColumn: SESSION_FK,
cookieMaxAgeSeconds: COOKIE_MAX_AGE,
},
};
}
// ── Backward-compat: default lawyer-shaped preset ─────────────────────────
//
// lawyer-directory imports `findUserById`, `requireAdmin`, etc. directly from
// directory-core/auth. We expose them at module level using a default preset
// so existing imports don't break. Other verticals should call createAuth({...})
// with their own config.
const _default = createAuth();
export const findUserByEmail = _default.findUserByEmail;
export const findUserAndVerifyPassword = _default.findUserAndVerifyPassword;
export const findUserById = _default.findUserById;
export const createSession = _default.createSession;
export const destroySession = _default.destroySession;
export const getSessionUser = _default.getSessionUser;
export const setSessionCookie = _default.setSessionCookie;
export const clearSessionCookie = _default.clearSessionCookie;
export const authMiddleware = _default.authMiddleware;
export const requireUser = _default.requireUser;
export const requireAdmin = _default.requireAdmin;
export const PLANS = ['free', 'premium', 'pro'] as const;
export const STATUSES = ['active', 'suspended', 'deleted'] as const;