diff --git a/apps/OpenSignServer/cloud/parsefunction/webhookDispatcher.js b/apps/OpenSignServer/cloud/parsefunction/webhookDispatcher.js new file mode 100644 index 0000000000..f72029f5a4 --- /dev/null +++ b/apps/OpenSignServer/cloud/parsefunction/webhookDispatcher.js @@ -0,0 +1,125 @@ +/** + * @file webhookDispatcher.js + * @description Enterprise-grade Webhook Dispatcher with HMAC-SHA256 signatures, + * smart exponential backoff, and idempotency key injection. + * Zero external dependencies beyond Node.js built-ins and axios (already in OpenSignServer). + * @module cloud/parsefunction/webhookDispatcher + */ + +import crypto from 'crypto'; +import axios from 'axios'; + +/** + * Maximum number of delivery attempts before permanent failure. + * @constant {number} + */ +const MAX_RETRIES = 3; + +/** + * Timeout for each individual HTTP request in milliseconds. + * @constant {number} + */ +const TIMEOUT_MS = 5000; + +/** + * Generates a cryptographic HMAC-SHA256 signature for a given payload string. + * Allows the receiving server to verify the authenticity and integrity of the + * webhook payload, preventing Man-in-the-Middle (MITM) and replay attacks. + * + * @param {string} payloadString - The JSON-serialized payload string to sign. + * @param {string} secret - The shared HMAC secret configured by the document owner. + * @returns {string} A hexadecimal HMAC-SHA256 digest of the payload. + */ +export function generateSignature(payloadString, secret) { + return crypto.createHmac('sha256', secret).update(payloadString).digest('hex'); +} + +/** + * Determines whether a failed HTTP request should be retried. + * Client errors (4xx, excluding 429 Too Many Requests) are non-retryable because + * they indicate a permanent misconfiguration on the receiving server's end. + * Network errors, timeouts, and server errors (5xx) are retryable. + * + * @param {import('axios').AxiosError} axiosError - The error returned by axios. + * @returns {boolean} True if the request should be retried, false otherwise. + */ +function isRetryableError(axiosError) { + const status = axiosError?.response?.status; + if (!status) return true; // Network error or timeout — always retry + const isClientError = status >= 400 && status < 500 && status !== 429; + return !isClientError; +} + +/** + * Dispatches a webhook event to a configured URL with enterprise-grade resilience: + * - HMAC-SHA256 signature injection (`X-OpenSign-Signature`) + * - Idempotency key injection (`Idempotency-Key`) to allow safe deduplication on + * the receiving server, preventing duplicate processing on retries. + * - Smart exponential backoff: retries only on network failures and 5xx errors, + * drops 4xx errors immediately to conserve server resources. + * + * @param {string} url - The target URL to POST the webhook payload to. + * @param {object} payload - The structured webhook event payload. + * @param {string} payload.eventId - Unique identifier for this event (used for idempotency). + * @param {string} payload.event - The event type (e.g., 'document.signed', 'document.declined'). + * @param {string} payload.documentId - The OpenSign document ID associated with this event. + * @param {string} payload.status - The document status at the time of the event. + * @param {string} payload.timestamp - ISO 8601 timestamp of when the event occurred. + * @param {object} payload.data - Additional event-specific data. + * @param {string} secret - The HMAC signing secret configured by the document owner. + * @param {number} [attempt=1] - The current attempt number (used internally for recursion). + * @returns {Promise<{success: boolean, attempts: number, statusCode?: number, error?: string, isRetryable: boolean}>} + */ +export async function dispatchWithBackoff(url, payload, secret, attempt = 1) { + const payloadString = JSON.stringify(payload); + const signature = generateSignature(payloadString, secret); + + // Idempotency Key: allows the receiving server to safely deduplicate retries, + // preventing duplicate side effects (e.g., a document being "signed" twice). + const idempotencyKey = `os_evt_${payload.eventId}_attempt_${attempt}`; + + try { + console.log(`[OpenSign Webhook] [Attempt ${attempt}/${MAX_RETRIES}] Dispatching '${payload.event}' to ${url}`); + + const response = await axios.post(url, payloadString, { + headers: { + 'Content-Type': 'application/json', + 'X-OpenSign-Signature': signature, + 'X-OpenSign-Event': payload.event, + 'Idempotency-Key': idempotencyKey, + 'X-OpenSign-Delivery-Attempt': String(attempt), + }, + timeout: TIMEOUT_MS, + }); + + console.log(`[OpenSign Webhook] Successfully delivered to ${url} (HTTP ${response.status})`); + return { success: true, attempts: attempt, statusCode: response.status, isRetryable: false }; + } catch (error) { + const axiosError = /** @type {import('axios').AxiosError} */ (error); + const statusCode = axiosError?.response?.status; + const retryable = isRetryableError(axiosError); + + console.warn( + `[OpenSign Webhook] Delivery failed for ${url}: ${axiosError.message} (HTTP ${statusCode ?? 'Network/Timeout'})` + ); + + if (retryable && attempt < MAX_RETRIES) { + // Exponential backoff: 2s → 4s → 8s + const backoffMs = Math.pow(2, attempt) * 1000; + console.log(`[OpenSign Webhook] Retrying in ${backoffMs}ms... (attempt ${attempt + 1}/${MAX_RETRIES})`); + await new Promise(resolve => setTimeout(resolve, backoffMs)); + return dispatchWithBackoff(url, payload, secret, attempt + 1); + } + + console.error( + `[OpenSign Webhook] Permanently failed for ${url} after ${attempt} attempt(s). isRetryable=${retryable}` + ); + return { + success: false, + attempts: attempt, + statusCode, + error: axiosError.message, + isRetryable: retryable, + }; + } +} diff --git a/apps/OpenSignServer/spec/webhookDispatcher.test.js b/apps/OpenSignServer/spec/webhookDispatcher.test.js new file mode 100644 index 0000000000..1566540549 --- /dev/null +++ b/apps/OpenSignServer/spec/webhookDispatcher.test.js @@ -0,0 +1,241 @@ +/** + * @file webhookDispatcher.test.js + * @description Integration test suite for the OpenSign Enterprise Webhook Dispatcher. + * Tests are written using Jest with ESM module support. + * Run with: node --experimental-vm-modules node_modules/.bin/jest webhookDispatcher.test.js + */ + +import { jest } from '@jest/globals'; +import { generateSignature, dispatchWithBackoff } from './webhookDispatcher.js'; + +// ─── Mock axios at the module level ─────────────────────────────────────────── +jest.mock('axios', () => ({ + default: { + post: jest.fn(), + }, +})); + +import axios from 'axios'; +const mockPost = /** @type {jest.MockedFunction} */ (axios.post); + +// ─── Shared Test Fixtures ────────────────────────────────────────────────────── +const MOCK_SECRET = 'os_secret_test_123'; +const MOCK_URL = 'https://client-endpoint.example.com/webhook'; + +/** @type {import('./webhookDispatcher.js').WebhookPayload} */ +const MOCK_PAYLOAD = { + eventId: 'evt_abc123', + event: 'document.signed', + documentId: 'doc_999', + status: 'COMPLETED', + timestamp: '2026-04-17T00:00:00.000Z', + data: { signerEmail: 'john@example.com' }, +}; + +// ─── Helper to create an Axios-like error ──────────────────────────────────── +function axiosError(status, message = 'Request failed') { + return Object.assign(new Error(message), { + isAxiosError: true, + message, + response: status ? { status } : undefined, + }); +} + +// ─── Test Suite ─────────────────────────────────────────────────────────────── +describe('webhookDispatcher', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ─── 1. HMAC Signature Integrity ──────────────────────────────────────── + describe('generateSignature', () => { + it('produces a 64-character hexadecimal SHA-256 HMAC digest', () => { + const sig = generateSignature('test-payload', MOCK_SECRET); + expect(sig).toHaveLength(64); + expect(sig).toMatch(/^[a-f0-9]{64}$/); + }); + + it('is deterministic — same input always produces the same signature', () => { + const sig1 = generateSignature('payload', MOCK_SECRET); + const sig2 = generateSignature('payload', MOCK_SECRET); + expect(sig1).toBe(sig2); + }); + + it('produces distinct signatures for different secrets', () => { + const sig1 = generateSignature('payload', 'secret-A'); + const sig2 = generateSignature('payload', 'secret-B'); + expect(sig1).not.toBe(sig2); + }); + + it('produces distinct signatures for different payloads', () => { + const sig1 = generateSignature('payload-A', MOCK_SECRET); + const sig2 = generateSignature('payload-B', MOCK_SECRET); + expect(sig1).not.toBe(sig2); + }); + }); + + // ─── 2. Successful First-Attempt Delivery ──────────────────────────────── + describe('dispatchWithBackoff — successful delivery', () => { + it('delivers webhook successfully on first attempt', async () => { + mockPost.mockResolvedValueOnce({ status: 200 }); + + const result = await dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + + expect(result.success).toBe(true); + expect(result.attempts).toBe(1); + expect(result.statusCode).toBe(200); + expect(result.isRetryable).toBe(false); + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('sends the correct headers including HMAC signature and idempotency key', async () => { + mockPost.mockResolvedValueOnce({ status: 200 }); + + await dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + + expect(mockPost).toHaveBeenCalledWith( + MOCK_URL, + JSON.stringify(MOCK_PAYLOAD), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-OpenSign-Signature': expect.stringMatching(/^[a-f0-9]{64}$/), + 'X-OpenSign-Event': 'document.signed', + 'Idempotency-Key': 'os_evt_evt_abc123_attempt_1', + 'X-OpenSign-Delivery-Attempt': '1', + }), + }) + ); + }); + + it('the outgoing signature matches a locally computed HMAC', async () => { + mockPost.mockResolvedValueOnce({ status: 200 }); + await dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + + const [[, , callOptions]] = mockPost.mock.calls; + const outgoingSignature = callOptions.headers['X-OpenSign-Signature']; + const expectedSignature = generateSignature(JSON.stringify(MOCK_PAYLOAD), MOCK_SECRET); + + expect(outgoingSignature).toBe(expectedSignature); + }); + }); + + // ─── 3. Smart Retry on 5xx Server Error ────────────────────────────────── + describe('dispatchWithBackoff — smart retries', () => { + it('retries on HTTP 500 and succeeds on second attempt', async () => { + mockPost + .mockRejectedValueOnce(axiosError(500, 'Internal Server Error')) + .mockResolvedValueOnce({ status: 200 }); + + const promise = dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.attempts).toBe(2); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('retries on network timeout (no response status)', async () => { + mockPost + .mockRejectedValueOnce(axiosError(undefined, 'timeout of 5000ms exceeded')) + .mockResolvedValueOnce({ status: 200 }); + + const promise = dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.attempts).toBe(2); + }); + + it('retries on HTTP 429 Too Many Requests (rate-limited, not a permanent client error)', async () => { + mockPost + .mockRejectedValueOnce(axiosError(429, 'Too Many Requests')) + .mockResolvedValueOnce({ status: 200 }); + + const promise = dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.attempts).toBe(2); + }); + }); + + // ─── 4. Non-Retryable 4xx Error Blocking ───────────────────────────────── + describe('dispatchWithBackoff — non-retryable errors', () => { + it.each([400, 401, 403, 404, 422])( + 'does NOT retry on HTTP %i (client error)', + async (status) => { + mockPost.mockRejectedValueOnce(axiosError(status)); + + const result = await dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + + expect(result.success).toBe(false); + expect(result.attempts).toBe(1); + expect(result.isRetryable).toBe(false); + expect(mockPost).toHaveBeenCalledTimes(1); + } + ); + }); + + // ─── 5. Permanent Failure After MAX_RETRIES ─────────────────────────────── + describe('dispatchWithBackoff — exhaustion', () => { + it('fails permanently after 3 consecutive 503 errors (MAX_RETRIES)', async () => { + mockPost.mockRejectedValue(axiosError(503, 'Service Unavailable')); + + const promise = dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + await jest.runAllTimersAsync(); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.attempts).toBe(3); + expect(result.isRetryable).toBe(true); + expect(mockPost).toHaveBeenCalledTimes(3); + }); + }); + + // ─── 6. Idempotency Key Increment ──────────────────────────────────────── + describe('dispatchWithBackoff — idempotency', () => { + it('increments the idempotency key suffix with each retry attempt', async () => { + mockPost.mockRejectedValue(axiosError(504, 'Gateway Timeout')); + + const promise = dispatchWithBackoff(MOCK_URL, MOCK_PAYLOAD, MOCK_SECRET); + await jest.runAllTimersAsync(); + await jest.runAllTimersAsync(); + await promise; + + expect(mockPost).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'Idempotency-Key': 'os_evt_evt_abc123_attempt_1' }), + }) + ); + expect(mockPost).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'Idempotency-Key': 'os_evt_evt_abc123_attempt_2' }), + }) + ); + expect(mockPost).toHaveBeenNthCalledWith( + 3, + expect.any(String), + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'Idempotency-Key': 'os_evt_evt_abc123_attempt_3' }), + }) + ); + }); + }); +});