← back to Dw Nextjs Admin Login

src/capabilities.ts

215 lines

/**
 * @dw/nextjs-admin-login — capability layer (added v0.3.0)
 *
 * Reusable, config-driven capability model for the DW Next.js verticals
 * (Freddy / Grant / Hub / Patty / PoppyPetitions). Lets an app define an
 * admin login plus any number of lower-privilege "guest tier" logins, then:
 *
 *   - resolve a session username → capability set (getCapabilities),
 *   - verify a submitted username+password against every active login
 *     (verify), scrypt-hashed and constant-time, and
 *   - gate a request by method / AI-route in middleware (capabilityGate).
 *
 * This module is PURE (only node:crypto) so it is safe to import from
 * Next.js middleware (nodejs runtime). It is additive: existing exports
 * (createAuth / createLoginHandler) are unchanged, so apps that don't opt in
 * are unaffected.
 *
 * Each guest tier is only an active login when its `passwordEnv` is set in
 * the environment — an unset password means that tier cannot authenticate.
 */

import crypto from 'node:crypto';

export type CapabilityRole = 'admin' | 'member' | 'viewer';

export interface Capabilities {
  username: string;
  role: CapabilityRole;
  displayName: string;
  /** Sees admin controls / any admin-only UI affordance. */
  isAdmin: boolean;
  /** May mutate data (non-AI POST/PUT/PATCH/DELETE). */
  canWrite: boolean;
  /** May call AI (discover/generate) routes. */
  canUseAI: boolean;
}

/** A lower-privilege login tier, keyed by username in CapabilityRegistryConfig.guests. */
export interface GuestTier {
  role: CapabilityRole;
  displayName: string;
  canWrite: boolean;
  canUseAI: boolean;
  /** Env var holding this tier's password. Absent/empty ⇒ tier disabled. */
  passwordEnv: string;
}

export interface CapabilityRegistryConfig {
  /** Env var holding the admin username. Default 'AUTH_USERNAME'. */
  adminUsernameEnv?: string;
  /** Fallback admin username when the env is unset. Default 'admin'. */
  defaultAdminUsername?: string;
  /** Env var holding the admin password. Default 'AUTH_PASSWORD'. */
  adminPasswordEnv?: string;
  /** Display name for the admin account. Default 'Site Admin'. */
  adminDisplayName?: string;
  /** Guest tiers keyed by username (e.g. { guest: {...}, viewer: {...} }). */
  guests?: Record<string, GuestTier>;
}

export interface CapabilityRegistry {
  /** The resolved admin username. */
  readonly adminUsername: string;
  /** Usernames of every active login (admin + guests with a password set). */
  readonly activeUsernames: string[];
  /** Resolve capabilities for a session username. Unknown ⇒ most-restrictive. */
  getCapabilities(username: string): Capabilities;
  /**
   * Constant-time multi-credential check. Hashes the password once per active
   * login regardless of match so timing does not leak which usernames exist.
   * Returns the matched username, or null.
   */
  verify(username: string, password: string): string | null;
}

const SCRYPT_N = 16384;
const SCRYPT_KEYLEN = 64;

function deriveHash(plaintext: string, salt: Buffer): Buffer {
  return crypto.scryptSync(plaintext, salt, SCRYPT_KEYLEN, { N: SCRYPT_N });
}

function constantTimeEqualString(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  if (ab.length !== bb.length) {
    crypto.timingSafeEqual(ab, ab); // burn equivalent cycles
    return false;
  }
  return crypto.timingSafeEqual(ab, bb);
}

interface CredRecord {
  username: string;
  salt: Buffer;
  hash: Buffer;
  caps: Omit<Capabilities, 'username'>;
}

/**
 * Build a capability registry from config + the current environment. Call once
 * at module load in the consumer; it snapshots passwords/usernames from env at
 * construction time (matching how the rest of this package reads env).
 */
