Save worktree changes
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
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 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 getRequestPath(req) {
|
||||
const explicitPath = String(req && req.path ? req.path : '').trim();
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
|
||||
const rawUrl = String(req && req.url ? req.url : '').trim();
|
||||
if (!rawUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
} catch (_error) {
|
||||
return 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 = String(options && options.pathname || '').trim();
|
||||
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(req.body)}`);
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user