diff --git a/src/installation.ts b/src/installation.ts new file mode 100644 index 0000000..a5a026d --- /dev/null +++ b/src/installation.ts @@ -0,0 +1,79 @@ +import crypto from 'crypto'; +import { Router } from 'express'; +import { client as PartlyClient } from '@partly/integrations-client'; + +// Contact partly to set up a client ID, secret and authorization URL +const authorizationUrl = "http://localhost:3000/partly/install.complete"; // Authorization URL must be exactly as given to Partly during client ID / Secret setup +const scopes = "repairer"; // At this stage, must be either ["supplier"] or ["repairer"] +const clientId = "SAMPLE_INTEGRATION"; +const clientSecret = 'Zoopers'; + +// Simple in-memory cache for state values +const STATE_TTL_MS = 15 * 60 * 1000; // 15 minute expiry +const stateCache: { [key: string]: number } = {}; +const partlyClient = PartlyClient("https://integrations.partly.com"); + +const router = Router(); + +/** + * Installation Start - Called when clicked by the install button + * - Generates a random challenge that Partly will return to validate the request came from this integration + * - Redirects the web client to Partlys integrations authorization page to approve the installation + */ +router.get('/install.start', (req, res) => { + // Generate Challenge + const state = crypto.randomBytes(16).toString('hex'); +stateCache[state] = Date.now(); + // Implementation detail: Cache TTL eviction + for (const key in stateCache) { + if (Date.now() - stateCache[key] > STATE_TTL_MS) { + delete stateCache[key]; + } + } + // Send web client to Partly with clientID, state (challenge), authorizationUrl, scopes + const params = new URLSearchParams({ + client_id: clientId, + state, + authorization_url: authorizationUrl, + scopes, + }); + const redirectUrl = `https://app.partly.com/auth/integration/authorize?${params.toString()}`; + res.redirect(redirectUrl); +}); + +/** + * Installation Step 2 - Partly will redirect to this URL after the user has authorized the integration + * - It will check that the request came from the integration by checking the state value + */ +router.get('/install.complete', async (req, res) => { + // These will both exist if the request comes from Partly, this however cannot be guaranteed + const state: string = req.query.state?.toString() ?? ''; + const accessCode = req.query.code?.toString() ?? ''; + if (!stateCache[state]) { + // Security check, if the state param isn't one the server is aware of then this request cannot be trusted + // You can also add checks for account consistency, ie the user that made the request is the one that is completing the installation + res.status(403).send("Forbidden"); + return; + } + // Remove used state to prevent replay attacks + delete stateCache[state]; + // Docs for this endpoint can be seen here: https://integrations.partly.com/api/docs/redoc + // Base64 encode the client secret before sending + const encodedClientSecret = Buffer.from(clientSecret).toString('base64'); + const installationResult = await partlyClient.integrations.insert({ + client_id: clientId, + access_code: accessCode, + client_secret: encodedClientSecret + }, {}); + if (installationResult.is_err()) { + // Could check via status 2xx / 4xx / 5xx + // Partly also supplies structured error data in the body + res.status(400).send("Bad request"); + return; + } + // Returns a JSON matching the schema below + // { api_key: string, integration_id: uuid } + res.status(200).send(installationResult.unwrap_ok()); +}); + +export default router; diff --git a/src/server.ts b/src/server.ts index e8fc888..a8ad998 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,96 +1,23 @@ import path from 'path'; import express from 'express'; -import crypto from 'crypto'; -import { client as PartlyClient } from '@partly/integrations-client'; +import installationRouter from './installation'; +import webhooksRouter from './webhooks'; - -// Contact partly to set up a client ID, secret and authorization URL -const authorizationUrl = "http://localhost:3000/partly/install.complete"; // Authorization URL must be exactly as given to Partly during client ID / Secret setup -const scopes = "repairer"; // At this stage, must be either ["supplier"] or ["repairer"] -const clientId = "SAMPLE_INTEGRATION"; -const clientSecret = 'REDACTED'; // Can be provided on request - -// Simple in-memory cache for state values const app = express(); -const port = 3000; -const stateCache: { [key: string]: number } = {}; -const STATE_TTL_MS = 15 * 60 * 1000; // 15 minute expiry -const partlyClient = PartlyClient("https://integrations.partly.com"); +const port = 8030; // Base route - Displays a HTML page with an install button app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); -/** - * Installation Start - Called when clicked by the install button - * - Generates a random challenge that Partly will return to validate the request came from this integration - * - Redirects the web client to Partlys integrations authorization page to approve the installation - */ -app.get('/partly/install.start', (req, res) => { - // Generate Challenge - const state = crypto.randomBytes(16).toString('hex'); - stateCache[state] = Date.now(); - - // Implementation detail: Cache TTL eviction - for (const key in stateCache) { - if (Date.now() - stateCache[key] > STATE_TTL_MS) { - delete stateCache[key]; - } - } - - // Send web client to Partly with clientID, state (challenge), authorizationUrl, scopes - const params = new URLSearchParams({ - client_id: clientId, - state, - authorization_url: authorizationUrl, - scopes, - }); - - const redirectUrl = `https://app.partly.com/auth/integrations/authorize?${params.toString()}`; - res.redirect(redirectUrl); -}); - -/** - * Installation Step 2 - Partly will redirect to this URL after the user has authorized the integration - * - It will check that the request came from the integration by checking the state value - */ -app.get('/partly/install.complete', async (req, res) => { - // These will both exist if the request comes from Partly, this however cannot be guaranteed - const state: string = req.query.state?.toString() ?? ''; - const accessCode = req.query.code?.toString() ?? ''; +// Mount JSON parsing middleware +app.use(express.json()); - if (!stateCache[state]) { - // Security check, if the state param isn't one the server is aware of then this request cannot be trusted - // You can also add checks for account consistency, ie the user that made the request is the one that is completing the installation - res.status(403).send("Forbidden"); - return; - } - - // Remove used state to prevent replay attacks - delete stateCache[state]; - - // Docs for this endpoint can be seen here: https://integrations.partly.com/api/docs/redoc - // Base64 encode the client secret before sending - const encodedClientSecret = Buffer.from(clientSecret).toString('base64'); - const installationResult = await partlyClient.integrations.insert({ - client_id: clientId, - access_code: accessCode, - client_secret: encodedClientSecret - }, {}); - - if (installationResult.is_err()) { - // Could check via status 2xx / 4xx / 5xx - // Partly also supplies structured error data in the body - res.status(400).send("Bad request"); - return; - }; - - // Returns a JSON matching the schema below - // { api_key: string, integration_id: uuid } - res.status(200).send(installationResult.unwrap_ok()); -}); +// Mount installation and webhook routers +app.use('/', installationRouter); +app.use('/', webhooksRouter); -app.listen(port, () => { - console.log(`Server running at http://localhost:${port}`); +app.listen(port, "0.0.0.0", () => { + console.log(`Server running at http://0.0.0.0:${port}`); }); \ No newline at end of file diff --git a/src/webhooks.ts b/src/webhooks.ts new file mode 100644 index 0000000..8d31829 --- /dev/null +++ b/src/webhooks.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import crypto from 'crypto'; + +const router = Router(); + + +const webhookSecret = 'pwh_...'; // Redacted + +/** + * Webhook Accept - Called by Partlys Webhook System + * - Verifies the origin of the request + * - Logs the webhook + */ +router.post('/partly-webhook.accept', async (req, res) => { + const partlyHMACSignature = req.headers['partly-hmac-sha256'] as string; + + if (!partlyHMACSignature) { + res.status(400).send("Missing signature header"); + } + + const calculatedHmacDigest = crypto.createHmac('sha256', webhookSecret).update(JSON.stringify(req.body)).digest('base64'); + const hmacValid = crypto.timingSafeEqual(Buffer.from(calculatedHmacDigest, 'base64'), Buffer.from(partlyHMACSignature, 'base64')); + + if (!hmacValid) { + res.status(401).send('HMAC validation failed'); + } + console.log(req.body); + + const timestamp = req.body.timestamp; + + if (!timestamp) { + return res.status(400).send("Missing timestamp field"); + } + + const ts = new Date(timestamp); + const now = new Date(); + + if (ts.getTime() > now.getTime() || ts.getTime() < now.getTime() - 5 * 60 * 1000) { + return res.status(401).send("Timestamp is invalid"); + } + + // Process webhook here + console.log(res); + + res.status(200).send(); +}); + +export default router;