← back to Costa Rica

lib/auth.js

73 lines

'use strict';
// Zero-dependency auth: HMAC-SHA256 JWTs + scrypt password hashing, both from
// Node's built-in crypto. Tokens carry { sub, role, host_id }.
const crypto = require('crypto');

// NEVER reuse BASIC_AUTH_PASS (different security domain) or a hardcoded default.
let SECRET = process.env.JWT_SECRET;
if (!SECRET) {
  if (process.env.NODE_ENV === 'production') throw new Error('JWT_SECRET must be set in production');
  SECRET = crypto.randomBytes(32).toString('hex'); // ephemeral dev secret — tokens reset on restart
  console.warn('[auth] JWT_SECRET unset — using an ephemeral dev secret (dev only).');
}
const TTL_SEC = parseInt(process.env.JWT_TTL_SEC || String(60 * 60 * 24 * 30), 10); // 30d

const b64u = (buf) => Buffer.from(buf).toString('base64url');
const b64uJson = (o) => b64u(JSON.stringify(o));

function signToken(payload, ttl = TTL_SEC) {
  const header = { alg: 'HS256', typ: 'JWT' };
  const now = Math.floor(Date.now() / 1000);
  const body = { iat: now, exp: now + ttl, ...payload };
  const data = `${b64uJson(header)}.${b64uJson(body)}`;
  const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
  return `${data}.${sig}`;
}

function verifyToken(token) {
  if (!token) return null;
  const parts = token.split('.');
  if (parts.length !== 3) return null;
  const [h, p, sig] = parts;
  // M3 — pin the header alg to HS256 before HMAC-verifying (defense-in-depth
  // against a future asymmetric refactor / alg-confusion; reject alg:none too).
  let header; try { header = JSON.parse(Buffer.from(h, 'base64url').toString()); } catch { return null; }
  if (!header || header.alg !== 'HS256') return null;
  const expect = crypto.createHmac('sha256', SECRET).update(`${h}.${p}`).digest('base64url');
  try {
    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
  } catch { return null; }
  let body; try { body = JSON.parse(Buffer.from(p, 'base64url').toString()); } catch { return null; }
  if (body.exp && Math.floor(Date.now() / 1000) > body.exp) return null;
  return body;
}

// scrypt password hashing (salt$hash)
function hashPassword(pw) {
  const salt = crypto.randomBytes(16);
  const hash = crypto.scryptSync(pw, salt, 32);
  return `${salt.toString('hex')}$${hash.toString('hex')}`;
}
function verifyPassword(pw, stored) {
  if (!stored || !stored.includes('$')) return false;
  const [saltHex, hashHex] = stored.split('$');
  const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 32);
  try { return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); } catch { return false; }
}

// Express middleware
function authRequired(req, res, next) {
  const t = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
  const claims = verifyToken(t);
  if (!claims) return res.status(401).json({ error: 'unauthorized' });
  req.user = claims;
  next();
}
function optionalAuth(req, _res, next) {
  const t = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
  req.user = verifyToken(t) || null;
  next();
}

module.exports = { signToken, verifyToken, hashPassword, verifyPassword, authRequired, optionalAuth };