← 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;