Skip to content

Commit

Permalink
feat: Add support for preAuthentication and add Metabase credentials (#…
Browse files Browse the repository at this point in the history
…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>
  • Loading branch information
RicardoE105 and janober authored Jul 19, 2022
1 parent f958e6f commit 994c89a
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 7 deletions.
64 changes: 63 additions & 1 deletion packages/cli/src/CredentialsHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
WorkflowExecuteMode,
ITaskDataConnections,
LoggerProxy as Logger,
IHttpRequestHelper,
} from 'n8n-workflow';

// eslint-disable-next-line import/no-cycle
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/api/credentials.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -17,6 +22,7 @@ import {
ICredentialsResponse,
whereClause,
ResponseHelper,
CredentialTypes,
} from '..';

import { RESPONSE_ERROR_MESSAGES } from '../constants';
Expand Down Expand Up @@ -130,7 +136,6 @@ credentialsController.post(
}

const helper = new CredentialsHelper(encryptionKey);

return helper.testCredentials(req.user, credentials.type, credentials, nodeToTestWith);
}),
);
Expand Down
104 changes: 101 additions & 3 deletions packages/core/src/NodeExecuteFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand All @@ -1199,7 +1200,6 @@ export async function httpRequestWithAuthentication(
);
}

let credentialsDecrypted: ICredentialDataDecryptedObject | undefined;
if (additionalCredentialOptions?.credentialsDecrypted) {
credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data;
} else {
Expand All @@ -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,
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -1303,6 +1356,8 @@ export async function requestWithAuthentication(
additionalData: IWorkflowExecuteAdditionalData,
additionalCredentialOptions?: IAdditionalCredentialOptions,
) {
let credentialsDecrypted: ICredentialDataDecryptedObject | undefined;

try {
const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType);

Expand All @@ -1321,7 +1376,6 @@ export async function requestWithAuthentication(
);
}

let credentialsDecrypted: ICredentialDataDecryptedObject | undefined;
if (additionalCredentialOptions?.credentialsDecrypted) {
credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data;
} else {
Expand All @@ -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,
Expand All @@ -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);
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/core/test/Helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
IDataObject,
IDeferredPromise,
IExecuteWorkflowInfo,
IHttpRequestHelper,
IHttpRequestOptions,
INode,
INodeCredentialsDetails,
INodeExecutionData,
INodeParameters,
Expand All @@ -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 [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
76 changes: 76 additions & 0 deletions packages/nodes-base/credentials/MetabaseApi.credentials.ts
Original file line number Diff line number Diff line change
@@ -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',
},
};
}
Loading

0 comments on commit 994c89a

Please sign in to comment.