From 55fb52fec87dd129ac6a66cf949eaf260591b256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 15:59:07 +0200 Subject: [PATCH 1/8] :sparkles: Create controller --- packages/cli/src/api/oauth2Credential.api.ts | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/cli/src/api/oauth2Credential.api.ts diff --git a/packages/cli/src/api/oauth2Credential.api.ts b/packages/cli/src/api/oauth2Credential.api.ts new file mode 100644 index 0000000000..66dbe9f021 --- /dev/null +++ b/packages/cli/src/api/oauth2Credential.api.ts @@ -0,0 +1,61 @@ +/* eslint-disable import/no-cycle */ +import express from 'express'; +import { UserSettings } from 'n8n-core'; +import { LoggerProxy } from 'n8n-workflow'; +import { ResponseHelper } from '..'; +import { RESPONSE_ERROR_MESSAGES } from '../constants'; +import { CredentialsHelper } from '../CredentialsHelper'; +import { getLogger } from '../Logger'; +import { OAuthRequest } from '../requests'; + +export const oauth2CredentialController = express.Router(); + +/** + * Initialize Logger if needed + */ +oauth2CredentialController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +/** + * GET /oauth2-credential/scopes + */ +oauth2CredentialController.get( + '/scopes', + ResponseHelper.send(async (req: OAuthRequest.OAuth2Credential.Scopes): Promise => { + const { credentialType: type } = req.query; + + if (!type) { + LoggerProxy.debug( + 'Request for OAuth2 credential scopes failed because of missing credential type in query string', + ); + + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL_TYPE, + undefined, + 400, + ); + } + + if (!type.endsWith('OAuth2Api')) { + LoggerProxy.debug( + 'Request for OAuth2 credential scopes failed because requested credential type is not OAuth2', + ); + + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.CREDENTIAL_TYPE_NOT_OAUTH2, + undefined, + 400, + ); + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + + return new CredentialsHelper(encryptionKey).getScopes(type); + }), +); From 864486067754ad0e2ce96df6eec26d70eeb978d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 15:59:20 +0200 Subject: [PATCH 2/8] :zap: Mount controller --- packages/cli/src/Server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 81e6a87138..3e50aa1ec2 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -168,6 +168,7 @@ import { ExecutionEntity } from './databases/entities/ExecutionEntity'; import { SharedWorkflow } from './databases/entities/SharedWorkflow'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants'; import { credentialsController } from './api/credentials.api'; +import { oauth2CredentialController } from './api/oauth2Credential.api'; import { getInstanceBaseUrl, isEmailSetUp } from './UserManagement/UserManagementHelper'; require('body-parser-xml')(bodyParser); @@ -1932,6 +1933,8 @@ class App { // OAuth2-Credential/Auth // ---------------------------------------- + this.app.use(`/${this.restEndpoint}/oauth2-credential`, oauth2CredentialController); + // Authorize OAuth Data this.app.get( `/${this.restEndpoint}/oauth2-credential/auth`, From 94631f6fc232364e690002b4fe3fff2c6215fe73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 15:59:32 +0200 Subject: [PATCH 3/8] :pencil2: Add error messages --- packages/cli/src/constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 59062c4644..cae556dc96 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -7,6 +7,8 @@ import { RESPONSE_ERROR_MESSAGES as CORE_RESPONSE_ERROR_MESSAGES } from 'n8n-cor export const RESPONSE_ERROR_MESSAGES = { NO_CREDENTIAL: 'Credential not found', NO_ENCRYPTION_KEY: CORE_RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + NO_CREDENTIAL_TYPE: 'Missing query string param: `credentialType`', + CREDENTIAL_TYPE_NOT_OAUTH2: 'Credential type is not OAuth2 - no scopes can be provided', }; export const AUTH_COOKIE_NAME = 'n8n-auth'; From d70a5da262a8e433d2800d75c297f5212eae2561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 15:59:49 +0200 Subject: [PATCH 4/8] :sparkles: Create scopes fetcher --- packages/cli/src/CredentialsHelper.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 6477504b87..9b02a61871 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -285,6 +285,33 @@ export class CredentialsHelper extends ICredentialsHelper { return combineProperties; } + /** + * Returns the scope of a credential type + * + * @param {string} type + * @returns {string[]} + * @memberof CredentialsHelper + */ + getScopes(type: string): string[] { + const scopeProperty = this.getCredentialsProperties(type).find(({ name }) => name === 'scope'); + + if (!scopeProperty?.default || typeof scopeProperty.default !== 'string') { + const errorMessage = `No \`scope\` property found for credential type: ${type}`; + + Logger.error(errorMessage); + + throw new Error(errorMessage); + } + + const { default: scopeDefault } = scopeProperty; + + if (/ /.test(scopeDefault)) return scopeDefault.split(' '); + + if (/,/.test(scopeDefault)) return scopeDefault.split(','); + + return [scopeDefault]; + } + /** * Returns the decrypted credential data with applied overwrites * From 1af98bbe9d4c21992c8b5cad0f1d893e1b83bb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 16:00:08 +0200 Subject: [PATCH 5/8] :zap: Account for non-existent credential type --- packages/cli/src/CredentialTypes.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/CredentialTypes.ts b/packages/cli/src/CredentialTypes.ts index 7f48050038..8234d0bc55 100644 --- a/packages/cli/src/CredentialTypes.ts +++ b/packages/cli/src/CredentialTypes.ts @@ -16,7 +16,11 @@ class CredentialTypesClass implements ICredentialTypesInterface { } getByName(credentialType: string): ICredentialType { - return this.credentialTypes[credentialType].type; + try { + return this.credentialTypes[credentialType].type; + } catch (error) { + throw new Error(`Failed to find credential type: ${credentialType}`); + } } } From 2c3d03b5c8e71d7d423afbbcd4230c6517f15888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 16:00:42 +0200 Subject: [PATCH 6/8] :blue_book: Type scopes request --- packages/cli/src/requests.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 3ab1d7a301..f15c9d570a 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -243,6 +243,7 @@ export declare namespace OAuthRequest { namespace OAuth2Credential { type Auth = OAuth1Credential.Auth; type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>; + type Scopes = AuthenticatedRequest<{}, {}, {}, { credentialType: string }>; } } From 0e439c18a069c8b2b8eeb3de914ed5c60c1e7d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 17:54:35 +0200 Subject: [PATCH 7/8] :zap: Adjust error message --- packages/cli/src/CredentialTypes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/CredentialTypes.ts b/packages/cli/src/CredentialTypes.ts index 8234d0bc55..eceeb35fed 100644 --- a/packages/cli/src/CredentialTypes.ts +++ b/packages/cli/src/CredentialTypes.ts @@ -3,6 +3,7 @@ import { ICredentialTypeData, ICredentialTypes as ICredentialTypesInterface, } from 'n8n-workflow'; +import { RESPONSE_ERROR_MESSAGES } from './constants'; class CredentialTypesClass implements ICredentialTypesInterface { credentialTypes: ICredentialTypeData = {}; @@ -19,7 +20,7 @@ class CredentialTypesClass implements ICredentialTypesInterface { try { return this.credentialTypes[credentialType].type; } catch (error) { - throw new Error(`Failed to find credential type: ${credentialType}`); + throw new Error(`${RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL}: ${credentialType}`); } } } From fdcf0a7298da27eee34ae304cc9524fad1356a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Apr 2022 17:55:17 +0200 Subject: [PATCH 8/8] :test_tube: Add tests --- packages/cli/src/LoadNodesAndCredentials.ts | 1 + .../cli/test/integration/oauth2.api.test.ts | 125 ++++++++++++++++++ .../cli/test/integration/shared/types.d.ts | 2 +- packages/cli/test/integration/shared/utils.ts | 7 +- 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/integration/oauth2.api.test.ts diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index c1178c9058..10d85f8834 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -58,6 +58,7 @@ class LoadNodesAndCredentialsClass { // In case "n8n" package is the root and the packages are // in the "node_modules" folder underneath it. path.join(__dirname, '..', '..', 'node_modules', 'n8n-workflow'), + path.join(__dirname, '..', 'node_modules', 'n8n-workflow'), // for test run ]; for (const checkPath of checkPaths) { try { diff --git a/packages/cli/test/integration/oauth2.api.test.ts b/packages/cli/test/integration/oauth2.api.test.ts new file mode 100644 index 0000000000..2cf6eb9d01 --- /dev/null +++ b/packages/cli/test/integration/oauth2.api.test.ts @@ -0,0 +1,125 @@ +import express from 'express'; + +import * as utils from './shared/utils'; +import * as testDb from './shared/testDb'; +import { RESPONSE_ERROR_MESSAGES } from '../../src/constants'; + +import type { Role } from '../../src/databases/entities/Role'; +import { CredentialTypes, LoadNodesAndCredentials } from '../../src'; + +const SCOPES_ENDPOINT = '/oauth2-credential/scopes'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['oauth2-credential'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initConfigFile(); + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + utils.initTestLogger(); + + const loadNodesAndCredentials = LoadNodesAndCredentials(); + await loadNodesAndCredentials.init(); + + const credentialTypes = CredentialTypes(); + await credentialTypes.init(loadNodesAndCredentials.credentialTypes); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +describe('OAuth2 scopes', () => { + beforeEach(async () => { + await testDb.truncate(['User'], testDbName); + }); + + test(`GET ${SCOPES_ENDPOINT} should return scopes - comma-delimited`, async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent + .get(SCOPES_ENDPOINT) + .query({ credentialType: 'twistOAuth2Api' }); + + expect(response.statusCode).toBe(200); + + const scopes = response.body.data; + const TWIST_OAUTH2_API_SCOPES_TOTAL = 6; + + expect(Array.isArray(scopes)).toBe(true); + expect(scopes.length).toBe(TWIST_OAUTH2_API_SCOPES_TOTAL); + }); + + test(`GET ${SCOPES_ENDPOINT} should return scopes - whitespace-delimited`, async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent + .get(SCOPES_ENDPOINT) + .query({ credentialType: 'dropboxOAuth2Api' }); + + expect(response.statusCode).toBe(200); + + const scopes = response.body.data; + const DROPBOX_OAUTH2_API_SCOPES_TOTAL = 4; + + expect(Array.isArray(scopes)).toBe(true); + expect(scopes.length).toBe(DROPBOX_OAUTH2_API_SCOPES_TOTAL); + }); + + test(`GET ${SCOPES_ENDPOINT} should return scope - non-delimited`, async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent + .get(SCOPES_ENDPOINT) + .query({ credentialType: 'harvestOAuth2Api' }); + + expect(response.statusCode).toBe(200); + + const scopes = response.body.data; + + expect(Array.isArray(scopes)).toBe(true); + expect(scopes.length).toBe(1); + }); + + test(`GET ${SCOPES_ENDPOINT} should fail with missing credential type`, async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent.get(SCOPES_ENDPOINT); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL_TYPE); + }); + + test(`GET ${SCOPES_ENDPOINT} should fail with non-OAuth2 credential type`, async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent + .get(SCOPES_ENDPOINT) + .query({ credentialType: 'disqusApi' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe(RESPONSE_ERROR_MESSAGES.CREDENTIAL_TYPE_NOT_OAUTH2); + }); + + test(`GET ${SCOPES_ENDPOINT} should fail with wrong OAuth2 credential type`, async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent + .get(SCOPES_ENDPOINT) + .query({ credentialType: 'wrongOAuth2Api' }); + + expect(response.statusCode).toBe(500); + expect(response.body.message).toBe(`${RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL}: wrongOAuth2Api`); + }); +}); diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index bca1c86fbc..732d925d84 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -15,7 +15,7 @@ export type SmtpTestAccount = { }; }; -type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials'; +type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials' | 'oauth2-credential'; export type CredentialPayload = { name: string; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 4127eb98db..369f7f1dfa 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -26,6 +26,7 @@ import { credentialsController } from '../../../src/api/credentials.api'; import type { User } from '../../../src/databases/entities/User'; import type { EndpointGroup, SmtpTestAccount } from './types'; import type { N8nApp } from '../../../src/UserManagement/Interfaces'; +import { oauth2CredentialController } from '../../../src/api/oauth2Credential.api'; /** * Initialize a test server. @@ -63,6 +64,7 @@ export function initTestServer({ if (routerEndpoints.length) { const map: Record = { credentials: credentialsController, + 'oauth2-credential': oauth2CredentialController, }; for (const group of routerEndpoints) { @@ -105,7 +107,10 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const functionEndpoints: string[] = []; endpointGroups.forEach((group) => - (group === 'credentials' ? routerEndpoints : functionEndpoints).push(group), + (['credentials', 'oauth2-credential'].includes(group) + ? routerEndpoints + : functionEndpoints + ).push(group), ); return [routerEndpoints, functionEndpoints];