feat: Add support for preAuthentication and add Metabase credentials (#3399)

*  Add preAuthentication method to credentials

* Improvements

*  Improvements

*  Add feedback

* 🔥 Remove comments

*  Add generic type to autheticate method

*  Fix typo

*  Remove console.log and fix indentation

*  Minor improvements

*  Expire credentials in every credential test run

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Ricardo Espinoza 2022-07-19 04:09:06 -04:00 committed by GitHub
parent f958e6ffab
commit 994c89a6c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 290 additions and 7 deletions

View file

@ -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<ICredentialDataDecryptedObject | undefined> {
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,

View file

@ -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);
}),
);

View file

@ -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);
}
}
}

View file

@ -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<ICredentialDataDecryptedObject | undefined> {
return undefined;
};
getParentTypes(name: string): string[] {
return [];
}

View file

@ -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;
}

View file

@ -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',
},
};
}

View file

@ -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",

View file

@ -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<IHttpRequestOptions>;
abstract preAuthentication(
helpers: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
typeName: string,
node: INode,
credentialsExpired: boolean,
): Promise<ICredentialDataDecryptedObject | undefined>;
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<IDataObject>;
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;
}

View file

@ -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 [];
}