export function createCapabilityRegistry(cfg: CapabilityRegistryConfig = {}): CapabilityRegistry {
  const adminUsername =
    process.env[cfg.adminUsernameEnv ?? 'AUTH_USERNAME'] || cfg.defaultAdminUsername || 'admin';
  const adminPassword = process.env[cfg.adminPasswordEnv ?? 'AUTH_PASSWORD'];
  const adminDisplayName = cfg.adminDisplayName ?? 'Site Admin';
  const guests = cfg.guests ?? {};

  const adminCaps: Omit<Capabilities, 'username'> = {
    role: 'admin',
    displayName: adminDisplayName,
    isAdmin: true,
    canWrite: true,
    canUseAI: true,
  };

  // Capability lookup is independent of whether a login is active, so a live
  // session always resolves correctly even mid password-rotation.
  const capsByUsername = new Map<string, Omit<Capabilities, 'username'>>();
  capsByUsername.set(adminUsername, adminCaps);
  for (const [username, tier] of Object.entries(guests)) {
    if (username === adminUsername) continue; // never let a guest shadow admin
    capsByUsername.set(username, {
      role: tier.role,
      displayName: tier.displayName,
      isAdmin: false,
      canWrite: tier.canWrite,
      canUseAI: tier.canUseAI,
    });
  }

  // Active credential records (only logins with a password present).
  const records: CredRecord[] = [];
  if (adminPassword) {
    const salt = crypto.randomBytes(16);
    records.push({ username: adminUsername, salt, hash: deriveHash(adminPassword, salt), caps: adminCaps });
  }
  for (const [username, tier] of Object.entries(guests)) {
    if (username === adminUsername) continue;
    const pw = process.env[tier.passwordEnv];
    if (pw && pw.length > 0) {
      const salt = crypto.randomBytes(16);
      records.push({ username, salt, hash: deriveHash(pw, salt), caps: capsByUsername.get(username)! });
    }
  }

  function getCapabilities(username: string): Capabilities {
    const caps = capsByUsername.get(username);
    if (caps) return { username, ...caps };
    // Unknown username — fail safe to the most restrictive set.
    return { username, role: 'viewer', displayName: username, isAdmin: false, canWrite: false, canUseAI: false };
  }

  function verify(username: string, password: string): string | null {
    let matched: string | null = null;
    for (const rec of records) {
      const candidate = deriveHash(password, rec.salt);
      const userOk = constantTimeEqualString(username, rec.username);
      const passOk = crypto.timingSafeEqual(candidate, rec.hash);
      if (userOk && passOk) matched = rec.username;
    }
    return matched;
  }

  return {
    adminUsername,
    activeUsernames: records.map((r) => r.username),
    getCapabilities,
    verify,
  };
}

// ── Middleware capability gate ──────────────────────────────────────────────

export interface CapabilityGateInput {
  capabilities: Capabilities;
  /** HTTP method of the request. */
  method: string;
  /** Whether this path is an AI (paid generate/discover) route. */
  isAiRoute: boolean;
}

export interface CapabilityGateResult {
  /** true ⇒ allow; false ⇒ block with status/error. */
  ok: boolean;
  status?: number;
  error?: string;
}

const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

/**
 * Authoritative per-request capability check for middleware:
 *   - AI routes require canUseAI,
 *   - other mutating methods require canWrite,
 *   - everything else (reads) is allowed.
 * Returns { ok:false, status, error } to block, or { ok:true } to allow.
 */
export function capabilityGate(input: CapabilityGateInput): CapabilityGateResult {
  const { capabilities, method, isAiRoute } = input;
  if (isAiRoute) {
    if (!capabilities.canUseAI) {
      return { ok: false, status: 403, error: 'AI features are not available for this account' };
    }
    return { ok: true };
  }
  if (MUTATING.has(method) && !capabilities.canWrite) {
    return { ok: false, status: 403, error: 'This account is read-only' };
  }
  return { ok: true };
}