Skip to content

Commit

Permalink
Merge pull request #284 from blockfrost/feat/typed-verify-webhook
Browse files Browse the repository at this point in the history
feat: typed return value in verifyWebhookSignature
  • Loading branch information
vladimirvolek authored Dec 20, 2023
2 parents c2d4220 + 7fc2b1f commit b713bb0
Show file tree
Hide file tree
Showing 7 changed files with 198 additions and 19 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Updated Blockfrost OpenAPI to 0.1.60
- Deps bump
- `verifyWebhookSignature` now returns strongly typed webhook event payload. Additionally, `webhookPayload` parameter now accepts both string and object types, eliminating the need to manually convert the payload to a string.

## [5.4.0] - 2023-07-26

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockfrost/blockfrost-js",
"version": "5.4.0",
"version": "6.0.0-beta.0",
"description": "A JavaScript/TypeScript SDK for interacting with the https://blockfrost.io API",
"keywords": [
"blockfrost",
Expand Down
16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import {
BlockfrostServerError,
SignatureVerificationError,
} from './utils/errors';
import {
WebhookEvent,
WebhookEventBlock,
WebhookEventDelegation,
WebhookEventEpoch,
WebhookEventTransaction,
} from './types/webhook';

type Responses = Schemas;

Expand All @@ -27,4 +34,11 @@ export {
verifyWebhookSignature,
};

export type { Responses };
export type {
Responses,
WebhookEvent,
WebhookEventBlock,
WebhookEventTransaction,
WebhookEventEpoch,
WebhookEventDelegation,
};
57 changes: 57 additions & 0 deletions src/types/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Responses } from '..';

export type BlockPayload = Omit<Responses['block_content'], 'height'> & {
height: number;
};

export type TransactionPayload = {
tx: Responses['tx_content'];
inputs: Responses['tx_content_utxo']['inputs'];
outputs: Responses['tx_content_utxo']['outputs'];
};

export type StakeDelegationPayload = {
tx: Responses['tx_content'];
// delegations with pool data
delegations: Responses['tx_content_delegations'][number] & {
pool: Responses['pool'];
};
};

export type EpochPayload = {
previous_epoch: Responses['epoch_content'];
current_epoch: Pick<
Responses['epoch_content'],
'epoch' | 'start_time' | 'end_time'
>;
};

interface WebhookEventCommon {
id: string;
webhook_id: string;
created: number;
api_version: number;
}

export type WebhookEventBlock = WebhookEventCommon & {
type: 'block';
payload: BlockPayload;
};
export type WebhookEventTransaction = WebhookEventCommon & {
type: 'transaction';
payload: TransactionPayload[];
};
export type WebhookEventEpoch = WebhookEventCommon & {
type: 'epoch';
payload: EpochPayload;
};
export type WebhookEventDelegation = WebhookEventCommon & {
type: 'delegation';
payload: StakeDelegationPayload[];
};

export type WebhookEvent =
| WebhookEventBlock
| WebhookEventTransaction
| WebhookEventEpoch
| WebhookEventDelegation;
29 changes: 20 additions & 9 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import AssetFingerprint from '@emurgo/cip14-js';
import { ParseAssetResult } from '../types/utils';
import { SignatureVerificationError } from './errors';
import { WebhookEvent } from '../types/webhook';

