feat(Calendly Trigger Node): Add webhook request verification (#29482)

This commit is contained in:
Dawid Myslak 2026-05-07 07:55:20 +02:00 committed by GitHub
parent a772016e36
commit e929f9fbe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 299 additions and 0 deletions

View File

@ -1,3 +1,5 @@
import { randomBytes } from 'crypto';
import type {
IHookFunctions,
IWebhookFunctions,
@ -8,6 +10,7 @@ import type {
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { verifySignature } from './CalendlyTriggerHelpers';
import { calendlyApiRequest, getAuthenticationType } from './GenericFunctions';
export class CalendlyTrigger implements INodeType {
@ -216,11 +219,14 @@ export class CalendlyTrigger implements INodeType {
const scope = this.getNodeParameter('scope', 0) as string;
const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me');
const webhookSecret = randomBytes(32).toString('hex');
const body: IDataObject = {
url: webhookUrl,
events,
organization: resource.current_organization,
scope,
signing_key: webhookSecret,
};
if (scope === 'user') {
@ -235,6 +241,7 @@ export class CalendlyTrigger implements INodeType {
}
webhookData.webhookURI = responseData.resource.uri;
webhookData.webhookSecret = webhookSecret;
}
return true;
@ -276,6 +283,7 @@ export class CalendlyTrigger implements INodeType {
}
delete webhookData.webhookURI;
delete webhookData.webhookSecret;
}
}
@ -285,6 +293,14 @@ export class CalendlyTrigger implements INodeType {
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
if (!verifySignature.call(this)) {
const res = this.getResponseObject();
res.status(401).send('Unauthorized').end();
return {
noWebhookResponse: true,
};
}
const bodyData = this.getBodyData();
return {
workflowData: [this.helpers.returnJsonArray(bodyData)],

View File

@ -0,0 +1,69 @@
import { createHmac } from 'crypto';
import type { IWebhookFunctions } from 'n8n-workflow';
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
/**
* Calendly sends the signature in the `Calendly-Webhook-Signature` header
* with the format `t=<timestamp>,v1=<hex-signature>`. The signature is the
* HMAC SHA-256 of `<timestamp>.<raw-body>` using the `signing_key` provided
* when the webhook subscription was created.
*/
function parseSignatureHeader(header: string | null): {
timestamp: string | null;
signature: string | null;
} {
if (!header) {
return { timestamp: null, signature: null };
}
const parts = header.split(',');
let timestamp: string | null = null;
let signature: string | null = null;
for (const part of parts) {
const [rawKey, ...rest] = part.split('=');
if (!rawKey || rest.length === 0) continue;
const key = rawKey.trim();
const value = rest.join('=').trim();
if (key === 't') {
timestamp = value;
} else if (key === 'v1') {
signature = value;
}
}
return { timestamp, signature };
}
/**
* Verifies the Calendly webhook signature.
*
* Returns `true` if the signature is valid or if no `webhookSecret` is
* stored (backward compatibility with webhook subscriptions created before
* signing was supported).
*/
export function verifySignature(this: IWebhookFunctions): boolean {
const req = this.getRequestObject();
const webhookData = this.getWorkflowStaticData('node');
const secret = webhookData.webhookSecret;
const hasSecret = typeof secret === 'string' && secret.length > 0;
const headerValue = req.header('calendly-webhook-signature');
const { timestamp, signature } = parseSignatureHeader(
typeof headerValue === 'string' ? headerValue : null,
);
return verifySignatureGeneric({
getExpectedSignature: () => {
if (!hasSecret || typeof secret !== 'string' || !req.rawBody || !timestamp) {
return null;
}
const payload = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody);
const hmac = createHmac('sha256', secret);
hmac.update(`${timestamp}.`);
hmac.update(payload);
return hmac.digest('hex');
},
skipIfNoExpectedSignature: !hasSecret,
getActualSignature: () => signature,
getTimestamp: hasSecret ? () => timestamp : undefined,
});
}

View File

@ -0,0 +1,95 @@
import { randomBytes } from 'crypto';
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
import { CalendlyTrigger } from '../CalendlyTrigger.node';
import { verifySignature } from '../CalendlyTriggerHelpers';
import { calendlyApiRequest, getAuthenticationType } from '../GenericFunctions';
jest.mock('../GenericFunctions');
jest.mock('../CalendlyTriggerHelpers');
jest.mock('crypto', () => ({
...jest.requireActual('crypto'),
randomBytes: jest.fn(),
}));
describe('CalendlyTrigger', () => {
let trigger: CalendlyTrigger;
beforeEach(() => {
jest.clearAllMocks();
trigger = new CalendlyTrigger();
});
describe('webhookMethods.default.create', () => {
it('should generate a signing key and send it to Calendly via v2 API', async () => {
const webhookSecret = 'a'.repeat(64);
const userResource = {
uri: 'https://api.calendly.com/users/USER',
current_organization: 'https://api.calendly.com/organizations/ORG',
};
(getAuthenticationType as jest.Mock).mockResolvedValue('accessToken');
(randomBytes as jest.Mock).mockReturnValue({
toString: jest.fn().mockReturnValue(webhookSecret),
});
(calendlyApiRequest as jest.Mock)
.mockResolvedValueOnce({ resource: userResource })
.mockResolvedValueOnce({
resource: { uri: 'https://api.calendly.com/webhook_subscriptions/SUB' },
});
const webhookData: Record<string, unknown> = {};
const mockHook = {
getNodeWebhookUrl: jest.fn().mockReturnValue('https://example.com/webhook'),
getNodeParameter: jest.fn().mockImplementation((name: string) => {
if (name === 'events') return ['invitee.created'];
if (name === 'scope') return 'user';
}),
getWorkflowStaticData: jest.fn().mockReturnValue(webhookData),
} as unknown as IHookFunctions;
await trigger.webhookMethods!.default.create.call(mockHook);
expect(calendlyApiRequest).toHaveBeenLastCalledWith(
'POST',
'/webhook_subscriptions',
expect.objectContaining({ signing_key: webhookSecret }),
);
expect(webhookData.webhookSecret).toBe(webhookSecret);
});
});
describe('webhook', () => {
it('should return 401 when signature verification fails', async () => {
const mockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn(),
};
(verifySignature as jest.Mock).mockReturnValue(false);
const mockFn = {
getResponseObject: jest.fn().mockReturnValue(mockRes),
} as unknown as IWebhookFunctions;
const result = await trigger.webhook.call(mockFn);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockRes.status).toHaveBeenCalledWith(401);
});
it('should process the webhook when signature is valid', async () => {
const bodyData = { event: 'invitee.created', payload: { foo: 'bar' } };
(verifySignature as jest.Mock).mockReturnValue(true);
const mockFn = {
getBodyData: jest.fn().mockReturnValue(bodyData),
helpers: { returnJsonArray: jest.fn((data) => data) },
} as unknown as IWebhookFunctions;
const result = await trigger.webhook.call(mockFn);
expect(result).toEqual({ workflowData: [bodyData] });
});
});
});

View File

@ -0,0 +1,119 @@
import { createHmac } from 'crypto';
import type { IWebhookFunctions } from 'n8n-workflow';
import { verifySignature } from '../CalendlyTriggerHelpers';
describe('CalendlyTriggerHelpers', () => {
const testSecret = 'test-secret-key-12345';
const testPayload = Buffer.from('{"event":"invitee.created"}');
function buildSignatureHeader(secret: string, timestamp: number, payload: Buffer) {
const hmac = createHmac('sha256', secret);
hmac.update(`${timestamp}.`);
hmac.update(payload);
return `t=${timestamp},v1=${hmac.digest('hex')}`;
}
function createMockContext(opts: {
webhookSecret?: string;
headerValue: string | null;
rawBody: Buffer | string | undefined;
}) {
return {
getWorkflowStaticData: jest
.fn()
.mockReturnValue(
opts.webhookSecret !== undefined ? { webhookSecret: opts.webhookSecret } : {},
),
getRequestObject: jest.fn().mockReturnValue({
header: jest
.fn()
.mockImplementation((name: string) =>
name === 'calendly-webhook-signature' ? opts.headerValue : null,
),
rawBody: opts.rawBody,
}),
} as unknown as IWebhookFunctions;
}
describe('verifySignature', () => {
it('should skip verification when no secret is stored (backward compatibility)', () => {
const ctx = createMockContext({ headerValue: null, rawBody: testPayload });
expect(verifySignature.call(ctx)).toBe(true);
});
it('should accept a valid signature and timestamp', () => {
const timestamp = Math.floor(Date.now() / 1000);
const header = buildSignatureHeader(testSecret, timestamp, testPayload);
const ctx = createMockContext({
webhookSecret: testSecret,
headerValue: header,
rawBody: testPayload,
});
expect(verifySignature.call(ctx)).toBe(true);
});
it('should reject an invalid signature', () => {
const timestamp = Math.floor(Date.now() / 1000);
const ctx = createMockContext({
webhookSecret: testSecret,
headerValue: `t=${timestamp},v1=invalidsignature`,
rawBody: testPayload,
});
expect(verifySignature.call(ctx)).toBe(false);
});
it('should reject when signature header is missing', () => {
const ctx = createMockContext({
webhookSecret: testSecret,
headerValue: null,
rawBody: testPayload,
});
expect(verifySignature.call(ctx)).toBe(false);
});
it('should reject when timestamp is too old', () => {
const oldTimestamp = Math.floor(Date.now() / 1000) - 600;
const header = buildSignatureHeader(testSecret, oldTimestamp, testPayload);
const ctx = createMockContext({
webhookSecret: testSecret,
headerValue: header,
rawBody: testPayload,
});
expect(verifySignature.call(ctx)).toBe(false);
});
it('should reject a malformed header', () => {
const ctx = createMockContext({
webhookSecret: testSecret,
headerValue: 'not-a-valid-header',
rawBody: testPayload,
});
expect(verifySignature.call(ctx)).toBe(false);
});
it('should reject when raw body is missing', () => {
const timestamp = Math.floor(Date.now() / 1000);
const header = buildSignatureHeader(testSecret, timestamp, testPayload);
const ctx = createMockContext({
webhookSecret: testSecret,
headerValue: header,
rawBody: undefined,
});
expect(verifySignature.call(ctx)).toBe(false);
});
it('should handle raw body provided as a string', () => {
const timestamp = Math.floor(Date.now() / 1000);
const body = '{"event":"invitee.created"}';
const header = buildSignatureHeader(testSecret, timestamp, Buffer.from(body));
const ctx = createMockContext({
webhookSecret: testSecret,
headerValue: header,
rawBody: body,
});
expect(verifySignature.call(ctx)).toBe(true);
});
});
});