mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
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:
parent
94e403300b
commit
da41470311
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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' }]] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user