← back to Dw Nextjs Admin Login

src/index.ts

455 lines

/**
 * @dw/nextjs-admin-login
 *
 * Shared admin auth for DW Next.js verticals (Freddy / Grant / Hub / Patty / PoppyPetitions).
 * Collapses 5 byte-identical lib/auth.ts files + 5 near-identical login route handlers.
 *
 * Token format: base64(username:timestamp:sha256(username:timestamp:secret))
 * This is the canonical format already live across all 5 verticals — existing sessions
 * remain valid after consumers adopt this package.
 */

import { createHash, timingSafeEqual } from 'node:crypto';

// v0.3.0 — capability layer (guest tiers + middleware gate). Additive; opt-in.
export * from './capabilities.js';

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export interface AuthConfig {
  /** Required. Cookie name, e.g. 'freddy-auth'. */
  cookieName: string;
  /** Env var holding the session secret. Default: 'SESSION_SECRET'. */
  sessionSecretEnv?: string;
  /** Env var holding the expected username. Default: 'AUTH_USERNAME'. */
  usernameEnv?: string;
  /** Env var holding the expected password. Default: 'AUTH_PASSWORD'. */
  passwordEnv?: string;
  /** Default username when usernameEnv is unset in env. Default: 'admin'. */
  defaultUsername?: string;
  /** Session lifetime in seconds. Default: 86400 (24 h). */
  maxAgeSeconds?: number;
  /**
   * Accept Authorization: Basic <b64(user:pass)> in addition to cookie auth.
   * Only enable for Poppy / API-to-API callers. Default: false.
   */
  enableBasicAuthFallback?: boolean;
  /**
   * Append Secure flag to Set-Cookie when NODE_ENV === 'production'.
   * Default: true (fixes Freddy regression where the flag was absent).
   */
  enableSecureCookie?: boolean;
  /** Prefix for console.error / console.log lines. Default: '[auth/login]'. */
  logTag?: string;
}

/**
 * 2026-05-05 (v0.2.0): payload shape for org-scoped sessions.
 * orgId is null for legacy 3-part tokens minted before v0.2.0.
 */
export interface AuthSession {
  username: string;
  orgId: string | null;
}

export interface AuthHelpers {
  /** The cookie name passed to createAuth. */
  readonly COOKIE_NAME: string;
  /**
   * Mint a new signed session token for username.
   * Format: base64(username:timestamp:sha256(username:timestamp:secret))
   */
  createSession(username: string): string;
  /**
   * Validate a cookie/header on an incoming request.
   * Returns the authenticated username, or null if invalid / expired.
   */
  verifyAuth(req: Request): string | null;
  /** Build the Set-Cookie header value for a new session. */
  buildAuthCookie(token: string): string;
  /** Build the Set-Cookie header value that clears the session (logout). */
  buildLogoutCookie(): string;
  /**
   * SHA-256 hex digest. Matches the hashPassword implementation
   * used by all 5 verticals (not bcrypt — none of the 5 used it).
   */
  hashPassword(pw: string): string;
  /**
   * v0.2.0 — Mint a new signed session token that embeds orgId.
   * Format: base64(username:timestamp:orgId:sha256(username:timestamp:orgId:secret))
   * Verified by verifyAuthWithOrg. Backward compatible: old verifyAuth
   * rejects the 4-part format (returns null), so always issue v2 tokens
   * AFTER the consumer has migrated to verifyAuthWithOrg.
   */
  createSessionWithOrg(username: string, orgId: string | null): string;
  /**
   * v0.2.0 — Validate a cookie on an incoming request and return both
   * the username AND the embedded orgId. Accepts both v1 (3-part, returns
   * orgId: null) and v2 (4-part, returns orgId: string) tokens — so
   * existing logged-in browsers don't get bumped at upgrade time.
   * Returns null if invalid / expired.
   */
  verifyAuthWithOrg(req: Request): AuthSession | null;
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
 * Read an env var at call-time (not module-load time) so tests can set
 * process.env after import.
 */
function env(name: string): string | undefined {
  return process.env[name];
}

function hmac(username: string, timestamp: string, secret: string): string {
  return createHash('sha256')
    .update(`${username}:${timestamp}:${secret}`)
    .digest('hex');
}

// v0.2.0 — HMAC for the org-scoped 4-part token format.
function hmacWithOrg(
  username: string,
  timestamp: string,
  orgId: string,
  secret: string,
): string {
  return createHash('sha256')
    .update(`${username}:${timestamp}:${orgId}:${secret}`)
    .digest('hex');
}

function constantTimeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a, 'utf8');
  const bb = Buffer.from(b, 'utf8');
  // Buffers must be the same length for timingSafeEqual.
  // If lengths differ we still do a comparison against a same-length buffer to
  // prevent short-circuit timing, then return false.
  if (ab.length !== bb.length) {
    timingSafeEqual(ab, ab); // burn time
    return false;
  }
  return timingSafeEqual(ab, bb);
}

