From dbc4b9ada540bb3152e54331a1cc8fff4689e9e2 Mon Sep 17 00:00:00 2001 From: Paul K Date: Wed, 5 Jun 2024 13:44:53 -0400 Subject: [PATCH] feat: add platform oauth api (#53) --- .github/workflows/build-platform-oauth.yaml | 28 ++++++ integrationos-platform-oauth/Dockerfile | 16 ++++ integrationos-platform-oauth/README.md | 16 ++++ integrationos-platform-oauth/package.json | 23 +++++ .../src/connections/clover/init.ts | 31 ++++++ .../src/connections/clover/refresh.ts | 11 +++ .../src/connections/freshbooks/init.ts | 58 +++++++++++ .../src/connections/freshbooks/refresh.ts | 67 +++++++++++++ .../src/connections/square/init.ts | 42 ++++++++ .../src/connections/square/refresh.ts | 47 +++++++++ integrationos-platform-oauth/src/index.ts | 96 +++++++++++++++++++ .../src/lib/helpers.ts | 28 ++++++ integrationos-platform-oauth/src/lib/types.ts | 11 +++ integrationos-platform-oauth/tsconfig.json | 17 ++++ 14 files changed, 491 insertions(+) create mode 100644 .github/workflows/build-platform-oauth.yaml create mode 100644 integrationos-platform-oauth/Dockerfile create mode 100644 integrationos-platform-oauth/README.md create mode 100644 integrationos-platform-oauth/package.json create mode 100644 integrationos-platform-oauth/src/connections/clover/init.ts create mode 100644 integrationos-platform-oauth/src/connections/clover/refresh.ts create mode 100644 integrationos-platform-oauth/src/connections/freshbooks/init.ts create mode 100644 integrationos-platform-oauth/src/connections/freshbooks/refresh.ts create mode 100644 integrationos-platform-oauth/src/connections/square/init.ts create mode 100644 integrationos-platform-oauth/src/connections/square/refresh.ts create mode 100644 integrationos-platform-oauth/src/index.ts create mode 100644 integrationos-platform-oauth/src/lib/helpers.ts create mode 100644 integrationos-platform-oauth/src/lib/types.ts create mode 100644 integrationos-platform-oauth/tsconfig.json diff --git a/.github/workflows/build-platform-oauth.yaml b/.github/workflows/build-platform-oauth.yaml new file mode 100644 index 00000000..bb978b53 --- /dev/null +++ b/.github/workflows/build-platform-oauth.yaml @@ -0,0 +1,28 @@ +on: + push: + branches: + - main + tags: + - "[0-9]+.[0-9]+.[0-9]+" + paths: + - .github/workflows/build-platform-oauth.yaml + - "integrationos-platform-oauth/**" + +env: + docker_image_tag: ${{ github.ref == 'refs/heads/main' && github.sha || github.ref_name }} + +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v3 + - uses: integration-os/google-artifact-registry-action@v2 + with: + image: "us-docker.pkg.dev/integrationos/docker-oss/platform-oauth:${{ env.docker_image_tag }}" + service_account: github-actions@integrationos.iam.gserviceaccount.com + workload_identity_provider: projects/356173785332/locations/global/workloadIdentityPools/github-actions/providers/github-actions diff --git a/integrationos-platform-oauth/Dockerfile b/integrationos-platform-oauth/Dockerfile new file mode 100644 index 00000000..6fc97dbe --- /dev/null +++ b/integrationos-platform-oauth/Dockerfile @@ -0,0 +1,16 @@ +ARG DOCKER_IMAGE="node:20-slim" + +FROM ${DOCKER_IMAGE} AS builder +COPY . /app/platform-oauth +WORKDIR /app/platform-oauth +RUN npm install && npm run build + +FROM ${DOCKER_IMAGE} +RUN apt-get update && apt-get install -y tini +COPY --from=builder /app/platform-oauth/dist /app/platform-oauth/dist +COPY --from=builder /app/platform-oauth/node_modules /app/platform-oauth/node_modules +COPY --from=builder /app/platform-oauth/package.json /app/platform-oauth/package.json +COPY --from=builder /app/platform-oauth/package-lock.json /app/platform-oauth/package-lock.json +WORKDIR /app/platform-oauth +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["node", "/app/platform-oauth/dist/index.js"] diff --git a/integrationos-platform-oauth/README.md b/integrationos-platform-oauth/README.md new file mode 100644 index 00000000..7897aeb9 --- /dev/null +++ b/integrationos-platform-oauth/README.md @@ -0,0 +1,16 @@ +# IntegrationOS OAuth API + +The API for OAuth based Connections. + +### Setup + +To change the default `PORT`: + +```bash +export EXPRESS_SERVER_PORT=3009 +``` + +```bash +> npm install +> npm start +``` diff --git a/integrationos-platform-oauth/package.json b/integrationos-platform-oauth/package.json new file mode 100644 index 00000000..a8ca3c3b --- /dev/null +++ b/integrationos-platform-oauth/package.json @@ -0,0 +1,23 @@ +{ + "name": "platform-oauth", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "ts-node src/index.ts", + "build": "tsc" + }, + "author": "@integrationos", + "license": "ISC", + "description": "", + "dependencies": { + "axios": "^1.7.2", + "dotenv": "^16.4.5", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" + } +} diff --git a/integrationos-platform-oauth/src/connections/clover/init.ts b/integrationos-platform-oauth/src/connections/clover/init.ts new file mode 100644 index 00000000..81b82bd3 --- /dev/null +++ b/integrationos-platform-oauth/src/connections/clover/init.ts @@ -0,0 +1,31 @@ +import axios from "axios"; + +import { DataObject, OAuthResponse } from "../../lib/types"; + +export const init = async ({ body }: DataObject): Promise => { + try { + const requestBody = { + grant_type: "authorization_code", + code: body.metadata?.code, + client_id: body.clientId, + client_secret: body.clientSecret, + }; + + const response = await axios.post(`${body.metadata.formData.CLOVER_REGION_DOMAIN}/oauth/token`, requestBody); + + const accessToken = response.data?.access_token; + + return { + accessToken, + refreshToken: accessToken, + expiresIn: 2147483647, + tokenType: "Bearer", + meta: { + merchantId: body.metadata?.additionalData?.merchant_id, + employeeId: body.metadata?.additionalData?.employee_id, + } + }; + } catch (error) { + throw new Error(`Error fetching access token for ${body.params?.platform}: ${error}`); + } +}; \ No newline at end of file diff --git a/integrationos-platform-oauth/src/connections/clover/refresh.ts b/integrationos-platform-oauth/src/connections/clover/refresh.ts new file mode 100644 index 00000000..37cd3fd7 --- /dev/null +++ b/integrationos-platform-oauth/src/connections/clover/refresh.ts @@ -0,0 +1,11 @@ +import { OAuthResponse } from "../../lib/types"; + +export const refresh = async (payload: any): Promise => { + return { + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + expiresIn: payload.expiresIn, + tokenType: payload.tokenType, + meta: payload.meta + }; +}; diff --git a/integrationos-platform-oauth/src/connections/freshbooks/init.ts b/integrationos-platform-oauth/src/connections/freshbooks/init.ts new file mode 100644 index 00000000..110fb783 --- /dev/null +++ b/integrationos-platform-oauth/src/connections/freshbooks/init.ts @@ -0,0 +1,58 @@ +import axios from "axios"; + +import { DataObject, OAuthResponse } from "../../lib/types"; + +export const init = async ({ body }: DataObject): Promise => { + try { + const requestBody = { + grant_type: "authorization_code", + code: body.metadata?.code, + client_id: body.clientId, + client_secret: body.clientSecret, + redirect_uri: body.metadata?.redirectUri, + }; + + const response = await axios.post( + `https://api.freshbooks.com/auth/oauth/token`, + requestBody + ); + + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + token_type: tokenType, + } = response.data; + + // Get Additional Information required by hitting me URL + const additionalData = await axios.get( + "https://api.freshbooks.com/auth/api/v1/users/me", + { + headers: { + Authorization: `${tokenType} ${accessToken}`, + }, + } + ); + + if (!additionalData?.data) { + throw new Error(`Access token validation failed.`); + } + + const { + business: { business_uuid: businessId, account_id: accountId }, + } = additionalData.data.response.business_memberships[0]; + + return { + accessToken, + refreshToken, + expiresIn, + tokenType, + meta: { + businessId, + accountId, + }, + }; + } catch (error) { + throw new Error(`Error fetching access token for freshbooks: ${error}`); + } +}; diff --git a/integrationos-platform-oauth/src/connections/freshbooks/refresh.ts b/integrationos-platform-oauth/src/connections/freshbooks/refresh.ts new file mode 100644 index 00000000..efa18675 --- /dev/null +++ b/integrationos-platform-oauth/src/connections/freshbooks/refresh.ts @@ -0,0 +1,67 @@ +import axios from "axios"; + +import { DataObject, OAuthResponse } from "../../lib/types"; + +export const refresh = async ({ body }: DataObject): Promise => { + try { + const { + OAUTH_CLIENT_ID: client_id, + OAUTH_CLIENT_SECRET: client_secret, + OAUTH_REFRESH_TOKEN: refresh_token, + OAUTH_REQUEST_PAYLOAD: { redirectUri: redirect_uri }, + } = body; + + const requestBody = { + grant_type: "refresh_token", + client_id, + refresh_token, + client_secret, + redirect_uri, + }; + + const response = await axios.post( + `https://api.freshbooks.com/auth/oauth/token`, + requestBody + ); + + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + token_type: tokenType, + } = response.data; + + // Get Additional Information required by hitting me URL + const additionalData = await axios.get( + "https://api.freshbooks.com/auth/api/v1/users/me", + { + headers: { + Authorization: `${tokenType} ${accessToken}`, + }, + } + ); + + if (!additionalData?.data) { + throw new Error(`Access token validation failed.`); + } + + const { + business: { business_uuid: businessId, account_id: accountId }, + } = additionalData.data.response.business_memberships[0]; + + return { + accessToken, + refreshToken, + expiresIn, + tokenType, + meta: { + businessId, + accountId, + }, + }; + } catch (error) { + throw new Error( + `Error fetching access token for ${body.params?.platform}: ${error}` + ); + } +}; diff --git a/integrationos-platform-oauth/src/connections/square/init.ts b/integrationos-platform-oauth/src/connections/square/init.ts new file mode 100644 index 00000000..3ba28f6e --- /dev/null +++ b/integrationos-platform-oauth/src/connections/square/init.ts @@ -0,0 +1,42 @@ +import axios from "axios"; + +import { DataObject, OAuthResponse } from "../../lib/types"; +import { convertToTimestamp } from "../../lib/helpers"; + +export const init = async ({ body }: DataObject): Promise => { + try { + const requestBody = { + grant_type: "authorization_code", + code: body.metadata?.code, + client_id: body.clientId, + client_secret: body.clientSecret, + redirect_uri: body.metadata?.redirectUri, + }; + + const response = await axios.post(`https://connect.squareup.com/oauth2/token`, requestBody); + + const { + data: { + access_token, + refresh_token, + expires_at, + token_type, + merchant_id, + short_lived + } + } = response; + + return { + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: Math.floor((await convertToTimestamp(expires_at) - (new Date().getTime())) / 1000), + tokenType: token_type === "bearer" ? "Bearer" : token_type, + meta: { + merchantId: merchant_id, + shortLived: short_lived + } + }; + } catch (error) { + throw new Error(`Error fetching access token for ${body.params?.platform}: ${error}`); + } +}; \ No newline at end of file diff --git a/integrationos-platform-oauth/src/connections/square/refresh.ts b/integrationos-platform-oauth/src/connections/square/refresh.ts new file mode 100644 index 00000000..846075d6 --- /dev/null +++ b/integrationos-platform-oauth/src/connections/square/refresh.ts @@ -0,0 +1,47 @@ +import axios from "axios"; + +import { DataObject, OAuthResponse } from "../../lib/types"; +import { convertToTimestamp } from "../../lib/helpers"; + +export const refresh = async ({ body }: DataObject): Promise => { + try { + const { + OAUTH_CLIENT_ID: client_id, + OAUTH_CLIENT_SECRET: client_secret, + OAUTH_REFRESH_TOKEN: refresh_token + } = body; + + const requestBody = { + grant_type: "refresh_token", + client_id, + client_secret, + refresh_token, + }; + + const response = await axios.post(`https://connect.squareup.com/oauth2/token`, requestBody); + + const { + data: { + access_token: accessToken, + refresh_token: refreshToken, + expires_at: expiresAt, + token_type: tokenType, + merchant_id: merchantId, + short_lived: shortLived + } + } = response; + + return { + accessToken, + refreshToken, + expiresIn: Math.floor((await convertToTimestamp(expiresAt) - new Date().getTime()) / 1000), + tokenType: tokenType === "bearer" ? "Bearer" : tokenType, + meta: { + merchantId, + shortLived + } + }; + } catch (error) { + throw new Error(`Error fetching access token for ${body.params?.platform}: ${error}`); + } +}; \ No newline at end of file diff --git a/integrationos-platform-oauth/src/index.ts b/integrationos-platform-oauth/src/index.ts new file mode 100644 index 00000000..eadab2d6 --- /dev/null +++ b/integrationos-platform-oauth/src/index.ts @@ -0,0 +1,96 @@ +import "dotenv/config"; +import path from "path"; +import express, { Express, Response } from "express"; + +import { checkExistence, getProjectPath, toCamelCase } from "./lib/helpers"; + +const app: Express = express(); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +const port = Number(process.env.EXPRESS_SERVER_PORT || 3000); +const hostname = process.env.EXPRESS_SERVER_HOSTNAME || "0.0.0.0"; + +const SERVER_OAUTH_PATH = "./connections"; + +app.get("/", (_, res: Response) => { + res.send("IntegrationOS"); +}); + +app.listen(port, hostname, () => { + console.log(`IntegrationOS Oauth Server is running on http://${hostname}:${port}`); +}); + +app.route("/oauth/:platform/init") + .get(async (req, res) => { + res.send(`To Init OAuth for Platform: ${await toCamelCase(req.params.platform)}, perform a POST request!`); + }) + .post(async (req, res) => { + try { + const { platform } = req.params; + const platformOAuthPath = path.join( + await getProjectPath(), + ...SERVER_OAUTH_PATH.split("/"), + await toCamelCase(platform) + ); + + if (!(await checkExistence(platformOAuthPath))) { + return res.status(404).send({ message: `Error: OAuth does not exist for ${platform}!` }); + } + + const { init } = require(`${platformOAuthPath}/init`); + + if (typeof init !== "function") { + return res.status(500).send({ message: `Error: Missing init function for ${platform}!` }); + } + + const response = await init({ + headers: req.headers, + params: req.params, + body: req.body + }); + + res.send(response); + } catch (error) { + console.error("Error during OAuth initialization:", error); + res.status(500).send({ message: "Internal Server Error" }); + } + }); + +app.route("/oauth/:platform/refresh") + .get(async (req, res) => { + res.send(`To Refresh OAuth for Platform: ${await toCamelCase(req.params.platform)}, perform a POST request!`); + }) + .post(async (req, res) => { + try { + const { platform } = req.params; + + const platformOAuthPath = path.join( + await getProjectPath(), + ...SERVER_OAUTH_PATH.split("/"), + await toCamelCase(platform) + ); + + if (!(await checkExistence(platformOAuthPath))) { + return res.status(404).send({ message: `Error: OAuth does not exist for ${platform}!` }); + } + + const { refresh } = require(`${platformOAuthPath}/refresh`); + + if (typeof refresh !== "function") { + return res.status(500).send({ message: `Error: Missing refresh function for ${platform}!` }); + } + + const response = await refresh({ + headers: req.headers, + params: req.params, + body: req.body + }); + + res.send(response); + } catch (error) { + console.error("Error during OAuth refresh:", error); + res.status(500).send({ message: "Internal Server Error" }); + } + }); diff --git a/integrationos-platform-oauth/src/lib/helpers.ts b/integrationos-platform-oauth/src/lib/helpers.ts new file mode 100644 index 00000000..7095dc67 --- /dev/null +++ b/integrationos-platform-oauth/src/lib/helpers.ts @@ -0,0 +1,28 @@ +import { promises } from "node:fs"; +import path from "path"; + +export const checkExistence = async (path: string) => { + try { + await promises.access(path); + + return true; + } catch (error) { + return false; + } +}; + +export const getProjectPath = async () => path.join(__dirname, ".."); + +export const toCamelCase = async (input: string) => { + let words = input.split(/[^a-zA-Z0-9]+/).filter((word) => word.length); + + for (let i = 1; i < words.length; i++) { + words[i] = words[i][0].toUpperCase() + words[i].substring(1).toLowerCase(); + } + + const result = words.join(""); + + return result[0].toLowerCase() + result.substring(1); +}; + +export const convertToTimestamp = async (dateString: string): Promise => new Date(dateString).getTime(); diff --git a/integrationos-platform-oauth/src/lib/types.ts b/integrationos-platform-oauth/src/lib/types.ts new file mode 100644 index 00000000..b1bf8f88 --- /dev/null +++ b/integrationos-platform-oauth/src/lib/types.ts @@ -0,0 +1,11 @@ +export interface DataObject { + [key: string]: any; +} + +export interface OAuthResponse { + accessToken: string; + refreshToken: string; + expiresIn: number; + tokenType: string; + meta: DataObject; +} diff --git a/integrationos-platform-oauth/tsconfig.json b/integrationos-platform-oauth/tsconfig.json new file mode 100644 index 00000000..caf74c21 --- /dev/null +++ b/integrationos-platform-oauth/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file