// @ts-check /** * Signed, org-bound, expiring tokens for the internal staff report. * * The CiviCRM extension's Engagement Report tab mints a token per render: * * sig = base64url( HMAC-SHA256( STAFF_REPORT_KEY, * "webform-mw.staff-report.v1::" ) ) * * and links to /staff/report?org=&exp=&sig=. The raw shared * secret never leaves the two servers, and a captured URL only unlocks one * org until it expires — unlike the legacy `?key=` form, which * unlocked every org forever. * * The PHP side (civi-extension/.../Page/Tab.php) must build the message * string byte-for-byte the same way. The fixed test vector in * staff-token.test.mjs was produced by the PHP implementation. * * Written as JS+JSDoc rather than TS so Node's built-in --test runner can * import this file directly — matches lib/mime.mjs. */ import { createHmac, timingSafeEqual } from "node:crypto"; /** * Longest validity window we accept, measured from now. The extension * issues 8-hour tokens; the extra headroom absorbs clock skew between the * Civi server and Amplify without letting anyone mint long-lived links. */ export const STAFF_TOKEN_MAX_TTL_SECONDS = 12 * 60 * 60; /** * @param {string} org * @param {string} exp */ function message(org, exp) { return `webform-mw.staff-report.v1:${org}:${exp}`; } /** * @param {string} secret * @param {string} org * @param {string} exp * @returns {string} */ export function signStaffToken(secret, org, exp) { return createHmac("sha256", secret).update(message(org, exp)).digest("base64url"); } /** * Constant-time string comparison. Lengths may differ; that case returns * false without calling timingSafeEqual (which would throw). * * @param {string} a * @param {string} b */ export function safeEqual(a, b) { const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && timingSafeEqual(ab, bb); } /** * @param {string | null | undefined} secret * @param {string | null | undefined} org * @param {string | null | undefined} exp * @param {string | null | undefined} sig * @param {number} [nowSeconds] * @returns {boolean} */ export function verifyStaffToken(secret, org, exp, sig, nowSeconds = Math.floor(Date.now() / 1000)) { if (!secret || !org || !exp || !sig) return false; if (!/^[1-9]\d*$/.test(org) || !/^[1-9]\d*$/.test(exp)) return false; const expN = Number(exp); if (expN <= nowSeconds) return false; if (expN - nowSeconds > STAFF_TOKEN_MAX_TTL_SECONDS) return false; return safeEqual(signStaffToken(secret, org, exp), sig); }