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 +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 96a84fa449..220cf6a24b 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 // ---------------------------------------- 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', 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: [ 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/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 b92a938354..fa50146007 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -58,6 +58,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", @@ -132,10 +133,11 @@ "dist/nodes/MoveBinaryData.node.js", "dist/nodes/MongoDb/MongoDb.node.js", "dist/nodes/MySql/MySql.node.js", - "dist/nodes/NextCloud/NextCloud.node.js", - "dist/nodes/Mandrill/Mandrill.node.js", + "dist/nodes/NextCloud/NextCloud.node.js", + "dist/nodes/Mandrill/Mandrill.node.js", "dist/nodes/NoOp.node.js", "dist/nodes/OpenWeatherMap.node.js", + "dist/nodes/OAuth.node.js", "dist/nodes/Pipedrive/Pipedrive.node.js", "dist/nodes/Pipedrive/PipedriveTrigger.node.js", "dist/nodes/Postgres/Postgres.node.js", @@ -167,9 +169,9 @@ "dist/nodes/Toggl/TogglTrigger.node.js", "dist/nodes/Vero/Vero.node.js", "dist/nodes/WriteBinaryFile.node.js", - "dist/nodes/Webhook.node.js", - "dist/nodes/Wordpress/Wordpress.node.js", - "dist/nodes/Xml.node.js" + "dist/nodes/Webhook.node.js", + "dist/nodes/Wordpress/Wordpress.node.js", + "dist/nodes/Xml.node.js" ] }, "devDependencies": {