From f6f05cb6729684c57fc64bc49c547fc099bf706e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 31 Jul 2026 13:54:12 +0200 Subject: [PATCH] feat(HTTP Request Node): Add Simplified Custom Auth generic credential (#35068) --- .../test/ToolHttpRequest.node.test.ts | 39 +++ .../nodes/tools/ToolHttpRequest/utils.ts | 13 + .../src/__tests__/credentials-helper.test.ts | 64 ++++- packages/cli/src/credentials-helper.ts | 54 +++- .../__tests__/credentials.service.test.ts | 95 +++++++ .../src/credentials/credentials.controller.ts | 24 ++ .../src/credentials/credentials.service.ts | 51 ++++ ...e-event-bus-destination-webhook.ee.test.ts | 92 +++++++ ...essage-event-bus-destination-webhook.ee.ts | 20 +- .../credentials-tester.service.test.ts | 236 +++++++++++++++++- .../services/credentials-tester.service.ts | 187 ++++++++++++-- .../components/CredentialsSelect.vue | 6 +- .../HttpTemplatedCustomAuth.credentials.ts | 103 ++++++++ .../HttpRequest/V2/HttpRequestV2.node.ts | 14 ++ .../HttpRequest/V3/HttpRequestV3.node.ts | 20 ++ .../test/node/HttpRequestV2.test.ts | 9 + packages/nodes-base/package.json | 1 + .../utils/__tests__/templated-auth.test.ts | 224 +++++++++++++++++ packages/nodes-base/utils/templated-auth.ts | 161 ++++++++++++ packages/workflow/src/interfaces.ts | 5 +- 20 files changed, 1390 insertions(+), 28 deletions(-) create mode 100644 packages/nodes-base/credentials/HttpTemplatedCustomAuth.credentials.ts create mode 100644 packages/nodes-base/utils/__tests__/templated-auth.test.ts create mode 100644 packages/nodes-base/utils/templated-auth.ts diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts index 1bb7b047dec..76f5ec05f93 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts @@ -238,6 +238,45 @@ describe('ToolHttpRequest', () => { ); }); + it('should apply Simplified Custom Auth through credential authentication', async () => { + helpers.httpRequestWithAuthentication.mockResolvedValue({ + body: 'Hello World', + headers: { 'content-type': 'text/plain' }, + }); + + executeFunctions.getNodeParameter.mockImplementation((paramName: string) => { + switch (paramName) { + case 'method': + return 'GET'; + case 'url': + return 'https://api.example.com/data'; + case 'authentication': + return 'genericCredentialType'; + case 'genericAuthType': + return 'httpTemplatedCustomAuth'; + case 'options': + return {}; + case 'placeholderDefinitions.values': + return []; + default: + return undefined; + } + }); + executeFunctions.getCredentials.mockResolvedValue({ + template: JSON.stringify({ headers: { Authorization: 'Bearer {{api_key}}' } }), + placeholderValues: JSON.stringify({ api_key: 'secret' }), + }); + + const { response } = await httpTool.supplyData.call(executeFunctions, 0); + const result = await (response as N8nTool).invoke({}); + + expect(result).toBe('Hello World'); + expect(helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'httpTemplatedCustomAuth', + expect.objectContaining({ url: 'https://api.example.com/data' }), + ); + }); + it('should not send generic credentials to a domain the credential restricts', async () => { executeFunctions.getNodeParameter.mockImplementation((paramName: string) => { switch (paramName) { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index b27a31741e0..e495a427f85 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -112,6 +112,19 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu }; } + if (genericType === 'httpTemplatedCustomAuth') { + const templatedAuth = await ctx.getCredentials('httpTemplatedCustomAuth', itemIndex); + + return async (options: IHttpRequestOptions) => { + assertCredentialUrlAllowed(ctx, templatedAuth, options); + return await ctx.helpers.httpRequestWithAuthentication.call( + ctx, + 'httpTemplatedCustomAuth', + options, + ); + }; + } + if (genericType === 'oAuth1Api') { const oAuth1 = await ctx.getCredentials('oAuth1Api', itemIndex); return async (options: IHttpRequestOptions) => { diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 15d8537a988..ab146afc1e7 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -27,7 +27,7 @@ import type { INodeCredentialsDetails, IWorkflowExecuteAdditionalData, } from 'n8n-workflow'; -import { deepCopy, Workflow } from 'n8n-workflow'; +import { deepCopy, jsonParse, Workflow } from 'n8n-workflow'; import { generateKeyPairSync } from 'node:crypto'; import type { MockInstance } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -246,6 +246,68 @@ describe('CredentialsHelper', () => { ).rejects.toThrow('save workflow to view'); }); + test('resolves variables and external secrets in marked JSON credential leaves', async () => { + const credentialType: ICredentialType = { + name: 'httpTemplatedCustomAuth', + displayName: 'Simplified Custom Auth', + properties: [ + { + displayName: 'Placeholder Values', + name: 'placeholderValues', + type: 'json', + default: '', + typeOptions: { resolveCredentialJsonLeaves: true }, + }, + ], + }; + mockNodesAndCredentials.getCredential.calledWith(credentialType.name).mockReturnValue({ + type: credentialType, + sourcePath: '', + }); + const credentialsOverwrites = mock(); + credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data); + const helper = new CredentialsHelper( + new CredentialTypes(mockNodesAndCredentials), + credentialsOverwrites, + credentialsRepository, + dynamicCredentialProxy, + secretsProviderRepository, + licenseState, + externalSecretsConfig, + mock(), + ); + const externalSecretsProxy = + mock>(); + externalSecretsProxy.hasProvider.mockReturnValue(true); + externalSecretsProxy.hasSecret.mockReturnValue(true); + externalSecretsProxy.getSecret.mockReturnValue('secret-api-key'); + const additionalData = mock({ + variables: { tenant: 'acme' }, + }); + additionalData.externalSecretsProxy = externalSecretsProxy; + additionalData.externalSecretProviderKeysAccessibleByCredential = new Set(['vault']); + + const result = await helper.applyDefaultsAndOverwrites( + additionalData, + { + placeholderValues: JSON.stringify({ + api_key: '={{ $secrets.vault.apiKey }}', + tenant: '={{ $vars.tenant }}', + }), + }, + credentialType.name, + 'internal', + ); + + expect(externalSecretsProxy.hasProvider.mock.calls).toEqual([['vault']]); + expect(externalSecretsProxy.hasSecret.mock.calls).toEqual([['vault', 'apiKey']]); + expect(externalSecretsProxy.getSecret.mock.calls).toEqual([['vault', 'apiKey']]); + expect(jsonParse(result.placeholderValues as string)).toEqual({ + api_key: 'secret-api-key', + tenant: 'acme', + }); + }); + test('preserves PKCE flag negotiated by dynamic client registration', async () => { const credentialType: ICredentialType = { name: 'mcpOAuth2Api', diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index e37fd8ba5f7..7cff2bfe8b8 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -8,6 +8,7 @@ import { Service } from '@n8n/di'; import { EntityNotFoundError } from '@n8n/typeorm'; import { Credentials, getAdditionalKeys } from 'n8n-core'; import type { + CredentialInformation, ICredentialDataDecryptedObject, ICredentialType, ICredentialsExpressionResolveValues, @@ -35,6 +36,7 @@ import { UnexpectedError, UserError, isExpression, + jsonParse, } from 'n8n-workflow'; import { CredentialTypes } from '@/credential-types'; @@ -81,6 +83,8 @@ const mockNodeTypes: INodeTypes = { }, }; +const INVALID_JSON_VALUE = Symbol('invalidJsonValue'); + @Service() export class CredentialsHelper extends ICredentialsHelper { constructor( @@ -103,8 +107,8 @@ export class CredentialsHelper extends ICredentialsHelper { credentials: ICredentialDataDecryptedObject, typeName: string, incomingRequestOptions: IHttpRequestOptions | IRequestOptionsSimplified, - workflow: Workflow, - node: INode, + workflow?: Workflow, + node?: INode, ): Promise { const requestOptions = incomingRequestOptions; const credentialType = this.credentialTypes.getByName(typeName); @@ -120,6 +124,11 @@ export class CredentialsHelper extends ICredentialsHelper { } if (typeof credentialType.authenticate === 'object') { + if (!workflow || !node) { + throw new UnexpectedError( + 'Workflow and node are required for declarative credential authentication', + ); + } // Predefined authentication method let keyResolved: string; @@ -456,6 +465,40 @@ export class CredentialsHelper extends ICredentialsHelper { return resolvedData; } + private parseJsonLeafExpressionFields( + credentialsProperties: INodeProperties[], + decryptedData: ICredentialDataDecryptedObject, + ): Map { + const parsedFields = new Map(); + + for (const property of credentialsProperties) { + if (!property.typeOptions?.resolveCredentialJsonLeaves) continue; + + const value = decryptedData[property.name]; + if (typeof value !== 'string' || value === '') continue; + + const parsed = jsonParse(value, { + fallbackValue: INVALID_JSON_VALUE, + }); + if (parsed === INVALID_JSON_VALUE) continue; + + parsedFields.set(property.name, value); + decryptedData[property.name] = parsed; + } + + return parsedFields; + } + + private stringifyJsonLeafExpressionFields( + decryptedData: ICredentialDataDecryptedObject, + parsedFields: Map, + ) { + for (const [propertyName, originalValue] of parsedFields) { + const serializedValue = JSON.stringify(decryptedData[propertyName]); + decryptedData[propertyName] = serializedValue ?? originalValue; + } + } + /** * Returns the decrypted credential data with applied overwrites */ @@ -615,6 +658,11 @@ export class CredentialsHelper extends ICredentialsHelper { decryptedData.usePkce = decryptedDataOriginal.usePkce; } + const parsedJsonLeafExpressionFields = this.parseJsonLeafExpressionFields( + credentialsProperties, + decryptedData, + ); + const additionalKeys = getAdditionalKeys(additionalData, mode, null, { isCredential: true, }); @@ -661,6 +709,8 @@ export class CredentialsHelper extends ICredentialsHelper { } } + this.stringifyJsonLeafExpressionFields(decryptedData, parsedJsonLeafExpressionFields); + return decryptedData; } diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 9a218971117..c6eb9fbae8d 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -670,6 +670,19 @@ describe('CredentialsService', () => { expect(result.json).toEqual(JSON.stringify({ port: '***', timeout: '***' }, null, 2)); }); + it('should keep expression leaf values visible', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { json: '{"token": "={{ $secrets.vault.replicate }}", "key": "abc"}' }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual( + JSON.stringify({ token: '={{ $secrets.vault.replicate }}', key: '***' }, null, 2), + ); + }); + it('should redact boolean leaf values', () => { credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); @@ -3822,6 +3835,88 @@ describe('CredentialsService', () => { }); }); + describe('probeById', () => { + const storedCredential = mock({ + id: 'cred-id', + name: 'Templated cred', + type: 'httpTemplatedCustomAuth', + }); + + const mockDecryptedData = (data: ICredentialDataDecryptedObject) => + vi.spyOn(service, 'decrypt').mockResolvedValue(data); + + it('should throw when the credential is not accessible to the user', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(null); + + await expect(service.probeById(memberUser, 'cred-id')).rejects.toThrow( + CredentialNotFoundError, + ); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith( + 'cred-id', + memberUser, + ['credential:read'], + ); + }); + + it('should throw when the credential has no test URL', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); + mockDecryptedData({ template: '{}' }); + + await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow( + 'The credential has no test URL to probe', + ); + expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled(); + }); + + it('should refuse an expression test URL instead of resolving it', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); + mockDecryptedData({ testUrl: '={{ $vars.url }}' }); + + await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow( + 'The credential has no test URL to probe', + ); + expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled(); + }); + + it('should probe the persisted test URL with parsed accepted status codes', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); + const data: ICredentialDataDecryptedObject = { + testUrl: 'https://api.example.com/me', + acceptedStatusCodes: '[401]', + }; + mockDecryptedData(data); + const verdict = { status: 'OK' as const, message: 'Connection successful!' }; + credentialsTester.probeCredentialAuth.mockResolvedValue(verdict); + + await expect(service.probeById(ownerUser, 'cred-id')).resolves.toEqual(verdict); + expect(credentialsTester.probeCredentialAuth).toHaveBeenCalledWith( + ownerUser.id, + 'httpTemplatedCustomAuth', + { id: 'cred-id', name: 'Templated cred', type: 'httpTemplatedCustomAuth', data }, + 'https://api.example.com/me', + { acceptedStatusCodes: [401] }, + ); + }); + + it('should ignore malformed accepted status codes', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); + mockDecryptedData({ testUrl: 'https://api.example.com/me', acceptedStatusCodes: 'nope' }); + credentialsTester.probeCredentialAuth.mockResolvedValue({ + status: 'OK', + message: 'Connection successful!', + }); + + await service.probeById(ownerUser, 'cred-id'); + expect(credentialsTester.probeCredentialAuth).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + { acceptedStatusCodes: undefined }, + ); + }); + }); + describe('isOAuthCredentialType', () => { it('returns true for the base OAuth1/OAuth2 types', () => { credentialTypes.getParentTypes.mockReturnValue([]); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index cedb850f197..4de37070ee0 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -156,6 +156,30 @@ export class CredentialsController { } } + /** + * Auth-probe a stored credential against the test URL saved in the + * credential itself. Complements `/test`, which needs the credential type + * to declare a test; generic types (e.g. Templated Custom Auth) have none, + * so they are probed against their own persisted test URL instead. + */ + @Post('/:credentialId/probe') + @ProjectScope('credential:read') + async probeCredentials( + req: AuthenticatedRequest, + _res: unknown, + @Param('credentialId') credentialId: string, + ) { + try { + return await this.credentialsService.probeById(req.user, credentialId); + } catch (error) { + if (error instanceof CredentialNotFoundError) { + throw new ForbiddenError(); + } + + throw error; + } + } + @Post('/') async createCredentials( req: AuthenticatedRequest, diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 89685f7884e..520a45594cc 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -156,6 +156,15 @@ type WorkflowCredentialResult = { connectedByMe?: boolean; }; +/** Codes an auth probe must not treat as rejection, stored as a JSON array in the credential. */ +function parseAcceptedStatusCodes(raw: unknown): number[] | undefined { + if (typeof raw !== 'string' || raw.trim() === '') return undefined; + const parsed = jsonParse(raw, { fallbackValue: null }); + if (!Array.isArray(parsed)) return undefined; + const codes = parsed.filter((code): code is number => Number.isInteger(code)); + return codes.length > 0 ? codes : undefined; +} + @Service() export class CredentialsService { constructor( @@ -1182,6 +1191,45 @@ export class CredentialsService { return await this.test(user.id, mergedCredentials); } + /** + * Auth-probe a stored credential against the test URL persisted in its own + * data (e.g. Templated Custom Auth, whose type declares no test of its own). + * The target is never caller-supplied, so a merely readable credential + * cannot be pointed at an arbitrary endpoint. + */ + async probeById(user: User, credentialId: string) { + const storedCredential = await this.credentialsFinderService.findCredentialForUser( + credentialId, + user, + ['credential:read'], + ); + + if (!storedCredential) { + throw new CredentialNotFoundError(credentialId); + } + + const data = await this.decrypt(storedCredential, true); + + // Expressions (leading '=') and non-http values are refused, not resolved. + const testUrl = data.testUrl; + if (typeof testUrl !== 'string' || !/^https?:\/\//i.test(testUrl)) { + throw new BadRequestError('The credential has no test URL to probe'); + } + + return await this.credentialsTester.probeCredentialAuth( + user.id, + storedCredential.type, + { + id: storedCredential.id, + name: storedCredential.name, + type: storedCredential.type, + data, + }, + testUrl, + { acceptedStatusCodes: parseAcceptedStatusCodes(data.acceptedStatusCodes) }, + ); + } + // Take data and replace all sensitive values with a sentinel value. // This will replace password fields and oauth data. redact(data: ICredentialDataDecryptedObject, credential: CredentialsEntity) { @@ -1296,6 +1344,9 @@ export class CredentialsService { ]), ); } + // Expressions are references (e.g. external secrets), not secrets — keep + // them visible and editable, mirroring the field-level password rule. + if (typeof obj === 'string' && obj.startsWith('={{')) return obj; return CUSTOM_AUTH_JSON_REDACTED_VALUE; } diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts index bd4df230c25..53469879397 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts @@ -118,6 +118,98 @@ describe('MessageEventBusDestinationWebhook', () => { }); }); + it('should apply Simplified Custom Auth credentials', async () => { + const { outboundHttp, request } = mockOutboundHttp(); + const credentialDetails = { id: 'credential-id', name: 'API credential' }; + const destination = new MessageEventBusDestinationWebhook( + mockEventBus, + { + __type: MessageEventBusDestinationTypeNames.webhook, + url: 'https://example.com/webhook', + authentication: 'genericCredentialType', + genericAuthType: 'httpTemplatedCustomAuth', + credentials: { httpTemplatedCustomAuth: credentialDetails }, + }, + outboundHttp, + ); + const credentialsHelper = mock(); + const decrypted = { + template: JSON.stringify({ headers: { Authorization: 'Bearer {{api_key}}' } }), + placeholderValues: JSON.stringify({ api_key: 'secret' }), + }; + credentialsHelper.getDecrypted.mockResolvedValue(decrypted); + credentialsHelper.authenticate.mockResolvedValue({ + url: 'https://example.com/webhook', + headers: { Authorization: 'Bearer secret' }, + }); + destination.credentialsHelper = credentialsHelper; + + await destination.receiveFromEventBus({ + msg: createMessage(), + confirmCallback: vi.fn(), + } as any); + + expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( + expect.anything(), + credentialDetails, + 'httpTemplatedCustomAuth', + 'internal', + undefined, + false, + ); + expect(credentialsHelper.authenticate).toHaveBeenCalledWith( + decrypted, + 'httpTemplatedCustomAuth', + expect.objectContaining({ url: 'https://example.com/webhook' }), + ); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer secret' }), + }), + ); + }); + + it('should not send when Simplified Custom Auth credentials cannot be resolved', async () => { + const { outboundHttp, request } = mockOutboundHttp(); + const destination = new MessageEventBusDestinationWebhook( + mockEventBus, + { + __type: MessageEventBusDestinationTypeNames.webhook, + url: 'https://example.com/webhook', + authentication: 'genericCredentialType', + genericAuthType: 'httpTemplatedCustomAuth', + credentials: { + httpTemplatedCustomAuth: { id: 'credential-id', name: 'API credential' }, + }, + }, + outboundHttp, + ); + const credentialsHelper = mock(); + credentialsHelper.getDecrypted.mockRejectedValue(new Error('Invalid credential template')); + destination.credentialsHelper = credentialsHelper; + + await expect( + destination.receiveFromEventBus({ + msg: createMessage(), + confirmCallback: vi.fn(), + } as Parameters[0]), + ).rejects.toThrow('Invalid credential template'); + expect(request).not.toHaveBeenCalled(); + }); + + it('should send without authentication when Simplified Custom Auth credentials are absent', async () => { + const { sentOptions } = await sendThroughDestination({ + __type: MessageEventBusDestinationTypeNames.webhook, + url: 'https://example.com/webhook', + authentication: 'genericCredentialType', + genericAuthType: 'httpTemplatedCustomAuth', + credentials: {}, + }); + + expect(sentOptions).not.toHaveProperty('auth'); + expect(sentOptions.headers).not.toHaveProperty('Authorization'); + }); + it('should map the message payload to the JSON body', async () => { const { sentOptions } = await sendThroughDestination({ __type: MessageEventBusDestinationTypeNames.webhook, diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts index 0e87327d93d..e39ab34456f 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts @@ -167,7 +167,7 @@ export class MessageEventBusDestinationWebhook return requestOptions; } - async matchDecryptedCredentialType(credentialType: string) { + async matchDecryptedCredentialType(credentialType: string, raw = true) { const foundCredential = Object.entries(this.credentials).find((e) => e[0] === credentialType); if (foundCredential) { const credentialsDecrypted = await this.credentialsHelper?.getDecrypted( @@ -178,7 +178,7 @@ export class MessageEventBusDestinationWebhook foundCredential[0], 'internal', undefined, - true, + raw, ); return credentialsDecrypted; } @@ -353,6 +353,7 @@ export class MessageEventBusDestinationWebhook let httpDigestAuth; let httpHeaderAuth; let httpQueryAuth; + let httpTemplatedCustomAuth; if (this.authentication === 'genericCredentialType') { if (this.genericAuthType === 'httpBasicAuth') { @@ -371,6 +372,11 @@ export class MessageEventBusDestinationWebhook try { httpQueryAuth = await this.matchDecryptedCredentialType('httpQueryAuth'); } catch {} + } else if (this.genericAuthType === 'httpTemplatedCustomAuth') { + httpTemplatedCustomAuth = await this.matchDecryptedCredentialType( + 'httpTemplatedCustomAuth', + false, + ); } } @@ -395,6 +401,16 @@ export class MessageEventBusDestinationWebhook username: httpDigestAuth.user as string, password: httpDigestAuth.password as string, }; + } else if (httpTemplatedCustomAuth) { + this.credentialsHelper ??= Container.get(CredentialsHelper); + Object.assign( + request, + await this.credentialsHelper.authenticate( + httpTemplatedCustomAuth, + 'httpTemplatedCustomAuth', + request, + ), + ); } try { diff --git a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts index b406419c0a2..baa539c02ba 100644 --- a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts @@ -1,13 +1,28 @@ -import type { ICredentialType, INodeType, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import { RoutingNode } from 'n8n-core'; +import type { + ICredentialType, + INode, + INodeType, + IWorkflowExecuteAdditionalData, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { CredentialTypes } from '@/credential-types'; import type { CredentialsHelper } from '@/credentials-helper'; import type { NodeTypes } from '@/node-types'; -import { CredentialsTester } from '@/services/credentials-tester.service'; +import { + AUTH_PROBE_ACCEPTED_MESSAGE, + CredentialsTester, +} from '@/services/credentials-tester.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +vi.mock('n8n-core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, RoutingNode: vi.fn() }; +}); + describe('CredentialsTester', () => { const credentialTypes = mock(); const nodeTypes = mock(); @@ -146,6 +161,21 @@ describe('CredentialsTester', () => { expect(redactedMessage.message).toBe('Test failed for apiKey *****key'); }); + it('should keep function-based tests working with the real routing engine untouched', async () => { + mockTestFunction.mockResolvedValue({ status: 'OK', message: 'fine' }); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({}); + + const result = await credentialsTester.testCredentials('user-id', 'testCredentials', { + id: 'credential-id', + name: 'credential-name', + type: 'testCredentials', + data: {}, + }); + + expect(result).toEqual({ status: 'OK', message: 'fine' }); + expect(RoutingNode).not.toHaveBeenCalled(); + }); + it('should not redact secrets with value shorter than 3 characters', async () => { mockTestFunction.mockResolvedValue({ status: 'Error', @@ -184,4 +214,206 @@ describe('CredentialsTester', () => { expect(redactedMessage.message).toBe('Test failed for apiKey se'); }); }); + + describe('probeCredentialAuth', () => { + const targetUrl = 'https://fal.run/fal-ai/flux/schnell'; + const credentials = () => ({ + id: 'credential-id', + name: 'fal.ai API Key', + type: 'httpHeaderAuth', + data: { name: 'Authorization', value: 'Key abc' }, + }); + + function mockRoutingNodeResult(outcome: { reject?: unknown; resolve?: unknown }) { + // Regular function — RoutingNode is instantiated with `new`. + (RoutingNode as unknown as Mock).mockImplementation(function () { + return { + runNode: outcome.reject + ? vi.fn().mockRejectedValue(outcome.reject) + : vi.fn().mockResolvedValue(outcome.resolve ?? [[{ json: {} }]]), + }; + }); + } + + function httpError(status: number) { + const error = new Error(`Request failed with status code ${status}`); + (error as Error & { cause: unknown }).cause = { + response: { status, statusText: `HTTP ${status}` }, + }; + return error; + } + + beforeEach(() => { + vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue( + {} as IWorkflowExecuteAdditionalData, + ); + credentialsHelper.applyDefaultsAndOverwrites.mockImplementation(async (_base, data) => data); + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: { name: 'n8n-nodes-base.noOp', version: 1, properties: [] }, + }), + ); + }); + + it('probes the target URL with the credential applied via the routing engine', async () => { + mockRoutingNodeResult({ resolve: [[{ json: {} }]] }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + ); + + expect(result.status).toBe('OK'); + // Some services answer 2xx regardless of the credential, so the green + // verdict states what happened instead of claiming verification. + expect(result.message).toBe(AUTH_PROBE_ACCEPTED_MESSAGE); + const nodeTypeArg = (RoutingNode as unknown as Mock).mock.calls[0][1] as INodeType; + expect(nodeTypeArg.description.properties[0].routing?.request).toEqual({ + url: targetUrl, + method: 'GET', + }); + }); + + it('fails only on an explicit auth rejection', async () => { + mockRoutingNodeResult({ reject: httpError(401) }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + ); + + expect(result.status).toBe('Error'); + expect(result.message).toContain('401'); + }); + + it('reports a non-auth error response as unverifiable instead of success', async () => { + mockRoutingNodeResult({ reject: httpError(405) }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + ); + + expect(result.status).toBe('Error'); + expect(result.message).toContain('405'); + expect(result.message).toContain('could not be verified'); + }); + + it('accepts a declared service-specific status code instead of rejecting on it', async () => { + mockRoutingNodeResult({ reject: httpError(401) }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + { acceptedStatusCodes: [401] }, + ); + + expect(result.status).toBe('OK'); + }); + + // The routing engine wraps HTTP failures in NodeApiError: the status is + // the string `httpCode` (plus context.data.status) — NOT cause.response, + // which only raw axios errors carry. A wrong Replicate key probed as + // "inconclusive" in production because the verdict read only the axios + // shape; these cases pin the real one. + function nodeApiError(status: number) { + const node = { + id: 'temp', + name: 'Temp-Node', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [0, 0], + parameters: {}, + } as INode; + return new NodeApiError( + node, + { message: `Request failed with status code ${status}` }, + { httpCode: String(status) }, + ); + } + + it('rejects a NodeApiError-wrapped 401 — the shape the routing engine actually throws', async () => { + mockRoutingNodeResult({ reject: nodeApiError(401) }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + ); + + expect(result.status).toBe('Error'); + expect(result.message).toContain('401'); + }); + + it('reports a NodeApiError-wrapped non-auth status as unverifiable', async () => { + mockRoutingNodeResult({ reject: nodeApiError(405) }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + ); + + expect(result.status).toBe('Error'); + expect(result.message).toContain('405'); + }); + + it('accepts a declared code on the NodeApiError shape too', async () => { + mockRoutingNodeResult({ reject: nodeApiError(401) }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + { acceptedStatusCodes: [401] }, + ); + + expect(result.status).toBe('OK'); + }); + + it('reports an unreachable service as unverifiable rather than success', async () => { + const error = new Error('connect ECONNREFUSED'); + (error as Error & { cause: unknown }).cause = { code: 'ECONNREFUSED' }; + mockRoutingNodeResult({ reject: error }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + ); + + expect(result.status).toBe('Error'); + expect(result.message).toContain('Could not reach'); + }); + + it('preserves credential configuration errors', async () => { + mockRoutingNodeResult({ + reject: new Error('No value set for placeholder {{api_key}}'), + }); + + const result = await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpTemplatedCustomAuth', + credentials(), + targetUrl, + ); + + expect(result).toEqual({ + status: 'Error', + message: 'No value set for placeholder {{api_key}}', + }); + }); + }); }); diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index 0a9a3c47c41..b37a379e1b2 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -45,6 +45,10 @@ import { CredentialsHelper } from '../credentials-helper'; const { OAUTH2_CREDENTIAL_TEST_SUCCEEDED, OAUTH2_CREDENTIAL_TEST_FAILED } = RESPONSE_ERROR_MESSAGES; +/** Auth-probe green verdict: states what a 2xx proves without claiming the + * key was verified — some services answer 2xx regardless of the credential. */ +export const AUTH_PROBE_ACCEPTED_MESSAGE = 'The service accepted the credential.'; + const mockNodesData: INodeTypeData = { mock: { sourcePath: '', @@ -189,7 +193,72 @@ export class CredentialsTester { return message; } - // eslint-disable-next-line complexity + /** Resolve overwrites/defaults onto the decrypted data; returns the secret paths for redaction. */ + private async prepareCredentialsForTest( + userId: User['id'], + credentialType: string, + credentialsDecrypted: ICredentialsDecrypted, + ): Promise<{ + baseAdditionalData: IWorkflowExecuteAdditionalData; + credentialsDataSecretKeys: string[]; + }> { + const baseAdditionalData = await WorkflowExecuteAdditionalData.getBase({ + userId, + projectId: credentialsDecrypted.homeProject?.id, + }); + + let credentialsDataSecretKeys: string[] = []; + if (credentialsDecrypted.data) { + // Keep all credentials data keys which have a secret value + credentialsDataSecretKeys = getExternalSecretExpressionPaths(credentialsDecrypted.data); + credentialsDecrypted.data = await this.credentialsHelper.applyDefaultsAndOverwrites( + baseAdditionalData, + credentialsDecrypted.data, + credentialType, + 'internal' as WorkflowExecuteMode, + undefined, + undefined, + ); + } + + return { baseAdditionalData, credentialsDataSecretKeys }; + } + + /** + * Test a credential against an ad-hoc URL when its type declares no test of + * its own (generic auth types like httpHeaderAuth). The credential is applied + * through its `authenticate` definition — the same way the HTTP Request node + * sends it — and only 401/403 count as rejection: any other response means + * the endpoint accepted the credential (it may still dislike the method or + * path), and an unreachable service is inconclusive rather than a failure. + */ + async probeCredentialAuth( + userId: User['id'], + credentialType: string, + credentialsDecrypted: ICredentialsDecrypted, + targetUrl: string, + options: { acceptedStatusCodes?: number[] } = {}, + ): Promise { + try { + await this.prepareCredentialsForTest(userId, credentialType, credentialsDecrypted); + } catch (error) { + this.logger.debug('Credential auth probe failed', error); + return { + status: 'Error', + message: error.message.toString(), + }; + } + + return await this.runRequestTest( + userId, + credentialType, + credentialsDecrypted, + { testRequest: { request: { url: targetUrl, method: 'GET' } } }, + 'authProbe', + options.acceptedStatusCodes, + ); + } + async testCredentials( userId: User['id'], credentialType: string, @@ -206,23 +275,11 @@ export class CredentialsTester { let credentialsDataSecretKeys: string[] = []; let baseAdditionalData: IWorkflowExecuteAdditionalData; try { - baseAdditionalData = await WorkflowExecuteAdditionalData.getBase({ + ({ baseAdditionalData, credentialsDataSecretKeys } = await this.prepareCredentialsForTest( userId, - projectId: credentialsDecrypted.homeProject?.id, - }); - - if (credentialsDecrypted.data) { - // Keep all credentials data keys which have a secret value - credentialsDataSecretKeys = getExternalSecretExpressionPaths(credentialsDecrypted.data); - credentialsDecrypted.data = await this.credentialsHelper.applyDefaultsAndOverwrites( - baseAdditionalData, - credentialsDecrypted.data, - credentialType, - 'internal' as WorkflowExecuteMode, - undefined, - undefined, - ); - } + credentialType, + credentialsDecrypted, + )); } catch (error) { this.logger.debug('Credential test failed', error); return { @@ -253,7 +310,87 @@ export class CredentialsTester { } // Credentials get tested via request instructions + return await this.runRequestTest( + userId, + credentialType, + credentialsDecrypted, + credentialTestFunction, + 'default', + ); + } + /** + * Decide an auth-probe outcome from a failed probe request. The routing + * engine wraps HTTP failures in NodeApiError — the status lives in the + * string `httpCode` (and `context.data.status`), NOT in `cause.response`, + * which only appears on raw axios errors. Verdicts: 401/403 (minus + * service-declared accepted codes) is an auth rejection; any other failure + * (404/405 on a wrong test URL, transport errors, …) proves nothing about + * the credential, so it must never render as success — report it as + * unverifiable instead of a false green check. The probe runs after the + * credential is saved, so an Error verdict never blocks the save. + */ + private resolveAuthProbeVerdict( + error: { + message?: unknown; + httpCode?: unknown; + context?: { data?: { status?: unknown } }; + cause?: { response?: { status?: unknown }; code?: unknown }; + }, + acceptedStatusCodes?: number[], + ): INodeCredentialTestResult { + const statusCode = + Number(error.httpCode) || + Number(error.context?.data?.status) || + Number(error.cause?.response?.status) || + undefined; + + if (statusCode === 401 || statusCode === 403) { + if (!acceptedStatusCodes?.includes(statusCode)) { + return { + status: 'Error', + message: `The service rejected the credential (HTTP ${statusCode}). Check the key and try again.`, + }; + } + // The service is documented to answer this code to a valid GET — the + // probe can't tell valid from invalid here, so don't overclaim. + return { status: 'OK', message: AUTH_PROBE_ACCEPTED_MESSAGE }; + } + + if (statusCode) { + return { + status: 'Error', + message: `The test URL answered HTTP ${statusCode}, so the credential could not be verified. The test URL may be wrong — it must be a read-only endpoint that answers an authenticated GET.`, + }; + } + + if (typeof error.message === 'string' && !error.cause?.code) { + return { status: 'Error', message: error.message }; + } + + this.logger.debug('Credential auth probe inconclusive', error); + return { + status: 'Error', + message: 'Could not reach the test URL to verify the credential.', + }; + } + + /** + * Execute a request-based credential test through the declarative routing + * engine. The `authProbe` verdict treats 401/403 as rejection, 2xx as + * success, and everything else (wrong test URL, unreachable service) as + * unverifiable — used for ad-hoc probes of generic credentials against a + * known endpoint. + */ + // eslint-disable-next-line complexity + private async runRequestTest( + userId: User['id'], + credentialType: string, + credentialsDecrypted: ICredentialsDecrypted, + credentialTestFunction: ICredentialTestRequestData, + verdict: 'default' | 'authProbe', + acceptedStatusCodes?: number[], + ): Promise { // TODO: Temp workflows get created at multiple locations (for example also LoadNodeParameterOptions), // check if some of them are identical enough that it can be combined @@ -356,6 +493,9 @@ export class CredentialsTester { response = await routingNode.runNode(); } catch (error) { this.errorReporter.error(error); + if (verdict === 'authProbe') { + return this.resolveAuthProbeVerdict(error, acceptedStatusCodes); + } // Do not fail any requests to allow custom error messages and // make logic easier if (error.cause?.response) { @@ -363,6 +503,7 @@ export class CredentialsTester { statusCode: error.cause.response.status, statusMessage: error.cause.response.statusText, }; + if (credentialTestFunction.testRequest.rules) { // Special testing rules are defined so check all in order for (const rule of credentialTestFunction.testRequest.rules) { @@ -420,6 +561,18 @@ export class CredentialsTester { } } + if (verdict === 'authProbe') { + // A 2xx proves the service accepted the request carrying the credential. + // For auth-enforcing test URLs that is verification; for endpoints that + // answer 2xx regardless (no auth required, or errors signalled in the + // body) it is not — the probe can't tell them apart, so the copy states + // what happened instead of claiming the key was verified. + return { + status: 'OK', + message: AUTH_PROBE_ACCEPTED_MESSAGE, + }; + } + return { status: 'OK', message: 'Connection successful!', diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialsSelect.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialsSelect.vue index 81d76a0591f..baedcb0aedb 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialsSelect.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialsSelect.vue @@ -73,8 +73,10 @@ function isSupported(name: string): boolean { for (const property of supported.has) { if (checkedCredType[property as keyof ICredentialType] !== undefined) { - // edge case: `httpHeaderAuth` has `authenticate` auth but belongs to generic auth - if (name === 'httpHeaderAuth' && property === 'authenticate') continue; + // generic-auth credentials (e.g. httpHeaderAuth) may also define + // `authenticate`; they belong in the generic auth dropdown, not the + // predefined credential type list + if (property === 'authenticate' && checkedCredType.genericAuth === true) continue; return true; } diff --git a/packages/nodes-base/credentials/HttpTemplatedCustomAuth.credentials.ts b/packages/nodes-base/credentials/HttpTemplatedCustomAuth.credentials.ts new file mode 100644 index 00000000000..80f550d247c --- /dev/null +++ b/packages/nodes-base/credentials/HttpTemplatedCustomAuth.credentials.ts @@ -0,0 +1,103 @@ +/* eslint-disable n8n-nodes-base/cred-class-name-unsuffixed */ +/* eslint-disable n8n-nodes-base/cred-class-field-name-unsuffixed */ +/* eslint-disable n8n-nodes-base/cred-class-field-documentation-url-missing */ +import type { IAuthenticate, ICredentialType, INodeProperties, Icon } from 'n8n-workflow'; + +import { applyTemplatedAuth } from '../utils/templated-auth'; + +export class HttpTemplatedCustomAuth implements ICredentialType { + name = 'httpTemplatedCustomAuth'; + + // Display-only name; the internal id stays `httpTemplatedCustomAuth` (it is + // persisted in credentials, recipes and the workflow-sdk type unions). + displayName = 'Simplified Custom Auth'; + + // No documentationUrl on purpose: the generic HTTP Request docs don't cover + // this type, and setting one makes the credential modal render a docs + // banner — the guided form and the AI Assistant handle setup help instead. + + genericAuth = true; + + icon: Icon = 'node:n8n-nodes-base.httpRequest'; + + properties: INodeProperties[] = [ + { + displayName: 'Template', + name: 'template', + type: 'json', + required: true, + description: + 'The authentication parts (headers, body, qs) added to every request this credential signs. {{placeholder}} markers are replaced with the matching entry from Placeholder Values. Must not contain secrets — those belong in the placeholder values.', + placeholder: '{ "headers": { "Authorization": "Bearer {{api_key}}" } }', + default: '', + }, + { + displayName: 'Placeholders', + name: 'placeholderDefs', + type: 'json', + description: + 'Describes the input shown for each {{placeholder}}: name, user-facing title, help text, and type ("password" masks the input, "plain" does not)', + placeholder: + '[ { "name": "api_key", "title": "API key", "info": "Found on the API keys page", "type": "password" } ]', + default: '', + }, + { + displayName: 'Placeholder Values', + name: 'placeholderValues', + type: 'json', + description: + 'The secret value that replaces each {{placeholder}} of the template when a request is sent, by placeholder name. Values are redacted after saving.', + placeholder: '{ "api_key": "..." }', + default: '', + typeOptions: { + redactJsonLeaves: true, + resolveCredentialJsonLeaves: true, + }, + }, + { + displayName: 'Test URL', + name: 'testUrl', + type: 'string', + description: + 'Side-effect-free GET endpoint the credential is verified against (e.g. an account or profile endpoint). Must never trigger billable work.', + default: '', + }, + { + displayName: 'Documentation URL', + name: 'docsUrl', + type: 'string', + description: + 'Provider page where the user creates/copies the secret (e.g. the API-keys dashboard). The AI Assistant help thread points the user there.', + default: '', + }, + { + // Machine-readable service identity: the credential type is shared by + // every service, so setup surfaces need this to offer a credential only + // to nodes calling the same service. The name field stays the human layer. + displayName: 'Service Host', + name: 'serviceHost', + type: 'string', + description: + 'Host of the API this credential authenticates against (e.g. api.pexels.com). Setup surfaces only offer this credential to nodes calling the same host (subdomains match). Set from the recipe when the credential is created; when empty, the credential is never offered automatically.', + placeholder: 'api.pexels.com', + default: '', + }, + { + displayName: 'Accepted Status Codes', + name: 'acceptedStatusCodes', + type: 'string', + description: + 'Status codes the credential test must not treat as an auth rejection, as a JSON array — e.g. [401] for services that answer 401 to a valid GET. Only 401 and 403 can ever count as rejection, so other codes are ignored.', + placeholder: '[401]', + default: '', + }, + ]; + + // Lets requestWithAuthentication consumers (auth probe, paginated requests) + // apply the credential without the HTTP node's generic-auth branch. + authenticate: IAuthenticate = async (credentials, requestOptions) => { + const authenticatedRequestOptions = { ...requestOptions }; + applyTemplatedAuth(credentials, authenticatedRequestOptions); + return await Promise.resolve(authenticatedRequestOptions); + }; +} diff --git a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts index 431249ee545..0afd0537151 100644 --- a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts @@ -20,6 +20,8 @@ import type { Readable } from 'stream'; import { sleep } from '@n8n/utils/sleep'; +import { applyTemplatedAuth } from '@utils/templated-auth'; + import type { IAuthDataSanitizeKeys } from '../GenericFunctions'; import { getAllowedDomains, @@ -648,6 +650,7 @@ export class HttpRequestV2 implements INodeType { let httpDigestAuth; let httpHeaderAuth; let httpQueryAuth; + let httpTemplatedCustomAuth; let oAuth1Api; let oAuth2Api; let nodeCredentialType; @@ -675,6 +678,10 @@ export class HttpRequestV2 implements INodeType { try { httpQueryAuth = await this.getCredentials('httpQueryAuth'); } catch {} + } else if (genericAuthType === 'httpTemplatedCustomAuth') { + try { + httpTemplatedCustomAuth = await this.getCredentials('httpTemplatedCustomAuth'); + } catch {} } else if (genericAuthType === 'oAuth1Api') { try { oAuth1Api = await this.getCredentials('oAuth1Api'); @@ -698,6 +705,7 @@ export class HttpRequestV2 implements INodeType { httpDigestAuth, httpHeaderAuth, httpQueryAuth, + httpTemplatedCustomAuth, oAuth1Api, oAuth2Api, ]) { @@ -1046,6 +1054,12 @@ export class HttpRequestV2 implements INodeType { }; authDataKeys.auth = ['pass']; } + if (httpTemplatedCustomAuth !== undefined) { + const templatedAuth = applyTemplatedAuth(httpTemplatedCustomAuth, requestOptions); + if (templatedAuth.headers) authDataKeys.headers = Object.keys(templatedAuth.headers); + if (templatedAuth.body) authDataKeys.body = Object.keys(templatedAuth.body); + if (templatedAuth.qs) authDataKeys.qs = Object.keys(templatedAuth.qs); + } if (requestOptions.headers!.accept === undefined) { if (responseFormat === 'json') { diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index c812b08a5cd..95e0ec90fc3 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -28,6 +28,7 @@ import { } from 'n8n-workflow'; import type { Readable } from 'stream'; +import { applyTemplatedAuth } from '@utils/templated-auth'; import { keysToLowercase } from '@utils/utilities'; import { mainProperties } from './Description'; @@ -125,6 +126,7 @@ export class HttpRequestV3 implements INodeType { let httpHeaderAuth; let httpQueryAuth; let httpCustomAuth; + let httpTemplatedCustomAuth; let oAuth1Api; let oAuth2Api; let sslCertificates; @@ -199,6 +201,12 @@ export class HttpRequestV3 implements INodeType { } else if (genericCredentialType === 'httpCustomAuth') { httpCustomAuth = await this.getCredentials('httpCustomAuth', itemIndex); allowedDomains = getAllowedDomains(this.getNode(), httpCustomAuth); + } else if (genericCredentialType === 'httpTemplatedCustomAuth') { + httpTemplatedCustomAuth = await this.getCredentials( + 'httpTemplatedCustomAuth', + itemIndex, + ); + allowedDomains = getAllowedDomains(this.getNode(), httpTemplatedCustomAuth); } else if (genericCredentialType === 'oAuth1Api') { oAuth1Api = await this.getCredentials('oAuth1Api', itemIndex); allowedDomains = getAllowedDomains(this.getNode(), oAuth1Api); @@ -602,6 +610,18 @@ export class HttpRequestV3 implements INodeType { authDataKeys.qs = Object.keys(customAuth.qs); } } + if (httpTemplatedCustomAuth !== undefined) { + const templatedAuth = applyTemplatedAuth(httpTemplatedCustomAuth, requestOptions); + if (templatedAuth.headers) { + authDataKeys.headers = Object.keys(templatedAuth.headers); + } + if (templatedAuth.body) { + authDataKeys.body = Object.keys(templatedAuth.body); + } + if (templatedAuth.qs) { + authDataKeys.qs = Object.keys(templatedAuth.qs); + } + } if (requestOptions.headers!.accept === undefined) { if (responseFormat === 'json') { diff --git a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts index c50d2520dad..b496490c406 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts @@ -100,6 +100,15 @@ describe('HttpRequestV2', () => { authField: 'qs', authValue: { Token: 'secretToken' }, }, + { + genericCredentialType: 'httpTemplatedCustomAuth', + credentials: { + template: JSON.stringify({ headers: { Authorization: 'Bearer {{api_key}}' } }), + placeholderValues: JSON.stringify({ api_key: 'templatedToken' }), + }, + authField: 'headers', + authValue: { Authorization: 'Bearer templatedToken' }, + }, { genericCredentialType: 'oAuth1Api', credentials: { oauth_token: 'token', oauth_token_secret: 'secret' }, diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 76d78accd03..be831b48352 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -197,6 +197,7 @@ "dist/credentials/HttpMultipleHeadersAuth.credentials.js", "dist/credentials/HttpCustomAuth.credentials.js", "dist/credentials/HttpQueryAuth.credentials.js", + "dist/credentials/HttpTemplatedCustomAuth.credentials.js", "dist/credentials/HttpSslAuth.credentials.js", "dist/credentials/HubspotApi.credentials.js", "dist/credentials/HubspotAppToken.credentials.js", diff --git a/packages/nodes-base/utils/__tests__/templated-auth.test.ts b/packages/nodes-base/utils/__tests__/templated-auth.test.ts new file mode 100644 index 00000000000..f04aaddea99 --- /dev/null +++ b/packages/nodes-base/utils/__tests__/templated-auth.test.ts @@ -0,0 +1,224 @@ +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import { applyTemplatedAuth, resolveTemplatedAuth } from '../templated-auth'; + +const credentialData = ( + template: object, + placeholderValues: object = {}, + placeholderDefs?: object[], +): ICredentialDataDecryptedObject => ({ + template: JSON.stringify(template), + placeholderValues: JSON.stringify(placeholderValues), + ...(placeholderDefs ? { placeholderDefs: JSON.stringify(placeholderDefs) } : {}), +}); + +describe('resolveTemplatedAuth', () => { + it('should resolve placeholders in headers, query and nested body values', () => { + const result = resolveTemplatedAuth( + credentialData( + { + headers: { Authorization: 'Bearer {{api_key}}', 'api-version': '{{api_version}}' }, + qs: { apikey: '{{api_key}}' }, + body: { auth: { token: '{{api_key}}' } }, + }, + { api_key: 'secret-key', api_version: '202404' }, + ), + ); + + expect(result).toEqual({ + headers: { Authorization: 'Bearer secret-key', 'api-version': '202404' }, + qs: { apikey: 'secret-key' }, + body: { auth: { token: 'secret-key' } }, + }); + }); + + it('should resolve multiple placeholders within one string', () => { + const result = resolveTemplatedAuth( + credentialData( + { headers: { 'X-Auth': '{{user}}:{{ token }}' } }, + { user: 'bot', token: 'abc' }, + ), + ); + + expect(result.headers).toEqual({ 'X-Auth': 'bot:abc' }); + }); + + it('should leave parts without placeholders untouched', () => { + const result = resolveTemplatedAuth( + credentialData({ headers: { Accept: 'application/json' }, qs: { page: 1 } }), + ); + + expect(result).toEqual({ headers: { Accept: 'application/json' }, qs: { page: 1 } }); + }); + + it('should throw when a placeholder has no value', () => { + expect(() => + resolveTemplatedAuth(credentialData({ headers: { 'X-Key': '{{api_key}}' } })), + ).toThrow('No value set for placeholder {{api_key}}'); + }); + + it('should throw when a placeholder value is empty', () => { + expect(() => + resolveTemplatedAuth( + credentialData({ headers: { 'X-Key': '{{api_key}}' } }, { api_key: '' }), + ), + ).toThrow('No value set for placeholder {{api_key}}'); + }); + + it('should throw when a placeholder value is not a plain value', () => { + expect(() => + resolveTemplatedAuth( + credentialData({ headers: { 'X-Key': '{{api_key}}' } }, { api_key: { nested: true } }), + ), + ).toThrow('must be a plain value'); + }); + + it.each(['constructor', 'toString'])('should not resolve inherited %s values', (name) => { + expect(() => + resolveTemplatedAuth(credentialData({ headers: { Authorization: `{{${name}}}` } })), + ).toThrow(`No value set for placeholder {{${name}}}`); + }); + + it('should throw on invalid template JSON', () => { + expect(() => resolveTemplatedAuth({ template: 'not json', placeholderValues: '{}' })).toThrow( + 'Invalid Simplified Custom Auth template JSON', + ); + }); + + it.each([ + ['array', []], + ['string', 'headers'], + ])('should reject a template parsed as a %s', (_, template) => { + expect(() => + resolveTemplatedAuth({ template: JSON.stringify(template), placeholderValues: '{}' }), + ).toThrow('Simplified Custom Auth template must be a JSON object'); + }); + + it.each(['headers', 'body', 'qs'])('should reject non-object template %s', (partName) => { + expect(() => resolveTemplatedAuth(credentialData({ [partName]: 'invalid' }))).toThrow( + `Simplified Custom Auth template ${partName} must be a JSON object`, + ); + }); + + it.each([ + ['array', []], + ['string', 'secret'], + ])('should reject placeholder values parsed as a %s', (_, placeholderValues) => { + expect(() => + resolveTemplatedAuth({ + template: '{}', + placeholderValues: JSON.stringify(placeholderValues), + }), + ).toThrow('Simplified Custom Auth placeholder values must be a JSON object'); + }); + + it('should keep reserved JSON keys as plain own properties', () => { + const result = resolveTemplatedAuth({ + template: '{"headers":{"__proto__":{"polluted":"{{value}}"}}}', + placeholderValues: '{"value":"x"}', + }); + + const headers = result.headers as object; + expect(Object.getOwnPropertyDescriptor(headers, '__proto__')?.value).toEqual({ + polluted: 'x', + }); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('should resolve an empty credential to no request parts', () => { + expect(resolveTemplatedAuth({})).toEqual({}); + }); + + describe('optional placeholders', () => { + const defs = [ + { name: 'api_key', title: 'API key' }, + { name: 'org', title: 'Organization', optional: true }, + ]; + + it('should omit template entries referencing an empty optional placeholder', () => { + const result = resolveTemplatedAuth( + credentialData( + { + headers: { Authorization: 'Key {{api_key}}', 'X-Org': '{{org}}' }, + qs: { org: '{{org}}' }, + }, + { api_key: 'secret' }, + defs, + ), + ); + + expect(result).toEqual({ headers: { Authorization: 'Key secret' }, qs: {} }); + }); + + it('should substitute an optional placeholder normally when a value is set', () => { + const result = resolveTemplatedAuth( + credentialData( + { headers: { Authorization: 'Key {{api_key}}', 'X-Org': '{{org}}' } }, + { api_key: 'secret', org: 'acme' }, + defs, + ), + ); + + expect(result.headers).toEqual({ Authorization: 'Key secret', 'X-Org': 'acme' }); + }); + + it('should omit a mixed entry when its optional placeholder is empty, even with statics', () => { + const result = resolveTemplatedAuth( + credentialData( + { headers: { Authorization: 'Key {{api_key}}', 'X-Scope': 'org:{{org}}' } }, + { api_key: 'secret', org: '' }, + defs, + ), + ); + + expect(result.headers).toEqual({ Authorization: 'Key secret' }); + }); + + it('should omit an entry before resolving its other missing placeholders', () => { + const result = resolveTemplatedAuth( + credentialData({ headers: { 'X-Scope': '{{org}}:{{api_key}}' } }, {}, defs), + ); + + expect(result.headers).toEqual({}); + }); + + it('should still fail closed for empty required placeholders', () => { + expect(() => + resolveTemplatedAuth( + credentialData({ headers: { 'X-Key': '{{api_key}}' } }, { api_key: '' }, defs), + ), + ).toThrow('No value set for placeholder {{api_key}}'); + }); + + it('should treat markers as required when the defs are unparseable', () => { + expect(() => + resolveTemplatedAuth({ + template: JSON.stringify({ headers: { 'X-Org': '{{org}}' } }), + placeholderValues: '{}', + placeholderDefs: 'not json', + }), + ).toThrow('No value set for placeholder {{org}}'); + }); + }); +}); + +describe('applyTemplatedAuth', () => { + const credentials = credentialData({ body: { token: '{{api_key}}' } }, { api_key: 'secret' }); + + it('should merge a body template into an object body', () => { + const requestOptions = { body: { payload: true } }; + + applyTemplatedAuth(credentials, requestOptions); + + expect(requestOptions.body).toEqual({ payload: true, token: 'secret' }); + }); + + it.each([ + ['raw', 'payload'], + ['binary', Buffer.from('payload')], + ])('should reject a %s request body', (_, body) => { + expect(() => applyTemplatedAuth(credentials, { body })).toThrow( + 'Simplified Custom Auth body templates cannot be applied to non-object request bodies', + ); + }); +}); diff --git a/packages/nodes-base/utils/templated-auth.ts b/packages/nodes-base/utils/templated-auth.ts new file mode 100644 index 00000000000..cf6ffceb723 --- /dev/null +++ b/packages/nodes-base/utils/templated-auth.ts @@ -0,0 +1,161 @@ +import isPlainObject from 'lodash/isPlainObject'; +import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow'; +import { jsonParse, UserError } from 'n8n-workflow'; + +const PLACEHOLDER_MARKER_REGEX = /\{\{\s*([\w.-]+)\s*\}\}/g; + +export type TemplatedAuthParts = { + headers?: IDataObject; + body?: IDataObject; + qs?: IDataObject; +}; + +type TemplatedAuthRequestOptions = { + headers?: IDataObject; + body?: unknown; + qs?: IDataObject; +}; + +/** A resolved string that must be dropped from the output (empty optional). */ +const OMIT = Symbol('omit'); + +function isEmptyPlaceholderValue(value: unknown): value is null | undefined | '' { + return value === undefined || value === null || value === ''; +} + +function isPlainDataObject(value: unknown): value is IDataObject { + return isPlainObject(value); +} + +function assertTemplatedAuthParts(value: unknown): asserts value is TemplatedAuthParts { + if (!isPlainDataObject(value)) { + throw new UserError('Simplified Custom Auth template must be a JSON object'); + } + + for (const partName of ['headers', 'body', 'qs'] satisfies Array) { + const part = value[partName]; + if (part !== undefined && !isPlainDataObject(part)) { + throw new UserError(`Simplified Custom Auth template ${partName} must be a JSON object`); + } + } +} + +/** Marker names whose placeholder def declares `optional: true`. */ +function optionalMarkerNames(credentialData: ICredentialDataDecryptedObject): Set { + const parsed = jsonParse((credentialData.placeholderDefs as string) || '[]', { + fallbackValue: [], + }); + if (!Array.isArray(parsed)) return new Set(); + const defs: unknown[] = parsed; + const names = new Set(); + for (const def of defs) { + if ( + typeof def === 'object' && + def !== null && + 'name' in def && + typeof def.name === 'string' && + 'optional' in def && + def.optional === true + ) { + names.add(def.name); + } + } + return names; +} + +/** + * Resolve the `{{placeholder}}` markers of a Templated Custom Auth credential + * into the request parts its template declares. Markers are substituted per + * string leaf after parsing (never on the raw JSON text), so a value can never + * change the template's structure. An unresolved or empty placeholder throws + * instead of letting a literal marker reach the service — unless its def marks + * it optional, in which case the containing template entry is omitted. + */ +export function resolveTemplatedAuth( + credentialData: ICredentialDataDecryptedObject, +): TemplatedAuthParts { + const template = jsonParse((credentialData.template as string) || '{}', { + errorMessage: 'Invalid Simplified Custom Auth template JSON', + }); + assertTemplatedAuthParts(template); + + const values = jsonParse((credentialData.placeholderValues as string) || '{}', { + errorMessage: 'Invalid Simplified Custom Auth placeholder values JSON', + }); + if (!isPlainDataObject(values)) { + throw new UserError('Simplified Custom Auth placeholder values must be a JSON object'); + } + const placeholderValues = new Map(Object.entries(values)); + const optionalMarkers = optionalMarkerNames(credentialData); + + const resolve = (part: T): T | typeof OMIT => { + if (typeof part === 'string') { + const shouldOmit = [...part.matchAll(PLACEHOLDER_MARKER_REGEX)].some( + ([, name]) => + isEmptyPlaceholderValue(placeholderValues.get(name)) && optionalMarkers.has(name), + ); + if (shouldOmit) return OMIT; + + const resolved = part.replace(PLACEHOLDER_MARKER_REGEX, (marker, name: string) => { + const value = placeholderValues.get(name); + if (isEmptyPlaceholderValue(value)) { + throw new UserError( + `No value set for placeholder ${marker} of the Simplified Custom Auth credential`, + ); + } + if (typeof value === 'object') { + throw new UserError( + `The value of placeholder ${marker} of the Simplified Custom Auth credential must be a plain value`, + ); + } + return String(value); + }); + return resolved as T; + } + if (Array.isArray(part)) { + return (part as unknown[]) + .map((entry) => resolve(entry)) + .filter((entry) => entry !== OMIT) as T; + } + if (typeof part === 'object' && part !== null) { + // Object.fromEntries defines own properties only, so template keys such + // as `__proto__` cannot reach the prototype chain. + return Object.fromEntries( + Object.entries(part) + .map(([key, entry]) => [key, resolve(entry)] as const) + .filter(([, entry]) => entry !== OMIT), + ) as T; + } + return part; + }; + + const resolved = resolve(template); + // The top level is always an object, so it can never resolve to OMIT. + return resolved === OMIT ? {} : resolved; +} + +/** Resolve and merge a Templated Custom Auth credential into request options. */ +export function applyTemplatedAuth( + credentialData: ICredentialDataDecryptedObject, + requestOptions: TemplatedAuthRequestOptions, +): TemplatedAuthParts { + const templatedAuth = resolveTemplatedAuth(credentialData); + + if (templatedAuth.headers) { + requestOptions.headers = { ...requestOptions.headers, ...templatedAuth.headers }; + } + if (templatedAuth.body) { + const existingBody = requestOptions.body; + if (existingBody !== undefined && !isPlainDataObject(existingBody)) { + throw new UserError( + 'Simplified Custom Auth body templates cannot be applied to non-object request bodies', + ); + } + requestOptions.body = { ...existingBody, ...templatedAuth.body }; + } + if (templatedAuth.qs) { + requestOptions.qs = { ...requestOptions.qs, ...templatedAuth.qs }; + } + + return templatedAuth; +} diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 126a40fa0a4..d2bc8dc7b60 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -240,8 +240,8 @@ export abstract class ICredentialsHelper { credentials: ICredentialDataDecryptedObject, typeName: string, requestOptions: IHttpRequestOptions | IRequestOptionsSimplified, - workflow: Workflow, - node: INode, + workflow?: Workflow, + node?: INode, ): Promise; abstract preAuthentication( @@ -1860,6 +1860,7 @@ export interface INodePropertyTypeOptions { password?: boolean; // Supported by: string copyButton?: boolean; // Supported by: string — renders a readonly value with a click-to-copy affordance redactJsonLeaves?: boolean; // Supported by: json (credential fields only) — redacts leaf values instead of the whole field + resolveCredentialJsonLeaves?: boolean; // Supported by: json (credential fields only) — resolves expressions in JSON leaf values ignoreCredentialExpressionResolveError?: boolean; // Supported by credentials fields outside execution contexts rows?: number; // Supported by: string showAlpha?: boolean; // Supported by: color