// src/utils/httpAuth.js // // Shared-secret auth for HTTP API endpoints (used by index.js). // // Why this exists // --------------- // The generic /:command HTTP router dispatches to the same handlers used by // the Webex bot, including destructive ones (offboardUser, provision-*, // vcMonitor, bulkAvSwitchCSV, …). Without auth, anyone with network access to // the bot's port can trigger them. // // Behavior // -------- // - The token is taken from process.env.HTTP_API_TOKEN (read at request time // so it stays in sync with hot-reloaded .env in dev). // - Accepted on either header: `Authorization: Bearer ` or // `X-API-Token: `. The query string is intentionally NOT supported // to avoid leaking the token into proxy/access logs. // - If HTTP_API_TOKEN is not configured the middleware fails closed with 503 // so destructive endpoints are *never* accidentally exposed when the // operator forgot to set the variable. // - Comparison uses crypto.timingSafeEqual to avoid timing oracles. import crypto from 'node:crypto'; import { logger } from './logger.js'; function extractToken(req) { const auth = req.headers['authorization']; if (typeof auth === 'string') { const match = auth.match(/^Bearer\s+(.+)$/i); if (match) return match[1].trim(); } const headerToken = req.headers['x-api-token']; if (typeof headerToken === 'string' && headerToken.trim()) { return headerToken.trim(); } return null; } function constantTimeEquals(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false; const aBuf = Buffer.from(a, 'utf8'); const bBuf = Buffer.from(b, 'utf8'); // Pad to equal length so timingSafeEqual doesn't throw on length mismatch // (and so the length difference itself isn't a side channel). if (aBuf.length !== bBuf.length) { const max = Math.max(aBuf.length, bBuf.length); const aPad = Buffer.alloc(max); const bPad = Buffer.alloc(max); aBuf.copy(aPad); bBuf.copy(bPad); crypto.timingSafeEqual(aPad, bPad); // burn cycles for shape consistency return false; } return crypto.timingSafeEqual(aBuf, bBuf); } /** * Returns an Express middleware that requires a valid API token on the request. * * @param {object} [opts] * @param {string} [opts.scope='http'] - Tag used in log lines for context. */ export function requireApiToken({ scope = 'http' } = {}) { return function (req, res, next) { const expected = process.env.HTTP_API_TOKEN; if (!expected) { logger( `auth:${scope}`, `Refusing ${req.method} ${req.originalUrl} — HTTP_API_TOKEN is not configured (fail-closed)`, 'error' ); return res.status(503).json({ error: 'HTTP_API_TOKEN not configured on server', hint: 'Set HTTP_API_TOKEN in the environment to enable authenticated HTTP API access.', }); } const provided = extractToken(req); if (!provided) { logger( `auth:${scope}`, `Unauthorized ${req.method} ${req.originalUrl} — missing token`, 'warn' ); return res .status(401) .set('WWW-Authenticate', 'Bearer realm="collabfinder"') .json({ error: 'Missing API token (Authorization: Bearer or X-API-Token header)' }); } if (!constantTimeEquals(provided, expected)) { logger( `auth:${scope}`, `Forbidden ${req.method} ${req.originalUrl} — invalid token`, 'warn' ); return res.status(403).json({ error: 'Invalid API token' }); } return next(); }; } /** * Convenience: returns true when HTTP_API_REQUIRE_AUTH is set to a truthy value, * meaning every /:command (not just mutating ones) should require a token. */ export function requireAuthForAllCommands() { const v = (process.env.HTTP_API_REQUIRE_AUTH || '').toLowerCase().trim(); return v === '1' || v === 'true' || v === 'yes'; } export default requireApiToken;