Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⚡ Add preAuthentication method to credentials - N8N-3804 #3399

Merged
merged 12 commits into from
Jul 19, 2022
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