Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions apps/OpenSignServer/cloud/parsefunction/webhookDispatcher.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
241 changes: 241 additions & 0 deletions apps/OpenSignServer/spec/webhookDispatcher.test.js
Original file line number Diff line number Diff line change
@@ -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<typeof axios.post>} */ (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' }),
})
);
});
});
});