Skip to content
Open
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
79 changes: 79 additions & 0 deletions src/installation.ts
Original file line number Diff line number Diff line change
@@ -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;
93 changes: 10 additions & 83 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
48 changes: 48 additions & 0 deletions src/webhooks.ts
Original file line number Diff line number Diff line change
@@ -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;