From add5ab29dd8ee658573b5b7fdcb29cae1c008702 Mon Sep 17 00:00:00 2001 From: eric-liu <126835597+ericyangliu@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:08:41 -0700 Subject: [PATCH] feat: Add AWS Assume Role support for Bedrock nodes (#28663) Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com> Co-authored-by: Declan Carroll --- package.json | 6 +- packages/@n8n/ai-utilities/package.json | 2 +- .../EmbeddingsAwsBedrock.node.ts | 36 +-- .../test/EmbeddingsAwsBedrock.test.ts | 120 +++++++ .../LmChatAwsBedrock/LmChatAwsBedrock.node.ts | 29 +- .../test/LmChatAwsBedrock.test.ts | 80 ++++- packages/@n8n/nodes-langchain/package.json | 1 + .../utils/aws/resolveAwsCredentials.ts | 99 ++++++ .../aws/test/resolveAwsCredentials.test.ts | 295 ++++++++++++++++++ packages/cli/package.json | 2 +- .../credentials/AwsAssumeRole.credentials.ts | 9 - .../credentials/common/aws/utils.test.ts | 74 ++--- .../credentials/common/aws/utils.ts | 22 +- .../test/aws-assume-role-utils.test.ts | 118 +++++++ packages/nodes-base/package.json | 2 + pnpm-lock.yaml | 23 +- pnpm-workspace.yaml | 4 + 17 files changed, 820 insertions(+), 102 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/test/EmbeddingsAwsBedrock.test.ts create mode 100644 packages/@n8n/nodes-langchain/utils/aws/resolveAwsCredentials.ts create mode 100644 packages/@n8n/nodes-langchain/utils/aws/test/resolveAwsCredentials.test.ts create mode 100644 packages/nodes-base/credentials/test/aws-assume-role-utils.test.ts diff --git a/package.json b/package.json index 2b4e53b6426..efaa08d74a0 100644 --- a/package.json +++ b/package.json @@ -141,9 +141,9 @@ "@smithy/config-resolver": ">=4.4.0", "@rudderstack/rudder-sdk-node@<=3.0.0": "3.0.0", "diff": "8.0.3", - "undici@5": "^6.24.0", - "undici@6": "^6.24.0", - "undici@7": "^7.24.0", + "undici@5": "catalog:undici-v6", + "undici@6": "catalog:undici-v6", + "undici@7": "catalog:undici-v7", "tar": "^7.5.11", "ajv@6": "6.14.0", "ajv@7": "8.18.0", diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index 668c0e14cb2..75487d68fd1 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -105,7 +105,7 @@ "@thednp/dommatrix": "^2.0.12", "pdf-parse": "catalog:", "proxy-from-env": "^1.1.0", - "undici": "^6.21.0" + "undici": "catalog:undici-v6" }, "peerDependencies": { "n8n-workflow": "*" diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts index c5ef4d941bf..10f5da37374 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts @@ -3,6 +3,7 @@ import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; import { BedrockEmbeddings } from '@langchain/aws'; import { NodeHttpHandler } from '@smithy/node-http-handler'; import { getNodeProxyAgent, logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities'; +import { awsNodeAuthOptions, awsNodeCredentials } from 'n8n-nodes-base/dist/nodes/Aws/utils'; import { NodeConnectionTypes, @@ -12,17 +13,14 @@ import { type SupplyData, } from 'n8n-workflow'; +import { resolveAwsCredentials } from '@utils/aws/resolveAwsCredentials'; + export class EmbeddingsAwsBedrock implements INodeType { description: INodeTypeDescription = { displayName: 'Embeddings AWS Bedrock', name: 'embeddingsAwsBedrock', icon: 'file:bedrock.svg', - credentials: [ - { - name: 'aws', - required: true, - }, - ], + credentials: awsNodeCredentials, group: ['transform'], version: 1, description: 'Use Embeddings AWS Bedrock', @@ -53,11 +51,13 @@ export class EmbeddingsAwsBedrock implements INodeType { baseURL: '=https://bedrock.{{$credentials?.region ?? "eu-central-1"}}.amazonaws.com', }, properties: [ + awsNodeAuthOptions, getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Model', name: 'model', type: 'options', + allowArbitraryValues: true, // Hide issues when model name is specified in the expression and does not match any of the options description: 'The model which will generate the completion. Learn more.', typeOptions: { @@ -106,24 +106,16 @@ export class EmbeddingsAwsBedrock implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const credentials = await this.getCredentials<{ - region: string; - secretAccessKey: string; - accessKeyId: string; - sessionToken: string; - }>('aws'); + const { region, credentials } = await resolveAwsCredentials(this, itemIndex); const modelName = this.getNodeParameter('model', itemIndex) as string; - const clientConfig: BedrockRuntimeClientConfig = { - region: credentials.region, - credentials: { - secretAccessKey: credentials.secretAccessKey, - accessKeyId: credentials.accessKeyId, - sessionToken: credentials.sessionToken, - }, - }; + const bedrockEndpoint = `https://bedrock-runtime.${region}.amazonaws.com`; + const proxyAgent = getNodeProxyAgent(bedrockEndpoint); - const proxyAgent = getNodeProxyAgent(); + const clientConfig: BedrockRuntimeClientConfig = { + region, + credentials, + }; if (proxyAgent) { clientConfig.requestHandler = new NodeHttpHandler({ httpAgent: proxyAgent, @@ -136,7 +128,7 @@ export class EmbeddingsAwsBedrock implements INodeType { client, model: modelName, maxRetries: 3, - region: credentials.region, + region, }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/test/EmbeddingsAwsBedrock.test.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/test/EmbeddingsAwsBedrock.test.ts new file mode 100644 index 00000000000..f7f56a32ca0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/test/EmbeddingsAwsBedrock.test.ts @@ -0,0 +1,120 @@ +import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; +import { BedrockEmbeddings } from '@langchain/aws'; +import { getNodeProxyAgent } from '@n8n/ai-utilities'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; +import type { Mocked } from 'vitest'; + +import { resolveAwsCredentials } from '@utils/aws/resolveAwsCredentials'; + +import { EmbeddingsAwsBedrock } from '../EmbeddingsAwsBedrock.node'; + +vi.mock('@aws-sdk/client-bedrock-runtime', () => ({ + BedrockRuntimeClient: vi.fn(), +})); +vi.mock('@langchain/aws', () => ({ + BedrockEmbeddings: vi.fn().mockImplementation(function () { + return {}; + }), +})); +vi.mock('@n8n/ai-utilities', () => ({ + getConnectionHintNoticeField: vi + .fn() + .mockReturnValue({ displayName: '', name: 'notice', type: 'notice', default: '' }), + getNodeProxyAgent: vi.fn(), + logWrapper: (x: T) => x, +})); +vi.mock('@utils/aws/resolveAwsCredentials', () => ({ + resolveAwsCredentials: vi.fn(), +})); + +const MockedBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient); +const MockedBedrockEmbeddings = vi.mocked(BedrockEmbeddings); +const mockedGetNodeProxyAgent = vi.mocked(getNodeProxyAgent); +const mockedResolveAwsCredentials = vi.mocked(resolveAwsCredentials); + +describe('EmbeddingsAwsBedrock', () => { + const mockNode: INode = { + id: '1', + name: 'Embeddings AWS Bedrock', + typeVersion: 1, + type: 'n8n-nodes-langchain.embeddingsAwsBedrock', + position: [0, 0], + parameters: {}, + }; + + function mockContext(model: string) { + const ctx = createMockExecuteFunction( + {}, + mockNode, + ) as Mocked; + ctx.getNodeParameter = vi + .fn() + .mockImplementation((name: string) => (name === 'model' ? model : undefined)); + ctx.getCredentials = vi.fn().mockResolvedValue({}); + ctx.getNode = vi.fn().mockReturnValue(mockNode); + return ctx; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockedGetNodeProxyAgent.mockReturnValue(undefined); + MockedBedrockRuntimeClient.mockImplementation(function () { + return {}; + } as unknown as typeof BedrockRuntimeClient); + }); + + it('wires resolveAwsCredentials output through BedrockRuntimeClient', async () => { + const fakeProvider = vi.fn(); + mockedResolveAwsCredentials.mockResolvedValue({ + region: 'us-east-1', + credentials: fakeProvider, + }); + + const node = new EmbeddingsAwsBedrock(); + await node.supplyData.call(mockContext('amazon.titan-embed-text-v1'), 0); + + expect(mockedResolveAwsCredentials).toHaveBeenCalledTimes(1); + const lastConfig = MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0]; + expect(lastConfig?.credentials).toBe(fakeProvider); + expect(lastConfig?.region).toBe('us-east-1'); + }); + + it('passes the supply item index to resolveAwsCredentials', async () => { + mockedResolveAwsCredentials.mockResolvedValue({ + region: 'us-east-1', + credentials: { accessKeyId: 'a', secretAccessKey: 'b' }, + }); + const node = new EmbeddingsAwsBedrock(); + const ctx = mockContext('amazon.titan-embed-text-v1'); + + await node.supplyData.call(ctx, 3); + + expect(mockedResolveAwsCredentials).toHaveBeenCalledWith(ctx, 3); + }); + + it('calls getNodeProxyAgent with the concrete Bedrock endpoint URL', async () => { + mockedGetNodeProxyAgent.mockReturnValue(undefined); + mockedResolveAwsCredentials.mockResolvedValue({ + region: 'eu-west-2', + credentials: { accessKeyId: 'a', secretAccessKey: 'b' }, + }); + const node = new EmbeddingsAwsBedrock(); + await node.supplyData.call(mockContext('amazon.titan-embed-text-v1'), 0); + expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith( + 'https://bedrock-runtime.eu-west-2.amazonaws.com', + ); + }); + + it('accepts arbitrary model values that are not in the loadOptions response', async () => { + mockedResolveAwsCredentials.mockResolvedValue({ + region: 'us-east-1', + credentials: { accessKeyId: 'a', secretAccessKey: 'b' }, + }); + const node = new EmbeddingsAwsBedrock(); + await node.supplyData.call(mockContext('custom.model.not-in-list-v1'), 0); + expect(MockedBedrockEmbeddings).toHaveBeenCalledWith( + expect.objectContaining({ model: 'custom.model.not-in-list-v1' }), + ); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts index 337f47bdff8..7e8c1e72b6c 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts @@ -8,6 +8,7 @@ import { getConnectionHintNoticeField, } from '@n8n/ai-utilities'; import { NodeHttpHandler } from '@smithy/node-http-handler'; +import { awsNodeAuthOptions, awsNodeCredentials } from 'n8n-nodes-base/dist/nodes/Aws/utils'; import { NodeConnectionTypes, @@ -17,6 +18,8 @@ import { type SupplyData, } from 'n8n-workflow'; +import { resolveAwsCredentials } from '@utils/aws/resolveAwsCredentials'; + export class LmChatAwsBedrock implements INodeType { description: INodeTypeDescription = { displayName: 'AWS Bedrock Chat Model', @@ -48,17 +51,13 @@ export class LmChatAwsBedrock implements INodeType { outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], - credentials: [ - { - name: 'aws', - required: true, - }, - ], + credentials: awsNodeCredentials, requestDefaults: { ignoreHttpStatusErrors: true, baseURL: '=https://bedrock.{{$credentials?.region ?? "eu-central-1"}}.amazonaws.com', }, properties: [ + awsNodeAuthOptions, getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiChain]), { displayName: 'Model Source', @@ -234,12 +233,7 @@ export class LmChatAwsBedrock implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const credentials = await this.getCredentials<{ - region: string; - secretAccessKey: string; - accessKeyId: string; - sessionToken: string; - }>('aws'); + const { region: credentialRegion, credentials } = await resolveAwsCredentials(this, itemIndex); const modelName = this.getNodeParameter('model', itemIndex) as string; const options = this.getNodeParameter('options', itemIndex, {}) as { temperature: number; @@ -248,21 +242,18 @@ export class LmChatAwsBedrock implements INodeType { // If the model is specified as a full ARN, extract the region from it // ARN format: arn:aws:bedrock:::inference-profile/ - let region = credentials.region; + let region = credentialRegion; const arnMatch = modelName.match(/^arn:aws:bedrock:([a-z0-9-]+):/); if (arnMatch) { region = arnMatch[1]; } // We set-up client manually to pass httpAgent and httpsAgent - const proxyAgent = getNodeProxyAgent(); + const bedrockEndpoint = `https://bedrock-runtime.${region}.amazonaws.com`; + const proxyAgent = getNodeProxyAgent(bedrockEndpoint); const clientConfig: BedrockRuntimeClientConfig = { region, - credentials: { - secretAccessKey: credentials.secretAccessKey, - accessKeyId: credentials.accessKeyId, - ...(credentials.sessionToken && { sessionToken: credentials.sessionToken }), - }, + credentials, }; if (proxyAgent) { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/test/LmChatAwsBedrock.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/test/LmChatAwsBedrock.test.ts index 5b2af2c4c6e..70f2f19e060 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/test/LmChatAwsBedrock.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/test/LmChatAwsBedrock.test.ts @@ -5,6 +5,8 @@ import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; import type { Mocked } from 'vitest'; +import { resolveAwsCredentials } from '@utils/aws/resolveAwsCredentials'; + import { LmChatAwsBedrock } from '../LmChatAwsBedrock.node'; vi.mock('@langchain/aws', () => ({ @@ -18,7 +20,9 @@ vi.mock('@n8n/ai-utilities', () => ({ N8nLlmTracing: vi.fn(), getNodeProxyAgent: vi.fn(), })); - +vi.mock('@utils/aws/resolveAwsCredentials', () => ({ + resolveAwsCredentials: vi.fn(), +})); vi.mock('@aws-sdk/client-bedrock-runtime', () => ({ BedrockRuntimeClient: vi.fn(), })); @@ -26,6 +30,7 @@ const MockedBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient); const MockedChatBedrockConverse = vi.mocked(ChatBedrockConverse); const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler); const mockedGetNodeProxyAgent = vi.mocked(getNodeProxyAgent); +const mockedResolveAwsCredentials = vi.mocked(resolveAwsCredentials); describe('LmChatAwsBedrock', () => { let node: LmChatAwsBedrock; @@ -62,6 +67,20 @@ describe('LmChatAwsBedrock', () => { mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn()); mockedGetNodeProxyAgent.mockReturnValue(undefined); + MockedBedrockRuntimeClient.mockImplementation(function () { + return {}; + } as unknown as typeof BedrockRuntimeClient); + MockedChatBedrockConverse.mockImplementation(function () { + return {}; + } as unknown as typeof ChatBedrockConverse); + const defaults = overrides.credentials ?? defaultCredentials; + mockedResolveAwsCredentials.mockResolvedValue({ + region: defaults.region as string, + credentials: { + accessKeyId: defaults.accessKeyId as string, + secretAccessKey: defaults.secretAccessKey as string, + }, + }); return mockContext; }; @@ -164,5 +183,64 @@ describe('LmChatAwsBedrock', () => { }), ); }); + + describe('AssumeRole wiring', () => { + it('constructs BedrockRuntimeClient with the provider returned by resolveAwsCredentials', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'anthropic.claude-3-sonnet-20240229-v1:0'; + if (paramName === 'options') return {}; + return undefined; + }); + const fakeProvider = vi.fn().mockResolvedValue({ + accessKeyId: 'ASIA_STUB', + secretAccessKey: 'secret', + sessionToken: 'token', + }); + mockedResolveAwsCredentials.mockResolvedValue({ + region: 'us-east-1', + credentials: fakeProvider, + }); + + await node.supplyData.call(ctx, 0); + + expect(mockedResolveAwsCredentials).toHaveBeenCalledTimes(1); + const lastConfig = MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0]; + expect(lastConfig?.credentials).toBe(fakeProvider); + expect(lastConfig?.region).toBe('us-east-1'); + }); + + it('passes the supply item index to resolveAwsCredentials', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'anthropic.claude-3-sonnet-20240229-v1:0'; + if (paramName === 'options') return {}; + return undefined; + }); + + await node.supplyData.call(ctx, 4); + + expect(mockedResolveAwsCredentials).toHaveBeenCalledWith(ctx, 4); + }); + + it('wires the concrete Bedrock endpoint into getNodeProxyAgent', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'anthropic.claude-3-sonnet-20240229-v1:0'; + if (paramName === 'options') return {}; + return undefined; + }); + mockedResolveAwsCredentials.mockResolvedValue({ + region: 'eu-central-1', + credentials: { accessKeyId: 'a', secretAccessKey: 'b' }, + }); + + await node.supplyData.call(ctx, 0); + + expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith( + 'https://bedrock-runtime.eu-central-1.amazonaws.com', + ); + }); + }); }); }); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 0667e65d4c6..60d32a2e7c3 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -228,6 +228,7 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.938.0", "@aws-sdk/client-sso-oidc": "3.808.0", + "@aws-sdk/credential-providers": "3.808.0", "@azure/identity": "catalog:", "@azure/search-documents": "12.1.0", "@getzep/zep-cloud": "1.0.6", diff --git a/packages/@n8n/nodes-langchain/utils/aws/resolveAwsCredentials.ts b/packages/@n8n/nodes-langchain/utils/aws/resolveAwsCredentials.ts new file mode 100644 index 00000000000..82bf44b81ca --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/aws/resolveAwsCredentials.ts @@ -0,0 +1,99 @@ +import { getNodeProxyAgent } from '@n8n/ai-utilities'; +import { fromTemporaryCredentials } from '@aws-sdk/credential-providers'; +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types'; +import type { + AwsAssumeRoleCredentialsType, + AwsIamCredentialsType, + AWSRegion, +} from 'n8n-nodes-base/dist/credentials/common/aws/types'; +import { getSystemCredentials } from 'n8n-nodes-base/dist/credentials/common/aws/system-credentials-utils'; +import { UserError, type ISupplyDataFunctions } from 'n8n-workflow'; + +export type ResolvedAwsCredentials = { + region: AWSRegion; + credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider; +}; + +export async function resolveAwsCredentials( + context: ISupplyDataFunctions, + itemIndex = 0, +): Promise { + const authentication = context.getNodeParameter('authentication', itemIndex, 'iam') as + | 'iam' + | 'assumeRole'; + + if (authentication !== 'assumeRole') { + const creds = (await context.getCredentials('aws')) as AwsIamCredentialsType; + const identity: AwsCredentialIdentity = { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + ...(creds.temporaryCredentials && creds.sessionToken + ? { sessionToken: creds.sessionToken } + : {}), + }; + return { region: creds.region, credentials: identity }; + } + + const creds = (await context.getCredentials('awsAssumeRole')) as AwsAssumeRoleCredentialsType; + + if (!creds.roleArn || creds.roleArn.trim() === '') { + throw new UserError('Role ARN is required when assuming a role.'); + } + if (!creds.externalId || creds.externalId.trim() === '') { + throw new UserError('External ID is required when assuming a role.'); + } + if (!creds.roleSessionName || creds.roleSessionName.trim() === '') { + throw new UserError('Role Session Name is required when assuming a role.'); + } + + let masterCredentials: AwsCredentialIdentity | AwsCredentialIdentityProvider; + if (creds.useSystemCredentialsForRole) { + masterCredentials = async () => { + const sys = await getSystemCredentials(); + if (!sys) { + throw new UserError( + 'System AWS credentials are required for role assumption. Please ensure AWS credentials are available via environment variables, instance metadata, or container role.', + ); + } + return { + accessKeyId: sys.accessKeyId, + secretAccessKey: sys.secretAccessKey, + ...(sys.sessionToken ? { sessionToken: sys.sessionToken } : {}), + }; + }; + } else { + if (!creds.stsAccessKeyId || creds.stsAccessKeyId.trim() === '') { + throw new UserError('STS Access Key ID is required when not using system credentials.'); + } + if (!creds.stsSecretAccessKey || creds.stsSecretAccessKey.trim() === '') { + throw new UserError('STS Secret Access Key is required when not using system credentials.'); + } + masterCredentials = { + accessKeyId: creds.stsAccessKeyId.trim(), + secretAccessKey: creds.stsSecretAccessKey.trim(), + ...(creds.stsSessionToken?.trim() ? { sessionToken: creds.stsSessionToken.trim() } : {}), + }; + } + + const stsTarget = `https://sts.${creds.region}.amazonaws.com`; + const proxyAgent = getNodeProxyAgent(stsTarget); + const requestHandler = proxyAgent + ? new NodeHttpHandler({ httpAgent: proxyAgent, httpsAgent: proxyAgent }) + : undefined; + + const provider = fromTemporaryCredentials({ + params: { + RoleArn: creds.roleArn.trim(), + RoleSessionName: creds.roleSessionName.trim(), + ExternalId: creds.externalId.trim(), + }, + masterCredentials, + clientConfig: { + region: creds.region, + ...(requestHandler ? { requestHandler } : {}), + }, + }); + + return { region: creds.region, credentials: provider }; +} diff --git a/packages/@n8n/nodes-langchain/utils/aws/test/resolveAwsCredentials.test.ts b/packages/@n8n/nodes-langchain/utils/aws/test/resolveAwsCredentials.test.ts new file mode 100644 index 00000000000..993d95d8ff0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/aws/test/resolveAwsCredentials.test.ts @@ -0,0 +1,295 @@ +import { UserError, type ISupplyDataFunctions, type INode } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +// Mock the SDK provider factory. Each call returns a function identity we can inspect. +vi.mock('@aws-sdk/credential-providers', () => ({ + fromTemporaryCredentials: vi.fn((args: unknown) => { + const provider = vi.fn().mockResolvedValue({ + accessKeyId: 'ASIAPROVIDER', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }); + (provider as unknown as { __constructedWith: unknown }).__constructedWith = args; + return provider; + }), +})); + +vi.mock('n8n-nodes-base/dist/credentials/common/aws/system-credentials-utils', () => ({ + getSystemCredentials: vi.fn(), +})); + +vi.mock('@n8n/ai-utilities', () => ({ + getNodeProxyAgent: vi.fn(), +})); + +import { fromTemporaryCredentials } from '@aws-sdk/credential-providers'; +import { getSystemCredentials } from 'n8n-nodes-base/dist/credentials/common/aws/system-credentials-utils'; +import { getNodeProxyAgent } from '@n8n/ai-utilities'; + +import { resolveAwsCredentials } from '../resolveAwsCredentials'; + +const mockedFromTemporaryCredentials = vi.mocked(fromTemporaryCredentials); +const mockedGetSystemCredentials = vi.mocked(getSystemCredentials); +const mockedGetNodeProxyAgent = vi.mocked(getNodeProxyAgent); + +function makeContext(opts: { + authentication?: 'iam' | 'assumeRole'; + awsCredential?: Record; + awsAssumeRoleCredential?: Record; + credentialId?: string; +}) { + const context = mock(); + context.getNodeParameter.mockImplementation((name: string, _idx: number, fallback?: unknown) => { + if (name === 'authentication') return opts.authentication ?? fallback ?? 'iam'; + throw new Error(`unexpected getNodeParameter: ${name}`); + }); + context.getCredentials.mockImplementation(async (type: string) => { + if (type === 'aws') return opts.awsCredential ?? {}; + if (type === 'awsAssumeRole') return opts.awsAssumeRoleCredential ?? {}; + throw new Error(`unexpected getCredentials: ${type}`); + }); + context.getNode.mockReturnValue({ + credentials: { + awsAssumeRole: { id: opts.credentialId ?? 'cred-id-123', name: 'Assume role' }, + }, + } as unknown as INode); + return context; +} + +describe('resolveAwsCredentials — IAM path', () => { + it('returns static identity when authentication is iam', async () => { + const context = makeContext({ + authentication: 'iam', + awsCredential: { + region: 'us-east-1', + accessKeyId: 'AKIATEST', + secretAccessKey: 'SECRET', + temporaryCredentials: false, + }, + }); + const result = await resolveAwsCredentials(context); + expect(result.region).toBe('us-east-1'); + expect(result.credentials).toEqual({ + accessKeyId: 'AKIATEST', + secretAccessKey: 'SECRET', + }); + }); + + it('includes sessionToken when temporaryCredentials is true', async () => { + const context = makeContext({ + authentication: 'iam', + awsCredential: { + region: 'us-east-1', + accessKeyId: 'AKIATEST', + secretAccessKey: 'SECRET', + temporaryCredentials: true, + sessionToken: 'SESSION', + }, + }); + const result = await resolveAwsCredentials(context); + expect(result.credentials).toEqual({ + accessKeyId: 'AKIATEST', + secretAccessKey: 'SECRET', + sessionToken: 'SESSION', + }); + }); + + it('defaults to iam path when authentication parameter is missing', async () => { + const context = makeContext({ + authentication: undefined, + awsCredential: { + region: 'eu-central-1', + accessKeyId: 'AKIA2', + secretAccessKey: 'SECRET2', + temporaryCredentials: false, + }, + }); + const result = await resolveAwsCredentials(context); + expect(result.region).toBe('eu-central-1'); + expect(result.credentials).toEqual({ + accessKeyId: 'AKIA2', + secretAccessKey: 'SECRET2', + }); + }); + + it('reads authentication from the supplied item index', async () => { + const context = makeContext({ + authentication: 'iam', + awsCredential: { + region: 'us-east-1', + accessKeyId: 'AKIATEST', + secretAccessKey: 'SECRET', + temporaryCredentials: false, + }, + }); + await resolveAwsCredentials(context, 3); + expect(context.getNodeParameter).toHaveBeenCalledWith('authentication', 3, 'iam'); + }); +}); + +describe('resolveAwsCredentials — AssumeRole path', () => { + beforeEach(() => { + mockedFromTemporaryCredentials.mockClear(); + }); + + it('returns a provider function built from fromTemporaryCredentials', async () => { + const context = makeContext({ + authentication: 'assumeRole', + awsAssumeRoleCredential: { + region: 'us-east-1', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'ext-id', + roleSessionName: 'n8n-session', + stsAccessKeyId: 'AKIASTS', + stsSecretAccessKey: 'stsSecret', + useSystemCredentialsForRole: false, + }, + }); + const result = await resolveAwsCredentials(context); + expect(result.region).toBe('us-east-1'); + expect(typeof result.credentials).toBe('function'); + expect(fromTemporaryCredentials).toHaveBeenCalledTimes(1); + const callArg = mockedFromTemporaryCredentials.mock.calls[0][0] as { + params: { RoleArn: string; RoleSessionName: string; ExternalId: string }; + masterCredentials: unknown; + }; + expect(callArg.params).toEqual({ + RoleArn: 'arn:aws:iam::123456789012:role/TestRole', + RoleSessionName: 'n8n-session', + ExternalId: 'ext-id', + }); + expect(callArg.masterCredentials).toEqual({ + accessKeyId: 'AKIASTS', + secretAccessKey: 'stsSecret', + }); + }); +}); + +describe('resolveAwsCredentials — AssumeRole validation', () => { + const baseAssumeRoleCreds = { + region: 'us-east-1', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'ext-id', + roleSessionName: 'n8n-session', + stsAccessKeyId: 'AKIASTS', + stsSecretAccessKey: 'stsSecret', + useSystemCredentialsForRole: false, + }; + + it('throws when roleArn is missing', async () => { + const context = makeContext({ + authentication: 'assumeRole', + awsAssumeRoleCredential: { ...baseAssumeRoleCreds, roleArn: '' }, + }); + await expect(resolveAwsCredentials(context)).rejects.toThrow(UserError); + await expect(resolveAwsCredentials(context)).rejects.toThrow( + 'Role ARN is required when assuming a role.', + ); + }); + + it('throws when externalId is missing', async () => { + const context = makeContext({ + authentication: 'assumeRole', + awsAssumeRoleCredential: { ...baseAssumeRoleCreds, externalId: '' }, + }); + await expect(resolveAwsCredentials(context)).rejects.toThrow(UserError); + await expect(resolveAwsCredentials(context)).rejects.toThrow( + 'External ID is required when assuming a role.', + ); + }); + + it('throws when roleSessionName is missing', async () => { + const context = makeContext({ + authentication: 'assumeRole', + awsAssumeRoleCredential: { ...baseAssumeRoleCreds, roleSessionName: '' }, + }); + await expect(resolveAwsCredentials(context)).rejects.toThrow(UserError); + await expect(resolveAwsCredentials(context)).rejects.toThrow( + 'Role Session Name is required when assuming a role.', + ); + }); +}); + +describe('resolveAwsCredentials — useSystemCredentialsForRole', () => { + beforeEach(() => { + mockedGetSystemCredentials.mockReset(); + mockedFromTemporaryCredentials.mockClear(); + }); + + it('passes a function (refreshable provider) as masterCredentials, not a snapshot', async () => { + mockedGetSystemCredentials.mockResolvedValue({ + accessKeyId: 'AKIASYS', + secretAccessKey: 'sysSecret', + sessionToken: 'sysToken', + source: 'environment', + }); + const context = makeContext({ + authentication: 'assumeRole', + awsAssumeRoleCredential: { + region: 'us-east-1', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'ext-id', + roleSessionName: 'n8n-session', + useSystemCredentialsForRole: true, + }, + }); + await resolveAwsCredentials(context); + + const callArg = mockedFromTemporaryCredentials.mock.calls[0][0] as { + masterCredentials: unknown; + }; + expect(typeof callArg.masterCredentials).toBe('function'); + + // Simulate SDK invoking the provider twice; each call should re-read system credentials. + const masterProvider = callArg.masterCredentials as () => Promise; + await masterProvider(); + await masterProvider(); + expect(getSystemCredentials).toHaveBeenCalledTimes(2); + }); +}); + +describe('resolveAwsCredentials — proxy target URL', () => { + beforeEach(() => { + mockedGetNodeProxyAgent.mockReset(); + mockedFromTemporaryCredentials.mockClear(); + }); + + it('calls getNodeProxyAgent with the concrete STS endpoint URL', async () => { + mockedGetNodeProxyAgent.mockReturnValue(undefined); + const context = makeContext({ + authentication: 'assumeRole', + awsAssumeRoleCredential: { + region: 'eu-west-2', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'ext-id', + roleSessionName: 'n8n-session', + stsAccessKeyId: 'AKIASTS', + stsSecretAccessKey: 'stsSecret', + useSystemCredentialsForRole: false, + }, + }); + await resolveAwsCredentials(context); + expect(getNodeProxyAgent).toHaveBeenCalledWith('https://sts.eu-west-2.amazonaws.com'); + }); + + it('builds a NodeHttpHandler only when getNodeProxyAgent returns a proxy', async () => { + mockedGetNodeProxyAgent.mockReturnValue(undefined); + const context = makeContext({ + authentication: 'assumeRole', + awsAssumeRoleCredential: { + region: 'us-east-1', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'ext-id', + roleSessionName: 'n8n-session', + stsAccessKeyId: 'AKIASTS', + stsSecretAccessKey: 'stsSecret', + useSystemCredentialsForRole: false, + }, + }); + await resolveAwsCredentials(context); + const callArg = mockedFromTemporaryCredentials.mock.calls[0][0] as { + clientConfig: { requestHandler?: unknown }; + }; + expect(callArg.clientConfig.requestHandler).toBeUndefined(); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 86e8ce4d227..04b0c79f2bd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -224,7 +224,7 @@ "sucrase": "3.35.0", "swagger-ui-express": "5.0.1", "tar": "^7.5.11", - "undici": "^7.16.0", + "undici": "catalog:undici-v7", "uuid": "catalog:", "validator": "13.15.22", "ws": "8.20.1", diff --git a/packages/nodes-base/credentials/AwsAssumeRole.credentials.ts b/packages/nodes-base/credentials/AwsAssumeRole.credentials.ts index edd6bb94616..5b2a11522f2 100644 --- a/packages/nodes-base/credentials/AwsAssumeRole.credentials.ts +++ b/packages/nodes-base/credentials/AwsAssumeRole.credentials.ts @@ -137,15 +137,6 @@ export class AwsAssumeRole implements ICredentialType { sessionToken: string; }; - if (!credentials.roleArn || credentials.roleArn.trim() === '') { - throw new ApplicationError('Role ARN is required when assuming a role.'); - } - if (!credentials.externalId || credentials.externalId.trim() === '') { - throw new ApplicationError('External ID is required when assuming a role.'); - } - if (!credentials.roleSessionName || credentials.roleSessionName.trim() === '') { - throw new ApplicationError('Role Session Name is required when assuming a role.'); - } try { securityHeaders = await assumeRole(credentials, region); finalCredentials = { ...credentials, ...securityHeaders }; diff --git a/packages/nodes-base/credentials/common/aws/utils.test.ts b/packages/nodes-base/credentials/common/aws/utils.test.ts index 1059c1a2a06..584c92134de 100644 --- a/packages/nodes-base/credentials/common/aws/utils.test.ts +++ b/packages/nodes-base/credentials/common/aws/utils.test.ts @@ -43,6 +43,7 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: true, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', roleSessionName: 'test-session', }; @@ -119,6 +120,7 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: true, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', roleSessionName: 'test-session', }; @@ -195,6 +197,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: true, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', }; jest.spyOn(systemCredentialsUtils, 'getSystemCredentials').mockResolvedValue(null); @@ -264,6 +268,7 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', @@ -319,6 +324,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -359,6 +366,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsSecretAccessKey: 'sts-secret-key', }; @@ -374,6 +383,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: ' ', stsSecretAccessKey: 'sts-secret-key', }; @@ -390,6 +401,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', }; @@ -405,6 +418,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: ' ', }; @@ -421,6 +436,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: ' sts-access-key ', stsSecretAccessKey: ' sts-secret-key ', stsSessionToken: ' sts-session-token ', @@ -464,6 +481,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws-cn:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -503,6 +522,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -544,6 +565,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -564,6 +587,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -589,6 +614,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -613,6 +640,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -644,6 +673,8 @@ describe('assumeRole', () => { customEndpoints: false, useSystemCredentialsForRole: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; @@ -669,52 +700,13 @@ describe('assumeRole', () => { }); describe('default values', () => { - it('should use default role session name when not provided', async () => { - const credentials: AwsAssumeRoleCredentialsType = { - region: 'us-east-1', - customEndpoints: false, - useSystemCredentialsForRole: false, - roleArn: 'arn:aws:iam::123456789012:role/TestRole', - stsAccessKeyId: 'sts-access-key', - stsSecretAccessKey: 'sts-secret-key', - }; - - const mockResponse = { - ok: true, - text: jest.fn().mockResolvedValue(''), - }; - - mockFetch.mockResolvedValue(mockResponse as any); - - mockParseString.mockImplementation((_xml, _options, callback) => { - callback(null, { - AssumeRoleResponse: { - AssumeRoleResult: { - Credentials: { - AccessKeyId: 'assumed-access-key', - SecretAccessKey: 'assumed-secret-key', - SessionToken: 'assumed-session-token', - }, - }, - }, - }); - }); - - await assumeRole(credentials, 'us-east-1'); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://sts.us-east-1.amazonaws.com', - expect.objectContaining({ - body: expect.stringContaining('RoleSessionName=n8n-session'), - }), - ); - }); - it('should default useSystemCredentialsForRole to false when not provided', async () => { const credentials: AwsAssumeRoleCredentialsType = { region: 'us-east-1', customEndpoints: false, roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-123', + roleSessionName: 'test-session', stsAccessKeyId: 'sts-access-key', stsSecretAccessKey: 'sts-secret-key', }; diff --git a/packages/nodes-base/credentials/common/aws/utils.ts b/packages/nodes-base/credentials/common/aws/utils.ts index c63859fc07e..188d4c6f7ef 100644 --- a/packages/nodes-base/credentials/common/aws/utils.ts +++ b/packages/nodes-base/credentials/common/aws/utils.ts @@ -7,6 +7,7 @@ import { type IDataObject, type IHttpRequestOptions, type IRequestOptions, + UserError, } from 'n8n-workflow'; import { parseString } from 'xml2js'; import type { Request } from 'aws4'; @@ -19,6 +20,8 @@ import { type AwsSecurityHeaders, } from './types'; import { sign } from 'aws4'; +import { getProxyForUrl } from 'proxy-from-env'; +import { ProxyAgent } from 'undici'; import { getSystemCredentials } from './system-credentials-utils'; @@ -291,6 +294,17 @@ export async function assumeRole( sessionToken: string; }> { assertSupportedAwsRegion(region); + + if (!credentials.roleArn || credentials.roleArn.trim() === '') { + throw new UserError('Role ARN is required when assuming a role.'); + } + if (!credentials.externalId || credentials.externalId.trim() === '') { + throw new UserError('External ID is required when assuming a role.'); + } + if (!credentials.roleSessionName || credentials.roleSessionName.trim() === '') { + throw new UserError('Role Session Name is required when assuming a role.'); + } + let stsCallCredentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }; const useSystemCredentialsForRole = credentials.useSystemCredentialsForRole ?? false; @@ -365,11 +379,15 @@ export async function assumeRole( throw new ApplicationError('Failed to sign STS request'); } - const response = await fetch(stsEndpoint, { + const proxyUrl = getProxyForUrl(stsEndpoint); + const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined; + const requestInit: RequestInit & { dispatcher?: unknown } = { method: 'POST', headers: signOpts.headers as Record, body: bodyContent, - }); + ...(dispatcher ? { dispatcher } : {}), + }; + const response = await fetch(stsEndpoint, requestInit); if (!response.ok) { const errorText = await response.text(); diff --git a/packages/nodes-base/credentials/test/aws-assume-role-utils.test.ts b/packages/nodes-base/credentials/test/aws-assume-role-utils.test.ts new file mode 100644 index 00000000000..e9161a25117 --- /dev/null +++ b/packages/nodes-base/credentials/test/aws-assume-role-utils.test.ts @@ -0,0 +1,118 @@ +import { assumeRole } from '@credentials/common/aws/utils'; +import type { AwsAssumeRoleCredentialsType } from '@credentials/common/aws/types'; +import { UserError } from 'n8n-workflow'; + +function baseCredentials( + overrides: Partial = {}, +): AwsAssumeRoleCredentialsType { + return { + region: 'us-east-1', + customEndpoints: false, + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'external-id-value', + roleSessionName: 'n8n-session', + stsAccessKeyId: 'AKIA_TEST', + stsSecretAccessKey: 'secret-value', + useSystemCredentialsForRole: false, + ...overrides, + }; +} + +describe('assumeRole() — centralized validation', () => { + it('throws when roleArn is missing', async () => { + await expect(assumeRole(baseCredentials({ roleArn: '' }), 'us-east-1')).rejects.toThrow( + UserError, + ); + await expect(assumeRole(baseCredentials({ roleArn: '' }), 'us-east-1')).rejects.toThrow( + 'Role ARN is required when assuming a role.', + ); + }); + + it('throws when roleArn is whitespace-only', async () => { + await expect(assumeRole(baseCredentials({ roleArn: ' ' }), 'us-east-1')).rejects.toThrow( + UserError, + ); + await expect(assumeRole(baseCredentials({ roleArn: ' ' }), 'us-east-1')).rejects.toThrow( + 'Role ARN is required when assuming a role.', + ); + }); + + it('throws when externalId is missing', async () => { + await expect(assumeRole(baseCredentials({ externalId: '' }), 'us-east-1')).rejects.toThrow( + UserError, + ); + await expect(assumeRole(baseCredentials({ externalId: '' }), 'us-east-1')).rejects.toThrow( + 'External ID is required when assuming a role.', + ); + }); + + it('throws when roleSessionName is missing', async () => { + await expect(assumeRole(baseCredentials({ roleSessionName: '' }), 'us-east-1')).rejects.toThrow( + UserError, + ); + await expect(assumeRole(baseCredentials({ roleSessionName: '' }), 'us-east-1')).rejects.toThrow( + 'Role Session Name is required when assuming a role.', + ); + }); +}); + +// Mock global fetch so we can inspect dispatcher behavior without hitting the network. +const fetchMock = jest.fn(); +beforeEach(() => { + fetchMock.mockReset(); + // Return a minimal STS-success XML so assumeRole() completes. + fetchMock.mockResolvedValue({ + ok: true, + text: async () => + '' + + 'ASIATEST' + + 'SECRET' + + 'TOKEN' + + '', + }); + (globalThis as { fetch: unknown }).fetch = fetchMock as unknown as typeof fetch; +}); + +describe('assumeRole() — proxy-aware transport', () => { + const originalEnv = { ...process.env }; + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('passes an undici ProxyAgent dispatcher when HTTPS_PROXY is set', async () => { + process.env.HTTPS_PROXY = 'http://proxy.example.com:3128'; + delete process.env.NO_PROXY; + + await assumeRole(baseCredentials(), 'us-east-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit & { dispatcher?: unknown }]; + expect(init.dispatcher).toBeDefined(); + expect((init.dispatcher as { constructor: { name: string } }).constructor.name).toBe( + 'ProxyAgent', + ); + }); + + it('does not pass a dispatcher when no proxy env var resolves', async () => { + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + delete process.env.NO_PROXY; + + await assumeRole(baseCredentials(), 'us-east-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit & { dispatcher?: unknown }]; + expect(init.dispatcher).toBeUndefined(); + }); + + it('honors NO_PROXY for the STS host', async () => { + process.env.HTTPS_PROXY = 'http://proxy.example.com:3128'; + process.env.NO_PROXY = '.amazonaws.com'; + + await assumeRole(baseCredentials(), 'us-east-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit & { dispatcher?: unknown }]; + expect(init.dispatcher).toBeUndefined(); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 03b8f54f431..b277ce40b7b 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -962,6 +962,7 @@ "pg": "catalog:", "pg-promise": "11.9.1", "promise-ftp": "1.3.5", + "proxy-from-env": "^1.1.0", "redis": "4.6.14", "rfc2047": "4.0.1", "rhea": "3.0.4", @@ -975,6 +976,7 @@ "ssh2-sftp-client": "12.1.0", "tmp-promise": "3.0.3", "ts-ics": "1.2.2", + "undici": "catalog:undici-v6", "uuid": "catalog:", "vm2": "catalog:", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cfd0a7df6a..5f9bfa98847 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,14 @@ catalogs: storybook: specifier: ^10.1.11 version: 10.1.11 + undici-v6: + undici: + specifier: ^6.24.0 + version: 6.24.1 + undici-v7: + undici: + specifier: ^7.16.0 + version: 7.24.6 overrides: libphonenumber-js: npm:empty-npm-package@1.0.0 @@ -521,7 +529,7 @@ overrides: diff: 8.0.3 undici@5: ^6.24.0 undici@6: ^6.24.0 - undici@7: ^7.24.0 + undici@7: ^7.16.0 tar: ^7.5.11 ajv@6: 6.14.0 ajv@7: 8.18.0 @@ -861,7 +869,7 @@ importers: specifier: 3.0.3 version: 3.0.3 undici: - specifier: ^6.24.0 + specifier: catalog:undici-v6 version: 6.24.1 zod: specifier: 3.25.67 @@ -2358,6 +2366,9 @@ importers: '@aws-sdk/client-sso-oidc': specifier: 3.808.0 version: 3.808.0 + '@aws-sdk/credential-providers': + specifier: 3.808.0 + version: 3.808.0 '@azure/identity': specifier: 4.13.0 version: 4.13.0 @@ -3333,7 +3344,7 @@ importers: specifier: ^7.5.11 version: 7.5.11 undici: - specifier: ^7.24.0 + specifier: catalog:undici-v7 version: 7.24.6 uuid: specifier: 'catalog:' @@ -4858,6 +4869,9 @@ importers: promise-ftp: specifier: 1.3.5 version: 1.3.5(promise-ftp-common@1.1.5) + proxy-from-env: + specifier: ^1.1.0 + version: 1.1.0 redis: specifier: 4.6.14 version: 4.6.14 @@ -4897,6 +4911,9 @@ importers: ts-ics: specifier: 1.2.2 version: 1.2.2(date-fns@2.30.0)(lodash@4.18.1)(zod@3.25.67) + undici: + specifier: catalog:undici-v6 + version: 6.24.1 uuid: specifier: 'catalog:' version: 11.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 30ac56f6ed6..16d9fc19602 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -158,6 +158,10 @@ catalogs: eslint-plugin-playwright: 2.2.2 playwright: 1.60.0 playwright-core: 1.60.0 + undici-v6: + undici: ^6.24.0 + undici-v7: + undici: ^7.16.0 frontend: '@sentry/vue': ^10.36.0 '@testing-library/jest-dom': ^6.6.3