synt_backend
Version:
Synt light-weight node backend service
834 lines (720 loc) • 22 kB
JavaScript
import { merge } from "lodash";
import { request } from "https";
import { readFileSync } from "fs";
import {
IbanityClient,
IbanityToken as _IbanityToken,
} from "./../mysql/models/index";
import sha512 from "crypto-js/sha512";
import Base64 from "crypto-js/enc-base64";
import HmacSHA256 from "crypto-js/hmac-sha256";
/**
* Client
* @param {Object} options Options object
* @return {Client} Returns itself
*/
class Client {
constructor(name, params) {
// Defaults
const defaults = {
client_id: null,
env: name === "production" ? "production" : "development",
debug: true,
prefixDebug: "[ibanity] ",
};
this.name = name;
this.params = params;
// Merge defaults with passed objects
this.options = merge({}, defaults);
if (this.options.debug) {
console.log(this.options.prefixDebug + "Client - createClient");
}
// Define OAuth object
this.oauth = {
expires_at: null,
access_token: null,
refresh_token: null,
};
// Define API url
this.apiUrl = "api.ibanity.com";
this.authUrl =
(this.options.env === "production" ? "" : "sandbox-") +
"authorization.myponto.com/oauth2/auth";
return this;
}
// private
async initOptions() {
// Get info from database
const client = await IbanityClient.findOne({ where: { name: this.name } });
// client options
const options = {
client_id: client.client_id,
client_secret: client.client_secret,
username: client.username,
password: client.password,
env: this.name === "production" ? "production" : "development",
debug: true,
prefixDebug: "[ibanity] ",
redirect_uri: this.getRedirectUri(),
payment_redirect_uri: this.getPaymentRedirectUri(),
code_verifier: client.code_verifier,
code_challenge: client.code_challenge,
code_challenge_method: client.code_challenge_method,
};
// Merge defaults with passed objects
this.options = merge({}, this.options, options);
if (this.options.debug) {
console.log(this.options.prefixDebug + "Client - Options Added");
}
}
// private
getRedirectUri() {
throw new Error("Should be implemented in child class");
}
// private
getPaymentRedirectUri() {
throw new Error("Should be implemented in child class");
}
// private
async getExistingIbanityToken() {
throw new Error("Should be implemented in child class");
}
// private
async initOAuth(code = null) {
const IbanityToken = await this.getExistingIbanityToken();
if (IbanityToken) {
// Define OAuth object
let oauth = {
code,
expires_at: IbanityToken.expires_at,
access_token: IbanityToken.access_token,
refresh_token: IbanityToken.refresh_token,
};
this.oauth = merge({}, this.oauth, oauth);
}
}
// public
async init() {
await this.initOptions();
await this.initOAuth();
return this;
}
// private
async createIbanityToken() {
throw new Error("Should be implemented in child class");
}
// private
async updateDatabaseTokens(token) {
this.oauth.access_token = token.access_token;
this.oauth.refresh_token = token.refresh_token || null;
const date = new Date(
Date.now() + parseInt(token.expires_in) * 1000
).toISOString();
this.oauth.expires_at = date;
this.oauth.scope = token.scope;
// do not save client_credentials
if (token.refresh_token) {
try {
let IbanityToken = await this.getExistingIbanityToken();
if (IbanityToken) {
IbanityToken.access_token = token.access_token;
IbanityToken.refresh_token = token.refresh_token;
IbanityToken.expires_at = date;
IbanityToken.scope = token.scope;
await IbanityToken.save();
} else {
await this.createIbanityToken(this.oauth);
}
} catch (error) {
console.log(error);
}
}
}
/**
* private
* Retrieve oauth token from API endpoint
*/
async token(grant_type) {
if (this.options.debug) {
console.log(
this.options.prefixDebug + '.token - send token: "' + grant_type + '"'
);
}
// define oauth data object
let data = {
grant_type,
};
switch (grant_type) {
case "authorization_code":
data.code = this.oauth.code;
data.redirect_uri = this.options.redirect_uri;
data.code_verifier = this.options.code_verifier;
data.client_id = this.options.client_id;
break;
case "refresh_token":
data.refresh_token = this.oauth.refresh_token;
break;
case "client_credentials":
data.client_id = this.options.client_id;
break;
}
const token = await this.makeRequest(
"/ponto-connect/oauth2/token",
"POST",
{},
data
);
if (this.options.debug) {
console.log(
this.options.prefixDebug +
".token - Retreived token: " +
JSON.stringify(token)
);
}
await this.updateDatabaseTokens(token);
return token;
}
/**
* private
* Checks the client for valid authentication
* Generates a new token if needed and possible
*/
async checkAuth() {
if (this.options.debug) {
console.log(this.options.prefixDebug + ".checkAuth - Checking auth");
}
// Check if the client is authorized
if (this.oauth.expires_at > Date.now() && this.oauth.access_token) {
if (this.options.debug) {
console.log(
this.options.prefixDebug +
".checkAuth - Client is authorized and token is still valid"
);
}
// Client is authorized and token is still valid
return {
isValid: true,
};
} else if (this.oauth.refresh_token) {
if (this.options.debug) {
console.log(
this.options.prefixDebug +
".checkAuth - Either the token has expired, or the client is not authorized but has a refresh token"
);
}
// Either the token has expired, or the client is not authorized but has a refresh token
// With this info a new token can be requested
try {
const token = await this.token("refresh_token");
if (token?.error) {
return {
isValid: false,
};
}
return {
isValid: true,
token,
};
} catch (error) {
return {
isValid: false,
};
}
} else {
if (this.options.debug) {
console.log(
this.options.prefixDebug +
".checkAuth - No token, refresh token or request code found, this client is in no way authenticated"
);
}
// No token, refresh token or request code found, this client is in no way authenticated
return {
isValid: false,
};
}
}
// private
async makeRequest(endpoint, method, params, data) {
const requiresAuth = !(endpoint === "/token");
let body;
// if a (POST) data object is passed, stringify it
if (!requiresAuth && typeof data === "object") {
body = new URLSearchParams(data).toString();
} else if (requiresAuth && typeof data === "object") {
body = JSON.stringify(data);
endpoint = encodeURI(endpoint);
} else {
body = data || null;
}
// Set headers
const headers = {
"Cache-Control": "no-cache",
Accept: "application/json",
};
// If this is a POST, set appropriate headers
if (method === "POST" || method === "PUT") {
if (body) {
headers["Content-Length"] = body.length;
}
headers["Content-Type"] = "application/json";
}
// Wrap the request in a function
// sync headers when making request
if (endpoint.includes("oauth2/token")) {
// token
let encodedAuthHeader = Buffer.from(
`${this.options.client_id}:${this.options.client_secret}`
).toString("base64");
headers["Authorization"] = `Basic ${encodedAuthHeader}`;
} else {
// all other
// TODO: check auth validity
headers["Authorization"] = `Bearer ${this.oauth.access_token}`;
}
// https://documentation.ibanity.com/security#http-signature
// https://documentation.ibanity.com/support/http-signature-generator
if (this.options.env === "production" && method === "POST") {
console.log(this.options.prefixDebug + ".headers - Start:" + body);
let sha = sha512(body);
console.log(this.options.prefixDebug + ".headers - sha:" + sha);
let base = Base64.stringify(sha);
console.log(this.options.prefixDebug + ".headers - base:" + base);
let digest = "SHA-512=" + base;
headers["digest"] = digest;
console.log(this.options.prefixDebug + ".headers - Digest:" + digest);
let request_target = method.toLowerCase() + " " + endpoint;
console.log(this.options.prefixDebug + ".headers - " + request_target);
let created = Math.round(new Date().getTime() / 1000);
console.log(this.options.prefixDebug + ".headers - " + created);
console.log(
this.options.prefixDebug + ".headers - " + JSON.stringify(headers)
);
let signing_string =
`(request-target): ${request_target}\n` +
`host: ${this.apiUrl}\n` +
`digest: ${digest}\n` +
`(created): ${created}\n` +
`authorization: Bearer ${this.oauth.access_token}`;
console.log(this.options.prefixDebug + ".headers ss - " + signing_string);
console.log(
this.options.prefixDebug + ".headers - " + JSON.stringify(headers)
);
let headersList = `(request-target) host digest (created) authorization`;
console.log(this.options.prefixDebug + ".headers - " + headersList);
if (this.options.debug) {
console.log(
this.options.prefixDebug + ".headers - " + JSON.stringify(headers)
);
}
let key = readFileSync("certs/ibanity/signature/private_key.pem", {
encoding: "utf8",
});
let ss = HmacSHA256(signing_string, key);
console.log(this.options.prefixDebug + ".headers - ss:" + ss);
//FIXME: Please Dima (signature = BASE64(RSA_PSS_SIGN(PRIVATE_KEY, SIGNING_STRING)))
let signature = Base64.stringify(ss);
console.log(
this.options.prefixDebug + ".headers - signature:" + signature
);
headers["signature"] =
'keyId="' +
"5d0ccf99-e9af-4ab3-9205-5142439fe178" +
'",' +
"created=" +
created +
"," +
'algorithm="hs2019",' +
'headers="' +
headersList +
'",' +
'signature="' +
signature +
'"';
console.log(
this.options.prefixDebug + ".headers - " + JSON.stringify(headers)
);
}
if (this.options.debug) {
console.log(this.options.prefixDebug + ".sendRequest - doRequest");
if (requiresAuth) {
console.log(this.options.prefixDebug + ".sendRequest - requiresAuth");
}
}
// Set request options
const options = {
host: this.apiUrl,
port: 443,
method: method,
headers: headers,
key: readFileSync(
"certs/ibanity/" +
(this.options.env === "production" ? "production/" : "sandbox/") +
"private_key.pem"
),
cert: readFileSync(
"certs/ibanity/" +
(this.options.env === "production" ? "production/" : "sandbox/") +
"certificate.pem"
),
pfx: readFileSync(
"certs/ibanity/" +
(this.options.env === "production" ? "production/" : "sandbox/") +
"certificate.pfx"
),
passphrase: process.env.IBANITY_PASSPHRASE,
};
// Stringify URL params
const paramString = new URLSearchParams(params).toString();
// Check if the params object exists and is not empty
if (typeof params === "object" && paramString !== "") {
// If exists and not empty, add them to the endpoint
options.path = [endpoint, "?", paramString].join("");
} else {
options.path = endpoint;
}
// Make the request
return await handleRequest(options, body);
}
// private
async getOnboardingDetails() {
throw new Error("Should be implemented in child class");
}
/**
* private
* Sends a request to an endpoint
* @param {string} endpoint API endpoint
* @param {string} method HTTP method
* @param {object} params URL params
* @param {object} data POST data
*/
async sendRequest(endpoint, method, params, data) {
console.log("sendRequest", method, endpoint);
if (!endpoint.includes("oauth2/token")) {
// check if auth is valid
const waited = await Client.authChecker.startChecking();
if (waited) {
await this.initOAuth(this.oauth.code);
}
let isValid = false;
try {
const result = await this.checkAuth();
isValid = result.isValid;
} catch (error) {
isValid = false;
}
await Client.authChecker.finishChecking();
console.log("isValid", isValid);
if (isValid) {
// Make the request
return await this.makeRequest(endpoint, method, params, data);
} else {
// Auth is not valid, return a custom error
// Set onboarding details
await this.token("client_credentials");
const onboardingDetails = await this.getOnboardingDetails();
const response = await this.postOnboardingDetails(onboardingDetails);
let detailsId = "";
if (response.success) {
detailsId = response.data.id;
}
// return redirect error
throw {
success: false,
authenticated: false,
redirect:
"https://" +
(this.options.env === "production" ? "" : "sandbox-") +
"authorization.myponto.com/oauth2/auth?client_id=" +
this.options.client_id +
"&redirect_uri=" +
this.options.redirect_uri +
"&response_type=code&scope=ai pi offline_access&code_challenge=" +
this.options.code_challenge +
"&code_challenge_method=" +
this.options.code_challenge_method +
"&state=testtest" +
"&onboarding_details_id=" +
detailsId,
error:
"No valid authentication found, please set either a token request code, or a valid refresh token",
};
}
} else {
// Make the request
return await this.makeRequest(endpoint, method, params, data);
}
}
// private
async postOnboardingDetails(details) {
return await handleRequestErrors(() =>
this.makeRequest("/ponto-connect/onboarding-details/", "POST", null, {
data: {
type: "onboardingDetails",
attributes: { ...details },
},
})
);
}
// public
async postCode(code) {
this.oauth.code = code;
await this.token("authorization_code");
return true;
}
// public
async getAccounts() {
return await handleRequestErrors(() =>
this.sendRequest("/ponto-connect/accounts", "GET")
);
}
// public
async getFinancialInstitutions() {
return await handleRequestErrors(() =>
this.sendRequest("/ponto-connect/financial-institutions", "GET")
);
}
// public
async getTransactions(accountId) {
return await handleRequestErrors(() =>
this.sendRequest(
"/ponto-connect/accounts/" + accountId + "/transactions",
"GET"
)
);
}
// public
getAccountId() {
return "4999722a-625e-4c35-af9e-5a753214150c";
}
// public
async createPayment(bankInfo) {
let accountId = this.getAccountId();
return await handleRequestErrors(() =>
this.sendRequest(
"/ponto-connect/accounts/" + accountId + "/payments",
"POST",
null,
{
data: {
type: "payment",
attributes: {
currency: "EUR",
amount: bankInfo.total_amount,
creditorName: bankInfo.creditor.name,
creditorAccountReference: bankInfo.creditor.account,
creditorAccountReferenceType: "IBAN",
remittance_information: bankInfo.statement,
redirectUri:
this.options.payment_redirect_uri +
"?type=" +
bankInfo.trigger.type +
"&id=" +
bankInfo.trigger.id,
},
},
}
)
);
}
// public
async getPayment(id) {
let accountId = this.getAccountId();
return await handleRequestErrors(() =>
this.sendRequest(
"/ponto-connect/accounts/" + accountId + "/payments/" + id,
"GET"
)
);
}
}
class ClientVME extends Client {
// private
getRedirectUri() {
if (this.options.env === "production") {
return "https://synt.be/app/banking";
}
return "http://localhost:3000/app/banking";
}
// private
getPaymentRedirectUri() {
if (this.options.env === "production") {
return "https://synt.be/app/payment";
}
return "http://localhost:3000/app/payment";
}
// private
async getExistingIbanityToken() {
return await _IbanityToken.findOne({
where: { VMEId: this.params.VME.id },
});
}
// private
async createIbanityToken(oauth) {
await _IbanityToken.create({
UserId: this.params.User.id,
VMEId: this.params.VME.id,
...oauth,
});
}
// private
async getOnboardingDetails() {
return {
email: this.params.User.email,
firstName: this.params.User.first_name,
lastName: this.params.User.last_name,
organizationName: this.params.VME.alias,
enterpriseNumber: this.params.VME.Company.company_number,
vatNumber: this.params.VME.Company.vat_number,
addressStreetAddress: this.params.VME.Company.address,
addressCountry: "BE",
addressPostalCode: this.params.VME.Company.postal_code,
addressCity: this.params.VME.Company.city,
phoneNumber: this.params.VME.Company.phone,
};
}
}
class ClientIndividual extends Client {
// private
getRedirectUri() {
return "https://synt.be/app/banking/mobile";
}
// private
getPaymentRedirectUri() {
return "https://synt.be/app/payment/mobile";
}
// private
async getExistingIbanityToken() {
return await _IbanityToken.findOne({
where: { UserId: this.params.User.id, VMEId: null },
});
}
// private
async createIbanityToken(oauth) {
await _IbanityToken.create({
UserId: this.params.User.id,
VMEId: null,
...oauth,
});
}
// private
async getOnboardingDetails() {
let User = this.params.User;
let Company = false;
if (User.CompanyId) {
let Company = await User.getCompany();
User.setDataValue("Company", Company);
}
return {
email: this.params.User.email,
firstName: this.params.User.first_name,
lastName: this.params.User.last_name,
organizationName: Company
? Company.name
: `${User.first_name} ${User.last_name}`,
enterpriseNumber: Company ? Company.company_number : "",
vatNumber: Company ? Company.vat_number : "",
addressStreetAddress: Company ? Company.address : User.address,
addressCountry: "BE",
addressPostalCode: Company ? Company.postal_code : "",
addressCity: Company ? Company.city : "",
phoneNumber: Company ? Company.phone : User.phone,
};
}
}
class AuthChecker {
constructor() {
this.waiters = [];
this.isChecking = false;
}
async startChecking() {
if (!this.isChecking) {
this.isChecking = true;
return false;
} else {
await new Promise((resolve) => {
this.waiters.push(resolve);
});
await this.startChecking();
return true;
}
}
async finishChecking() {
this.isChecking = false;
this.waiters.forEach((resolve) => {
resolve();
});
this.waiters = [];
}
}
Client.authChecker = new AuthChecker();
/**
* Client constuctor
* @param {Object} options Options object
* @return {Promise<Client>} Returns a new instance of the Client object
*/
export async function createClient(params) {
let client;
let clientName =
process.env.NODE_ENV === "production" ? "production" : "sandbox";
if (params.mobile) {
client = new ClientIndividual(clientName, params);
} else {
client = new ClientVME(clientName, params);
}
await client.init();
return client;
}
async function handleRequest(options, body) {
return new Promise((resolve, reject) => {
// Make the request
const req = request(options, function (res) {
let responseData = "";
// Set the response to utf-8 encoding
res.setEncoding("utf-8");
// Add chunk to responseData
res.on("data", function (chunk) {
responseData += chunk;
});
// Request ended, wrap up
res.on("end", function () {
try {
responseData = JSON.parse(responseData);
resolve(responseData);
} catch (e) {
// Don't parse responseData
console.log("no response data to parse");
}
});
});
// Handle API errors
req.on("error", function (err) {
reject(err);
});
// Write request body
if (options.method === "POST" || options.method === "PUT") {
req.write(body);
}
// End request
req.end();
});
}
async function handleRequestErrors(makeRequest) {
try {
const response = await makeRequest();
if (response.errors) {
return {
success: false,
error: response.errors[0].detail,
};
}
return {
success: true,
...response,
};
} catch (err) {
return {
success: false,
...err,
};
}
}