Source: middleware/authenticate.js

/**
 * @module middleware/authenticate
 * @description Centralised authentication middleware (strategy pattern).
 *
 * All token extraction and verification logic lives in this single file so
 * adding a new auth method (API keys, service accounts, SAML, etc.) means
 * adding one strategy object — no route files need to change.
 *
 * ### Strategies
 * | Name             | Token source                        | Verifier                                    | Sets on `req`                              |
 * |------------------|-------------------------------------|---------------------------------------------|----------------------------------------------|
 * | `jwt-cookie`     | `access_token` HttpOnly cookie      | HS256 JWT verify + revocation check         | `req.authUser` (JWT payload)               |
 * | `jwt-bearer`     | `Authorization: Bearer` header      | Same as jwt-cookie                          | `req.authUser`                             |
 * | `jwt-query`      | `?token=` query param (SSE)         | Same as jwt-cookie                          | `req.authUser`                             |
 * | `trigger-token`  | `Authorization: Bearer` header      | SHA-256 hash lookup in `webhook_tokens`     | `req.triggerToken`, `req.triggerProject`    |
 *
 * ### CSRF integration
 * `req.authStrategy` is set on every authenticated request.  The CSRF
 * middleware in `appSetup.js` checks `COOKIE_STRATEGIES.has(req.authStrategy)`
 * — non-cookie strategies are automatically exempt without manual regex
 * carve-outs.
 *
 * ### Backward compatibility
 * `requireAuth` is re-exported from `routes/auth.js` as an alias for
 * `requireUser` so existing imports continue to work without changes.
 */

import crypto from "crypto";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as webhookTokenRepo from "../database/repositories/webhookTokenRepo.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { redis, redisSub, isRedisAvailable } from "../utils/redisClient.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// ─── Strategy name constants ──────────────────────────────────────────────────

/** @enum {string} Auth strategy identifiers. */
export const AUTH_TYPE = Object.freeze({
  JWT_COOKIE:    "jwt-cookie",
  JWT_BEARER:    "jwt-bearer",
  JWT_QUERY:     "jwt-query",
  TRIGGER_TOKEN: "trigger-token",
});

/**
 * Strategies that use cookies and therefore require CSRF protection.
 * Any strategy NOT in this set is automatically CSRF-exempt.
 * @type {Set<string>}
 */
export const COOKIE_STRATEGIES = new Set([AUTH_TYPE.JWT_COOKIE]);

// ─── JWT primitives ───────────────────────────────────────────────────────────

/** JWT cookie name — HttpOnly so JS cannot read the token. */
export const AUTH_COOKIE = "access_token";

/**
 * Sign a JWT with HS256 using only Node.js `crypto` (no external library).
 *
 * @param   {Object} payload      - Claims to include.
 * @param   {string} secret       - HMAC secret (32+ chars recommended).
 * @param   {number} [expiresInSec=28800] - Token lifetime in seconds (default 8 hours).
 * @returns {string}                The signed JWT string.
 */
export function signJwt(payload, secret, expiresInSec = 8 * 60 * 60) {
  const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
  const body   = Buffer.from(JSON.stringify({ ...payload, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + expiresInSec })).toString("base64url");
  const sig    = crypto.createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");
  return `${header}.${body}.${sig}`;
}

/**
 * Verify and decode a JWT signed with HS256.
 * Returns the decoded payload if valid, or `null` if invalid/expired/malformed.
 * Uses constant-time signature comparison and explicit buffer length check.
 *
 * @param   {string}       token  - The JWT string to verify.
 * @param   {string}       secret - The HMAC secret used for signing.
 * @returns {Object|null}           Decoded payload, or `null` on failure.
 */
export function verifyJwt(token, secret) {
  try {
    const parts = token?.split(".");
    if (parts?.length !== 3) return null;
    const [header, body, sig] = parts;
    const expected = crypto.createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");
    const sigBuf = Buffer.from(sig);
    const expBuf = Buffer.from(expected);
    if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return null;
    const payload = JSON.parse(Buffer.from(body, "base64url").toString());
    if (payload.exp < Math.floor(Date.now() / 1000)) return null;
    return payload;
  } catch { return null; }
}

