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