/**
* Derives an address with derivation path `m/1852'/1815'/account'/role/addressIndex`
Expand Down Expand Up @@ -199,6 +200,16 @@ export const parseAsset = (hex: string): ParseAssetResult => {
};
};

const parseWebhookPayload = (payload: unknown) => {
if (Buffer.isBuffer(payload)) {
return JSON.parse(payload.toString('utf8'));
} else if (typeof payload === 'string') {
return JSON.parse(payload);
} else {
return payload;
}
};

/**
* Verifies webhook signature
* @remarks
Expand Down Expand Up @@ -230,9 +241,8 @@ export const verifyWebhookSignature = (
);
}

const decodedWebhookPayload = Buffer.isBuffer(webhookPayload)
? webhookPayload.toString('utf8')
: webhookPayload;
const parsedWebhookPayload = parseWebhookPayload(webhookPayload);
const stringifiedWebhookPayload = JSON.stringify(parsedWebhookPayload);

const decodedSignatureHeader = Buffer.isBuffer(signatureHeader)
? signatureHeader.toString('utf8')
Expand Down Expand Up @@ -261,7 +271,7 @@ export const verifyWebhookSignature = (
// timestamp and at least one signature must be present
throw new SignatureVerificationError('Invalid signature header format.', {
signatureHeader: decodedSignatureHeader,
webhookPayload: decodedWebhookPayload,
webhookPayload: stringifiedWebhookPayload,
});
}

Expand All @@ -270,7 +280,7 @@ export const verifyWebhookSignature = (
'No signatures with supported version scheme.',
{
signatureHeader: decodedSignatureHeader,
webhookPayload: decodedWebhookPayload,
webhookPayload: stringifiedWebhookPayload,
},
);
}
Expand All @@ -279,7 +289,7 @@ export const verifyWebhookSignature = (
for (const signature of signatures) {
// Recreate signature by concatenating timestamp with stringified payload,
// then compute HMAC using sha256 and provided secret (auth token)
const signaturePayload = `${timestamp}.${decodedWebhookPayload}`;
const signaturePayload = `${timestamp}.${stringifiedWebhookPayload}`;
const hmac = createHmac('sha256', secret)
.update(signaturePayload)
.digest('hex');
Expand All @@ -295,7 +305,7 @@ export const verifyWebhookSignature = (
'No signature matches the expected signature for the payload.',
{
signatureHeader: decodedSignatureHeader,
webhookPayload: decodedWebhookPayload,
webhookPayload: stringifiedWebhookPayload,
},
);
}
Expand All @@ -307,11 +317,12 @@ export const verifyWebhookSignature = (
"Signature's timestamp is outside of the time tolerance",
{
signatureHeader: decodedSignatureHeader,
webhookPayload: decodedWebhookPayload,
webhookPayload: stringifiedWebhookPayload,
},
);
} else {
// Successfully validate the signature only if it is within timestamp_tolerance_seconds tolerance
return true;
const webhookEvent = parsedWebhookPayload as WebhookEvent;
return webhookEvent;
}
};
106 changes: 101 additions & 5 deletions test/fixtures/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,31 @@ export const verifyWebhookSignatureFixtures = [
secret: '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
timestampToleranceSeconds: undefined,
mockCurrentTimestamp: 1650013856 + 1,
result: true,
result: {
id: '47668401-c3a4-42d4-bac1-ad46515924a3',
webhook_id: 'cf68eb9c-635f-415e-a5a8-6233638f28d7',
created: 1650013856,
type: 'block',
payload: {
time: 1650013853,
height: 7126256,
hash: 'f49521b67b440e5030adf124aee8f88881b7682ba07acf06c2781405b0f806a4',
slot: 58447562,
epoch: 332,
epoch_slot: 386762,
slot_leader: 'pool1njjr0zn7uvydjy8067nprgwlyxqnznp9wgllfnag24nycgkda25',
size: 34617,
tx_count: 13,
output: '13403118309871',
fees: '4986390',
block_vrf:
'vrf_vk197w95j9alkwt8l4g7xkccknhn4pqwx65c5saxnn5ej3cpmps72msgpw69d',
previous_block:
'9e3f5bfc9f0be44cf6e14db9ed5f1efb6b637baff0ea1740bb6711786c724915',
next_block: null,
confirmations: 0,
},
},
},
{
description: 'only unsupported sig version',
Expand All @@ -77,7 +101,31 @@ export const verifyWebhookSignatureFixtures = [
secret: '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
timestampToleranceSeconds: undefined,
mockCurrentTimestamp: 1650013856 + 580,
result: true,
result: {
id: '47668401-c3a4-42d4-bac1-ad46515924a3',
webhook_id: 'cf68eb9c-635f-415e-a5a8-6233638f28d7',
created: 1650013856,
type: 'block',
payload: {
time: 1650013853,
height: 7126256,
hash: 'f49521b67b440e5030adf124aee8f88881b7682ba07acf06c2781405b0f806a4',
slot: 58447562,
epoch: 332,
epoch_slot: 386762,
slot_leader: 'pool1njjr0zn7uvydjy8067nprgwlyxqnznp9wgllfnag24nycgkda25',
size: 34617,
tx_count: 13,
output: '13403118309871',
fees: '4986390',
block_vrf:
'vrf_vk197w95j9alkwt8l4g7xkccknhn4pqwx65c5saxnn5ej3cpmps72msgpw69d',
previous_block:
'9e3f5bfc9f0be44cf6e14db9ed5f1efb6b637baff0ea1740bb6711786c724915',
next_block: null,
confirmations: 0,
},
},
},
{
description: 'invalid timestamp (+610s)',
Expand All @@ -99,7 +147,31 @@ export const verifyWebhookSignatureFixtures = [
secret: '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
timestampToleranceSeconds: 650,
mockCurrentTimestamp: 1650013856 + 610,
result: true,
result: {
id: '47668401-c3a4-42d4-bac1-ad46515924a3',
webhook_id: 'cf68eb9c-635f-415e-a5a8-6233638f28d7',
created: 1650013856,
type: 'block',
payload: {
time: 1650013853,
height: 7126256,
hash: 'f49521b67b440e5030adf124aee8f88881b7682ba07acf06c2781405b0f806a4',
slot: 58447562,
epoch: 332,
epoch_slot: 386762,
slot_leader: 'pool1njjr0zn7uvydjy8067nprgwlyxqnznp9wgllfnag24nycgkda25',
size: 34617,
tx_count: 13,
output: '13403118309871',
fees: '4986390',
block_vrf:
'vrf_vk197w95j9alkwt8l4g7xkccknhn4pqwx65c5saxnn5ej3cpmps72msgpw69d',
previous_block:
'9e3f5bfc9f0be44cf6e14db9ed5f1efb6b637baff0ea1740bb6711786c724915',
next_block: null,
confirmations: 0,
},
},
},
{
description: 'invalid signature',
Expand All @@ -121,7 +193,31 @@ export const verifyWebhookSignatureFixtures = [
secret: '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
timestampToleranceSeconds: undefined,
mockCurrentTimestamp: 1650013856 + 1,
result: true,
result: {
id: '47668401-c3a4-42d4-bac1-ad46515924a3',
webhook_id: 'cf68eb9c-635f-415e-a5a8-6233638f28d7',
created: 1650013856,
type: 'block',
payload: {
time: 1650013853,
height: 7126256,
hash: 'f49521b67b440e5030adf124aee8f88881b7682ba07acf06c2781405b0f806a4',
slot: 58447562,
epoch: 332,
epoch_slot: 386762,
slot_leader: 'pool1njjr0zn7uvydjy8067nprgwlyxqnznp9wgllfnag24nycgkda25',
size: 34617,
tx_count: 13,
output: '13403118309871',
fees: '4986390',
block_vrf:
'vrf_vk197w95j9alkwt8l4g7xkccknhn4pqwx65c5saxnn5ej3cpmps72msgpw69d',
previous_block:
'9e3f5bfc9f0be44cf6e14db9ed5f1efb6b637baff0ea1740bb6711786c724915',
next_block: null,
confirmations: 0,
},
},
},
{
description: '2 invalid signatures',
Expand All @@ -143,7 +239,7 @@ export const verifyWebhookSignatureFixtures = [
secret: 'abc',
timestampToleranceSeconds: undefined,
mockCurrentTimestamp: 1650013856 + 1,
result: false,
result: SignatureVerificationError,
},
];

Expand Down
6 changes: 3 additions & 3 deletions test/tests/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ describe('helpers', () => {
fixture.timestampToleranceSeconds,
);

if (fixture.result === true) {
expect(response()).toStrictEqual(fixture.result);
} else {
if (fixture.result === SignatureVerificationError) {
expect(response).toThrowError(SignatureVerificationError);
} else {
expect(response()).toStrictEqual(fixture.result);
}

vi.useRealTimers();
Expand Down

0 comments on commit b713bb0

Please sign in to comment.