← 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 };