// ─── JWT secret management ────────────────────────────────────────────────────

/** @type {string|null} */
let _cachedSecret = null;

/**
 * Get the JWT signing secret.
 *
 * Resolution order:
 * 1. `JWT_SECRET` env var (required in production, recommended everywhere)
 * 2. Dev/test only: auto-generate a random 256-bit secret and persist it to
 *    `backend/data/.jwt-secret` so tokens survive server restarts.
 *
 * @returns {string} The secret (always ≥ 32 chars).
 * @throws {Error} In production if `JWT_SECRET` is missing or too short.
 */
export function getJwtSecret() {
  if (_cachedSecret) return _cachedSecret;

  const envSecret = process.env.JWT_SECRET;
  if (envSecret && envSecret.length >= 32) {
    _cachedSecret = envSecret;
    return _cachedSecret;
  }

  if (process.env.NODE_ENV === "production") {
    throw new Error("[auth] FATAL: JWT_SECRET is missing or too short. Set a 32+ char secret in .env for production.");
  }

  const secretPath = path.join(__dirname, "..", "..", "data", ".jwt-secret");

  try {
    const existing = fs.readFileSync(secretPath, "utf-8").trim();
    if (existing.length >= 32) {
      _cachedSecret = existing;
      console.warn(formatLogLine("warn", null, "Using auto-generated JWT secret from data/.jwt-secret. Set JWT_SECRET in .env for production."));
      return _cachedSecret;
    }
  } catch { /* file doesn't exist yet */ }

  const newSecret = crypto.randomBytes(32).toString("base64url");
  try {
    const dir = path.dirname(secretPath);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(secretPath, newSecret, "utf-8");
    console.warn(formatLogLine("warn", null, "Generated new JWT secret → data/.jwt-secret. Set JWT_SECRET in .env for production."));
  } catch (err) {
    console.warn(formatLogLine("warn", null, `Could not persist JWT secret to disk: ${err.message}`));
  }
  _cachedSecret = newSecret;
  return _cachedSecret;
}

// ─── Token revocation ─────────────────────────────────────────────────────────

// When Redis is available (INF-002), revoked JTIs are stored as Redis keys
// with a TTL matching the token's remaining lifetime.  This means:
//   1. Revocations survive server restarts.
//   2. Revocations are shared across all instances in a multi-process deployment.
//   3. Expired entries are cleaned up automatically by Redis TTL.
// When Redis is NOT available, the original in-memory Map is used as fallback.

/** In-memory fallback revocation list: { jti → expiry_timestamp_sec } */
const _localRevokedTokens = new Map();

/** Redis pub/sub channel for broadcasting revocations across instances. */
const REVOKE_CHANNEL = "sentri:token:revoked";

// Purge expired entries from the in-memory Map every hour.
// When Redis is available, the local Map is still populated (by set(), pub/sub,
// and the startup pre-load), so expired entries must still be cleaned up to
// prevent unbounded memory growth.
const _purgeInterval = setInterval(() => {
  const now = Date.now() / 1000;
  for (const [jti, exp] of _localRevokedTokens) {
    if (exp < now) _localRevokedTokens.delete(jti);
  }
}, 60 * 60 * 1000);
_purgeInterval.unref();

// Subscribe to the revocation broadcast channel so revocations from other
// instances are reflected in this instance's local Map. This keeps has()
// synchronous while ensuring cross-instance consistency.
if (redisSub) {
  redisSub.subscribe(REVOKE_CHANNEL).catch(err => {
    console.warn(formatLogLine("warn", null, `[auth] Redis subscribe for revocations failed: ${err.message}`));
  });
  redisSub.on("message", (channel, message) => {
    if (channel !== REVOKE_CHANNEL) return;
    try {
      const { jti, exp } = JSON.parse(message);
      if (jti && exp) _localRevokedTokens.set(jti, exp);
    } catch { /* malformed — ignore */ }
  });
}

