diff --git a/packages/backend/src/apps/slack/info.json b/packages/backend/src/apps/slack/info.json index 86c16f97..45a4ccd2 100644 --- a/packages/backend/src/apps/slack/info.json +++ b/packages/backend/src/apps/slack/info.json @@ -6,6 +6,7 @@ "authDocUrl": "https://automatisch.io/docs/connections/slack", "primaryColor": "2DAAE1", "supportsConnections": true, + "baseUrl": "https://slack.com/api", "fields": [ { "key": "accessToken", diff --git a/packages/backend/src/apps/slack2/auth/index.ts b/packages/backend/src/apps/slack2/auth/index.ts new file mode 100644 index 00000000..99c3fdf3 --- /dev/null +++ b/packages/backend/src/apps/slack2/auth/index.ts @@ -0,0 +1,100 @@ +import verifyCredentials from './verify-credentials'; +import isStillVerified from './is-still-verified'; + +export default { + fields: [ + { + key: 'accessToken', + label: 'Access Token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Access token of slack that Automatisch will connect to.', + clickToCopy: false, + }, + ], + authenticationSteps: [ + { + step: 1, + type: 'mutation', + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'accessToken', + value: '{fields.accessToken}', + }, + ], + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + ], + reconnectionSteps: [ + { + step: 1, + type: 'mutation', + name: 'resetConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'accessToken', + value: '{fields.accessToken}', + }, + ], + }, + ], + }, + { + step: 3, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/slack2/auth/is-still-verified.ts b/packages/backend/src/apps/slack2/auth/is-still-verified.ts new file mode 100644 index 00000000..809ad202 --- /dev/null +++ b/packages/backend/src/apps/slack2/auth/is-still-verified.ts @@ -0,0 +1,12 @@ +import verifyCredentials from './verify-credentials'; + +const isStillVerified = async ($: any) => { + try { + await verifyCredentials($); + return true; + } catch (error) { + return false; + } +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/slack2/auth/verify-credentials.ts b/packages/backend/src/apps/slack2/auth/verify-credentials.ts new file mode 100644 index 00000000..0c29c0ad --- /dev/null +++ b/packages/backend/src/apps/slack2/auth/verify-credentials.ts @@ -0,0 +1,33 @@ +import qs from 'qs'; + +const verifyCredentials = async ($: any) => { + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const stringifiedBody = qs.stringify({ + token: $.auth.accessToken, + }); + + const response = await $.http.post('/auth.test', stringifiedBody, { + headers, + }); + + if (response.data.ok === false) { + throw new Error( + `Error occured while verifying credentials: ${response.data.error}.(More info: https://api.slack.com/methods/auth.test#errors)` + ); + } + + const { bot_id: botId, user: screenName } = response.data; + + $.auth.set({ + botId, + screenName, + token: $.auth.accessToken, + }); + + return response.data; +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/slack2/index.ts b/packages/backend/src/apps/slack2/index.ts new file mode 100644 index 00000000..d9e88499 --- /dev/null +++ b/packages/backend/src/apps/slack2/index.ts @@ -0,0 +1,8 @@ +export default { + name: 'Slack', + key: 'slack', + iconUrl: './assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/connections/slack', + supportsConnections: true, + baseUrl: 'https://slack.com/api', +}; diff --git a/packages/backend/src/apps/twitter/info.json b/packages/backend/src/apps/twitter/info.json index a9a4cd6c..c268e290 100644 --- a/packages/backend/src/apps/twitter/info.json +++ b/packages/backend/src/apps/twitter/info.json @@ -6,6 +6,7 @@ "authDocUrl": "https://automatisch.io/docs/connections/twitter", "primaryColor": "2DAAE1", "supportsConnections": true, + "baseUrl": "https://api.twitter.com", "fields": [ { "key": "oAuthRedirectUrl", diff --git a/packages/backend/src/apps/twitter2/auth/create-auth-data.ts b/packages/backend/src/apps/twitter2/auth/create-auth-data.ts new file mode 100644 index 00000000..548b1769 --- /dev/null +++ b/packages/backend/src/apps/twitter2/auth/create-auth-data.ts @@ -0,0 +1,43 @@ +import { IJSONObject, IField } from '@automatisch/types'; +import oauthClient from '../common/oauth-client'; +import { URLSearchParams } from 'url'; + +export default async function createAuthData($: any) { + try { + const oauthRedirectUrlField = $.app.fields.find( + (field: IField) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + + const requestData = { + url: `${$.app.baseUrl}/oauth/request_token`, + method: 'POST', + data: { oauth_callback: callbackUrl }, + }; + + const authHeader = oauthClient($).toHeader( + oauthClient($).authorize(requestData) + ); + + const response = await $.http.post(`/oauth/request_token`, null, { + headers: { ...authHeader }, + }); + + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + url: `${$.app.baseUrl}/oauth/authorize?oauth_token=${responseData.oauth_token}`, + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + }); + } catch (error) { + const errorMessages = error.response.data.errors + .map((error: IJSONObject) => error.message) + .join(' '); + + throw new Error( + `Error occured while verifying credentials: ${errorMessages}` + ); + } +} diff --git a/packages/backend/src/apps/twitter2/auth/index.ts b/packages/backend/src/apps/twitter2/auth/index.ts new file mode 100644 index 00000000..a5690b2f --- /dev/null +++ b/packages/backend/src/apps/twitter2/auth/index.ts @@ -0,0 +1,219 @@ +import createAuthData from './create-auth-data'; +import verifyCredentials from './verify-credentials'; +import isStillVerified from './is-still-verified'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/twitter/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Twitter OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'API Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + authenticationSteps: [ + { + step: 1, + type: 'mutation', + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'consumerKey', + value: '{fields.consumerKey}', + }, + { + name: 'consumerSecret', + value: '{fields.consumerSecret}', + }, + ], + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'createAuthData', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + { + step: 3, + type: 'openWithPopup', + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{createAuthData.url}', + }, + ], + }, + { + step: 4, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'oauthVerifier', + value: '{openAuthPopup.oauth_verifier}', + }, + ], + }, + ], + }, + { + step: 5, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + ], + reconnectionSteps: [ + { + step: 1, + type: 'mutation', + name: 'resetConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'consumerKey', + value: '{fields.consumerKey}', + }, + { + name: 'consumerSecret', + value: '{fields.consumerSecret}', + }, + ], + }, + ], + }, + { + step: 3, + type: 'mutation', + name: 'createAuthData', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + { + step: 4, + type: 'openWithPopup', + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{createAuthData.url}', + }, + ], + }, + { + step: 5, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'oauthVerifier', + value: '{openAuthPopup.oauth_verifier}', + }, + ], + }, + ], + }, + { + step: 6, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + ], + + createAuthData, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/twitter2/auth/is-still-verified.ts b/packages/backend/src/apps/twitter2/auth/is-still-verified.ts new file mode 100644 index 00000000..dbbdac52 --- /dev/null +++ b/packages/backend/src/apps/twitter2/auth/is-still-verified.ts @@ -0,0 +1,12 @@ +import getCurrentUser from '../common/get-current-user'; + +const isStillVerified = async ($: any) => { + try { + await getCurrentUser($); + return true; + } catch (error) { + return false; + } +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/twitter2/auth/verify-credentials.ts b/packages/backend/src/apps/twitter2/auth/verify-credentials.ts new file mode 100644 index 00000000..6747045a --- /dev/null +++ b/packages/backend/src/apps/twitter2/auth/verify-credentials.ts @@ -0,0 +1,21 @@ +const verifyCredentials = async ($: any) => { + try { + const response = await $.http.post( + `/oauth/access_token?oauth_verifier=${$.auth.oauthVerifier}&oauth_token=${$.auth.accessToken}`, + null + ); + + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + userId: responseData.user_id, + screenName: responseData.screen_name, + }); + } catch (error) { + throw new Error(error.response.data); + } +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/twitter2/common/oauth-client/index.ts b/packages/backend/src/apps/twitter2/common/oauth-client/index.ts new file mode 100644 index 00000000..7ddc7398 --- /dev/null +++ b/packages/backend/src/apps/twitter2/common/oauth-client/index.ts @@ -0,0 +1,22 @@ +import crypto from 'crypto'; +import OAuth from 'oauth-1.0a'; + +const oauthClient = ($: any) => { + const consumerData = { + key: $.auth.consumerKey as string, + secret: $.auth.consumerSecret as string, + }; + + return new OAuth({ + consumer: consumerData, + signature_method: 'HMAC-SHA1', + hash_function(base_string, key) { + return crypto + .createHmac('sha1', key) + .update(base_string) + .digest('base64'); + }, + }); +}; + +export default oauthClient; diff --git a/packages/backend/src/apps/twitter2/index.ts b/packages/backend/src/apps/twitter2/index.ts new file mode 100644 index 00000000..b78d4872 --- /dev/null +++ b/packages/backend/src/apps/twitter2/index.ts @@ -0,0 +1,8 @@ +export default { + name: 'Twitter', + key: 'twitter', + iconUrl: './assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/connections/twitter', + supportsConnections: true, + baseUrl: 'https://api.twitter.com', +}; diff --git a/packages/backend/src/graphql/mutations/create-auth-data.ts b/packages/backend/src/graphql/mutations/create-auth-data.ts index 8a4b8886..2ce1aa65 100644 --- a/packages/backend/src/graphql/mutations/create-auth-data.ts +++ b/packages/backend/src/graphql/mutations/create-auth-data.ts @@ -1,5 +1,7 @@ import Context from '../../types/express/context'; import axios from 'axios'; +import prepareGlobalVariableForConnection from '../../helpers/global-variable/connection'; +import App from '../../models/app'; type Params = { input: { @@ -19,29 +21,24 @@ const createAuthData = async ( }) .throwIfNotFound(); - const appClass = (await import(`../../apps/${connection.key}`)).default; - if (!connection.formattedData) { return null; } - const appInstance = new appClass(connection); - const authLink = await appInstance.authenticationClient.createAuthData(); + const authInstance = (await import(`../../apps/${connection.key}2/auth`)) + .default; + const app = App.findOneByKey(connection.key); + + const $ = prepareGlobalVariableForConnection(connection, app); + await authInstance.createAuthData($); try { - await axios.get(authLink.url); + await axios.get(connection.formattedData.url as string); } catch (error) { throw new Error('Error occured while creating authorization URL!'); } - await connection.$query().patch({ - formattedData: { - ...connection.formattedData, - ...authLink, - }, - }); - - return authLink; + return connection.formattedData; }; export default createAuthData; diff --git a/packages/backend/src/graphql/mutations/verify-connection.ts b/packages/backend/src/graphql/mutations/verify-connection.ts index b0863aa5..a73c6cc9 100644 --- a/packages/backend/src/graphql/mutations/verify-connection.ts +++ b/packages/backend/src/graphql/mutations/verify-connection.ts @@ -1,5 +1,6 @@ import Context from '../../types/express/context'; import App from '../../models/app'; +import prepareGlobalVariableForConnection from '../../helpers/global-variable/connection'; type Params = { input: { @@ -19,18 +20,14 @@ const verifyConnection = async ( }) .throwIfNotFound(); - const appClass = (await import(`../../apps/${connection.key}`)).default; const app = App.findOneByKey(connection.key); + const authInstance = (await import(`../../apps/${connection.key}2/auth`)) + .default; - const appInstance = new appClass(connection); - const verifiedCredentials = - await appInstance.authenticationClient.verifyCredentials(); + const $ = prepareGlobalVariableForConnection(connection, app); + await authInstance.verifyCredentials($); connection = await connection.$query().patchAndFetch({ - formattedData: { - ...connection.formattedData, - ...verifiedCredentials, - }, verified: true, draft: false, }); diff --git a/packages/backend/src/helpers/global-variable/connection.ts b/packages/backend/src/helpers/global-variable/connection.ts new file mode 100644 index 00000000..5143c865 --- /dev/null +++ b/packages/backend/src/helpers/global-variable/connection.ts @@ -0,0 +1,26 @@ +import HttpClient from '../http-client'; +import Connection from '../../models/connection'; +import { IJSONObject, IApp } from '@automatisch/types'; + +const prepareGlobalVariableForConnection = ( + connection: Connection, + appData: IApp +) => { + return { + auth: { + set: async (args: IJSONObject) => { + await connection.$query().patchAndFetch({ + formattedData: { + ...connection.formattedData, + ...args, + }, + }); + }, + ...connection.formattedData, + }, + app: appData, + http: new HttpClient({ baseURL: appData.baseUrl }), + }; +}; + +export default prepareGlobalVariableForConnection; diff --git a/packages/backend/src/models/connection.ts b/packages/backend/src/models/connection.ts index 8d44fd07..c4d26513 100644 --- a/packages/backend/src/models/connection.ts +++ b/packages/backend/src/models/connection.ts @@ -12,7 +12,7 @@ import Telemetry from '../helpers/telemetry'; class Connection extends Base { id!: string; key!: string; - data = ''; + data: string; formattedData?: IJSONObject; userId!: string; verified = false; diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 49ec37c4..0168a9fc 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -154,6 +154,7 @@ export interface IApp { authDocUrl: string; primaryColor: string; supportsConnections: boolean; + baseUrl: string; fields: IField[]; authenticationSteps: IAuthenticationStep[]; reconnectionSteps: IAuthenticationStep[];