UNPKG

synt_backend

Version:

Synt light-weight node backend service

834 lines (720 loc) 22 kB
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, }; } }