mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 18:49:20 +02:00
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:
parent
ee7aa0b66a
commit
add5ab29dd
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "*"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user