diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 95d1c70e38ae7..d9532c334d108 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -37,6 +37,7 @@ import { WorkflowExecuteMode, ITaskDataConnections, LoggerProxy as Logger, + IHttpRequestHelper, } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle @@ -140,6 +141,61 @@ export class CredentialsHelper extends ICredentialsHelper { return requestOptions as IHttpRequestOptions; } + async preAuthentication( + helpers: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + typeName: string, + node: INode, + credentialsExpired: boolean, + ): Promise { + const credentialType = this.credentialTypes.getByName(typeName); + + const expirableProperty = credentialType.properties.find( + (property) => property.type === 'hidden' && property?.typeOptions?.expirable === true, + ); + + if (expirableProperty === undefined || expirableProperty.name === undefined) { + return undefined; + } + + // check if the node is the mockup node used for testing + // if so, it means this is a credential test and not normal node execution + const isTestingCredentials = + node?.parameters?.temp === '' && node?.type === 'n8n-nodes-base.noOp'; + + if (credentialType.preAuthentication) { + if (typeof credentialType.preAuthentication === 'function') { + // if the expirable property is empty in the credentials + // or are expired, call pre authentication method + // or the credentials are being tested + if ( + credentials[expirableProperty?.name] === '' || + credentialsExpired || + isTestingCredentials + ) { + const output = await credentialType.preAuthentication.call(helpers, credentials); + + // if there is data in the output, make sure the returned + // property is the expirable property + // else the database will not get updated + if (output[expirableProperty.name] === undefined) { + return undefined; + } + + if (node.credentials) { + await this.updateCredentials( + node.credentials[credentialType.name], + credentialType.name, + Object.assign(credentials, output), + ); + return Object.assign(credentials, output); + } + } + } + } + return undefined; + } + /** * Resolves the given value in case it is an expression */ @@ -538,6 +594,12 @@ export class CredentialsHelper extends ICredentialsHelper { ? nodeType.description.version.slice(-1)[0] : nodeType.description.version, position: [0, 0], + credentials: { + [credentialType]: { + id: credentialsDecrypted.id.toString(), + name: credentialsDecrypted.name, + }, + }, }; const workflowData = { @@ -622,7 +684,7 @@ export class CredentialsHelper extends ICredentialsHelper { } catch (error) { // Do not fail any requests to allow custom error messages and // make logic easier - if (error.cause.response) { + if (error.cause?.response) { const errorResponseData = { statusCode: error.cause.response.status, statusMessage: error.cause.response.statusText, diff --git a/packages/cli/src/api/credentials.api.ts b/packages/cli/src/api/credentials.api.ts index 67d235957c1f5..24cba004169f9 100644 --- a/packages/cli/src/api/credentials.api.ts +++ b/packages/cli/src/api/credentials.api.ts @@ -6,7 +6,12 @@ import express from 'express'; import { In } from 'typeorm'; import { UserSettings, Credentials } from 'n8n-core'; -import { INodeCredentialTestResult, LoggerProxy } from 'n8n-workflow'; +import { + INodeCredentialsDetails, + INodeCredentialTestResult, + LoggerProxy, + WorkflowExecuteMode, +} from 'n8n-workflow'; import { getLogger } from '../Logger'; import { @@ -17,6 +22,7 @@ import { ICredentialsResponse, whereClause, ResponseHelper, + CredentialTypes, } from '..'; import { RESPONSE_ERROR_MESSAGES } from '../constants'; @@ -130,7 +136,6 @@ credentialsController.post( } const helper = new CredentialsHelper(encryptionKey); - return helper.testCredentials(req.user, credentials.type, credentials, nodeToTestWith); }), ); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index bba6e421693b5..87b0beb623b7a 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1182,6 +1182,7 @@ export async function httpRequestWithAuthentication( additionalData: IWorkflowExecuteAdditionalData, additionalCredentialOptions?: IAdditionalCredentialOptions, ) { + let credentialsDecrypted: ICredentialDataDecryptedObject | undefined; try { const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType); if (parentTypes.includes('oAuth1Api')) { @@ -1199,7 +1200,6 @@ export async function httpRequestWithAuthentication( ); } - let credentialsDecrypted: ICredentialDataDecryptedObject | undefined; if (additionalCredentialOptions?.credentialsDecrypted) { credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data; } else { @@ -1213,6 +1213,20 @@ export async function httpRequestWithAuthentication( ); } + const data = await additionalData.credentialsHelper.preAuthentication( + { helpers: { httpRequest: this.helpers.httpRequest } }, + credentialsDecrypted, + credentialsType, + node, + false, + ); + + if (data) { + // make the updated property in the credentials + // available to the authenticate method + Object.assign(credentialsDecrypted, data); + } + requestOptions = await additionalData.credentialsHelper.authenticate( credentialsDecrypted, credentialsType, @@ -1223,6 +1237,45 @@ export async function httpRequestWithAuthentication( ); return await httpRequest(requestOptions); } catch (error) { + // if there is a pre authorization method defined and + // the method failed due to unathorized request + if ( + error.response?.status === 401 && + additionalData.credentialsHelper.preAuthentication !== undefined + ) { + try { + if (credentialsDecrypted !== undefined) { + // try to refresh the credentials + const data = await additionalData.credentialsHelper.preAuthentication( + { helpers: { httpRequest: this.helpers.httpRequest } }, + credentialsDecrypted, + credentialsType, + node, + true, + ); + + if (data) { + // make the updated property in the credentials + // available to the authenticate method + Object.assign(credentialsDecrypted, data); + } + + requestOptions = await additionalData.credentialsHelper.authenticate( + credentialsDecrypted, + credentialsType, + requestOptions, + workflow, + node, + additionalData.timezone, + ); + } + // retry the request + return await httpRequest(requestOptions); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } + } + throw new NodeApiError(this.getNode(), error); } } @@ -1303,6 +1356,8 @@ export async function requestWithAuthentication( additionalData: IWorkflowExecuteAdditionalData, additionalCredentialOptions?: IAdditionalCredentialOptions, ) { + let credentialsDecrypted: ICredentialDataDecryptedObject | undefined; + try { const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType); @@ -1321,7 +1376,6 @@ export async function requestWithAuthentication( ); } - let credentialsDecrypted: ICredentialDataDecryptedObject | undefined; if (additionalCredentialOptions?.credentialsDecrypted) { credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data; } else { @@ -1335,6 +1389,20 @@ export async function requestWithAuthentication( ); } + const data = await additionalData.credentialsHelper.preAuthentication( + { helpers: { httpRequest: this.helpers.httpRequest } }, + credentialsDecrypted, + credentialsType, + node, + false, + ); + + if (data) { + // make the updated property in the credentials + // available to the authenticate method + Object.assign(credentialsDecrypted, data); + } + requestOptions = await additionalData.credentialsHelper.authenticate( credentialsDecrypted, credentialsType, @@ -1346,7 +1414,37 @@ export async function requestWithAuthentication( return await proxyRequestToAxios(requestOptions as IDataObject); } catch (error) { - throw new NodeApiError(this.getNode(), error); + try { + if (credentialsDecrypted !== undefined) { + // try to refresh the credentials + const data = await additionalData.credentialsHelper.preAuthentication( + { helpers: { httpRequest: this.helpers.httpRequest } }, + credentialsDecrypted, + credentialsType, + node, + true, + ); + + if (data) { + // make the updated property in the credentials + // available to the authenticate method + Object.assign(credentialsDecrypted, data); + } + + requestOptions = await additionalData.credentialsHelper.authenticate( + credentialsDecrypted, + credentialsType, + requestOptions as IHttpRequestOptions, + workflow, + node, + additionalData.timezone, + ); + } + // retry the request + return await proxyRequestToAxios(requestOptions as IDataObject); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } } } diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index d719dd51eb701..5acbcc6eda12a 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -6,7 +6,9 @@ import { IDataObject, IDeferredPromise, IExecuteWorkflowInfo, + IHttpRequestHelper, IHttpRequestOptions, + INode, INodeCredentialsDetails, INodeExecutionData, INodeParameters, @@ -33,6 +35,17 @@ export class CredentialsHelper extends ICredentialsHelper { return requestParams; } + async preAuthentication( + helpers: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + typeName: string, + node: INode, + credentialsExpired: boolean, + ): Promise { + return undefined; + }; + + getParentTypes(name: string): string[] { return []; } diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index c32d22ead49f3..619faf44319f1 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -639,10 +639,11 @@ export default mixins(showMessage, nodeHelpers).extend({ if (this.isCredentialTestable) { this.isTesting = true; - // Add the full data including defaults for testing credentialDetails.data = this.credentialData; + credentialDetails.id = this.credentialId; + await this.testCredential(credentialDetails); this.isTesting = false; } diff --git a/packages/nodes-base/credentials/MetabaseApi.credentials.ts b/packages/nodes-base/credentials/MetabaseApi.credentials.ts new file mode 100644 index 0000000000000..6de56d47649fa --- /dev/null +++ b/packages/nodes-base/credentials/MetabaseApi.credentials.ts @@ -0,0 +1,76 @@ +import { + IAuthenticateGeneric, + ICredentialDataDecryptedObject, + ICredentialTestRequest, + ICredentialType, + IHttpRequestHelper, + INodeProperties, +} from 'n8n-workflow'; + +export class MetabaseApi implements ICredentialType { + name = 'metabaseApi'; + displayName = 'Metabase API'; + documentationUrl = 'metabase'; + properties: INodeProperties[] = [ + { + displayName: 'Session Token', + name: 'sessionToken', + type: 'hidden', + typeOptions: { + expirable: true, + }, + default: '', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + }, + { + displayName: 'Username', + name: 'username', + type: 'string', + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + ]; + + // method will only be called if "sessionToken" (the expirable property) + // is empty or is expired + async preAuthentication(this: IHttpRequestHelper, credentials: ICredentialDataDecryptedObject) { + // make reques to get session token + const url = credentials.url as string; + const { id } = (await this.helpers.httpRequest({ + method: 'POST', + url: `${url.endsWith('/') ? url.slice(0, -1) : url}/api/session`, + body: { + username: credentials.username, + password: credentials.password, + }, + })) as { id: string }; + return { sessionToken: id }; + } + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + 'X-Metabase-Session': '={{$credentials.sessionToken}}', + }, + }, + }; + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials?.url}}', + url: '/api/user/current', + }, + }; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index dec5e55ce29cb..dcd01d953d3e9 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -183,6 +183,7 @@ "dist/credentials/MediumApi.credentials.js", "dist/credentials/MediumOAuth2Api.credentials.js", "dist/credentials/MessageBirdApi.credentials.js", + "dist/credentials/MetabaseApi.credentials.js", "dist/credentials/MicrosoftDynamicsOAuth2Api.credentials.js", "dist/credentials/MicrosoftExcelOAuth2Api.credentials.js", "dist/credentials/MicrosoftGraphSecurityOAuth2Api.credentials.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 4a87b614ba914..0e8712d5efb2b 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -166,6 +166,9 @@ export interface IRequestOptionsSimplifiedAuth { skipSslCertificateValidation?: boolean | string; } +export interface IHttpRequestHelper { + helpers: { httpRequest: IAllExecuteFunctions['helpers']['httpRequest'] }; +} export abstract class ICredentialsHelper { encryptionKey: string; @@ -184,6 +187,14 @@ export abstract class ICredentialsHelper { defaultTimezone: string, ): Promise; + abstract preAuthentication( + helpers: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + typeName: string, + node: INode, + credentialsExpired: boolean, + ): Promise; + abstract getCredentials( nodeCredentials: INodeCredentialsDetails, type: string, @@ -269,6 +280,10 @@ export interface ICredentialType { documentationUrl?: string; __overwrittenProperties?: string[]; authenticate?: IAuthenticate; + preAuthentication?: ( + this: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + ) => Promise; test?: ICredentialTestRequest; genericAuth?: boolean; } @@ -894,6 +909,7 @@ export interface INodePropertyTypeOptions { rows?: number; // Supported by: string showAlpha?: boolean; // Supported by: color sortable?: boolean; // Supported when "multipleValues" set to true + expirable?: boolean; // Supported by: hidden (only in the credentials) [key: string]: any; } diff --git a/packages/workflow/test/Helpers.ts b/packages/workflow/test/Helpers.ts index 6f84e58d2ceb6..bee0476d32766 100644 --- a/packages/workflow/test/Helpers.ts +++ b/packages/workflow/test/Helpers.ts @@ -14,6 +14,7 @@ import { IExecuteResponsePromiseData, IExecuteSingleFunctions, IExecuteWorkflowInfo, + IHttpRequestHelper, IHttpRequestOptions, IN8nHttpFullResponse, IN8nHttpResponse, @@ -111,6 +112,16 @@ export class CredentialsHelper extends ICredentialsHelper { return requestParams; } + async preAuthentication( + helpers: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + typeName: string, + node: INode, + credentialsExpired: boolean, + ): Promise<{ updatedCredentials: boolean; data: ICredentialDataDecryptedObject }> { + return { updatedCredentials: false, data: {} } + }; + getParentTypes(name: string): string[] { return []; }