// ---------------------------------------------------------------------------
// createAuth factory
// ---------------------------------------------------------------------------

export function createAuth(cfg: AuthConfig): AuthHelpers {
  const {
    cookieName,
    sessionSecretEnv = 'SESSION_SECRET',
    usernameEnv = 'AUTH_USERNAME',
    passwordEnv = 'AUTH_PASSWORD',
    defaultUsername = 'admin',
    maxAgeSeconds = 86400,
    enableBasicAuthFallback = false,
    enableSecureCookie = true,
  } = cfg;

  // ---- session helpers ----

  function getSecret(): string {
    const s = env(sessionSecretEnv);
    if (!s) {
      throw new Error(
        `${sessionSecretEnv} env var is required (no fallback for production safety)`,
      );
    }
    return s;
  }

  function _createSession(username: string): string {
    const secret = getSecret();
    const timestamp = Date.now().toString();
    const sig = hmac(username, timestamp, secret);
    const payload = `${username}:${timestamp}:${sig}`;
    return Buffer.from(payload).toString('base64');
  }

  function _verifySessionToken(token: string): string | null {
    try {
      const secret = env(sessionSecretEnv);
      if (!secret) return null;

      const decoded = Buffer.from(token, 'base64').toString('utf-8');
      const parts = decoded.split(':');
      // username may itself contain colons only if someone passed a weird
      // username, but our createSession always splits into exactly 3 parts
      // (username, timestamp, 64-char hex sig). Guard strictly.
      if (parts.length !== 3) return null;

      const [username, timestamp, signature] = parts as [string, string, string];

      const age = Date.now() - parseInt(timestamp, 10);
      if (isNaN(age) || age > maxAgeSeconds * 1000) return null;

      const expected = hmac(username, timestamp, secret);
      if (!constantTimeEqual(signature, expected)) return null;

      return username;
    } catch {
      return null;
    }
  }

  function _verifyAuth(req: Request): string | null {
    // Basic auth fallback (opt-in, Poppy only)
    if (enableBasicAuthFallback) {
      const authHeader = req.headers.get('authorization');
      if (authHeader?.startsWith('Basic ')) {
        try {
          const expectedUser = env(usernameEnv) ?? defaultUsername;
          const expectedPass = env(passwordEnv);
          if (expectedPass) {
            const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
            const colonIdx = decoded.indexOf(':');
            if (colonIdx !== -1) {
              const user = decoded.slice(0, colonIdx);
              const pass = decoded.slice(colonIdx + 1);
              if (constantTimeEqual(user, expectedUser) && constantTimeEqual(pass, expectedPass)) {
                return user;
              }
            }
          }
        } catch {
          // fall through to cookie auth
        }
      }
    }

    // Cookie auth
    const cookieHeader = req.headers.get('cookie');
    if (!cookieHeader) return null;

    const cookies = Object.fromEntries(
      cookieHeader.split(';').map((c) => {
        const [key, ...rest] = c.trim().split('=');
        return [key ?? '', rest.join('=')];
      }),
    );

    const token = cookies[cookieName];
    if (!token) return null;

    return _verifySessionToken(token);
  }

  function _buildAuthCookie(token: string): string {
    const useSecure =
      enableSecureCookie && process.env['NODE_ENV'] === 'production';
    const parts = [
      `${cookieName}=${token}`,
      `Path=/`,
      `HttpOnly`,
      `SameSite=Lax`,
      `Max-Age=${maxAgeSeconds}`,
    ];
    if (useSecure) parts.push('Secure');
    return parts.join('; ');
  }

  function _buildLogoutCookie(): string {
    return `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
  }

  function _hashPassword(pw: string): string {
    return createHash('sha256').update(pw).digest('hex');
  }

  // -------------------------------------------------------------------------
  // v0.2.0 — org-scoped session helpers
  // -------------------------------------------------------------------------

  function _createSessionWithOrg(username: string, orgId: string | null): string {
    // If orgId is null we deliberately mint a v1 (3-part) token so a future
    // verifyAuthWithOrg call returns orgId: null — same behavior, smaller
    // token, no cross-format edge cases. Only emit v2 when orgId is real.
    if (orgId === null) {
      return _createSession(username);
    }
    const secret = getSecret();
    const timestamp = Date.now().toString();
    const sig = hmacWithOrg(username, timestamp, orgId, secret);
    const payload = `${username}:${timestamp}:${orgId}:${sig}`;
    return Buffer.from(payload).toString('base64');
  }

  function _verifySessionTokenWithOrg(token: string): AuthSession | null {
    try {
      const secret = env(sessionSecretEnv);
      if (!secret) return null;

      const decoded = Buffer.from(token, 'base64').toString('utf-8');
      const parts = decoded.split(':');

      // v1 (3-part): username:timestamp:sig — orgId unknown, return null orgId.
      if (parts.length === 3) {
        const username = _verifySessionToken(token);
        return username ? { username, orgId: null } : null;
      }

      // v2 (4-part): username:timestamp:orgId:sig
      if (parts.length !== 4) return null;
      const [username, timestamp, orgId, signature] = parts as [string, string, string, string];

      const age = Date.now() - parseInt(timestamp, 10);
      if (isNaN(age) || age > maxAgeSeconds * 1000) return null;

      const expected = hmacWithOrg(username, timestamp, orgId, secret);
      if (!constantTimeEqual(signature, expected)) return null;

      return { username, orgId };
    } catch {
      return null;
    }
  }

  function _verifyAuthWithOrg(req: Request): AuthSession | null {
    // Basic auth fallback (opt-in, Poppy only). Returns no orgId since
    // basic-auth carries no embedded session payload.
    if (enableBasicAuthFallback) {
      const authHeader = req.headers.get('authorization');
      if (authHeader?.startsWith('Basic ')) {
        try {
          const expectedUser = env(usernameEnv) ?? defaultUsername;
          const expectedPass = env(passwordEnv);
          if (expectedPass) {
            const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
            const colonIdx = decoded.indexOf(':');
            if (colonIdx !== -1) {
              const user = decoded.slice(0, colonIdx);
              const pass = decoded.slice(colonIdx + 1);
              if (constantTimeEqual(user, expectedUser) && constantTimeEqual(pass, expectedPass)) {
                return { username: user, orgId: null };
              }
            }
          }
        } catch {
          // fall through to cookie auth
        }
      }
    }

    const cookieHeader = req.headers.get('cookie');
    if (!cookieHeader) return null;

    const cookies = Object.fromEntries(
      cookieHeader.split(';').map((c) => {
        const [key, ...rest] = c.trim().split('=');
        return [key ?? '', rest.join('=')];
      }),
    );

    const token = cookies[cookieName];
    if (!token) return null;

    return _verifySessionTokenWithOrg(token);
  }

  return {
    COOKIE_NAME: cookieName,
    createSession: _createSession,
    verifyAuth: _verifyAuth,
    buildAuthCookie: _buildAuthCookie,
    buildLogoutCookie: _buildLogoutCookie,
    hashPassword: _hashPassword,
    createSessionWithOrg: _createSessionWithOrg,
    verifyAuthWithOrg: _verifyAuthWithOrg,
  };
}

// ---------------------------------------------------------------------------
// createLoginHandler — replaces app/api/auth/login/route.ts in each project
// ---------------------------------------------------------------------------

/**
 * NextRequest / NextResponse are peer-dep types from 'next/server'.
 * We use the global Request/Response interfaces here and cast at the boundary
 * so this package has no hard runtime dependency on Next.js itself.
 *
 * In the consumer's route.ts:
 *   import { NextRequest, NextResponse } from 'next/server';
 *   export const POST = createLoginHandler(helpers, cfg) as (req: NextRequest) => Promise<NextResponse>;
 */
type NextRequestLike = Request & {
  json(): Promise<unknown>;
};

type NextResponseLike = Response;

interface NextResponseConstructor {
  json(body: unknown, init?: ResponseInit): NextResponseLike;
}

/**
 * Resolve NextResponse at runtime from 'next/server' so we don't
 * import Next.js at module-load time (keeps the package installable outside a
 * Next.js project and lets unit tests stub the peer dep via a loader hook).
 *
 * Using new Function('specifier', 'return import(specifier)') prevents
 * TypeScript from statically resolving 'next/server' at compile time, so the
 * build succeeds even without next installed in this package's node_modules.
 */
const dynamicImport = new Function('specifier', 'return import(specifier)') as
  (s: string) => Promise<unknown>;

async function getNR(): Promise<NextResponseConstructor> {
  const mod = (await dynamicImport('next/server')) as { NextResponse: NextResponseConstructor };
  return mod.NextResponse;
}

export function createLoginHandler(
  helpers: AuthHelpers,
  cfg: AuthConfig,
): (req: Request) => Promise<Response> {
  const tag = cfg.logTag ?? '[auth/login]';
  const usernameEnv = cfg.usernameEnv ?? 'AUTH_USERNAME';
  const passwordEnv = cfg.passwordEnv ?? 'AUTH_PASSWORD';
  const defaultUsername = cfg.defaultUsername ?? 'admin';

  return async function POST(req: NextRequestLike): Promise<NextResponseLike> {
    const NR = await getNR();
    try {
      const body = (await req.json()) as Record<string, unknown>;
      const { username, password } = body as { username?: string; password?: string };

      if (!username || !password) {
        return NR.json({ error: 'Username and password are required' }, { status: 400 });
      }

      const expectedUsername = process.env[usernameEnv] ?? defaultUsername;
      const expectedPassword = process.env[passwordEnv];

      if (!expectedPassword) {
        console.error(`${tag} ${passwordEnv} env unset — refusing all logins (fail-closed)`);
        return NR.json({ error: 'Server misconfigured' }, { status: 500 });
      }

      // Constant-time compare for both fields.
      const userOk = constantTimeEqual(username, expectedUsername);
      const passOk = constantTimeEqual(password, expectedPassword);

      if (!userOk || !passOk) {
        return NR.json({ error: 'Invalid credentials' }, { status: 401 });
      }

      const token = helpers.createSession(username);
      const cookieValue = helpers.buildAuthCookie(token);

      const response = NR.json({ success: true });
      response.headers.set('Set-Cookie', cookieValue);
      return response;
    } catch (err) {
      console.error(`${tag} Error:`, (err as Error).message);
      return NR.json({ error: 'Internal server error' }, { status: 500 });
    }
  };
}