feat(Acuity Scheduling Trigger Node): Add webhook request verification (#29261)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dawid Myslak 2026-05-11 22:54:07 +02:00 committed by GitHub
parent 94e403300b
commit da41470311
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 238 additions and 0 deletions

View File

@ -8,6 +8,7 @@ import type {
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { verifySignature } from './AcuitySchedulingTriggerHelpers';
import { acuitySchedulingApiRequest } from './GenericFunctions';
export class AcuitySchedulingTrigger implements INodeType {
@ -161,6 +162,12 @@ export class AcuitySchedulingTrigger implements INodeType {
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
if (!(await verifySignature.call(this))) {
const res = this.getResponseObject();
res.status(401).send('Unauthorized').end();
return { noWebhookResponse: true };
}
const req = this.getRequestObject();
const resolveData = this.getNodeParameter('resolveData', false) as boolean;

View File

@ -0,0 +1,41 @@
import { createHmac } from 'crypto';
import type { IWebhookFunctions } from 'n8n-workflow';
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
export async function verifySignature(this: IWebhookFunctions): Promise<boolean> {
const authentication = this.getNodeParameter('authentication', 'apiKey') as string;
// OAuth2 flows do not expose an API key that can be used as the shared secret,
// so verification is skipped to remain backward compatible.
if (authentication !== 'apiKey') {
return true;
}
const req = this.getRequestObject();
let apiKey: string | undefined;
try {
const credentials = await this.getCredentials('acuitySchedulingApi');
apiKey = typeof credentials?.apiKey === 'string' ? credentials.apiKey : undefined;
} catch {
return true;
}
return verifySignatureGeneric({
getExpectedSignature: () => {
if (!apiKey || !req.rawBody) {
return null;
}
const hmac = createHmac('sha256', apiKey);
const payload = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody);
hmac.update(payload);
return hmac.digest('base64');
},
skipIfNoExpectedSignature: !apiKey,
getActualSignature: () => {
const signature = req.header('x-acuity-signature');
return typeof signature === 'string' ? signature : null;
},
});
}

View File

@ -0,0 +1,97 @@
import type { IDataObject, IWebhookFunctions } from 'n8n-workflow';
import { AcuitySchedulingTrigger } from '../AcuitySchedulingTrigger.node';
import { verifySignature } from '../AcuitySchedulingTriggerHelpers';
import { acuitySchedulingApiRequest } from '../GenericFunctions';
jest.mock('../AcuitySchedulingTriggerHelpers', () => ({
verifySignature: jest.fn(),
}));
jest.mock('../GenericFunctions', () => ({
acuitySchedulingApiRequest: jest.fn(),
}));
const mockedVerifySignature = jest.mocked(verifySignature);
const mockedAcuitySchedulingApiRequest = jest.mocked(acuitySchedulingApiRequest);
describe('AcuitySchedulingTrigger', () => {
let trigger: AcuitySchedulingTrigger;
let response: { status: jest.Mock; send: jest.Mock; end: jest.Mock };
let ctx: IWebhookFunctions;
const requestBody: IDataObject = { action: 'appointment.scheduled', id: 123 };
const buildContext = (body: IDataObject = requestBody): IWebhookFunctions => {
response = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
};
return {
getRequestObject: jest.fn().mockReturnValue({ body }),
getResponseObject: jest.fn().mockReturnValue(response),
getNodeParameter: jest.fn(),
helpers: {
returnJsonArray: jest.fn().mockImplementation((data: IDataObject) => [data]),
},
} as unknown as IWebhookFunctions;
};
beforeEach(() => {
jest.clearAllMocks();
trigger = new AcuitySchedulingTrigger();
ctx = buildContext();
mockedVerifySignature.mockResolvedValue(true);
});
describe('webhook', () => {
it('triggers workflow when signature is valid and resolveData is false', async () => {
(ctx.getNodeParameter as jest.Mock).mockImplementation((name: string) =>
name === 'resolveData' ? false : undefined,
);
const result = await trigger.webhook.call(ctx);
expect(mockedVerifySignature).toHaveBeenCalled();
expect(result).toEqual({ workflowData: [[requestBody]] });
});
it('returns 401 when signature is invalid', async () => {
mockedVerifySignature.mockResolvedValue(false);
const result = await trigger.webhook.call(ctx);
expect(response.status).toHaveBeenCalledWith(401);
expect(response.send).toHaveBeenCalledWith('Unauthorized');
expect(response.end).toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
expect(mockedAcuitySchedulingApiRequest).not.toHaveBeenCalled();
});
it('triggers workflow when no signing secret is configured (backward compat)', async () => {
mockedVerifySignature.mockResolvedValue(true);
(ctx.getNodeParameter as jest.Mock).mockImplementation((name: string) =>
name === 'resolveData' ? false : undefined,
);
const result = await trigger.webhook.call(ctx);
expect(result).toEqual({ workflowData: [[requestBody]] });
});
it('resolves data via API when resolveData is true', async () => {
ctx = buildContext({ id: 123 });
(ctx.getNodeParameter as jest.Mock).mockImplementation((name: string) => {
if (name === 'resolveData') return true;
if (name === 'event') return 'appointment.scheduled';
return undefined;
});
mockedAcuitySchedulingApiRequest.mockResolvedValue({ id: 123, name: 'Resolved' });
const result = await trigger.webhook.call(ctx);
expect(mockedAcuitySchedulingApiRequest).toHaveBeenCalledWith('GET', '/appointments/123', {});
expect(result).toEqual({ workflowData: [[{ id: 123, name: 'Resolved' }]] });
});
});
});

View File

@ -0,0 +1,93 @@
import { createHmac } from 'crypto';
import { mock } from 'jest-mock-extended';
import type { IWebhookFunctions } from 'n8n-workflow';
import { verifySignature } from '../AcuitySchedulingTriggerHelpers';
describe('AcuitySchedulingTriggerHelpers', () => {
describe('verifySignature', () => {
const apiKey = 'test-acuity-api-key';
const rawBody = '{"action":"appointment.scheduled","id":"123","calendarID":"1"}';
const validSignature = createHmac('sha256', apiKey)
.update(Buffer.from(rawBody))
.digest('base64');
type BuildOpts = {
authentication?: string;
credentials?: Record<string, unknown>;
signatureHeader?: string | null;
body?: Buffer | string | undefined;
throwOnGetCredentials?: boolean;
};
const buildContext = (opts: BuildOpts = {}) => {
const authentication = opts.authentication ?? 'apiKey';
const signatureHeader = opts.signatureHeader ?? null;
const body = 'body' in opts ? opts.body : Buffer.from(rawBody);
const ctx = mock<IWebhookFunctions>();
ctx.getNodeParameter.mockReturnValue(authentication);
if (opts.throwOnGetCredentials) {
ctx.getCredentials.mockRejectedValue(new Error('not found'));
} else {
ctx.getCredentials.mockResolvedValue(opts.credentials ?? { apiKey });
}
ctx.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((name: string) => {
if (name === 'x-acuity-signature') return signatureHeader;
return null;
}),
rawBody: body,
} as never);
return ctx;
};
it('returns true when no api key is configured (backward compat)', async () => {
const ctx = buildContext({ credentials: { apiKey: '' } });
expect(await verifySignature.call(ctx)).toBe(true);
});
it('returns true when authentication is OAuth2 (no api key available)', async () => {
const ctx = buildContext({ authentication: 'oAuth2' });
expect(await verifySignature.call(ctx)).toBe(true);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(ctx.getCredentials).not.toHaveBeenCalled();
});
it('returns true when signature is valid', async () => {
const ctx = buildContext({ signatureHeader: validSignature });
expect(await verifySignature.call(ctx)).toBe(true);
});
it('returns false when signature is invalid', async () => {
const ctx = buildContext({ signatureHeader: 'invalid-signature' });
expect(await verifySignature.call(ctx)).toBe(false);
});
it('returns false when signature header is missing', async () => {
const ctx = buildContext({ signatureHeader: null });
expect(await verifySignature.call(ctx)).toBe(false);
});
it('returns false when raw body is missing', async () => {
const ctx = buildContext({ signatureHeader: validSignature, body: undefined });
expect(await verifySignature.call(ctx)).toBe(false);
});
it('returns true when getCredentials throws (backward compat)', async () => {
const ctx = buildContext({ throwOnGetCredentials: true });
expect(await verifySignature.call(ctx)).toBe(true);
});
it('handles string raw body the same as buffer', async () => {
const ctx = buildContext({ signatureHeader: validSignature, body: rawBody });
expect(await verifySignature.call(ctx)).toBe(true);
});
});
});