import crypto from 'node:crypto'; import { safeEqual } from '../libs/auth.js'; const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; const usedNonces = new Map(); const normalizePath = (path) => { if (!path || path === '/') { return '/'; } return path.endsWith('/') ? path.slice(0, -1) : path; }; const stableStringify = (value) => { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; } if (value && typeof value === 'object') { return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; } return JSON.stringify(value); }; const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const getUsers = () => { return process.env.PINNACLE_PROXY_USERS?.split(';') .map(item => item.trim()) .filter(Boolean) .map(item => { const [username, secret, permissions=''] = item.split(':'); return { permissions: permissions.split(',').map(permission => normalizePath(permission.trim())).filter(Boolean), secret, username, }; }) .filter(user => user.username && user.secret) ?? []; }; const getUser = (username) => getUsers().find(user => safeEqual(user.username, username)); const cleanupUsedNonces = () => { const now = Date.now(); usedNonces.forEach((expiresAt, key) => { if (expiresAt <= now) { usedNonces.delete(key); } }); }; const checkNonce = (username, nonce) => { cleanupUsedNonces(); const key = `${username}:${nonce}`; if (usedNonces.has(key)) { return false; } usedNonces.set(key, Date.now() + MAX_CLOCK_SKEW_MS); return true; }; const hasPermission = (user, path) => { const targetPath = normalizePath(path); return user.permissions.includes('*') || user.permissions.includes(targetPath); }; const getBodyHash = (body) => { if (body === undefined || body === null || Object.keys(body).length === 0) { return sha256(''); } return sha256(stableStringify(body)); }; const buildSigningPayload = (req, timestamp, nonce) => [ req.method.toUpperCase(), normalizePath(req.path), stableStringify(req.query ?? {}), getBodyHash(req.body), timestamp, nonce, ].join('\n'); const requirePinnacleProxyAuth = (req, res, next) => { const username = req.get('x-api-user'); const timestamp = req.get('x-api-timestamp'); const nonce = req.get('x-api-nonce'); const signature = req.get('x-api-signature'); if (!username || !timestamp || !nonce || !signature) { return res.unauthorized('Missing API signature headers'); } const requestTime = Number(timestamp); if (!Number.isFinite(requestTime) || Math.abs(Date.now() - requestTime) > MAX_CLOCK_SKEW_MS) { return res.unauthorized('Invalid API timestamp'); } const user = getUser(username); if (!user) { return res.unauthorized('Invalid API user'); } if (!hasPermission(user, req.path)) { return res.status(403).json({ statusCode: 403, code: -1, message: 'Forbidden' }); } const payload = buildSigningPayload(req, timestamp, nonce); const expectedSignature = crypto.createHmac('sha256', user.secret).update(payload).digest('hex'); if (!safeEqual(signature, expectedSignature)) { return res.unauthorized('Invalid API signature'); } if (!checkNonce(username, nonce)) { return res.unauthorized('API nonce has already been used'); } req.pinnacleProxyUser = { username }; return next(); }; export default requirePinnacleProxyAuth;