diff --git a/package-lock.json b/package-lock.json index a3408cf..8fdc3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0-alpha", "license": "MIT", "dependencies": { + "@partly/integrations-client": "0.1.1-alpha", + "dotenv": "^17.2.3", "express": "^5.1.0" }, "devDependencies": { @@ -60,6 +62,15 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@partly/integrations-client": { + "version": "0.1.1-alpha", + "resolved": "https://registry.npmjs.org/@partly/integrations-client/-/integrations-client-0.1.1-alpha.tgz", + "integrity": "sha512-5beF0oDjiWipyEBO6xZu8pzkgitGyR/n8kCperO87T53CtF4WQD1zNOp+G+NNfl5/ubmz6NEb8RSzzLLx8o9cw==", + "license": "Partly Group Proprietary", + "dependencies": { + "tslib": "2.6.2" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -481,6 +492,18 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1435,6 +1458,12 @@ } } }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/package.json b/package.json index 617e1dc..5c6d626 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "author": "", "license": "MIT", "dependencies": { - "express": "^5.1.0", - "@partly/integrations-client": "0.1.1-alpha" + "@partly/integrations-client": "0.1.1-alpha", + "dotenv": "^17.2.3", + "express": "^5.1.0" } } diff --git a/src/server.ts b/src/server.ts index e8fc888..5d1a0ab 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,96 +1,233 @@ -import path from 'path'; -import express from 'express'; -import crypto from 'crypto'; -import { client as PartlyClient } from '@partly/integrations-client'; +import crypto from "crypto"; +import express from "express"; +import path from "path"; +import 'dotenv/config' +// Server configuration +const PORT = process.env.PORT || 3000; +const HOST = process.env.HOST || "0.0.0.0"; +const PARTLY_BASE_URL = process.env.PARTLY_BASE_URL || "https://app.partly.com"; +const PARTLY_API_BASE_URL = process.env.PARTLY_API_BASE_URL ||"https://integrations.partly.com"; -// 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 +// Integration configuration +// +// Please contact partly to set up a client ID, client secret, and to configure +// your redirect URL +// +const REDIRECT_URL = `http://${HOST}:${PORT}/partly/install.complete`; +const CLIENT_ID = process.env.CLIENT_ID || "SAMPLE_INTEGRATION"; +const CLIENT_SECRET = process.env.CLIENT_SECRET || ""; // Provided on request +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ""; // Provided on request +const SCOPES = "repairer"; // Either ["supplier"] or ["repairer"] + +if (CLIENT_SECRET === "") { + console.warn( + "WARNING: Please set your CLIENT_SECRET in src/server.ts before running the sample." + ); + process.exit(1); +} + +if (WEBHOOK_SECRET === "") { + console.warn( + "WARNING: Please set your WEBHOOK_SECRET in src/server.ts before running the sample." + ); + process.exit(1); +} + +// In-memory example storage for installations & state values +const INSTALLATIONS: { [integrationId: string]: string } = {}; +const STATE_CACHE: { [key: string]: number } = {}; + +express() + .use(express.json()) + + /** + * Index route: Displays a HTML page with an install button. + */ + .get("/", (req, res) => { + res.sendFile(path.join(__dirname, "../public/index.html")); + }) + + /** + * Installation Step 1: + * + * - User initiates installation by clicking the install button (see + * public/index.html.) + * - We generate a random challenge that Partly will return to validate the + * request came from this integration, referred to as "state." + * - We redirect the web client to Partly's integrations authorization page where + * the user is prompted to approve the installationn. + */ + .get("/partly/install.start", (req, res) => { + const state = generateState(); + + // Send web client to Partly with client ID, state, redirect URL, and scopes + const params = new URLSearchParams({ + client_id: CLIENT_ID, + state: state, + redirect_url: REDIRECT_URL, + scopes: SCOPES, + }); + + res.redirect(`${PARTLY_BASE_URL}/auth/integration/authorize?${params}`); + }) + + /** + * Installation Step 2: + * + * - Partly will redirect to this URL after the user has authorized the + * integration. + * - The state and access code query parameters will be provided by Partly. + * - The state parameter must be validated to ensure the request is legitimate. + */ + .get("/partly/install.complete", async (req, res) => { + const state = req.query.state?.toString() ?? ""; + const accessCode = req.query.code?.toString() ?? ""; + + // validate state parameter + if (!validateStateOnce(state)) { + console.error("Invalid state parameter:", state); + res.status(403).send("Forbidden: invalid state parameter"); + return; + } + + // complete installation by exchanging the access code for an API key + // Please refer to: https://integrations.partly.com/api/docs/redoc + const response = await fetch( + `${PARTLY_API_BASE_URL}/api/2026-01/integrations.insert`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + access_code: accessCode, + client_secret: + // base64 encode the client secret before sending + Buffer.from(CLIENT_SECRET).toString("base64"), + }), + } + ); + + // successful response contains api_key and integration_id + if (response.ok) { + const body = (await response.json()) as { + api_key: string; + integration_id: string; + }; + + // installation successful! store the api_key and integration_id securely + // for future use + INSTALLATIONS[body.integration_id] = body.api_key; + + return res.status(200).send({ + message: "Installation successful", + }); + } + + // installation failed + // Please refer to Partly's API documentation for error response details + return res + .status(400) + .send( + `Installation failed: ${(await response.text()) || response.status}` + ); + }) + + /** + * Receive Partly Webhooks: + * + * Webhook payloads are sent as signed JSON blobs. The signature is provided + * in the `partly-hmac-sha256` header. The HMAC signature is calculated using + * the shared WEBHOOK_SECRET. + */ + .post("/partly-webhook.accept", async (req, res) => { + const partlyHMACSignature = req.headers["partly-hmac-sha256"]; + + if (typeof partlyHMACSignature !== "string") { + return res.status(400).send("Missing signature header"); + } + + // Validate HMAC signature + const calculatedHmacDigest = crypto + .createHmac("sha256", WEBHOOK_SECRET) + .update(JSON.stringify(req.body)) + .digest("base64"); + const hmacValid = crypto.timingSafeEqual( + Buffer.from(calculatedHmacDigest, "base64"), + Buffer.from(partlyHMACSignature, "base64") + ); + + if (!hmacValid) { + return res.status(401).send("HMAC validation failed"); + } + + const timestamp = req.body.timestamp; + + if (!timestamp) { + return res.status(400).send("Missing timestamp field"); + } + + const ts = new Date(timestamp).getTime(); + const now = new Date().getTime(); + + // Validate timestamp is within tolerance to prevent replay attacks + const TOLERANCE = 5 * 60 * 1000; // 5 minutes + if (Math.abs(now - ts) > TOLERANCE) { + return res.status(401).send("Timestamp is invalid"); + } + + return res.status(200).send({ + message: "Webhook received successfully", + }); + }) + + // Start server + .listen(PORT, HOST, () => { + console.log(`Server running at http://${HOST}:${PORT}`); + }); + +//------------------------------------------------------------------------------ +// Supporting Functions +//------------------------------------------------------------------------------ // 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"); - -// 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 + * Generates a random state string and caches it with a timestamp. */ -app.get('/partly/install.start', (req, res) => { +function generateState(): string { // 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]; + const state = crypto.randomBytes(16).toString("hex"); + STATE_CACHE[state] = Date.now(); + + // evict old states (implementation detail) + for (const key in STATE_CACHE) { + if (Date.now() - STATE_CACHE[key] > STATE_TTL_MS) { + delete STATE_CACHE[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); -}); + return state; +} /** - * 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 + * Validates the state parameter and removes it from the cache. */ -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() ?? ''; - - 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()); -}); - -app.listen(port, () => { - console.log(`Server running at http://localhost:${port}`); -}); \ No newline at end of file +function validateStateOnce(state: string): boolean { + const timestamp = STATE_CACHE[state]; + + // Check if state exists + if (!timestamp) { + return false; + } + + // Remove state to ensure one-time use + delete STATE_CACHE[state]; // Remove expired state + + // Check if state is still valid + return timestamp + STATE_TTL_MS > Date.now(); +}