From 8228b8505feb0a321a51caad9e123db8f336ba2b Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 13 Jan 2020 20:46:58 -0600 Subject: [PATCH] :zap: Abstract OAuth signing and make credentials extendable --- packages/cli/src/Interfaces.ts | 2 +- packages/cli/src/Server.ts | 6 +- .../cli/src/WorkflowExecuteAdditionalData.ts | 38 ++++++++ packages/core/package.json | 1 + packages/core/src/Interfaces.ts | 14 +++ packages/core/src/NodeExecuteFunctions.ts | 91 ++++++++++++++++++- packages/core/test/Helpers.ts | 2 + .../src/components/CredentialsEdit.vue | 18 +++- .../GithubOAuth2Api.credentials.ts | 15 +++ packages/nodes-base/nodes/HttpRequest.node.ts | 24 ++++- packages/nodes-base/nodes/OAuth.node.ts | 80 +++++++++++++++- packages/nodes-base/package.json | 1 + packages/workflow/src/Interfaces.ts | 6 +- 13 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 packages/nodes-base/credentials/GithubOAuth2Api.credentials.ts diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 4000c30785..a5837b8fc6 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -70,7 +70,7 @@ export interface ICredentialsBase { updatedAt: Date; } -export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted{ +export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted { id: number | string | ObjectID; } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 0a93f18e34..900dcb012f 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -758,11 +758,7 @@ class App { const findQuery = {} as FindManyOptions; // Make sure the variable has an expected value - if (req.query.includeData === 'true') { - req.query.includeData = true; - } else { - req.query.includeData = false; - } + req.query.includeData = (req.query.includeData === 'true' || req.query.includeData === true); if (req.query.includeData !== true) { // Return only the fields we need diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index d5b471def9..e234c11410 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1,5 +1,6 @@ import { Db, + ICredentialsDb, IExecutionDb, IExecutionFlattedDb, IPushDataExecutionFinished, @@ -13,11 +14,13 @@ import { } from './'; import { + Credentials, UserSettings, WorkflowExecute, } from 'n8n-core'; import { + ICredentialDataDecryptedObject, IDataObject, IExecuteData, IExecuteWorkflowInfo, @@ -369,6 +372,40 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi } +/** + * Updates credentials with new data + * + * @export + * @param {string} name Name of the credentials to update + * @param {string} type Type of the credentials to update + * @param {ICredentialDataDecryptedObject} data The new credential data + * @param {string} encryptionKey The encryption key to use + * @returns {Promise} + */ +export async function updateCredentials(name: string, type: string, data: ICredentialDataDecryptedObject, encryptionKey: string): Promise { + const foundCredentials = await Db.collections.Credentials!.find({ name, type }); + + if (!foundCredentials.length) { + throw new Error(`Could not find credentials for type "${type}" with name "${name}".`); + } + + const credentialsDb = foundCredentials[0]; + + // Encrypt the data + const credentials = new Credentials(credentialsDb.name, credentialsDb.type, credentialsDb.nodesAccess); + credentials.setData(data, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialsData.updatedAt = this.getCurrentDate(); + + // TODO: also add user automatically depending on who is logged in, if anybody is logged in + + // Save the credentials in DB + await Db.collections.Credentials!.save(newCredentialsData); +} + + /** * Returns the base additional data without webhooks * @@ -395,6 +432,7 @@ export async function getBase(credentials: IWorkflowCredentials, currentNodePara executeWorkflow, restApiUrl: urlBaseWebhook + config.get('endpoints.rest') as string, timezone, + updateCredentials, webhookBaseUrl, webhookTestBaseUrl, currentNodeParameters, diff --git a/packages/core/package.json b/packages/core/package.json index 2e20ad20e9..97673d21ca 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,7 @@ "typescript": "~3.7.4" }, "dependencies": { + "client-oauth2": "^4.2.5", "cron": "^1.7.2", "crypto-js": "^3.1.9-1", "lodash.get": "^4.4.2", diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index 3b9a501969..8e0666a0b1 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -1,4 +1,5 @@ import { + IAllExecuteFunctions, IBinaryData, ICredentialType, IDataObject, @@ -18,6 +19,7 @@ import { } from 'n8n-workflow'; +import { OptionsWithUri } from 'request'; import * as requestPromise from 'request-promise-native'; interface Constructable { @@ -35,6 +37,7 @@ export interface IExecuteFunctions extends IExecuteFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } @@ -44,6 +47,7 @@ export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any }; } @@ -52,15 +56,22 @@ export interface IPollFunctions extends IPollFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } +export interface IResponseError extends Error { + statusCode?: number; +} + + export interface ITriggerFunctions extends ITriggerFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } @@ -84,6 +95,7 @@ export interface IUserSettings { export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase { helpers: { request?: requestPromise.RequestPromiseAPI, + requestOAuth?: (this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions) => Promise, // tslint:disable-line:no-any }; } @@ -91,6 +103,7 @@ export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase { export interface IHookFunctions extends IHookFunctionsBase { helpers: { request: requestPromise.RequestPromiseAPI, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any }; } @@ -99,6 +112,7 @@ export interface IWebhookFunctions extends IWebhookFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 2f90d0118f..cfdcdc8061 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -2,11 +2,13 @@ import { Credentials, IHookFunctions, ILoadOptionsFunctions, + IResponseError, IWorkflowSettings, BINARY_ENCODING, } from './'; import { + IAllExecuteFunctions, IBinaryData, IContextObject, ICredentialDataDecryptedObject, @@ -34,9 +36,11 @@ import { WorkflowExecuteMode, } from 'n8n-workflow'; -import { get } from 'lodash'; +import * as clientOAuth2 from 'client-oauth2'; +import { get, unset } from 'lodash'; import * as express from "express"; import * as path from 'path'; +import { OptionsWithUri } from 'request'; import * as requestPromise from 'request-promise-native'; import { Magic, MAGIC_MIME_TYPE } from 'mmmagic'; @@ -101,6 +105,70 @@ export async function prepareBinaryData(binaryData: Buffer, filePath?: string, m +/** + * Makes a request using OAuth data for authentication + * + * @export + * @param {IAllExecuteFunctions} this + * @param {string} credentialsType + * @param {(OptionsWithUri | requestPromise.RequestPromiseOptions)} requestOptions + * @param {INode} node + * @param {IWorkflowExecuteAdditionalData} additionalData + * @returns + */ +export function requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, node: INode, additionalData: IWorkflowExecuteAdditionalData) { + const credentials = this.getCredentials(credentialsType) as ICredentialDataDecryptedObject; + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (credentials.oauthTokenData === undefined) { + throw new Error('OAuth credentials not connected!'); + } + + const oAuthClient = new clientOAuth2({}); + const oauthTokenData = JSON.parse(credentials.oauthTokenData as string); + const token = oAuthClient.createToken(oauthTokenData); + + // Signs the request by adding authorization headers or query parameters depending + // on the token-type used. + const newRequestOptions = token.sign(requestOptions as clientOAuth2.RequestObject); + + return this.helpers.request!(newRequestOptions) + .catch(async (error: IResponseError) => { + // TODO: Check if also other codes are possible + if (error.statusCode === 401) { + // TODO: Whole refresh process is not tested yet + // Token is probably not valid anymore. So try refresh it. + const newToken = await token.refresh(); + + const newCredentialsData = newToken.data; + unset(newCredentialsData, 'csrfSecret'); + unset(newCredentialsData, 'oauthTokenData'); + + // Find the name of the credentials + if (!node.credentials || !node.credentials[credentialsType]) { + throw new Error(`The node "${node.name}" does not have credentials of type "${credentialsType}"!`); + } + const name = node.credentials[credentialsType]; + + // Save the refreshed token + await additionalData.updateCredentials(name, credentialsType, newCredentialsData, additionalData.encryptionKey); + + // Make the request again with the new token + const newRequestOptions = newToken.sign(requestOptions as clientOAuth2.RequestObject); + + return this.helpers.request!(newRequestOptions); + } + + // Unknown error so simply throw it + throw error; + }); +} + + + /** * Takes generic input data and brings it into the json format n8n uses. * @@ -355,6 +423,9 @@ export function getExecutePollFunctions(workflow: Workflow, node: INode, additio helpers: { prepareBinaryData, request: requestPromise, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise { + return requestOAuth.call(this, credentialsType, requestOptions, node, additionalData); + }, returnJsonArray, }, }; @@ -406,6 +477,9 @@ export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, addi helpers: { prepareBinaryData, request: requestPromise, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise { + return requestOAuth.call(this, credentialsType, requestOptions, node, additionalData); + }, returnJsonArray, }, }; @@ -484,6 +558,9 @@ export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunEx helpers: { prepareBinaryData, request: requestPromise, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise { + return requestOAuth.call(this, credentialsType, requestOptions, node, additionalData); + }, returnJsonArray, }, }; @@ -563,6 +640,9 @@ export function getExecuteSingleFunctions(workflow: Workflow, runExecutionData: helpers: { prepareBinaryData, request: requestPromise, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise { + return requestOAuth.call(this, credentialsType, requestOptions, node, additionalData); + }, }, }; })(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex); @@ -610,6 +690,9 @@ export function getLoadOptionsFunctions(workflow: Workflow, node: INode, additio }, helpers: { request: requestPromise, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise { + return requestOAuth.call(this, credentialsType, requestOptions, node, additionalData); + }, }, }; return that; @@ -665,6 +748,9 @@ export function getExecuteHookFunctions(workflow: Workflow, node: INode, additio }, helpers: { request: requestPromise, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise { + return requestOAuth.call(this, credentialsType, requestOptions, node, additionalData); + }, }, }; return that; @@ -747,6 +833,9 @@ export function getExecuteWebhookFunctions(workflow: Workflow, node: INode, addi helpers: { prepareBinaryData, request: requestPromise, + requestOAuth(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise { + return requestOAuth.call(this, credentialsType, requestOptions, node, additionalData); + }, returnJsonArray, }, }; diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index a0a68f4a42..8a92f35718 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -1,6 +1,7 @@ import { set } from 'lodash'; import { + ICredentialDataDecryptedObject, IExecuteWorkflowInfo, INodeExecutionData, INodeParameters, @@ -280,6 +281,7 @@ export function WorkflowExecuteAdditionalData(waitPromise: IDeferredPromise => {}, webhookBaseUrl: 'webhook', webhookTestBaseUrl: 'webhook-test', }; diff --git a/packages/editor-ui/src/components/CredentialsEdit.vue b/packages/editor-ui/src/components/CredentialsEdit.vue index 7be7a1326a..735c1adfc2 100644 --- a/packages/editor-ui/src/components/CredentialsEdit.vue +++ b/packages/editor-ui/src/components/CredentialsEdit.vue @@ -169,13 +169,21 @@ export default mixins( }, methods: { getCredentialTypeData (name: string): ICredentialType | null { - for (const credentialData of this.credentialTypes) { - if (credentialData.name === name) { - return credentialData; - } + let credentialData = this.$store.getters.credentialType(name); + + if (credentialData === null || credentialData.extends === undefined) { + return credentialData; } - return null; + // Credentials extends another one. So get the properties of the one it + // extends and add them. + credentialData = JSON.parse(JSON.stringify(credentialData)); + for (const credentialTypeName of credentialData.extends) { + const data = this.$store.getters.credentialType(credentialTypeName); + credentialData.properties.push.apply(credentialData.properties, data.properties); + } + + return credentialData; }, credentialsCreated (data: ICredentialsDecryptedResponse): void { this.$emit('credentialsCreated', data); diff --git a/packages/nodes-base/credentials/GithubOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GithubOAuth2Api.credentials.ts new file mode 100644 index 0000000000..74df45d8a6 --- /dev/null +++ b/packages/nodes-base/credentials/GithubOAuth2Api.credentials.ts @@ -0,0 +1,15 @@ +import { + ICredentialType, +} from 'n8n-workflow'; + + +export class GithubOAuth2Api implements ICredentialType { + name = 'githubOAuth2Api'; + // name = 'oAuth2Api/githubOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Github OAuth2 API'; + properties = [ + ]; +} diff --git a/packages/nodes-base/nodes/HttpRequest.node.ts b/packages/nodes-base/nodes/HttpRequest.node.ts index 32da2e4019..c5d9b89d9a 100644 --- a/packages/nodes-base/nodes/HttpRequest.node.ts +++ b/packages/nodes-base/nodes/HttpRequest.node.ts @@ -67,6 +67,17 @@ export class HttpRequest implements INodeType { }, }, }, + { + name: 'oAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ { @@ -86,6 +97,10 @@ export class HttpRequest implements INodeType { name: 'Header Auth', value: 'headerAuth' }, + { + name: 'OAuth2', + value: 'oAuth2' + }, { name: 'None', value: 'none' @@ -504,6 +519,7 @@ export class HttpRequest implements INodeType { const httpBasicAuth = this.getCredentials('httpBasicAuth'); const httpDigestAuth = this.getCredentials('httpDigestAuth'); const httpHeaderAuth = this.getCredentials('httpHeaderAuth'); + const oAuth2Api = this.getCredentials('oAuth2Api'); let requestOptions: OptionsWithUri; let setUiParameter: IDataObject; @@ -658,7 +674,13 @@ export class HttpRequest implements INodeType { } // Now that the options are all set make the actual http request - const response = await this.helpers.request(requestOptions); + let response; + + if (oAuth2Api !== undefined) { + response = await this.helpers.requestOAuth.call(this, 'oAuth2Api', requestOptions); + } else { + response = await this.helpers.request(requestOptions); + } if (responseFormat === 'file') { const dataPropertyName = this.getNodeParameter('dataPropertyName', 0) as string; diff --git a/packages/nodes-base/nodes/OAuth.node.ts b/packages/nodes-base/nodes/OAuth.node.ts index bf2f193a2f..4f5ff5debb 100644 --- a/packages/nodes-base/nodes/OAuth.node.ts +++ b/packages/nodes-base/nodes/OAuth.node.ts @@ -1,3 +1,5 @@ +import { OptionsWithUri } from 'request'; + import { IExecuteFunctions } from 'n8n-core'; import { INodeExecutionData, @@ -36,11 +38,71 @@ export class OAuth implements INodeType { value: 'get', description: 'Returns the OAuth token data.', }, + { + name: 'Request', + value: 'request', + description: 'Make an OAuth signed requ.', + }, ], default: 'get', description: 'The operation to perform.', }, - + { + displayName: 'Request Method', + name: 'requestMethod', + type: 'options', + displayOptions: { + show: { + operation: [ + 'request', + ], + }, + }, + options: [ + { + name: 'DELETE', + value: 'DELETE' + }, + { + name: 'GET', + value: 'GET' + }, + { + name: 'HEAD', + value: 'HEAD' + }, + { + name: 'PATCH', + value: 'PATCH' + }, + { + name: 'POST', + value: 'POST' + }, + { + name: 'PUT', + value: 'PUT' + }, + ], + default: 'GET', + description: 'The request method to use.', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + displayOptions: { + show: { + operation: [ + 'request', + ], + }, + }, + default: '', + placeholder: 'http://example.com/index.html', + description: 'The URL to make the request to.', + required: true, + }, ] }; @@ -62,6 +124,22 @@ export class OAuth implements INodeType { // without knowledge of the node. return [this.helpers.returnJsonArray(JSON.parse(credentials.oauthTokenData as string))]; + } else if (operation === 'request') { + const url = this.getNodeParameter('url', 0) as string; + const requestMethod = this.getNodeParameter('requestMethod', 0) as string; + + // Authorization Code Grant + const requestOptions: OptionsWithUri = { + headers: { + 'User-Agent': 'some-user', + }, + method: requestMethod, + uri: url, + json: true, + }; + + const responseData = await this.helpers.requestOAuth.call(this, 'oAuth2Api', requestOptions); + return [this.helpers.returnJsonArray(responseData)]; } else { throw new Error('Unknown operation'); } diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index fa50146007..0ae62f9f07 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -40,6 +40,7 @@ "dist/credentials/FileMaker.credentials.js", "dist/credentials/FlowApi.credentials.js", "dist/credentials/GithubApi.credentials.js", + "dist/credentials/GithubOAuth2Api.credentials.js", "dist/credentials/GitlabApi.credentials.js", "dist/credentials/GoogleApi.credentials.js", "dist/credentials/HttpBasicAuth.credentials.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 8fd424ba19..0a69f2e53a 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2,6 +2,8 @@ import { Workflow } from './Workflow'; import { WorkflowHooks } from './WorkflowHooks'; import * as express from 'express'; +export type IAllExecuteFunctions = IExecuteFunctions | IExecuteSingleFunctions | IHookFunctions | ILoadOptionsFunctions | IPollFunctions | ITriggerFunctions | IWebhookFunctions; + export interface IBinaryData { [key: string]: string | undefined; data: string; @@ -58,6 +60,7 @@ export interface ICredentialsEncrypted { export interface ICredentialType { name: string; displayName: string; + extends?: string[]; properties: INodeProperties[]; } @@ -78,7 +81,7 @@ export interface ICredentialData { } // The encrypted credentials which the nodes can access -export type CredentialInformation = string | number | boolean; +export type CredentialInformation = string | number | boolean | IDataObject; // The encrypted credentials which the nodes can access @@ -649,6 +652,7 @@ export interface IWorkflowExecuteAdditionalData { httpRequest?: express.Request; restApiUrl: string; timezone: string; + updateCredentials: (name: string, type: string, data: ICredentialDataDecryptedObject, encryptionKey: string) => Promise; webhookBaseUrl: string; webhookTestBaseUrl: string; currentNodeParameters? : INodeParameters[];