// Page and request authentication signing helpers. const crypto = require('crypto'); const PAGE_TOKEN_HEADER = 'x-pulse-page-auth'; const REQUEST_TIMESTAMP_HEADER = 'x-pulse-request-timestamp'; const REQUEST_SIGNATURE_HEADER = 'x-pulse-request-signature'; const PAGE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; const REQUEST_AUTH_MAX_SKEW_MS = 5 * 60 * 1000; function getSharedSecret() { return String(process.env.PULSE_SIGNAGE_SHARED_SECRET || process.env.PLAYER_API_SHARED_SECRET || process.env.PLAYER_SHARED_SECRET || '').trim(); } function toBase64Url(value) { return Buffer.from(String(value || ''), 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function fromBase64Url(value) { const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); return Buffer.from(padded, 'base64').toString('utf8'); } function canonicalize(value) { if (Array.isArray(value)) { return value.map(canonicalize); } if (Buffer.isBuffer(value)) { return value.toString('base64'); } if (value && typeof value === 'object') { if (value instanceof Date) { return value.toISOString(); } const canonical = {}; Object.keys(value).sort().forEach(function (key) { const normalizedValue = canonicalize(value[key]); if (normalizedValue !== undefined) { canonical[key] = normalizedValue; } }); return canonical; } return value === undefined ? undefined : value; } function normalizeRequestPath(pathValue) { const value = String(pathValue || '').trim(); if (!value) { return ''; } try { return decodeURIComponent(value); } catch (_error) { return value; } } function stableJson(value) { return JSON.stringify(canonicalize(value)); } function hashPayload(value) { const normalized = Buffer.isBuffer(value) ? value : Buffer.from(stableJson(value === undefined ? null : value) || '', 'utf8'); return crypto.createHash('sha256').update(normalized).digest('hex'); } function normalizeRequestAuthBody(req) { if (!req) { return null; } const body = req.body; if (body === undefined || body === null) { return null; } if (Buffer.isBuffer(body)) { return body; } if (typeof body === 'object' && !Array.isArray(body)) { const hasFields = Object.keys(body).length > 0; if (!hasFields) { const contentLength = Number(String(req.headers && req.headers['content-length'] || '').trim() || 0); const contentType = String(req.headers && req.headers['content-type'] || '').trim(); if (!contentLength || !contentType) { return null; } } } return body; } function getRequestPath(req) { const explicitPath = String(req && req.path ? req.path : '').trim(); if (explicitPath) { return normalizeRequestPath(explicitPath); } const rawUrl = String(req && req.url ? req.url : '').trim(); if (!rawUrl) { return ''; } try { return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname); } catch (_error) { return normalizeRequestPath(rawUrl.split('?')[0] || ''); } } function signText(secret, text) { return crypto.createHmac('sha256', String(secret || '')).update(String(text || ''), 'utf8').digest('hex'); } function timingSafeEqualHex(expectedHex, actualHex) { const expected = Buffer.from(String(expectedHex || ''), 'hex'); const actual = Buffer.from(String(actualHex || ''), 'hex'); return expected.length === actual.length && expected.length > 0 && crypto.timingSafeEqual(expected, actual); } function buildPageAuthPayload(payload) { const issuedAt = Date.now(); const normalizedPayload = canonicalize(payload || {}); return Object.assign({}, normalizedPayload, { issuedAt: issuedAt, expiresAt: issuedAt + PAGE_TOKEN_TTL_MS }); } function createPageAuthBundle(payload) { const secret = getSharedSecret(); if (!secret) { return { token: '', issuedAt: null, expiresAt: null }; } const payloadWithExpiry = buildPageAuthPayload(payload); const encodedPayload = toBase64Url(stableJson(payloadWithExpiry)); const signature = signText(secret, `page\n${encodedPayload}`); return { token: `${encodedPayload}.${signature}`, issuedAt: payloadWithExpiry.issuedAt, expiresAt: payloadWithExpiry.expiresAt }; } function createPageAuthToken(payload) { return createPageAuthBundle(payload).token; } function verifyPageAuthToken(token) { const secret = getSharedSecret(); if (!secret) { return null; } const normalizedToken = String(token || '').trim(); if (!normalizedToken) { return null; } const separatorIndex = normalizedToken.lastIndexOf('.'); if (separatorIndex <= 0) { return null; } const payloadPart = normalizedToken.slice(0, separatorIndex); const signaturePart = normalizedToken.slice(separatorIndex + 1); const expectedSignature = signText(secret, `page\n${payloadPart}`); if (!timingSafeEqualHex(expectedSignature, signaturePart)) { return null; } try { const payload = JSON.parse(fromBase64Url(payloadPart)); const now = Date.now(); const issuedAt = Number(payload && payload.issuedAt); const expiresAt = Number(payload && payload.expiresAt); if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) { return null; } if (issuedAt > now + REQUEST_AUTH_MAX_SKEW_MS) { return null; } if (expiresAt <= now) { return null; } return payload; } catch (_error) { return null; } } function createRequestAuthHeaders(options) { const secret = getSharedSecret(); if (!secret) { return {}; } const method = String(options && options.method || 'GET').trim().toUpperCase(); const pathname = normalizeRequestPath(options && options.pathname || ''); const timestamp = String(options && options.timestamp || Date.now()).trim(); const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null); const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`); return { [REQUEST_TIMESTAMP_HEADER]: timestamp, [REQUEST_SIGNATURE_HEADER]: signature }; } function verifyRequestAuth(req) { const secret = getSharedSecret(); if (!secret) { return true; } const timestamp = String(req && req.headers ? req.headers[REQUEST_TIMESTAMP_HEADER] || '' : '').trim(); const signature = String(req && req.headers ? req.headers[REQUEST_SIGNATURE_HEADER] || '' : '').trim(); if (!timestamp || !signature) { return false; } const parsedTimestamp = Number(timestamp); if (!Number.isFinite(parsedTimestamp)) { return false; } const now = Date.now(); if (Math.abs(now - parsedTimestamp) > REQUEST_AUTH_MAX_SKEW_MS) { return false; } const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(normalizeRequestAuthBody(req))}`); return timingSafeEqualHex(expectedSignature, signature); } function createPageFetchAuthScript(token, cookiePath) { const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim(); if (!normalizedToken) { return ''; } const normalizedCookiePath = String(cookiePath || '').trim(); const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt)) ? Number(token.expiresAt) : null; const renewSkewMs = REQUEST_AUTH_MAX_SKEW_MS; return [ '' ].join(''); } module.exports = { getSharedSecret: getSharedSecret, PAGE_TOKEN_HEADER: PAGE_TOKEN_HEADER, REQUEST_TIMESTAMP_HEADER: REQUEST_TIMESTAMP_HEADER, REQUEST_SIGNATURE_HEADER: REQUEST_SIGNATURE_HEADER, PAGE_TOKEN_TTL_MS: PAGE_TOKEN_TTL_MS, REQUEST_AUTH_MAX_SKEW_MS: REQUEST_AUTH_MAX_SKEW_MS, createPageAuthToken: createPageAuthToken, createPageAuthBundle: createPageAuthBundle, verifyPageAuthToken: verifyPageAuthToken, createRequestAuthHeaders: createRequestAuthHeaders, verifyRequestAuth: verifyRequestAuth, createPageFetchAuthScript: createPageFetchAuthScript };