From b6b2a7db177044f5750438cca4c193f54e926441 Mon Sep 17 00:00:00 2001 From: Toni Devine <118546181+devinetoni@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:47:06 +0000 Subject: [PATCH] feat(GraphQL Node): Add support for predefined credential types (#37671) --- packages/core/src/constants.ts | 4 + .../nodes-base/nodes/GraphQL/GraphQL.node.ts | 39 ++- .../nodes/GraphQL/test/GraphQL.node.test.ts | 224 ++++++++++++++++++ 3 files changed, 266 insertions(+), 1 deletion(-) diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 4d5e262d738..c6ac15d3599 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -4,6 +4,8 @@ export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest'; export const HTTP_REQUEST_AS_TOOL_NODE_TYPE = 'n8n-nodes-base.httpRequestTool'; export const HTTP_REQUEST_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolHttpRequest'; +export const GRAPHQL_NODE_TYPE = 'n8n-nodes-base.graphql'; +export const GRAPHQL_AS_TOOL_NODE_TYPE = 'n8n-nodes-base.graphqlTool'; /** * Node types whose runtime credential access bypasses the per-node @@ -18,6 +20,8 @@ export const FULL_ACCESS_NODE_TYPES = new Set([ HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_AS_TOOL_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE, + GRAPHQL_NODE_TYPE, + GRAPHQL_AS_TOOL_NODE_TYPE, ]); export const RESTRICT_FILE_ACCESS_TO = 'N8N_RESTRICT_FILE_ACCESS_TO'; diff --git a/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts b/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts index d8f378c5d0b..82f29b93c3f 100644 --- a/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts +++ b/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts @@ -18,7 +18,7 @@ import { jsonParse, } from 'n8n-workflow'; -import { getAllowedDomains } from '../HttpRequest/GenericFunctions'; +import { getAllowedDomains, getOAuth2AdditionalParameters } from '../HttpRequest/GenericFunctions'; export class GraphQL implements INodeType { description: INodeTypeDescription = { @@ -106,7 +106,14 @@ export class GraphQL implements INodeType { name: 'authentication', type: 'options', noDataExpression: true, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items options: [ + { + name: 'Predefined Credential Type', + value: 'predefinedCredentialType', + description: + "We've already implemented auth for many services so that you don't have to set it up manually", + }, { name: 'Basic Auth', value: 'basicAuth', @@ -143,6 +150,20 @@ export class GraphQL implements INodeType { default: 'none', description: 'The way to authenticate', }, + { + displayName: 'Credential Type', + name: 'nodeCredentialType', + type: 'credentialsSelect', + noDataExpression: true, + required: true, + default: '', + credentialTypes: ['extends:oAuth2Api', 'extends:oAuth1Api', 'has:authenticate'], + displayOptions: { + show: { + authentication: ['predefinedCredentialType'], + }, + }, + }, { displayName: 'HTTP Request Method', name: 'requestMethod', @@ -344,6 +365,8 @@ export class GraphQL implements INodeType { let httpQueryAuth; let oAuth1Api; let oAuth2Api; + let nodeCredentialType; + let predefinedCredentialData; const authentication = this.getNodeParameter('authentication', 0) as string; try { @@ -361,6 +384,9 @@ export class GraphQL implements INodeType { oAuth1Api = await this.getCredentials('oAuth1Api'); } else if (authentication === 'oAuth2') { oAuth2Api = await this.getCredentials('oAuth2Api'); + } else if (authentication === 'predefinedCredentialType') { + nodeCredentialType = this.getNodeParameter('nodeCredentialType', 0) as string; + predefinedCredentialData = await this.getCredentials(nodeCredentialType); } } catch {} @@ -402,6 +428,8 @@ export class GraphQL implements INodeType { allowedDomains = getAllowedDomains(this.getNode(), oAuth1Api); } else if (oAuth2Api !== undefined) { allowedDomains = getAllowedDomains(this.getNode(), oAuth2Api); + } else if (predefinedCredentialData !== undefined) { + allowedDomains = getAllowedDomains(this.getNode(), predefinedCredentialData); } requestOptions = { @@ -517,6 +545,15 @@ export class GraphQL implements INodeType { ); // since we are using `resolveWithFullResponse: true`, we need to grab the body response = response.body; + } else if (nodeCredentialType !== undefined) { + const additionalOAuth2Options = getOAuth2AdditionalParameters(nodeCredentialType); + response = await this.helpers.requestWithAuthentication.call( + this, + nodeCredentialType, + requestOptions, + additionalOAuth2Options && { oauth2: additionalOAuth2Options }, + itemIndex, + ); } else { response = await this.helpers.request(requestOptions); } diff --git a/packages/nodes-base/nodes/GraphQL/test/GraphQL.node.test.ts b/packages/nodes-base/nodes/GraphQL/test/GraphQL.node.test.ts index b18f1801606..07910a93e31 100644 --- a/packages/nodes-base/nodes/GraphQL/test/GraphQL.node.test.ts +++ b/packages/nodes-base/nodes/GraphQL/test/GraphQL.node.test.ts @@ -261,6 +261,9 @@ describe('GraphQL Node', () => { mockExecuteFunctions.helpers.requestOAuth2.mockResolvedValue({ body: { data: { ok: true } }, }); + mockExecuteFunctions.helpers.requestWithAuthentication.mockResolvedValue({ + data: { ok: true }, + }); return mockExecuteFunctions; }; @@ -462,5 +465,226 @@ describe('GraphQL Node', () => { .calls[0][0] as IRequestOptions; expect(requestOptions.allowedDomains).toBeUndefined(); }); + + it('calls requestWithAuthentication for a predefined credential type', async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + requestMethod: 'POST', + endpoint: 'https://api.github.com/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + { + githubApi: { accessToken: 'token123' }, + }, + ); + + const node = new GraphQL(); + await node.execute.call(mockExecuteFunctions); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('githubApi'); + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.helpers.requestWithAuthentication.mock.calls[0][0]).toBe( + 'githubApi', + ); + expect(mockExecuteFunctions.helpers.requestWithAuthentication.mock.calls[0][1]).toMatchObject( + { + uri: 'https://api.github.com/graphql', + }, + ); + expect(mockExecuteFunctions.helpers.request).not.toHaveBeenCalled(); + }); + + it('passes credential allowedDomains for a predefined credential type', async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + requestMethod: 'POST', + endpoint: 'https://api.github.com/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + { + githubApi: { + accessToken: 'token123', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'api.github.com', + }, + }, + ); + + const node = new GraphQL(); + await node.execute.call(mockExecuteFunctions); + + const requestOptions = mockExecuteFunctions.helpers.requestWithAuthentication.mock + .calls[0][1] as IRequestOptions; + expect(requestOptions).toMatchObject({ allowedDomains: 'api.github.com' }); + }); + + it('applies OAuth2 additional parameters for a known predefined credential type', async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'slackOAuth2Api', + requestMethod: 'POST', + endpoint: 'https://slack.com/api/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + { + slackOAuth2Api: { accessToken: 'token123' }, + }, + ); + + const node = new GraphQL(); + await node.execute.call(mockExecuteFunctions); + + expect(mockExecuteFunctions.helpers.requestWithAuthentication.mock.calls[0][2]).toEqual({ + oauth2: { tokenType: 'Bearer', property: 'authed_user.access_token' }, + }); + }); + + it('does not set OAuth2 additional options for a predefined credential type outside the OAuth2 quirks table', async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + requestMethod: 'POST', + endpoint: 'https://api.github.com/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + { + githubApi: { accessToken: 'token123' }, + }, + ); + + const node = new GraphQL(); + await node.execute.call(mockExecuteFunctions); + + expect( + mockExecuteFunctions.helpers.requestWithAuthentication.mock.calls[0][2], + ).toBeUndefined(); + }); + + it("throws when a predefined credential's domain restriction is 'none'", async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + requestMethod: 'POST', + endpoint: 'https://api.github.com/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + { + githubApi: { + accessToken: 'token123', + allowedHttpRequestDomains: 'none', + }, + }, + ); + + const node = new GraphQL(); + await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow( + 'configured to prevent use within an HTTP Request or GraphQL node', + ); + expect(mockExecuteFunctions.helpers.requestWithAuthentication).not.toHaveBeenCalled(); + }); + + it("throws when a predefined credential's domain restriction is 'domains' but none are configured", async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + requestMethod: 'POST', + endpoint: 'https://api.github.com/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + { + githubApi: { + accessToken: 'token123', + allowedHttpRequestDomains: 'domains', + allowedDomains: '', + }, + }, + ); + + const node = new GraphQL(); + await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow( + 'No allowed domains specified', + ); + expect(mockExecuteFunctions.helpers.requestWithAuthentication).not.toHaveBeenCalled(); + }); + + it('still attempts requestWithAuthentication when the initial credential fetch fails', async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + requestMethod: 'POST', + endpoint: 'https://api.github.com/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + {}, + ); + mockExecuteFunctions.helpers.requestWithAuthentication.mockRejectedValue( + new Error('No credentials found of type githubApi'), + ); + + const node = new GraphQL(); + await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow(); + + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.helpers.requestWithAuthentication.mock.calls[0][0]).toBe( + 'githubApi', + ); + expect(mockExecuteFunctions.helpers.request).not.toHaveBeenCalled(); + }); + + it('wraps requestWithAuthentication errors without leaking request options', async () => { + const mockExecuteFunctions = createMockExecuteFunctions( + { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + requestMethod: 'POST', + endpoint: 'https://api.github.com/graphql', + requestFormat: 'json', + responseFormat: 'json', + query: '{ok}', + }, + { + githubApi: { accessToken: 'token123' }, + }, + ); + const requestError = Object.assign(new Error('ECONNREFUSED'), { + code: 'ECONNREFUSED', + isAxiosError: true, + options: { + headers: { Authorization: 'credential-value' }, + }, + }); + mockExecuteFunctions.helpers.requestWithAuthentication.mockRejectedValue(requestError); + + const node = new GraphQL(); + const error: unknown = await node.execute.call(mockExecuteFunctions).catch((error) => error); + + expect(error).toBeInstanceOf(NodeApiError); + expect(error).toHaveProperty('context.itemIndex', 0); + expect({ ...(error as NodeApiError) }).not.toHaveProperty('options'); + expect(JSON.stringify({ ...(error as NodeApiError) })).not.toContain('credential-value'); + }); }); });