Skip to content
Merged
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
61 changes: 59 additions & 2 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ const fs = require('fs');
const path = require('path');
const express = require('express');

const { ContextPrepareService, APIError } = require('@castleio/sdk');
const {
ContextPrepareService,
APIError,
WebhookVerificationError,
} = require('@castleio/sdk');

const { demos, demoList, validUrls } = require('./demo_config');

Expand Down Expand Up @@ -51,9 +55,22 @@ function buildApp(castle = require('./castle')) {
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

app.use(express.json());
// Keep the raw body around so incoming webhooks can be signature-verified
// (the HMAC is computed over the exact bytes Castle sent).
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
app.use('/static', express.static(path.join(__dirname, 'static')));

// In-memory store of the most recent webhooks received from Castle. A real
// app would persist these; an array is plenty for a localhost demo.
const receivedWebhooks = [];
let webhookSeq = 0;

// Serve the Castle browser SDK straight from the npm install (node_modules)
// instead of vendoring it into the repo. It ends up at /vendor/castle-js/...
const CASTLE_JS_DIR = path.join(
Expand Down Expand Up @@ -105,6 +122,46 @@ function buildApp(castle = require('./castle')) {
});
});

// Webhooks demo. The receiver below stores verified payloads; this page lists
// them. Registered before the catch-all `/:demoName` route so it wins.
app.get('/webhooks', (req, res) => {
const protocol = req.get('x-forwarded-proto') || req.protocol;
res.render('webhooks', {
...getDefaultParams(),
...demos.webhooks,
demo_name: 'webhooks',
webhooks: true,
webhook_endpoint: `${protocol}://${req.get('host')}/webhooks/castle`,
webhooks_received: receivedWebhooks,
});
});

// Receives webhooks from Castle. The signature is verified against the raw
// body; anything that fails verification gets a 404 so we don't reveal the
// endpoint to unauthenticated callers.
app.post('/webhooks/castle', (req, res) => {
try {
castle.verifyWebhookSignature(
req.rawBody || Buffer.from(''),
req.get('X-Castle-Signature')
);
} catch (err) {
if (err instanceof WebhookVerificationError) {
return res.status(404).render('error', getDefaultParams());
}
throw err;
}

receivedWebhooks.unshift({
id: (webhookSeq += 1),
received_at: new Date().toISOString(),
body: req.body,
});
receivedWebhooks.length = Math.min(receivedWebhooks.length, 50);

return res.status(204).end();
});

app.get('/:demoName', (req, res) => {
const params = getDefaultParams();
const { demoName } = req.params;
Expand Down
4 changes: 4 additions & 0 deletions demo_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const demos = {
friendly_name: 'privacy',
blurb: "Request or delete a user's data with the Privacy API.",
},
webhooks: {
friendly_name: 'webhooks',
blurb: 'Verify and inspect incoming Castle webhooks.',
},
};

const demoList = Object.entries(demos).map(([url, demo]) => ({ url, ...demo }));
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Server-rendered pages:
- **password reset** – `$password_reset` via the non-blocking `log` endpoint
- **lists** – the Lists API (`createList`, `fetchAllLists`)
- **privacy** – the Privacy API (`requestUserData`, `deleteUserData`)
- **webhooks** – incoming Castle webhooks are signature-verified with `verifyWebhookSignature` (against the `X-Castle-Signature` header) and the most recent payloads are listed

Post-login `/account` page:

Expand Down
2 changes: 1 addition & 1 deletion static/styles.css

Large diffs are not rendered by default.

42 changes: 40 additions & 2 deletions test/app.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';

const request = require('supertest');
const { Castle, APIError } = require('@castleio/sdk');
const { Castle, APIError, WebhookVerificationError } = require('@castleio/sdk');
const { buildApp } = require('../app');

// Non-secret demo identity used across the tests.
Expand Down Expand Up @@ -65,7 +65,7 @@ describe('page routes', () => {
expect(res.text).not.toContain('/vendor/castle-js/castle.browser.js');
});

test.each(['signup', 'password_reset', 'lists', 'privacy'])(
test.each(['signup', 'password_reset', 'lists', 'privacy', 'webhooks'])(
'GET /%s renders',
async (name) => {
const res = await request(app).get('/' + name);
Expand Down Expand Up @@ -295,3 +295,41 @@ describe('account-level APIs', () => {
expect(res.body.result.error).toMatch(/401/);
});
});

describe('webhooks', () => {
test('a verified webhook is stored and listed', async () => {
const castle = stubbedCastle();
jest.spyOn(castle, 'verifyWebhookSignature').mockReturnValue(undefined);
const app = buildApp(castle);

const post = await request(app)
.post('/webhooks/castle')
.set('X-Castle-Signature', 'valid')
.send({ type: 'review.opened', data: { id: 'rev_1' } });

expect(post.status).toBe(204);
expect(castle.verifyWebhookSignature).toHaveBeenCalledTimes(1);

const list = await request(app).get('/webhooks');
expect(list.status).toBe(200);
expect(list.text).toContain('review.opened');
});

test('a webhook that fails verification is rejected with a 404', async () => {
const castle = stubbedCastle();
jest.spyOn(castle, 'verifyWebhookSignature').mockImplementation(() => {
throw new WebhookVerificationError('Invalid signature');
});
const app = buildApp(castle);

const res = await request(app)
.post('/webhooks/castle')
.set('X-Castle-Signature', 'bad')
.send({ type: 'review.opened' });

expect(res.status).toBe(404);

const list = await request(app).get('/webhooks');
expect(list.text).toContain('No webhooks received yet.');
});
});
26 changes: 26 additions & 0 deletions views/webhooks.pug
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
extends demo

block ui
p This page lists the most recent webhooks Castle has delivered to this app. Each one is signature-verified before it is stored.
.field
label receiver endpoint
input.input(type="text" value=webhook_endpoint readonly onclick="this.select()")
.btn-row
a.btn(href="/webhooks") Refresh
if webhooks_received && webhooks_received.length
each wh in webhooks_received
.result-block.mt-4
.label ##{wh.id} · #{wh.received_at}
pre(class="whitespace-pre-wrap text-[0.8rem]")= JSON.stringify(wh.body, null, 2)
else
p.text-muted.mt-4 No webhooks received yet.

block desc
p Point a webhook at
code= " " + webhook_endpoint
| from the Castle dashboard (Settings → Webhooks). Incoming requests are verified with
code verifyWebhookSignature
| against the
code X-Castle-Signature
| header; anything that fails verification gets a 404.
p Because this demo runs on localhost, Castle needs a public tunnel (e.g. ngrok) to reach the receiver.
Loading