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 <declan@n8n.io>
This commit is contained in:
eric-liu 2026-06-04 08:08:41 -07:00 committed by GitHub
parent ee7aa0b66a
commit add5ab29dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 820 additions and 102 deletions

View File

@ -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",

View File

@ -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": "*"

View File

@ -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. <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/foundation-models.html">Learn more</a>.',
typeOptions: {
@ -106,24 +106,16 @@ export class EmbeddingsAwsBedrock implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
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 {

View File

@ -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: <T>(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<ISupplyDataFunctions>(
{},
mockNode,
) as Mocked<ISupplyDataFunctions>;
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' }),
);
});
});

View File

@ -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<SupplyData> {
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:<region>:<account-id>:inference-profile/<profile-id>
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) {

View File

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

View File

@ -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",

View File

@ -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<ResolvedAwsCredentials> {
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 };
}

View File

@ -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<string, unknown>;
awsAssumeRoleCredential?: Record<string, unknown>;
credentialId?: string;
}) {
const context = mock<ISupplyDataFunctions>();
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<unknown>;
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();
});
});

View File

@ -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",

View File

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

View File

@ -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('<?xml version="1.0" encoding="UTF-8"?>'),
};
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',
};

View File

@ -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<string, string>,
body: bodyContent,
});
...(dispatcher ? { dispatcher } : {}),
};
const response = await fetch(stsEndpoint, requestInit);
if (!response.ok) {
const errorText = await response.text();

View File

@ -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> = {},
): 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 () =>
'<AssumeRoleResponse><AssumeRoleResult><Credentials>' +
'<AccessKeyId>ASIATEST</AccessKeyId>' +
'<SecretAccessKey>SECRET</SecretAccessKey>' +
'<SessionToken>TOKEN</SessionToken>' +
'</Credentials></AssumeRoleResult></AssumeRoleResponse>',
});
(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();
});
});

View File

@ -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",

View File

@ -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

View File

@ -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