From ed93611f43c26d9a99418262f59a0213524e3451 Mon Sep 17 00:00:00 2001 From: Ram Yalamanchili Date: Wed, 1 Jan 2020 22:44:36 -0800 Subject: [PATCH 1/6] Fix issue with tracking changes to rest api code in dev mode --- packages/cli/commands/start.ts | 2 +- packages/cli/nodemon.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 5494b5c5c3..e8f1694b9e 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -176,7 +176,7 @@ export class Start extends Command { Start.openBrowser(); } this.log(`\nPress "o" to open in Browser.`); - process.stdin.on("data", (key) => { + process.stdin.on("data", (key: string) => { if (key === 'o') { Start.openBrowser(); inputText = ''; diff --git a/packages/cli/nodemon.json b/packages/cli/nodemon.json index efb39c6667..5bdb290fb2 100644 --- a/packages/cli/nodemon.json +++ b/packages/cli/nodemon.json @@ -9,6 +9,6 @@ "index.ts", "src" ], - "exec": "npm start", + "exec": "npm run build && npm start", "ext": "ts" -} \ No newline at end of file +} From 3b450b4372acfa4b56f025a1d55b6fa05f1409f8 Mon Sep 17 00:00:00 2001 From: Ram Yalamanchili Date: Wed, 1 Jan 2020 22:46:16 -0800 Subject: [PATCH 2/6] Add debugger support for vuejs --- packages/editor-ui/vue.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor-ui/vue.config.js b/packages/editor-ui/vue.config.js index f70f41c5b2..b47e0c4b23 100644 --- a/packages/editor-ui/vue.config.js +++ b/packages/editor-ui/vue.config.js @@ -12,6 +12,7 @@ module.exports = { }, }, configureWebpack: { + devtool: 'source-map', plugins: [ new GoogleFontsPlugin({ fonts: [ From d2ea3ce877b4233e9c1cb20f8e2882e42ad964e0 Mon Sep 17 00:00:00 2001 From: Ram Yalamanchili Date: Wed, 1 Jan 2020 22:47:11 -0800 Subject: [PATCH 3/6] Add OAuth2 Authorization and Callback rest endpoints URL generation for OAuth2 authorization and the subsequent login callback are handled at the backend API. While this can be done client side, the credentials are better managed entirely on the server side. --- packages/cli/package.json | 2 + packages/cli/src/Server.ts | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index 48bbacb418..df55d5a6b0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -78,9 +78,11 @@ "basic-auth": "^2.0.1", "body-parser": "^1.18.3", "body-parser-xml": "^1.1.0", + "client-oauth2": "^4.2.5", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", "convict": "^5.0.0", + "csrf": "^3.1.0", "dotenv": "^8.0.0", "express": "^4.16.4", "flatted": "^2.0.0", diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 41fda80372..f5a608331a 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -10,6 +10,9 @@ import * as bodyParser from 'body-parser'; require('body-parser-xml')(bodyParser); import * as history from 'connect-history-api-fallback'; import * as requestPromise from 'request-promise-native'; +import * as _ from 'lodash'; +import * as clientOAuth2 from 'client-oauth2'; +import * as csrf from 'csrf'; import { ActiveExecutions, @@ -721,6 +724,8 @@ class App { // Encrypt the data const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess); + _.unset(incomingData.data, 'csrfSecret'); + _.unset(incomingData.data, 'oauthTokenData'); credentials.setData(incomingData.data, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; @@ -840,8 +845,138 @@ class App { return returnData; })); + // ---------------------------------------- + // OAuth2-Credential/Auth + // ---------------------------------------- + // Returns all the credential types which are defined in the loaded n8n-modules + this.app.get('/rest/oauth2-credential/auth', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + if (req.query.id === undefined) { + throw new Error('Required credential id is missing!'); + } + + const result = await Db.collections.Credentials!.findOne(req.query.id); + if (result === undefined) { + res.status(404).send('The credential is not known.'); + return ''; + } + + let encryptionKey = undefined; + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to decrypt the credentials!'); + } + + const credentials = new Credentials(result.name, result.type, result.nodesAccess, result.data); + (result as ICredentialsDecryptedDb).data = credentials.getData(encryptionKey!); + (result as ICredentialsDecryptedResponse).id = result.id.toString(); + + const oauthCredentials = (result as ICredentialsDecryptedDb).data; + if (oauthCredentials === undefined) { + throw new Error('Unable to read OAuth credentials'); + } + + let token = new csrf(); + // Generate a CSRF prevention token and send it as a OAuth2 state stringma/ERR + oauthCredentials.csrfSecret = token.secretSync(); + const state = { + 'token': token.create(oauthCredentials.csrfSecret), + 'cid': req.query.id + } + const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64') as string; + + const oAuthObj = new clientOAuth2({ + clientId: _.get(oauthCredentials, 'clientId') as string, + clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string, + accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string, + authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string, + redirectUri: _.get(oauthCredentials, 'callbackUrl', WebhookHelpers.getWebhookBaseUrl()) as string, + scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ','), + state: stateEncodedStr + }); + + credentials.setData(oauthCredentials, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; + + // Add special database related data + newCredentialsData.updatedAt = this.getCurrentDate(); + + // Update the credentials in DB + await Db.collections.Credentials!.update(req.query.id, newCredentialsData); + + return oAuthObj.code.getUri(); + })); + + // ---------------------------------------- + // OAuth2-Credential/Callback + // ---------------------------------------- + + // Verify and store app code. Generate access tokens and store for respective credential. + this.app.get('/rest/oauth2-credential/callback', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const {code, state: stateEncoded} = req.query; + if (code === undefined || stateEncoded === undefined) { + throw new Error('Insufficient parameters for OAuth2 callback') + } + + let state; + try { + state = JSON.parse(Buffer.from(stateEncoded, 'base64').toString()); + } catch (error) { + throw new Error('Invalid state format returned'); + } + + const result = await Db.collections.Credentials!.findOne(state.cid); + if (result === undefined) { + res.status(404).send('The credential is not known.'); + return ''; + } + + let encryptionKey = undefined; + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to decrypt the credentials!'); + } + + const credentials = new Credentials(result.name, result.type, result.nodesAccess, result.data); + (result as ICredentialsDecryptedDb).data = credentials.getData(encryptionKey!); + const oauthCredentials = (result as ICredentialsDecryptedDb).data; + if (oauthCredentials === undefined) { + throw new Error('Unable to read OAuth credentials'); + } + + let token = new csrf(); + if (oauthCredentials.csrfSecret === undefined || !token.verify(oauthCredentials.csrfSecret as string, state.token)) { + res.status(404).send('The OAuth2 callback state is invalid.'); + return ''; + } + + const oAuthObj = new clientOAuth2({ + clientId: _.get(oauthCredentials, 'clientId') as string, + clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string, + accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string, + authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string, + redirectUri: _.get(oauthCredentials, 'callbackUrl', WebhookHelpers.getWebhookBaseUrl()) as string, + scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ',') + }); + + const oauthToken = await oAuthObj.code.getToken(req.originalUrl); + if (oauthToken === undefined) { + throw new Error('Unable to get access tokens'); + } + + oauthCredentials.oauthTokenData = JSON.stringify(oauthToken.data); + _.unset(oauthCredentials, 'csrfSecret'); + credentials.setData(oauthCredentials, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; + // Add special database related data + newCredentialsData.updatedAt = this.getCurrentDate(); + // Save the credentials in DB + await Db.collections.Credentials!.update(state.cid, newCredentialsData); + + return 'Success!'; + })); + // ---------------------------------------- // Executions // ---------------------------------------- From cb73853680449dbfae5b8ec141ed4e6464a0ce8d Mon Sep 17 00:00:00 2001 From: Ram Yalamanchili Date: Wed, 1 Jan 2020 22:49:18 -0800 Subject: [PATCH 4/6] Add UI logic to support OAuth authentication flow Add support in credentialsList to kickoff an OAuth2 authorization flow. This enables users to authenticate and allow n8n to store the resulting keys in the backend. --- packages/editor-ui/src/Interface.ts | 2 ++ .../src/components/CredentialsList.vue | 20 +++++++++++++++++-- .../src/components/mixins/restApi.ts | 15 ++++++++++++++ packages/editor-ui/src/router.ts | 6 ++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e6e34fb9d5..a79ebdd8fd 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -145,6 +145,8 @@ export interface IRestApi { deleteExecutions(sendData: IExecutionDeleteFilter): Promise; retryExecution(id: string, loadWorkflow?: boolean): Promise; getTimezones(): Promise; + OAuth2CredentialAuthorize(sendData: ICredentialsResponse): Promise; + OAuth2Callback(code: string, state: string): Promise; } export interface IBinaryDisplayData { diff --git a/packages/editor-ui/src/components/CredentialsList.vue b/packages/editor-ui/src/components/CredentialsList.vue index 758adf1a4c..eeece990bc 100644 --- a/packages/editor-ui/src/components/CredentialsList.vue +++ b/packages/editor-ui/src/components/CredentialsList.vue @@ -25,10 +25,12 @@ + width="180"> @@ -91,6 +93,20 @@ export default mixins( this.editCredentials = null; this.credentialEditDialogVisible = true; }, + async OAuth2CredentialAuthorize (credential: ICredentialsResponse) { + let url; + try { + url = await this.restApi().OAuth2CredentialAuthorize(credential) as string; + } catch (error) { + this.$showError(error, 'OAuth Authorization Error', 'Error generating authorization URL:'); + return; + } + + const params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=0,height=0,left=-1000,top=-1000`; + const oauthPopup = window.open(url, 'OAuth2 Authorization', params); + + console.log(oauthPopup); + }, editCredential (credential: ICredentialsResponse) { const editCredentials = { id: credential.id, @@ -124,7 +140,7 @@ export default mixins( try { this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials)); } catch (error) { - this.$showError(error, 'Proble loading credentials', 'There was a problem loading the credentials:'); + this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:'); this.isDataLoading = false; return; } diff --git a/packages/editor-ui/src/components/mixins/restApi.ts b/packages/editor-ui/src/components/mixins/restApi.ts index a72520718e..a2cdbd6584 100644 --- a/packages/editor-ui/src/components/mixins/restApi.ts +++ b/packages/editor-ui/src/components/mixins/restApi.ts @@ -252,6 +252,21 @@ export const restApi = Vue.extend({ return self.restApi().makeRestApiRequest('GET', `/credential-types`); }, + // Get OAuth2 Authorization URL using the stored credentials + OAuth2CredentialAuthorize: (sendData: ICredentialsResponse): Promise => { + return self.restApi().makeRestApiRequest('GET', `/oauth2-credential/auth`, sendData); + }, + + // Verify OAuth2 provider callback and kick off token generation + OAuth2Callback: (code: string, state: string): Promise => { + const sendData = { + 'code': code, + 'state': state + }; + + return self.restApi().makeRestApiRequest('POST', `/oauth2-credential/callback`, sendData); + }, + // Returns the execution with the given name getExecution: async (id: string): Promise => { const response = await self.restApi().makeRestApiRequest('GET', `/executions/${id}`); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 14a31c7e80..f33d028f94 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -19,6 +19,12 @@ export default new Router({ sidebar: MainSidebar, }, }, + { + path: '/oauth2/callback', + name: 'OAuth2Callback', + components: { + }, + }, { path: '/workflow', name: 'NodeViewNew', From c44cfffdd91df5e60013a310a5d048399b57a192 Mon Sep 17 00:00:00 2001 From: Ram Yalamanchili Date: Wed, 1 Jan 2020 22:51:23 -0800 Subject: [PATCH 5/6] Add OAuth2 credential type --- .../credentials/OAuth2Api.credentials.ts | 56 +++++++++++++++++++ packages/nodes-base/package.json | 1 + 2 files changed, 57 insertions(+) create mode 100644 packages/nodes-base/credentials/OAuth2Api.credentials.ts diff --git a/packages/nodes-base/credentials/OAuth2Api.credentials.ts b/packages/nodes-base/credentials/OAuth2Api.credentials.ts new file mode 100644 index 0000000000..452fdb8f57 --- /dev/null +++ b/packages/nodes-base/credentials/OAuth2Api.credentials.ts @@ -0,0 +1,56 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class OAuth2Api implements ICredentialType { + name = 'OAuth2Api'; + displayName = 'OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + }, + { + displayName: 'Callback URL', + name: 'callbackUrl', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + }, + { + displayName: 'Client ID', + name: 'clientId', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index caf67cc069..3074251ef7 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -56,6 +56,7 @@ "dist/credentials/MySql.credentials.js", "dist/credentials/NextCloudApi.credentials.js", "dist/credentials/OpenWeatherMapApi.credentials.js", + "dist/credentials/OAuth2Api.credentials.js", "dist/credentials/PipedriveApi.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/PayPalApi.credentials.js", From bd2713d83adfa1e7b019faad34b45da5d9d2abbb Mon Sep 17 00:00:00 2001 From: Ram Yalamanchili Date: Wed, 1 Jan 2020 22:51:41 -0800 Subject: [PATCH 6/6] OAuth2 testing node --- packages/nodes-base/nodes/OAuth.node.ts | 104 ++++++++++++++++++++++++ packages/nodes-base/package.json | 3 +- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/nodes/OAuth.node.ts diff --git a/packages/nodes-base/nodes/OAuth.node.ts b/packages/nodes-base/nodes/OAuth.node.ts new file mode 100644 index 0000000000..189ae9e408 --- /dev/null +++ b/packages/nodes-base/nodes/OAuth.node.ts @@ -0,0 +1,104 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + GenericValue, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { set } from 'lodash'; + +import * as util from 'util'; +import { connectionFields } from './ActiveCampaign/ConnectionDescription'; + +export class OAuth implements INodeType { + description: INodeTypeDescription = { + displayName: 'OAuth', + name: 'oauth', + icon: 'fa:code-branch', + group: ['input'], + version: 1, + description: 'Gets, sends data to Oauth API Endpoint and receives generic information.', + defaults: { + name: 'OAuth', + color: '#0033AA', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'OAuth2Api', + required: true, + } + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Get', + value: 'get', + description: 'Returns the value of a key from oauth.', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // get + // ---------------------------------- + { + displayName: 'Name', + name: 'propertyName', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get' + ], + }, + }, + default: 'propertyName', + required: true, + description: 'Name of the property to write received data to.
Supports dot-notation.
Example: "data.person[0].name"', + }, + ] + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = this.getCredentials('OAuth2Api'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (credentials.oauthTokenData === undefined) { + throw new Error('OAuth credentials not connected'); + } + + const operation = this.getNodeParameter('operation', 0) as string; + if (operation === 'get') { + const items = this.getInputData(); + const returnItems: INodeExecutionData[] = []; + + let item: INodeExecutionData; + + // credentials.oauthTokenData has the refreshToken and accessToken available + // it would be nice to have credentials.getOAuthToken() which returns the accessToken + // and also handles an error case where if the token is to be refreshed, it does so + // without knowledge of the node. + console.log('Got OAuth credentials!', credentials.oauthTokenData); + + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + item = { json: { itemIndex } }; + returnItems.push(item); + } + return [returnItems]; + } else { + throw new Error('Unknown operation'); + } + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 3074251ef7..28c220136d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -166,7 +166,8 @@ "dist/nodes/Xml.node.js", "dist/nodes/Mandrill/Mandrill.node.js", "dist/nodes/Todoist/Todoist.node.js", - "dist/nodes/Xml.node.js" + "dist/nodes/Xml.node.js", + "dist/nodes/OAuth.node.js" ] }, "devDependencies": {