feat(GraphQL Node): Add support for predefined credential types (#37671)

This commit is contained in:
Toni Devine 2026-09-03 17:47:06 +00:00 committed by GitHub
parent 30d00cc61a
commit b6b2a7db17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 266 additions and 1 deletions

View File

@ -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<string>([
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';

View File

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

View File

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