// On startup, pre-load recent revocations from Redis into the local Map
// so tokens revoked before this instance started are still rejected.
// Check `redis !== null` (client created) rather than `isRedisAvailable()`
// (client connected) because the ioredis `connect` event fires async after
// all module-level code runs — `isRedisAvailable()` would always be false here.
// The redis client queues commands until connected, so the scan will execute
// once the connection is established.
if (redis) {
  (async () => {
    try {
      let cursor = "0";
      do {
        const [nextCursor, keys] = await redis.scan(cursor, "MATCH", "sentri:revoked:*", "COUNT", 100);
        cursor = nextCursor;
        for (const key of keys) {
          const jti = key.slice("sentri:revoked:".length);
          const ttl = await redis.ttl(key);
          if (ttl > 0) {
            _localRevokedTokens.set(jti, Math.floor(Date.now() / 1000) + ttl);
          }
        }
      } while (cursor !== "0");
    } catch (err) {
      console.warn(formatLogLine("warn", null, `[auth] Failed to pre-load revocations from Redis: ${err.message}`));
    }
  })();
}

/**
 * Revoked tokens facade — `.set()` / `.has()` API matching the original Map
 * so all existing callers (auth.js logout, refresh) work without changes.
 * Delegates to Redis when available. Cross-instance sync is handled by
 * pub/sub — set() publishes to the REVOKE_CHANNEL so other instances
 * update their local Maps, and has() always checks the local Map (fast,
 * synchronous, and kept in sync by the subscriber).
 */
export const revokedTokens = {
  set(jti, exp) {
    _localRevokedTokens.set(jti, exp);
    if (isRedisAvailable()) {
      const ttl = Math.max(1, Math.ceil(exp - Date.now() / 1000));
      redis.set(`sentri:revoked:${jti}`, "1", "EX", ttl).catch(() => {});
      // Broadcast to other instances so their local Maps are updated.
      redis.publish(REVOKE_CHANNEL, JSON.stringify({ jti, exp })).catch(() => {});
    }
  },
  has(jti) {
    return _localRevokedTokens.has(jti);
  },
};

// ─── Strategy definitions ─────────────────────────────────────────────────────

/**
 * JWT verification shared by all jwt-* strategies.
 * @param {string} token - Raw JWT string.
 * @returns {Object|null} Decoded payload or null.
 */
function verifyJwtToken(token) {
  const payload = verifyJwt(token, getJwtSecret());
  if (!payload) return null;
  if (payload.jti && revokedTokens.has(payload.jti)) return null;
  return payload;
}

/** @type {Array<{name: string, extract: Function, verify: Function}>} */
const STRATEGIES = [
  // 1. JWT from HttpOnly cookie (primary for browser sessions)
  {
    name: AUTH_TYPE.JWT_COOKIE,
    extract: (req) => req.cookies?.[AUTH_COOKIE] || null,
    verify: (token) => {
      const payload = verifyJwtToken(token);
      return payload ? { strategy: AUTH_TYPE.JWT_COOKIE, authUser: payload } : null;
    },
  },
  // 2. JWT from Authorization: Bearer header (backward compat, direct API consumers)
  {
    name: AUTH_TYPE.JWT_BEARER,
    extract: (req) => {
      const h = req.headers.authorization;
      return h?.startsWith("Bearer ") ? h.slice(7) : null;
    },
    verify: (token) => {
      const payload = verifyJwtToken(token);
      return payload ? { strategy: AUTH_TYPE.JWT_BEARER, authUser: payload } : null;
    },
  },
  // 3. JWT from ?token= query param (SSE / EventSource fallback)
  {
    name: AUTH_TYPE.JWT_QUERY,
    extract: (req) => req.query.token || null,
    verify: (token) => {
      const payload = verifyJwtToken(token);
      return payload ? { strategy: AUTH_TYPE.JWT_QUERY, authUser: payload } : null;
    },
  },
  // 4. Per-project trigger token (CI/CD pipelines)
  {
    name: AUTH_TYPE.TRIGGER_TOKEN,
    extract: (req) => {
      const h = req.headers.authorization;
      return h?.startsWith("Bearer ") ? h.slice(7).trim() : null;
    },
    verify: (token, req) => {
      if (!token) return null;
      const tokenRow = webhookTokenRepo.findByHash(webhookTokenRepo.hashToken(token));
      if (!tokenRow) return null;
      const project = projectRepo.getById(req.params.id);
      if (!project) return null;
      if (tokenRow.projectId !== project.id) return null;
      return {
        strategy: AUTH_TYPE.TRIGGER_TOKEN,
        triggerToken: tokenRow,
        triggerProject: project,
      };
    },
  },
];

