diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 5494b5c5c..e8f1694b9 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 efb39c666..5bdb290fb 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 96a84fa44..f3fdb87bc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.44.0", + "version": "0.45.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -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", @@ -93,8 +95,8 @@ "lodash.get": "^4.4.2", "mongodb": "^3.2.3", "n8n-core": "~0.20.0", - "n8n-editor-ui": "~0.31.0", - "n8n-nodes-base": "~0.39.0", + "n8n-editor-ui": "~0.32.0", + "n8n-nodes-base": "~0.40.0", "n8n-workflow": "~0.20.0", "open": "^7.0.0", "pg": "^7.11.0", diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 41fda8037..9eecf6706 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, @@ -654,6 +657,10 @@ class App { throw new Error('No encryption key got found to encrypt the credentials!'); } + if (incomingData.name === '') { + throw new Error('Credentials have to have a name set!'); + } + // Check if credentials with the same name and type exist already const findQuery = { where: { @@ -693,6 +700,10 @@ class App { const id = req.params.id; + if (incomingData.name === '') { + throw new Error('Credentials have to have a name set!'); + } + // Add the date for newly added node access permissions for (const nodeAccess of incomingData.nodesAccess) { if (!nodeAccess.date) { @@ -721,6 +732,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 +853,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/package.json b/packages/editor-ui/package.json index abbdab835..ea5898388 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.31.0", + "version": "0.32.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e6e34fb9d..a79ebdd8f 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/About.vue b/packages/editor-ui/src/components/About.vue new file mode 100644 index 000000000..62563a1c2 --- /dev/null +++ b/packages/editor-ui/src/components/About.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/editor-ui/src/components/CredentialsList.vue b/packages/editor-ui/src/components/CredentialsList.vue index 758adf1a4..eeece990b 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/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index cbf0664e7..71388c2cd 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -1,5 +1,6 @@ + + + @@ -168,6 +169,7 @@ import { IWorkflowDataUpdate, } from '../Interface'; +import About from '@/components/About.vue'; import CredentialsEdit from '@/components/CredentialsEdit.vue'; import CredentialsList from '@/components/CredentialsList.vue'; import ExecutionsList from '@/components/ExecutionsList.vue'; @@ -196,6 +198,7 @@ export default mixins( .extend({ name: 'MainHeader', components: { + About, CredentialsEdit, CredentialsList, ExecutionsList, @@ -204,6 +207,7 @@ export default mixins( }, data () { return { + aboutDialogVisible: false, isCollapsed: true, credentialNewDialogVisible: false, credentialOpenDialogVisible: false, @@ -251,9 +255,6 @@ export default mixins( currentWorkflow (): string { return this.$route.params.name; }, - versionCli (): string { - return this.$store.getters.versionCli; - }, workflowExecution (): IExecutionResponse | null { return this.$store.getters.getWorkflowExecution; }, @@ -269,6 +270,9 @@ export default mixins( this.$store.commit('setWorkflowExecutionData', null); this.updateNodesExecutionIssues(); }, + closeAboutDialog () { + this.aboutDialogVisible = false; + }, closeWorkflowOpenDialog () { this.workflowOpenDialogVisible = false; }, @@ -434,6 +438,8 @@ export default mixins( this.saveCurrentWorkflow(); } else if (key === 'workflow-save-as') { this.saveCurrentWorkflow(true); + } else if (key === 'help-about') { + this.aboutDialogVisible = true; } else if (key === 'workflow-settings') { this.workflowSettingsDialogVisible = true; } else if (key === 'workflow-new') { @@ -466,6 +472,9 @@ export default mixins(