406 lines
14 KiB
JavaScript
406 lines
14 KiB
JavaScript
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) {
|
|
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
|
|
if (!normalizedToken) {
|
|
return '';
|
|
}
|
|
|
|
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 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 setPageAuthToken(nextToken, nextExpiresAt) {',
|
|
' pageAuthToken = String(nextToken || "").trim();',
|
|
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
|
|
' window.__pulsePageAuthToken = pageAuthToken;',
|
|
' window.__pulsePageAuthExpiresAt = 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;',
|
|
' 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
|
|
}; |