// Build a name → strategy lookup for fast filtering.
const _strategyMap = new Map(STRATEGIES.map(s => [s.name, s]));

// ─── Public middleware factory ─────────────────────────────────────────────────

/**
 * Create an Express middleware that authenticates the request using the
 * specified strategies (tried in declaration order).
 *
 * On success, sets `req.authUser`, `req.triggerToken`, `req.triggerProject`,
 * and `req.authStrategy` as appropriate for the winning strategy.
 *
 * @param {...string} allowedNames - Strategy names to try (from {@link AUTH_TYPE}).
 *   If empty, tries ALL strategies.
 * @returns {Function} Express middleware `(req, res, next)`.
 */
export function authenticate(...allowedNames) {
  const allowed = allowedNames.length
    ? allowedNames.map(n => _strategyMap.get(n)).filter(Boolean)
    : STRATEGIES;

  return (req, res, next) => {
    for (const strategy of allowed) {
      const raw = strategy.extract(req);
      if (!raw) continue;
      const result = strategy.verify(raw, req);
      if (result) {
        if (result.authUser)       req.authUser       = result.authUser;
        if (result.triggerToken)   req.triggerToken    = result.triggerToken;
        if (result.triggerProject) req.triggerProject  = result.triggerProject;
        // Tag the strategy so CSRF middleware can auto-exempt non-cookie auth.
        req.authStrategy = result.strategy;
        return next();
      }
    }

    // ── Strategy-specific error messages ──────────────────────────────────
    // When only trigger-token is allowed, give CI-friendly diagnostics.
    if (allowedNames.length === 1 && allowedNames[0] === AUTH_TYPE.TRIGGER_TOKEN) {
      const authHeader = req.headers.authorization;
      if (!authHeader?.startsWith("Bearer ")) {
        return res.status(401).json({ error: "Authorization: Bearer <token> header required." });
      }
      const plaintext = authHeader.slice(7).trim();
      if (!plaintext) {
        return res.status(401).json({ error: "Empty token." });
      }
      // Token was present — check if it's valid but for the wrong project.
      const tokenRow = webhookTokenRepo.findByHash(webhookTokenRepo.hashToken(plaintext));
      if (!tokenRow) {
        return res.status(401).json({ error: "Invalid trigger token." });
      }
      const project = projectRepo.getById(req.params.id);
      if (!project) {
        return res.status(404).json({ error: "not found" });
      }
      if (tokenRow.projectId !== project.id) {
        return res.status(403).json({ error: "Token does not belong to this project." });
      }
    }

    // Default error for JWT strategies
    return res.status(401).json({ error: "Authentication required." });
  };
}

// ─── Convenience aliases ──────────────────────────────────────────────────────

/**
 * JWT auth middleware — tries cookie → bearer → query param.
 * This is the standard middleware for all user-facing API routes.
 * Backward-compatible drop-in replacement for the old `requireAuth`.
 */
export const requireUser = authenticate(
  AUTH_TYPE.JWT_COOKIE,
  AUTH_TYPE.JWT_BEARER,
  AUTH_TYPE.JWT_QUERY,
);

/**
 * Trigger token auth middleware — per-project Bearer token for CI/CD.
 * Sets `req.triggerToken` and `req.triggerProject` on success.
 */
export const requireTrigger = authenticate(AUTH_TYPE.TRIGGER_TOKEN);