/* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-shadow */ import type { ClientOAuth2Options, ClientOAuth2RequestObject, ClientOAuth2TokenData, OAuth2CredentialData, } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2'; import { Container } from '@n8n/di'; import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios from 'axios'; import crypto, { createHmac } from 'crypto'; import FormData from 'form-data'; import { IncomingMessage } from 'http'; import { Agent, type AgentOptions } from 'https'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import merge from 'lodash/merge'; import pick from 'lodash/pick'; import type { IAdditionalCredentialOptions, IAllExecuteFunctions, ICredentialDataDecryptedObject, IDataObject, IExecuteData, IExecuteFunctions, IHttpRequestOptions, IN8nHttpFullResponse, IN8nHttpResponse, INode, INodeExecutionData, IOAuth2Options, IPairedItemData, IPollFunctions, IRequestOptions, IRunExecutionData, ITriggerFunctions, IWebhookDescription, IWorkflowDataProxyAdditionalKeys, IWorkflowExecuteAdditionalData, NodeExecutionWithMetadata, NodeParameterValueType, PaginationOptions, RequestHelperFunctions, Workflow, WorkflowActivateMode, WorkflowExecuteMode, SSHTunnelFunctions, WebhookType, SchedulingFunctions, } from 'n8n-workflow'; import { NodeApiError, NodeHelpers, NodeOperationError, NodeSslError, deepCopy, isObjectEmpty, ExecutionBaseError, jsonParse, ApplicationError, sleep, } from 'n8n-workflow'; import type { Token } from 'oauth-1.0a'; import clientOAuth1 from 'oauth-1.0a'; import { stringify } from 'qs'; import { Readable } from 'stream'; import url, { URL, URLSearchParams } from 'url'; import { Logger } from '@/logging/logger'; // eslint-disable-next-line import/no-cycle import { binaryToString, parseIncomingMessage, parseRequestObject, PollContext, TriggerContext, } from './execution-engine/node-execution-context'; import { ScheduledTaskManager } from './execution-engine/scheduled-task-manager'; import { SSHClientsManager } from './execution-engine/ssh-clients-manager'; import type { IResponseError } from './interfaces'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default axios.defaults.headers.post = {}; axios.defaults.headers.put = {}; axios.defaults.headers.patch = {}; axios.defaults.paramsSerializer = (params) => { if (params instanceof URLSearchParams) { return params.toString(); } return stringify(params, { arrayFormat: 'indices' }); }; axios.interceptors.request.use((config) => { // If no content-type is set by us, prevent axios from force-setting the content-type to `application/x-www-form-urlencoded` if (config.data === undefined) { config.headers.setContentType(false, false); } return config; }); export const validateUrl = (url?: string): boolean => { if (!url) return false; try { new URL(url); return true; } catch (error) { return false; } }; function searchForHeader(config: AxiosRequestConfig, headerName: string) { if (config.headers === undefined) { return undefined; } const headerNames = Object.keys(config.headers); headerName = headerName.toLowerCase(); return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName); } const getHostFromRequestObject = ( requestObject: Partial<{ url: string; uri: string; baseURL: string; }>, ): string | null => { try { const url = (requestObject.url ?? requestObject.uri) as string; return new URL(url, requestObject.baseURL).hostname; } catch (error) { return null; } }; const getBeforeRedirectFn = (agentOptions: AgentOptions, axiosConfig: AxiosRequestConfig) => (redirectedRequest: Record) => { const redirectAgent = new Agent({ ...agentOptions, servername: redirectedRequest.hostname, }); redirectedRequest.agent = redirectAgent; redirectedRequest.agents.https = redirectAgent; if (axiosConfig.headers?.Authorization) { redirectedRequest.headers.Authorization = axiosConfig.headers.Authorization; } if (axiosConfig.auth) { redirectedRequest.auth = `${axiosConfig.auth.username}:${axiosConfig.auth.password}`; } }; function digestAuthAxiosConfig( axiosConfig: AxiosRequestConfig, response: AxiosResponse, auth: AxiosRequestConfig['auth'], ): AxiosRequestConfig { const authDetails = response.headers['www-authenticate'] .split(',') .map((v: string) => v.split('=')); if (authDetails) { const nonceCount = '000000001'; const cnonce = crypto.randomBytes(24).toString('hex'); const realm: string = authDetails .find((el: any) => el[0].toLowerCase().indexOf('realm') > -1)[1] .replace(/"/g, ''); // If authDetails does not have opaque, we should not add it to authorization. const opaqueKV = authDetails.find((el: any) => el[0].toLowerCase().indexOf('opaque') > -1); const opaque: string = opaqueKV ? opaqueKV[1].replace(/"/g, '') : undefined; const nonce: string = authDetails .find((el: any) => el[0].toLowerCase().indexOf('nonce') > -1)[1] .replace(/"/g, ''); const ha1 = crypto .createHash('md5') .update(`${auth?.username as string}:${realm}:${auth?.password as string}`) .digest('hex'); const urlURL = new url.URL(axios.getUri(axiosConfig)); const path = urlURL.pathname + urlURL.search; const ha2 = crypto .createHash('md5') .update(`${axiosConfig.method ?? 'GET'}:${path}`) .digest('hex'); const response = crypto .createHash('md5') .update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`) .digest('hex'); let authorization = `Digest username="${auth?.username as string}",realm="${realm}",` + `nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",` + `response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`; // Only when opaque exists, add it to authorization. if (opaque) { authorization += `,opaque="${opaque}"`; } if (axiosConfig.headers) { axiosConfig.headers.authorization = authorization; } else { axiosConfig.headers = { authorization }; } } return axiosConfig; } export async function invokeAxios( axiosConfig: AxiosRequestConfig, authOptions: IRequestOptions['auth'] = {}, ) { try { return await axios(axiosConfig); } catch (error) { if (authOptions.sendImmediately !== false || !(error instanceof axios.AxiosError)) throw error; // for digest-auth const { response } = error; if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) { throw error; } const { auth } = axiosConfig; delete axiosConfig.auth; axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth); return await axios(axiosConfig); } } /** * @deprecated This is only used by legacy request helpers, that are also deprecated */ export async function proxyRequestToAxios( workflow: Workflow | undefined, additionalData: IWorkflowExecuteAdditionalData | undefined, node: INode | undefined, uriOrObject: string | IRequestOptions, options?: IRequestOptions, ): Promise { let axiosConfig: AxiosRequestConfig = { maxBodyLength: Infinity, maxContentLength: Infinity, }; let configObject: IRequestOptions; if (typeof uriOrObject === 'string') { configObject = { uri: uriOrObject, ...options }; } else { configObject = uriOrObject ?? {}; } axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject)); try { const response = await invokeAxios(axiosConfig, configObject.auth); let body = response.data; if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') { parseIncomingMessage(body); } else if (body === '') { body = axiosConfig.responseType === 'arraybuffer' ? Buffer.alloc(0) : undefined; } await additionalData?.hooks?.runHook('nodeFetchedData', [workflow?.id, node]); return configObject.resolveWithFullResponse ? { body, headers: { ...response.headers }, statusCode: response.status, statusMessage: response.statusText, request: response.request, } : body; } catch (error) { const { config, response } = error; // Axios hydrates the original error with more data. We extract them. // https://github.com/axios/axios/blob/master/lib/core/enhanceError.js // Note: `code` is ignored as it's an expected part of the errorData. if (error.isAxiosError) { error.config = error.request = undefined; error.options = pick(config ?? {}, ['url', 'method', 'data', 'headers']); if (response) { Container.get(Logger).debug('Request proxied to Axios failed', { status: response.status }); let responseData = response.data; if (Buffer.isBuffer(responseData) || responseData instanceof Readable) { responseData = await binaryToString(responseData); } if (configObject.simple === false) { if (configObject.resolveWithFullResponse) { return { body: responseData, headers: response.headers, statusCode: response.status, statusMessage: response.statusText, }; } else { return responseData; } } error.message = `${response.status as number} - ${JSON.stringify(responseData)}`; throw Object.assign(error, { statusCode: response.status, /** * Axios adds `status` when serializing, causing `status` to be available only to the client. * Hence we add it explicitly to allow the backend to use it when resolving expressions. */ status: response.status, error: responseData, response: pick(response, ['headers', 'status', 'statusText']), }); } else if ('rejectUnauthorized' in configObject && error.code?.includes('CERT')) { throw new NodeSslError(error); } } throw error; } } // eslint-disable-next-line complexity function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequestConfig { // Destructure properties with the same name first. const { headers, method, timeout, auth, proxy, url } = n8nRequest; const axiosRequest: AxiosRequestConfig = { headers: headers ?? {}, method, timeout, auth, proxy, url, maxBodyLength: Infinity, maxContentLength: Infinity, } as AxiosRequestConfig; axiosRequest.params = n8nRequest.qs; if (n8nRequest.abortSignal) { axiosRequest.signal = n8nRequest.abortSignal; } if (n8nRequest.baseURL !== undefined) { axiosRequest.baseURL = n8nRequest.baseURL; } if (n8nRequest.disableFollowRedirect === true) { axiosRequest.maxRedirects = 0; } if (n8nRequest.encoding !== undefined) { axiosRequest.responseType = n8nRequest.encoding; } const host = getHostFromRequestObject(n8nRequest); const agentOptions: AgentOptions = {}; if (host) { agentOptions.servername = host; } if (n8nRequest.skipSslCertificateValidation === true) { agentOptions.rejectUnauthorized = false; } axiosRequest.httpsAgent = new Agent(agentOptions); axiosRequest.beforeRedirect = getBeforeRedirectFn(agentOptions, axiosRequest); if (n8nRequest.arrayFormat !== undefined) { axiosRequest.paramsSerializer = (params) => { return stringify(params, { arrayFormat: n8nRequest.arrayFormat }); }; } const { body } = n8nRequest; if (body) { // Let's add some useful header standards here. const existingContentTypeHeaderKey = searchForHeader(axiosRequest, 'content-type'); if (existingContentTypeHeaderKey === undefined) { axiosRequest.headers = axiosRequest.headers || {}; // We are only setting content type headers if the user did // not set it already manually. We're not overriding, even if it's wrong. if (body instanceof FormData) { axiosRequest.headers = { ...axiosRequest.headers, ...body.getHeaders(), }; } else if (body instanceof URLSearchParams) { axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded'; } } else if ( axiosRequest.headers?.[existingContentTypeHeaderKey] === 'application/x-www-form-urlencoded' ) { axiosRequest.data = new URLSearchParams(n8nRequest.body as Record); } // if there is a body and it's empty (does not have properties), // make sure not to send anything in it as some services fail when // sending GET request with empty body. if (typeof body === 'string' || (typeof body === 'object' && !isObjectEmpty(body))) { axiosRequest.data = body; } } if (n8nRequest.json) { const key = searchForHeader(axiosRequest, 'accept'); // If key exists, then the user has set both accept // header and the json flag. Header should take precedence. if (!key) { axiosRequest.headers = { ...axiosRequest.headers, Accept: 'application/json', }; } } const userAgentHeader = searchForHeader(axiosRequest, 'user-agent'); // If key exists, then the user has set both accept // header and the json flag. Header should take precedence. if (!userAgentHeader) { axiosRequest.headers = { ...axiosRequest.headers, 'User-Agent': 'n8n', }; } if (n8nRequest.ignoreHttpStatusErrors) { axiosRequest.validateStatus = () => true; } return axiosRequest; } const NoBodyHttpMethods = ['GET', 'HEAD', 'OPTIONS']; /** Remove empty request body on GET, HEAD, and OPTIONS requests */ export const removeEmptyBody = (requestOptions: IHttpRequestOptions | IRequestOptions) => { const method = requestOptions.method || 'GET'; if (NoBodyHttpMethods.includes(method) && isEmpty(requestOptions.body)) { delete requestOptions.body; } }; export async function httpRequest( requestOptions: IHttpRequestOptions, ): Promise { removeEmptyBody(requestOptions); const axiosRequest = convertN8nRequestToAxios(requestOptions); if ( axiosRequest.data === undefined || (axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET') ) { delete axiosRequest.data; } const result = await invokeAxios(axiosRequest, requestOptions.auth); if (requestOptions.returnFullResponse) { return { body: result.data, headers: result.headers, statusCode: result.status, statusMessage: result.statusText, }; } return result.data; } export function applyPaginationRequestData( requestData: IRequestOptions, paginationRequestData: PaginationOptions['request'], ): IRequestOptions { const preparedPaginationData: Partial = { ...paginationRequestData, uri: paginationRequestData.url, }; if ('formData' in requestData) { preparedPaginationData.formData = paginationRequestData.body; delete preparedPaginationData.body; } else if ('form' in requestData) { preparedPaginationData.form = paginationRequestData.body; delete preparedPaginationData.body; } return merge({}, requestData, preparedPaginationData); } /** * Makes a request using OAuth data for authentication * * @param {(IHttpRequestOptions | IRequestOptions)} requestOptions * */ export async function requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, requestOptions: IHttpRequestOptions | IRequestOptions, node: INode, additionalData: IWorkflowExecuteAdditionalData, oAuth2Options?: IOAuth2Options, isN8nRequest = false, ) { removeEmptyBody(requestOptions); const credentials = (await this.getCredentials( credentialsType, )) as unknown as OAuth2CredentialData; // Only the OAuth2 with authorization code grant needs connection if (credentials.grantType === 'authorizationCode' && credentials.oauthTokenData === undefined) { throw new ApplicationError('OAuth credentials not connected'); } const oAuthClient = new ClientOAuth2({ clientId: credentials.clientId, clientSecret: credentials.clientSecret, accessTokenUri: credentials.accessTokenUrl, scopes: (credentials.scope as string).split(' '), ignoreSSLIssues: credentials.ignoreSSLIssues, authentication: credentials.authentication ?? 'header', }); let oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData; // if it's the first time using the credentials, get the access token and save it into the DB. if ( credentials.grantType === 'clientCredentials' && (oauthTokenData === undefined || Object.keys(oauthTokenData).length === 0 || oauthTokenData.access_token === '') // stub ) { const { data } = await oAuthClient.credentials.getToken(); // Find the credentials if (!node.credentials?.[credentialsType]) { throw new ApplicationError('Node does not have credential type', { extra: { nodeName: node.name }, tags: { credentialType: credentialsType }, }); } const nodeCredentials = node.credentials[credentialsType]; credentials.oauthTokenData = data; // Save the refreshed token await additionalData.credentialsHelper.updateCredentials( nodeCredentials, credentialsType, credentials as unknown as ICredentialDataDecryptedObject, ); oauthTokenData = data; } const accessToken = get(oauthTokenData, oAuth2Options?.property as string) || oauthTokenData.accessToken; const refreshToken = oauthTokenData.refreshToken; const token = oAuthClient.createToken( { ...oauthTokenData, ...(accessToken ? { access_token: accessToken } : {}), ...(refreshToken ? { refresh_token: refreshToken } : {}), }, oAuth2Options?.tokenType || oauthTokenData.tokenType, ); (requestOptions as IRequestOptions).rejectUnauthorized = !credentials.ignoreSSLIssues; // Signs the request by adding authorization headers or query parameters depending // on the token-type used. const newRequestOptions = token.sign(requestOptions as ClientOAuth2RequestObject); const newRequestHeaders = (newRequestOptions.headers = newRequestOptions.headers ?? {}); // If keep bearer is false remove the it from the authorization header if (oAuth2Options?.keepBearer === false && typeof newRequestHeaders.Authorization === 'string') { newRequestHeaders.Authorization = newRequestHeaders.Authorization.split(' ')[1]; } if (oAuth2Options?.keyToIncludeInAccessTokenHeader) { Object.assign(newRequestHeaders, { [oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken, }); } if (isN8nRequest) { return await this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { if (error.response?.status === 401) { this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, ); const tokenRefreshOptions: IDataObject = {}; if (oAuth2Options?.includeCredentialsOnRefreshOnBody) { const body: IDataObject = { client_id: credentials.clientId, ...(credentials.grantType === 'authorizationCode' && { client_secret: credentials.clientSecret as string, }), }; tokenRefreshOptions.body = body; tokenRefreshOptions.headers = { Authorization: '', }; } let newToken; this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); // if it's OAuth2 with client credentials grant type, get a new token // instead of refreshing it. if (credentials.grantType === 'clientCredentials') { newToken = await token.client.credentials.getToken(); } else { newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options); } this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); credentials.oauthTokenData = newToken.data; // Find the credentials if (!node.credentials?.[credentialsType]) { throw new ApplicationError('Node does not have credential type', { extra: { nodeName: node.name, credentialType: credentialsType }, }); } const nodeCredentials = node.credentials[credentialsType]; await additionalData.credentialsHelper.updateCredentials( nodeCredentials, credentialsType, credentials as unknown as ICredentialDataDecryptedObject, ); const refreshedRequestOption = newToken.sign(requestOptions as ClientOAuth2RequestObject); if (oAuth2Options?.keyToIncludeInAccessTokenHeader) { Object.assign(newRequestHeaders, { [oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken, }); } return await this.helpers.httpRequest(refreshedRequestOption); } throw error; }); } const tokenExpiredStatusCode = oAuth2Options?.tokenExpiredStatusCode === undefined ? 401 : oAuth2Options?.tokenExpiredStatusCode; return await this.helpers .request(newRequestOptions as IRequestOptions) .then((response) => { const requestOptions = newRequestOptions as any; if ( requestOptions.resolveWithFullResponse === true && requestOptions.simple === false && response.statusCode === tokenExpiredStatusCode ) { throw response; } return response; }) .catch(async (error: IResponseError) => { if (error.statusCode === tokenExpiredStatusCode) { // Token is probably not valid anymore. So try refresh it. const tokenRefreshOptions: IDataObject = {}; if (oAuth2Options?.includeCredentialsOnRefreshOnBody) { const body: IDataObject = { client_id: credentials.clientId, client_secret: credentials.clientSecret, }; tokenRefreshOptions.body = body; // Override authorization property so the credentials are not included in it tokenRefreshOptions.headers = { Authorization: '', }; } this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, ); let newToken; // if it's OAuth2 with client credentials grant type, get a new token // instead of refreshing it. if (credentials.grantType === 'clientCredentials') { newToken = await token.client.credentials.getToken(); } else { newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options); } this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); credentials.oauthTokenData = newToken.data; // Find the credentials if (!node.credentials?.[credentialsType]) { throw new ApplicationError('Node does not have credential type', { tags: { credentialType: credentialsType }, extra: { nodeName: node.name }, }); } const nodeCredentials = node.credentials[credentialsType]; // Save the refreshed token await additionalData.credentialsHelper.updateCredentials( nodeCredentials, credentialsType, credentials as unknown as ICredentialDataDecryptedObject, ); this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been saved to database successfully.`, ); // Make the request again with the new token const newRequestOptions = newToken.sign(requestOptions as ClientOAuth2RequestObject); newRequestOptions.headers = newRequestOptions.headers ?? {}; if (oAuth2Options?.keyToIncludeInAccessTokenHeader) { Object.assign(newRequestOptions.headers, { [oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken, }); } return await this.helpers.request(newRequestOptions as IRequestOptions); } // Unknown error so simply throw it throw error; }); } /** * Makes a request using OAuth1 data for authentication */ export async function requestOAuth1( this: IAllExecuteFunctions, credentialsType: string, requestOptions: IHttpRequestOptions | IRequestOptions, isN8nRequest = false, ) { removeEmptyBody(requestOptions); const credentials = await this.getCredentials(credentialsType); if (credentials === undefined) { throw new ApplicationError('No credentials were returned'); } if (credentials.oauthTokenData === undefined) { throw new ApplicationError('OAuth credentials not connected'); } const oauth = new clientOAuth1({ consumer: { key: credentials.consumerKey as string, secret: credentials.consumerSecret as string, }, signature_method: credentials.signatureMethod as string, hash_function(base, key) { let algorithm: string; switch (credentials.signatureMethod) { case 'HMAC-SHA256': algorithm = 'sha256'; break; case 'HMAC-SHA512': algorithm = 'sha512'; break; default: algorithm = 'sha1'; break; } return createHmac(algorithm, key).update(base).digest('base64'); }, }); const oauthTokenData = credentials.oauthTokenData as IDataObject; const token: Token = { key: oauthTokenData.oauth_token as string, secret: oauthTokenData.oauth_token_secret as string, }; // @ts-expect-error @TECH_DEBT: Remove request library requestOptions.data = { ...requestOptions.qs, ...requestOptions.form }; // Fixes issue that OAuth1 library only works with "url" property and not with "uri" if ('uri' in requestOptions && !requestOptions.url) { requestOptions.url = requestOptions.uri; delete requestOptions.uri; } requestOptions.headers = oauth.toHeader( oauth.authorize(requestOptions as unknown as clientOAuth1.RequestOptions, token), ) as unknown as Record; if (isN8nRequest) { return await this.helpers.httpRequest(requestOptions as IHttpRequestOptions); } return await this.helpers .request(requestOptions as IRequestOptions) .catch(async (error: IResponseError) => { // Unknown error so simply throw it throw error; }); } export async function httpRequestWithAuthentication( this: IAllExecuteFunctions, credentialsType: string, requestOptions: IHttpRequestOptions, workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, additionalCredentialOptions?: IAdditionalCredentialOptions, ) { removeEmptyBody(requestOptions); // Cancel this request on execution cancellation if ('getExecutionCancelSignal' in this) { requestOptions.abortSignal = this.getExecutionCancelSignal(); } let credentialsDecrypted: ICredentialDataDecryptedObject | undefined; try { const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType); if (parentTypes.includes('oAuth1Api')) { return await requestOAuth1.call(this, credentialsType, requestOptions, true); } if (parentTypes.includes('oAuth2Api')) { return await requestOAuth2.call( this, credentialsType, requestOptions, node, additionalData, additionalCredentialOptions?.oauth2, true, ); } if (additionalCredentialOptions?.credentialsDecrypted) { credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data; } else { credentialsDecrypted = await this.getCredentials(credentialsType); } if (credentialsDecrypted === undefined) { throw new NodeOperationError( node, `Node "${node.name}" does not have any credentials of type "${credentialsType}" set`, { level: 'warning' }, ); } const data = await additionalData.credentialsHelper.preAuthentication( { helpers: this.helpers }, 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, requestOptions, workflow, node, ); return await httpRequest(requestOptions); } catch (error) { // if there is a pre authorization method defined and // the method failed due to unauthorized 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: this.helpers }, 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, ); } // retry the request return await httpRequest(requestOptions); } catch (error) { throw new NodeApiError(this.getNode(), error); } } throw new NodeApiError(this.getNode(), error); } } /** * Takes generic input data and brings it into the json format n8n uses. * * @param {(IDataObject | IDataObject[])} jsonData */ export function returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[] { const returnData: INodeExecutionData[] = []; if (!Array.isArray(jsonData)) { jsonData = [jsonData]; } jsonData.forEach((data: IDataObject & { json?: IDataObject }) => { if (data?.json) { // We already have the JSON key so avoid double wrapping returnData.push({ ...data, json: data.json }); } else { returnData.push({ json: data }); } }); return returnData; } /** * Takes generic input data and brings it into the new json, pairedItem format n8n uses. * @param {(IPairedItemData)} itemData * @param {(INodeExecutionData[])} inputData */ export function constructExecutionMetaData( inputData: INodeExecutionData[], options: { itemData: IPairedItemData | IPairedItemData[] }, ): NodeExecutionWithMetadata[] { const { itemData } = options; return inputData.map((data: INodeExecutionData) => { const { json, ...rest } = data; return { json, pairedItem: itemData, ...rest } as NodeExecutionWithMetadata; }); } /** * Automatically put the objects under a 'json' key and don't error, * if some objects contain json/binary keys and others don't, throws error 'Inconsistent item format' * * @param {INodeExecutionData | INodeExecutionData[]} executionData */ export function normalizeItems( executionData: INodeExecutionData | INodeExecutionData[], ): INodeExecutionData[] { if (typeof executionData === 'object' && !Array.isArray(executionData)) { executionData = executionData.json ? [executionData] : [{ json: executionData as IDataObject }]; } if (executionData.every((item) => typeof item === 'object' && 'json' in item)) return executionData; if (executionData.some((item) => typeof item === 'object' && 'json' in item)) { throw new ApplicationError('Inconsistent item format'); } if (executionData.every((item) => typeof item === 'object' && 'binary' in item)) { const normalizedItems: INodeExecutionData[] = []; executionData.forEach((item) => { const json = Object.keys(item).reduce((acc, key) => { if (key === 'binary') return acc; return { ...acc, [key]: item[key] }; }, {}); normalizedItems.push({ json, binary: item.binary, }); }); return normalizedItems; } if (executionData.some((item) => typeof item === 'object' && 'binary' in item)) { throw new ApplicationError('Inconsistent item format'); } return executionData.map((item) => { return { json: item }; }); } // TODO: Move up later export async function requestWithAuthentication( this: IAllExecuteFunctions, credentialsType: string, requestOptions: IRequestOptions, workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, additionalCredentialOptions?: IAdditionalCredentialOptions, itemIndex?: number, ) { removeEmptyBody(requestOptions); let credentialsDecrypted: ICredentialDataDecryptedObject | undefined; try { const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType); if (credentialsType === 'oAuth1Api' || parentTypes.includes('oAuth1Api')) { return await requestOAuth1.call(this, credentialsType, requestOptions, false); } if (credentialsType === 'oAuth2Api' || parentTypes.includes('oAuth2Api')) { return await requestOAuth2.call( this, credentialsType, requestOptions, node, additionalData, additionalCredentialOptions?.oauth2, false, ); } if (additionalCredentialOptions?.credentialsDecrypted) { credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data; } else { credentialsDecrypted = await this.getCredentials( credentialsType, itemIndex, ); } if (credentialsDecrypted === undefined) { throw new NodeOperationError( node, `Node "${node.name}" does not have any credentials of type "${credentialsType}" set`, { level: 'warning' }, ); } const data = await additionalData.credentialsHelper.preAuthentication( { helpers: this.helpers }, 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, requestOptions as IHttpRequestOptions, workflow, node, )) as IRequestOptions; return await proxyRequestToAxios(workflow, additionalData, node, requestOptions); } catch (error) { try { if (credentialsDecrypted !== undefined) { // try to refresh the credentials const data = await additionalData.credentialsHelper.preAuthentication( { helpers: this.helpers }, 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, )) as IRequestOptions; // retry the request return await proxyRequestToAxios(workflow, additionalData, node, requestOptions); } } throw error; } catch (error) { if (error instanceof ExecutionBaseError) throw error; throw new NodeApiError(this.getNode(), error); } } } /** * Returns the webhook URL of the webhook with the given name */ export function getNodeWebhookUrl( name: WebhookType, workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, additionalKeys: IWorkflowDataProxyAdditionalKeys, isTest?: boolean, ): string | undefined { let baseUrl = additionalData.webhookBaseUrl; if (isTest === true) { baseUrl = additionalData.webhookTestBaseUrl; } // eslint-disable-next-line @typescript-eslint/no-use-before-define const webhookDescription = getWebhookDescription(name, workflow, node); if (webhookDescription === undefined) { return undefined; } const path = workflow.expression.getSimpleParameterValue( node, webhookDescription.path, mode, additionalKeys, ); if (path === undefined) { return undefined; } const isFullPath: boolean = workflow.expression.getSimpleParameterValue( node, webhookDescription.isFullPath, mode, additionalKeys, undefined, false, ) as boolean; return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id, node, path.toString(), isFullPath); } /** * Returns the full webhook description of the webhook with the given name */ export function getWebhookDescription( name: WebhookType, workflow: Workflow, node: INode, ): IWebhookDescription | undefined { const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType.description.webhooks === undefined) { // Node does not have any webhooks so return return undefined; } for (const webhookDescription of nodeType.description.webhooks) { if (webhookDescription.name === name) { return webhookDescription; } } return undefined; } export const getRequestHelperFunctions = ( workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, runExecutionData: IRunExecutionData | null = null, connectionInputData: INodeExecutionData[] = [], ): RequestHelperFunctions => { const getResolvedValue = ( parameterValue: NodeParameterValueType, itemIndex: number, runIndex: number, executeData: IExecuteData, additionalKeys?: IWorkflowDataProxyAdditionalKeys, returnObjectAsString = false, ): NodeParameterValueType => { const mode: WorkflowExecuteMode = 'internal'; if ( typeof parameterValue === 'object' || (typeof parameterValue === 'string' && parameterValue.charAt(0) === '=') ) { return workflow.expression.getParameterValue( parameterValue, runExecutionData, runIndex, itemIndex, node.name, connectionInputData, mode, additionalKeys ?? {}, executeData, returnObjectAsString, ); } return parameterValue; }; return { httpRequest, // eslint-disable-next-line complexity async requestWithAuthenticationPaginated( this: IExecuteFunctions, requestOptions: IRequestOptions, itemIndex: number, paginationOptions: PaginationOptions, credentialsType?: string, additionalCredentialOptions?: IAdditionalCredentialOptions, ): Promise { const responseData = []; if (!requestOptions.qs) { requestOptions.qs = {}; } requestOptions.resolveWithFullResponse = true; requestOptions.simple = false; let tempResponseData: IN8nHttpFullResponse; let makeAdditionalRequest: boolean; let paginateRequestData: PaginationOptions['request']; const runIndex = 0; const additionalKeys: IWorkflowDataProxyAdditionalKeys = { $request: requestOptions, $response: {} as IN8nHttpFullResponse, $version: node.typeVersion, $pageCount: 0, }; const executeData: IExecuteData = { data: {}, node, source: null, }; const hashData = { identicalCount: 0, previousLength: 0, previousHash: '', }; do { paginateRequestData = getResolvedValue( paginationOptions.request as unknown as NodeParameterValueType, itemIndex, runIndex, executeData, additionalKeys, false, ) as object as PaginationOptions['request']; const tempRequestOptions = applyPaginationRequestData(requestOptions, paginateRequestData); if (!validateUrl(tempRequestOptions.uri as string)) { throw new NodeOperationError(node, `'${paginateRequestData.url}' is not a valid URL.`, { itemIndex, runIndex, type: 'invalid_url', }); } if (credentialsType) { tempResponseData = await this.helpers.requestWithAuthentication.call( this, credentialsType, tempRequestOptions, additionalCredentialOptions, ); } else { tempResponseData = await this.helpers.request(tempRequestOptions); } const newResponse: IN8nHttpFullResponse = Object.assign( { body: {}, headers: {}, statusCode: 0, }, pick(tempResponseData, ['body', 'headers', 'statusCode']), ); let contentBody: Exclude; if (newResponse.body instanceof Readable && paginationOptions.binaryResult !== true) { // Keep the original string version that we can use it to hash if needed contentBody = await binaryToString(newResponse.body as Buffer | Readable); const responseContentType = newResponse.headers['content-type']?.toString() ?? ''; if (responseContentType.includes('application/json')) { newResponse.body = jsonParse(contentBody, { fallbackValue: {} }); } else { newResponse.body = contentBody; } tempResponseData.__bodyResolved = true; tempResponseData.body = newResponse.body; } else { contentBody = newResponse.body; } if (paginationOptions.binaryResult !== true || tempResponseData.headers.etag) { // If the data is not binary (and so not a stream), or an etag is present, // we check via etag or hash if identical data is received let contentLength = 0; if ('content-length' in tempResponseData.headers) { contentLength = parseInt(tempResponseData.headers['content-length'] as string) || 0; } if (hashData.previousLength === contentLength) { let hash: string; if (tempResponseData.headers.etag) { // If an etag is provided, we use it as "hash" hash = tempResponseData.headers.etag as string; } else { // If there is no etag, we calculate a hash from the data in the body if (typeof contentBody !== 'string') { contentBody = JSON.stringify(contentBody); } hash = crypto.createHash('md5').update(contentBody).digest('base64'); } if (hashData.previousHash === hash) { hashData.identicalCount += 1; if (hashData.identicalCount > 2) { // Length was identical 5x and hash 3x throw new NodeOperationError( node, 'The returned response was identical 5x, so requests got stopped', { itemIndex, description: 'Check if "Pagination Completed When" has been configured correctly.', }, ); } } else { hashData.identicalCount = 0; } hashData.previousHash = hash; } else { hashData.identicalCount = 0; } hashData.previousLength = contentLength; } responseData.push(tempResponseData); additionalKeys.$response = newResponse; additionalKeys.$pageCount = (additionalKeys.$pageCount ?? 0) + 1; const maxRequests = getResolvedValue( paginationOptions.maxRequests, itemIndex, runIndex, executeData, additionalKeys, false, ) as number; if (maxRequests && additionalKeys.$pageCount >= maxRequests) { break; } makeAdditionalRequest = getResolvedValue( paginationOptions.continue, itemIndex, runIndex, executeData, additionalKeys, false, ) as boolean; if (makeAdditionalRequest) { if (paginationOptions.requestInterval) { const requestInterval = getResolvedValue( paginationOptions.requestInterval, itemIndex, runIndex, executeData, additionalKeys, false, ) as number; await sleep(requestInterval); } if (tempResponseData.statusCode < 200 || tempResponseData.statusCode >= 300) { // We have it configured to let all requests pass no matter the response code // via "requestOptions.simple = false" to not by default fail if it is for example // configured to stop on 404 response codes. For that reason we have to throw here // now an error manually if the response code is not a success one. let data = tempResponseData.body; if (data instanceof Readable && paginationOptions.binaryResult !== true) { data = await binaryToString(data as Buffer | Readable); } else if (typeof data === 'object') { data = JSON.stringify(data); } throw Object.assign( new Error(`${tempResponseData.statusCode} - "${data?.toString()}"`), { statusCode: tempResponseData.statusCode, error: data, isAxiosError: true, response: { headers: tempResponseData.headers, status: tempResponseData.statusCode, statusText: tempResponseData.statusMessage, }, }, ); } } } while (makeAdditionalRequest); return responseData; }, async httpRequestWithAuthentication( this, credentialsType, requestOptions, additionalCredentialOptions, ): Promise { return await httpRequestWithAuthentication.call( this, credentialsType, requestOptions, workflow, node, additionalData, additionalCredentialOptions, ); }, request: async (uriOrObject, options) => await proxyRequestToAxios(workflow, additionalData, node, uriOrObject, options), async requestWithAuthentication( this, credentialsType, requestOptions, additionalCredentialOptions, itemIndex, ): Promise { return await requestWithAuthentication.call( this, credentialsType, requestOptions, workflow, node, additionalData, additionalCredentialOptions, itemIndex, ); }, async requestOAuth1( this: IAllExecuteFunctions, credentialsType: string, requestOptions: IRequestOptions, ): Promise { return await requestOAuth1.call(this, credentialsType, requestOptions); }, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, requestOptions: IRequestOptions, oAuth2Options?: IOAuth2Options, ): Promise { return await requestOAuth2.call( this, credentialsType, requestOptions, node, additionalData, oAuth2Options, ); }, }; }; export const getSSHTunnelFunctions = (): SSHTunnelFunctions => ({ getSSHClient: async (credentials) => await Container.get(SSHClientsManager).getClient(credentials), }); export const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => { const scheduledTaskManager = Container.get(ScheduledTaskManager); return { registerCron: (cronExpression, onTick) => scheduledTaskManager.registerCron(workflow, cronExpression, onTick), }; }; /** * Returns a copy of the items which only contains the json data and * of that only the defined properties */ export function copyInputItems(items: INodeExecutionData[], properties: string[]): IDataObject[] { return items.map((item) => { const newItem: IDataObject = {}; for (const property of properties) { if (item.json[property] === undefined) { newItem[property] = null; } else { newItem[property] = deepCopy(item.json[property]); } } return newItem; }); } /** * Returns the execute functions the poll nodes have access to. */ // TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowManager.add export function getExecutePollFunctions( workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode, ): IPollFunctions { return new PollContext(workflow, node, additionalData, mode, activation); } /** * Returns the execute functions the trigger nodes have access to. */ // TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowManager.add export function getExecuteTriggerFunctions( workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode, ): ITriggerFunctions { return new TriggerContext(workflow, node, additionalData, mode, activation); }