mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(GraphQL Node): Add support for predefined credential types (#37671)
This commit is contained in:
parent
30d00cc61a
commit
b6b2a7db17
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user