Files
pulse-signage/src/request-auth.js
T
2026-08-03 19:18:30 +01:00

429 lines
15 KiB
JavaScript

// 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 [
'<script>',
' (function () {',
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
' var pageAuthCookieName = "pulse_page_auth";',
' var pageAuthRenewalTimer = null;',
' var pageAuthRenewalInFlight = null;',
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
' var originalFetch = window.fetch && window.fetch.bind(window);',
' if (!originalFetch) {',
' return;',
' }',
' function schedulePageAuthRenewal() {',
' if (pageAuthRenewalTimer) {',
' clearTimeout(pageAuthRenewalTimer);',
' pageAuthRenewalTimer = null;',
' }',
' if (!pageAuthToken || !pageAuthExpiresAt) {',
' return;',
' }',
' var delayMs = pageAuthExpiresAt - Date.now() - pageAuthRenewalSkewMs;',
' if (!Number.isFinite(delayMs) || delayMs < 1000) {',
' delayMs = 1000;',
' }',
' pageAuthRenewalTimer = setTimeout(function () {',
' renewPageAuthToken().catch(function () {',
' return null;',
' });',
' }, delayMs);',
' }',
' function writePageAuthCookie(nextToken, nextExpiresAt) {',
' if (!pageAuthCookiePath) {',
' return;',
' }',
' var cookieParts = [pageAuthCookieName + "=" + encodeURIComponent(String(nextToken || "").trim()), "Path=" + pageAuthCookiePath, "SameSite=Lax"];',
' var expiresInMs = Number(nextExpiresAt || 0) - Date.now();',
' if (Number.isFinite(expiresInMs) && expiresInMs > 0) {',
' cookieParts.push("Max-Age=" + Math.max(1, Math.floor(expiresInMs / 1000)));',
' } else {',
' cookieParts.push("Max-Age=0");',
' }',
' if (window.location.protocol === "https:") {',
' cookieParts.push("Secure");',
' }',
' document.cookie = cookieParts.join("; ");',
' }',
' function setPageAuthToken(nextToken, nextExpiresAt) {',
' pageAuthToken = String(nextToken || "").trim();',
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' schedulePageAuthRenewal();',
' }',
' async function renewPageAuthToken() {',
' if (!pageAuthToken || pageAuthRenewalInFlight) {',
' return pageAuthToken;',
' }',
' pageAuthRenewalInFlight = originalFetch("/api/auth/page", {',
' method: "POST",',
' headers: new Headers({',
' "Accept": "application/json",',
' ' + JSON.stringify(PAGE_TOKEN_HEADER) + ': pageAuthToken',
' })',
' }).then(function (response) {',
' if (!response.ok) {',
' return null;',
' }',
' return response.json().catch(function () {',
' return null;',
' });',
' }).then(function (payload) {',
' if (!payload || !payload.token) {',
' return null;',
' }',
' setPageAuthToken(payload.token, payload.expiresAt);',
' return pageAuthToken;',
' }).finally(function () {',
' pageAuthRenewalInFlight = null;',
' });',
' return pageAuthRenewalInFlight;',
' }',
' window.__pulseSetPageAuthToken = setPageAuthToken;',
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' window.addEventListener("focus", function () {',
' schedulePageAuthRenewal();',
' });',
' document.addEventListener("visibilitychange", function () {',
' if (document.visibilityState === "visible") {',
' schedulePageAuthRenewal();',
' }',
' });',
' window.fetch = function (input, init) {',
' try {',
' var requestUrl = input instanceof Request ? new URL(input.url, window.location.href) : new URL(String(input), window.location.href);',
' if (requestUrl.origin === window.location.origin && requestUrl.pathname.indexOf("/api/") === 0) {',
' var isRenewalRequest = requestUrl.pathname === "/api/auth/page";',
' var requestInit = init ? Object.assign({}, init) : {};',
' var headers = input instanceof Request ? new Headers(input.headers) : new Headers(requestInit.headers || {});',
' headers.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', pageAuthToken);',
' if (input instanceof Request) {',
' requestInit = { headers: headers };',
' var firstResponse = originalFetch(new Request(input, requestInit));',
' return firstResponse.then(function (response) {',
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
' return response;',
' }',
' return renewPageAuthToken().then(function (nextToken) {',
' if (!nextToken) {',
' return response;',
' }',
' var retryHeaders = new Headers(input.headers);',
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
' return originalFetch(new Request(input, { headers: retryHeaders }));',
' }).catch(function () {',
' return response;',
' });',
' });',
' }',
' requestInit.headers = headers;',
' var firstRequest = originalFetch(input, requestInit);',
' return firstRequest.then(function (response) {',
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
' return response;',
' }',
' return renewPageAuthToken().then(function (nextToken) {',
' if (!nextToken) {',
' return response;',
' }',
' var retryRequestInit = init ? Object.assign({}, init) : {};',
' var retryHeaders = new Headers(retryRequestInit.headers || {});',
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
' retryRequestInit.headers = retryHeaders;',
' return originalFetch(input, retryRequestInit);',
' }).catch(function () {',
' return response;',
' });',
' });',
' }',
' } catch (_error) {',
' return originalFetch(input, init);',
' }',
' return originalFetch(input, init);',
' };',
' schedulePageAuthRenewal();',
' }());',
'</script>'
].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
};