first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,119 @@
import type { INodeProperties } from 'n8n-workflow';
import { regions } from './types';
export const awsRegionProperty: INodeProperties = {
displayName: 'Region',
name: 'region',
type: 'options',
options: regions.map((r) => ({
name: `${r.displayName} (${r.location}) - ${r.name}`,
value: r.name,
})),
default: 'us-east-1',
};
export const awsCustomEndpoints: INodeProperties[] = [
{
displayName: 'Custom Endpoints',
name: 'customEndpoints',
type: 'boolean',
default: false,
},
{
displayName: 'Rekognition Endpoint',
name: 'rekognitionEndpoint',
description:
'If you use Amazon VPC to host n8n, you can establish a connection between your VPC and Rekognition using a VPC endpoint. Leave blank to use the default endpoint.',
type: 'string',
displayOptions: {
show: {
customEndpoints: [true],
},
},
default: '',
placeholder: 'https://rekognition.{region}.amazonaws.com',
},
{
displayName: 'Lambda Endpoint',
name: 'lambdaEndpoint',
description:
'If you use Amazon VPC to host n8n, you can establish a connection between your VPC and Lambda using a VPC endpoint. Leave blank to use the default endpoint.',
type: 'string',
displayOptions: {
show: {
customEndpoints: [true],
},
},
default: '',
placeholder: 'https://lambda.{region}.amazonaws.com',
},
{
displayName: 'SNS Endpoint',
name: 'snsEndpoint',
description:
'If you use Amazon VPC to host n8n, you can establish a connection between your VPC and SNS using a VPC endpoint. Leave blank to use the default endpoint.',
type: 'string',
displayOptions: {
show: {
customEndpoints: [true],
},
},
default: '',
placeholder: 'https://sns.{region}.amazonaws.com',
},
{
displayName: 'SES Endpoint',
name: 'sesEndpoint',
description:
'If you use Amazon VPC to host n8n, you can establish a connection between your VPC and SES using a VPC endpoint. Leave blank to use the default endpoint.',
type: 'string',
displayOptions: {
show: {
customEndpoints: [true],
},
},
default: '',
placeholder: 'https://email.{region}.amazonaws.com',
},
{
displayName: 'SQS Endpoint',
name: 'sqsEndpoint',
description:
'If you use Amazon VPC to host n8n, you can establish a connection between your VPC and SQS using a VPC endpoint. Leave blank to use the default endpoint.',
type: 'string',
displayOptions: {
show: {
customEndpoints: [true],
},
},
default: '',
placeholder: 'https://sqs.{region}.amazonaws.com',
},
{
displayName: 'S3 Endpoint',
name: 's3Endpoint',
description:
'If you use Amazon VPC to host n8n, you can establish a connection between your VPC and S3 using a VPC endpoint. Leave blank to use the default endpoint.',
type: 'string',
displayOptions: {
show: {
customEndpoints: [true],
},
},
default: '',
placeholder: 'https://s3.{region}.amazonaws.com',
},
{
displayName: 'SSM Endpoint',
name: 'ssmEndpoint',
description: 'Endpoint for AWS Systems Manager (SSM)',
type: 'string',
displayOptions: {
show: {
customEndpoints: [true],
},
},
default: '',
placeholder: 'https://ssm.{region}.amazonaws.com',
},
];
@@ -0,0 +1,814 @@
import { ApplicationError } from 'n8n-workflow';
global.fetch = jest.fn();
class MockSecurityConfig {
awsSystemCredentialsAccess = true;
}
const mockContainer = {
get: jest.fn(),
};
const mockReadFile = jest.fn();
jest.mock('@n8n/di', () => ({
Container: mockContainer,
}));
jest.mock('@n8n/config', () => ({
SecurityConfig: MockSecurityConfig,
}));
jest.mock('fs/promises', () => ({
readFile: mockReadFile,
}));
import * as systemCredentialsUtils from './system-credentials-utils';
const mockEnvGetter = jest.fn();
jest.spyOn(systemCredentialsUtils, 'envGetter').mockImplementation(mockEnvGetter);
const { envGetter, getSystemCredentials, credentialsResolver } = systemCredentialsUtils;
describe('system-credentials-utils', () => {
let mockSecurityConfigInstance: MockSecurityConfig;
beforeEach(() => {
jest.clearAllMocks();
mockSecurityConfigInstance = new MockSecurityConfig();
mockContainer.get.mockReturnValue(mockSecurityConfigInstance);
mockEnvGetter.mockReturnValue(undefined);
(global.fetch as jest.Mock).mockReset();
mockReadFile.mockReset();
});
describe('envGetter', () => {
it('should be called with correct environment variable names', () => {
mockEnvGetter.mockReturnValue('test-value');
const result = envGetter('TEST_VAR');
expect(mockEnvGetter).toHaveBeenCalledWith('TEST_VAR');
expect(result).toBe('test-value');
});
});
describe('getSystemCredentials', () => {
it('should throw ApplicationError when AWS system credentials access is disabled', async () => {
mockSecurityConfigInstance.awsSystemCredentialsAccess = false;
await expect(getSystemCredentials()).rejects.toThrow(ApplicationError);
await expect(getSystemCredentials()).rejects.toThrow(
'Access to AWS system credentials disabled, contact your administrator.',
);
});
it('should return credentials from environment resolver', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_ACCESS_KEY_ID':
return 'test-access-key';
case 'AWS_SECRET_ACCESS_KEY':
return 'test-secret-key';
case 'AWS_SESSION_TOKEN':
return 'test-session-token';
default:
return undefined;
}
});
const result = await getSystemCredentials();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-session-token',
source: 'environment',
});
});
it('should return null when no credentials are found', async () => {
mockEnvGetter.mockReturnValue(undefined);
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const result = await getSystemCredentials();
expect(result).toBeNull();
});
});
describe('getEnvironmentCredentials', () => {
it('should return credentials when AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are available via envGetter', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_ACCESS_KEY_ID':
return ' test-access-key ';
case 'AWS_SECRET_ACCESS_KEY':
return ' test-secret-key ';
case 'AWS_SESSION_TOKEN':
return ' test-session-token ';
default:
return undefined;
}
});
const result = await credentialsResolver.environment();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-session-token',
});
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID');
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY');
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_SESSION_TOKEN');
});
it('should return credentials without session token when only access key and secret are available', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_ACCESS_KEY_ID':
return 'test-access-key';
case 'AWS_SECRET_ACCESS_KEY':
return 'test-secret-key';
case 'AWS_SESSION_TOKEN':
return undefined;
default:
return undefined;
}
});
const result = await credentialsResolver.environment();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: undefined,
});
});
it('should return null when AWS_ACCESS_KEY_ID is missing', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_ACCESS_KEY_ID':
return undefined;
case 'AWS_SECRET_ACCESS_KEY':
return 'test-secret-key';
default:
return undefined;
}
});
const result = await credentialsResolver.environment();
expect(result).toBeNull();
});
it('should return null when AWS_SECRET_ACCESS_KEY is missing', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_ACCESS_KEY_ID':
return 'test-access-key';
case 'AWS_SECRET_ACCESS_KEY':
return undefined;
default:
return undefined;
}
});
const result = await credentialsResolver.environment();
expect(result).toBeNull();
});
it('should trim whitespace from credentials', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_ACCESS_KEY_ID':
return ' test-access-key ';
case 'AWS_SECRET_ACCESS_KEY':
return ' test-secret-key ';
case 'AWS_SESSION_TOKEN':
return ' test-session-token ';
default:
return undefined;
}
});
const result = await credentialsResolver.environment();
expect(result?.accessKeyId).toBe('test-access-key');
expect(result?.secretAccessKey).toBe('test-secret-key');
expect(result?.sessionToken).toBe('test-session-token');
});
});
describe('getContainerMetadataCredentials', () => {
it('should return null when AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is not available via envGetter', async () => {
mockEnvGetter.mockImplementation((key: string) => {
if (key === 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI') {
return undefined;
}
return undefined;
});
const result = await credentialsResolver.containerMetadata();
expect(result).toBeNull();
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI');
});
it('should fetch credentials successfully with relative URI from envGetter', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI':
return '/v2/credentials/test-uuid';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return undefined;
default:
return undefined;
}
});
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
const result = await credentialsResolver.containerMetadata();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-token',
});
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI');
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_CONTAINER_AUTHORIZATION_TOKEN');
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.2/v2/credentials/test-uuid',
expect.objectContaining({
method: 'GET',
headers: {
'User-Agent': 'n8n-aws-credential',
},
}),
);
});
it('should include authorization header when AWS_CONTAINER_AUTHORIZATION_TOKEN is available via envGetter', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI':
return '/v2/credentials/test-uuid';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return 'test-auth-token';
default:
return undefined;
}
});
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
await credentialsResolver.containerMetadata();
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.2/v2/credentials/test-uuid',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
Authorization: 'Bearer test-auth-token',
},
}),
);
});
it('should return null when fetch fails', async () => {
mockEnvGetter.mockImplementation((key: string) => {
if (key === 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI') {
return '/v2/credentials/test-uuid';
}
return undefined;
});
(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
});
const result = await credentialsResolver.containerMetadata();
expect(result).toBeNull();
});
it('should return null when fetch throws an error', async () => {
mockEnvGetter.mockImplementation((key: string) => {
if (key === 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI') {
return '/v2/credentials/test-uuid';
}
return undefined;
});
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const result = await credentialsResolver.containerMetadata();
expect(result).toBeNull();
});
});
describe('getPodIdentityCredentials', () => {
it('should return null when AWS_CONTAINER_CREDENTIALS_FULL_URI is not available via envGetter', async () => {
mockEnvGetter.mockImplementation((key: string) => {
if (key === 'AWS_CONTAINER_CREDENTIALS_FULL_URI') {
return undefined;
}
return undefined;
});
const result = await credentialsResolver.podIdentity();
expect(result).toBeNull();
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_CONTAINER_CREDENTIALS_FULL_URI');
});
it('should fetch credentials successfully with full URI from envGetter', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'https://eks-pod-identity.amazonaws.com/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return undefined;
default:
return undefined;
}
});
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
const result = await credentialsResolver.podIdentity();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-token',
});
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_CONTAINER_CREDENTIALS_FULL_URI');
expect(mockEnvGetter).toHaveBeenCalledWith('AWS_CONTAINER_AUTHORIZATION_TOKEN');
expect(global.fetch).toHaveBeenCalledWith(
'https://eks-pod-identity.amazonaws.com/v1/credentials',
expect.objectContaining({
method: 'GET',
headers: {
'User-Agent': 'n8n-aws-credential',
},
}),
);
});
it('should include authorization header when AWS_CONTAINER_AUTHORIZATION_TOKEN is available via envGetter', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'https://eks-pod-identity.amazonaws.com/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return 'test-auth-token';
default:
return undefined;
}
});
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
await credentialsResolver.podIdentity();
expect(global.fetch).toHaveBeenCalledWith(
'https://eks-pod-identity.amazonaws.com/v1/credentials',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
Authorization: 'test-auth-token',
},
}),
);
});
it('should return null when fetch fails', async () => {
mockEnvGetter.mockImplementation((key: string) => {
if (key === 'AWS_CONTAINER_CREDENTIALS_FULL_URI') {
return 'https://eks-pod-identity.amazonaws.com/v1/credentials';
}
return undefined;
});
(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
});
const result = await credentialsResolver.podIdentity();
expect(result).toBeNull();
});
it('should return null when fetch throws an error', async () => {
mockEnvGetter.mockImplementation((key: string) => {
if (key === 'AWS_CONTAINER_CREDENTIALS_FULL_URI') {
return 'https://eks-pod-identity.amazonaws.com/v1/credentials';
}
return undefined;
});
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const result = await credentialsResolver.podIdentity();
expect(result).toBeNull();
});
it('should read token from file when AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE is set', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'http://169.254.170.23/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE':
return '/var/run/secrets/pods.eks.amazonaws.com/serviceaccount/eks-pod-identity-token';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return undefined;
default:
return undefined;
}
});
mockReadFile.mockResolvedValue('file-based-token');
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
const result = await credentialsResolver.podIdentity();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-token',
});
expect(mockReadFile).toHaveBeenCalledWith(
'/var/run/secrets/pods.eks.amazonaws.com/serviceaccount/eks-pod-identity-token',
'utf-8',
);
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.23/v1/credentials',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
Authorization: 'file-based-token',
},
}),
);
});
it('should trim whitespace from file-based token', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'http://169.254.170.23/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE':
return '/var/run/secrets/token';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return undefined;
default:
return undefined;
}
});
mockReadFile.mockResolvedValue(' \n file-token-with-whitespace \n ');
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
await credentialsResolver.podIdentity();
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.23/v1/credentials',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
Authorization: 'file-token-with-whitespace',
},
}),
);
});
it('should fall back to AWS_CONTAINER_AUTHORIZATION_TOKEN when file read fails', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'http://169.254.170.23/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE':
return '/nonexistent/token/file';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return 'fallback-direct-token';
default:
return undefined;
}
});
mockReadFile.mockRejectedValue(new Error('ENOENT: no such file or directory'));
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
const result = await credentialsResolver.podIdentity();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-token',
});
expect(mockReadFile).toHaveBeenCalledWith('/nonexistent/token/file', 'utf-8');
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.23/v1/credentials',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
Authorization: 'fallback-direct-token',
},
}),
);
});
it('should prioritize AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE over AWS_CONTAINER_AUTHORIZATION_TOKEN', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'http://169.254.170.23/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE':
return '/var/run/secrets/token';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return 'direct-token-should-not-be-used';
default:
return undefined;
}
});
mockReadFile.mockResolvedValue('file-token-has-priority');
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
await credentialsResolver.podIdentity();
expect(mockReadFile).toHaveBeenCalledWith('/var/run/secrets/token', 'utf-8');
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.23/v1/credentials',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
Authorization: 'file-token-has-priority',
},
}),
);
});
it('should not attempt to read file when AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE is not set', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'http://169.254.170.23/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE':
return undefined;
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return 'direct-token';
default:
return undefined;
}
});
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
await credentialsResolver.podIdentity();
expect(mockReadFile).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.23/v1/credentials',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
Authorization: 'direct-token',
},
}),
);
});
it('should work without any authorization token (neither file nor direct)', async () => {
mockEnvGetter.mockImplementation((key: string) => {
switch (key) {
case 'AWS_CONTAINER_CREDENTIALS_FULL_URI':
return 'http://169.254.170.23/v1/credentials';
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE':
return undefined;
case 'AWS_CONTAINER_AUTHORIZATION_TOKEN':
return undefined;
default:
return undefined;
}
});
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
const result = await credentialsResolver.podIdentity();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-token',
});
expect(mockReadFile).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith(
'http://169.254.170.23/v1/credentials',
expect.objectContaining({
headers: {
'User-Agent': 'n8n-aws-credential',
},
}),
);
// Ensure Authorization header is not included
expect((global.fetch as jest.Mock).mock.calls[0][1].headers.Authorization).toBeUndefined();
});
});
describe('getInstanceMetadataCredentials', () => {
it('should fetch credentials successfully from EC2 instance metadata', async () => {
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('test-token'),
})
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('test-role'),
})
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
const result = await credentialsResolver.instanceMetadata();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-token',
});
expect(global.fetch).toHaveBeenCalledTimes(3);
});
it('should fallback to IMDSv1 when IMDSv2 token request fails', async () => {
const mockCredentials = {
AccessKeyId: 'test-access-key',
SecretAccessKey: 'test-secret-key',
Token: 'test-token',
};
(global.fetch as jest.Mock)
.mockRejectedValueOnce(new Error('Token request failed'))
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('test-role'),
})
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockCredentials),
});
const result = await credentialsResolver.instanceMetadata();
expect(result).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-token',
});
});
it('should return null when role name request fails', async () => {
(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('test-token'),
})
.mockResolvedValueOnce({
ok: false,
});
const result = await credentialsResolver.instanceMetadata();
expect(result).toBeNull();
});
it('should return null when credentials are incomplete', async () => {
const incompleteCredentials = {
AccessKeyId: 'test-access-key',
};
(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('test-token'),
})
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('test-role'),
})
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(incompleteCredentials),
});
const result = await credentialsResolver.instanceMetadata();
expect(result).toBeNull();
});
it('should return null when fetch throws an error', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const result = await credentialsResolver.instanceMetadata();
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,269 @@
import { SecurityConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { ApplicationError } from 'n8n-workflow';
import { readFile } from 'fs/promises';
type Resolvers = 'environment' | 'podIdentity' | 'containerMetadata' | 'instanceMetadata';
type ReturnData = {
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
};
export const envGetter = (key: string): string | undefined => process.env[key];
export const credentialsResolver: Record<Resolvers, () => Promise<ReturnData | null>> = {
environment: getEnvironmentCredentials,
instanceMetadata: getInstanceMetadataCredentials,
containerMetadata: getContainerMetadataCredentials,
podIdentity: getPodIdentityCredentials,
};
/**
* Retrieves AWS credentials from various system sources following the AWS credential chain.
* Attempts to get credentials in the following order:
* 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN)
* 2. EKS Pod Identity (AWS_CONTAINER_CREDENTIALS_FULL_URI)
* 3. ECS/Fargate container metadata (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)
* 4. EC2 instance metadata service
*/
export async function getSystemCredentials() {
if (!Container.get(SecurityConfig).awsSystemCredentialsAccess) {
throw new ApplicationError(
'Access to AWS system credentials disabled, contact your administrator.',
);
}
const resolveOrder: Resolvers[] = [
'environment',
'podIdentity',
'containerMetadata',
'instanceMetadata',
];
for (const resolver of resolveOrder) {
try {
const credentials = await credentialsResolver[resolver]();
if (credentials) return { ...credentials, source: resolver };
} catch (error) {
// Ignore and continue to the next resolver
}
}
return null;
}
async function getEnvironmentCredentials() {
const accessKeyId = envGetter('AWS_ACCESS_KEY_ID');
const secretAccessKey = envGetter('AWS_SECRET_ACCESS_KEY');
const sessionToken = envGetter('AWS_SESSION_TOKEN');
if (accessKeyId && secretAccessKey) {
return {
accessKeyId: accessKeyId.trim(),
secretAccessKey: secretAccessKey.trim(),
sessionToken: sessionToken?.trim(),
};
}
return null;
}
/**
* Retrieves AWS credentials from EC2 instance metadata service (IMDSv2-aware).
* This function is used when running on an EC2 instance with an attached IAM role.
* It first attempts to obtain an IMDSv2 session token and includes it in all metadata requests.
* Falls back to IMDSv1 if IMDSv2 is unavailable (older or less restricted environments).
*
* @returns Promise resolving to credentials object or null if not running on EC2 or no role attached
*
* @see {@link https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html IAM Roles for Amazon EC2}
*/
async function getInstanceMetadataCredentials() {
try {
const baseUrl = 'http://169.254.169.254/latest';
const headers: Record<string, string> = {
'User-Agent': 'n8n-aws-credential',
};
// Try to obtain an IMDSv2 token
try {
const tokenResponse = await fetch(`${baseUrl}/api/token`, {
method: 'PUT',
headers: {
'X-aws-ec2-metadata-token-ttl-seconds': '21600',
'User-Agent': 'n8n-aws-credential',
},
signal: AbortSignal.timeout(2000),
});
if (tokenResponse.ok) {
const token = await tokenResponse.text();
headers['X-aws-ec2-metadata-token'] = token;
}
} catch {
// IMDSv2 may be disabled; continue with IMDSv1
}
const roleResponse = await fetch(`${baseUrl}/meta-data/iam/security-credentials/`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(2000),
});
if (!roleResponse.ok) {
return null;
}
const roleName = (await roleResponse.text()).trim();
if (!roleName) {
return null;
}
const credentialsResponse = await fetch(
`${baseUrl}/meta-data/iam/security-credentials/${roleName}`,
{
method: 'GET',
headers,
signal: AbortSignal.timeout(2000),
},
);
if (!credentialsResponse.ok) {
return null;
}
const credentialsData = await credentialsResponse.json();
if (!credentialsData?.AccessKeyId || !credentialsData?.SecretAccessKey) {
return null;
}
return {
accessKeyId: credentialsData.AccessKeyId,
secretAccessKey: credentialsData.SecretAccessKey,
sessionToken: credentialsData.Token,
};
} catch (error) {
return null;
}
}
/**
* Retrieves AWS credentials from ECS/Fargate container metadata service.
* This function is used when running in an ECS task or Fargate container with a task role.
* It uses the AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable to fetch credentials.
* When AWS_CONTAINER_AUTHORIZATION_TOKEN is available, it includes the Authorization header
* as required by AWS for container credential endpoints.
*
* @returns Promise resolving to credentials object or null if not running in ECS/Fargate or no task role
*
* @see {@link https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html IAM Roles for Tasks}
*/
async function getContainerMetadataCredentials() {
try {
const relativeUri = envGetter('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI');
if (!relativeUri) {
return null;
}
const authToken = envGetter('AWS_CONTAINER_AUTHORIZATION_TOKEN');
const headers: Record<string, string> = {
'User-Agent': 'n8n-aws-credential',
};
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
const response = await fetch(`http://169.254.170.2${relativeUri}`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
return null;
}
const credentialsData = await response.json();
return {
accessKeyId: credentialsData.AccessKeyId,
secretAccessKey: credentialsData.SecretAccessKey,
sessionToken: credentialsData.Token,
};
} catch (error) {
return null;
}
}
/**
* Retrieves AWS credentials from EKS Pod Identity service.
* This function is used when running in an EKS pod with Pod Identity configured.
* It uses the AWS_CONTAINER_CREDENTIALS_FULL_URI environment variable to fetch credentials.
* When AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE or AWS_CONTAINER_AUTHORIZATION_TOKEN is available,
* it includes the Authorization header as required by AWS for Pod Identity credential endpoints.
* The file-based token takes precedence over the direct token, following AWS SDK behavior.
*
* Unlike when retrieving AWS Credentials from Container Metadata for ECS/Fargate, the Authorization
* header should NOT include a 'Bearer ' prefix as the EKS Pod Identity Agent uses the header value
* directly when making the AssumeRoleForPodIdentity API call.
*
* @returns Promise resolving to credentials object or null if not running with EKS Pod Identity
*
* @see {@link https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html EKS Pod Identities}
*/
async function getPodIdentityCredentials() {
const fullUri = envGetter('AWS_CONTAINER_CREDENTIALS_FULL_URI');
if (!fullUri) {
return null;
}
try {
let authToken: string | undefined;
// Check for file-based token first (used by EKS Pod Identity)
const authTokenFile = envGetter('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE');
if (authTokenFile) {
try {
authToken = (await readFile(authTokenFile, 'utf-8')).trim();
} catch (error) {
// If file read fails, fall back to direct token
}
}
// Fall back to direct token (used by ECS Task Roles)
if (!authToken) {
authToken = envGetter('AWS_CONTAINER_AUTHORIZATION_TOKEN');
}
const headers: Record<string, string> = {
'User-Agent': 'n8n-aws-credential',
};
if (authToken) {
headers.Authorization = `${authToken}`;
}
const response = await fetch(fullUri, {
method: 'GET',
headers,
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
return null;
}
const credentialsData = await response.json();
return {
accessKeyId: credentialsData.AccessKeyId,
secretAccessKey: credentialsData.SecretAccessKey,
sessionToken: credentialsData.Token,
};
} catch (error) {
return null;
}
}
@@ -0,0 +1,232 @@
export const AWS_CHINA_DOMAIN = 'amazonaws.com.cn';
export const AWS_GLOBAL_DOMAIN = 'amazonaws.com';
type RegionData = {
name: string;
displayName: string;
location: string;
domain?: string;
};
export const regions: RegionData[] = [
{
name: 'af-south-1',
displayName: 'Africa',
location: 'Cape Town',
},
{
name: 'ap-east-1',
displayName: 'Asia Pacific',
location: 'Hong Kong',
},
{
name: 'ap-south-1',
displayName: 'Asia Pacific',
location: 'Mumbai',
},
{
name: 'ap-south-2',
displayName: 'Asia Pacific',
location: 'Hyderabad',
},
{
name: 'ap-southeast-1',
displayName: 'Asia Pacific',
location: 'Singapore',
},
{
name: 'ap-southeast-2',
displayName: 'Asia Pacific',
location: 'Sydney',
},
{
name: 'ap-southeast-3',
displayName: 'Asia Pacific',
location: 'Jakarta',
},
{
name: 'ap-southeast-4',
displayName: 'Asia Pacific',
location: 'Melbourne',
},
{
name: 'ap-southeast-5',
displayName: 'Asia Pacific',
location: 'Malaysia',
},
{
name: 'ap-southeast-7',
displayName: 'Asia Pacific',
location: 'Thailand',
},
{
name: 'ap-northeast-1',
displayName: 'Asia Pacific',
location: 'Tokyo',
},
{
name: 'ap-northeast-2',
displayName: 'Asia Pacific',
location: 'Seoul',
},
{
name: 'ap-northeast-3',
displayName: 'Asia Pacific',
location: 'Osaka',
},
{
name: 'ca-central-1',
displayName: 'Canada',
location: 'Central',
},
{
name: 'ca-west-1',
displayName: 'Canada West',
location: 'Calgary',
},
{
name: 'cn-north-1',
displayName: 'China',
location: 'Beijing',
domain: AWS_CHINA_DOMAIN,
},
{
name: 'cn-northwest-1',
displayName: 'China',
location: 'Ningxia',
domain: AWS_CHINA_DOMAIN,
},
{
name: 'eu-central-1',
displayName: 'Europe',
location: 'Frankfurt',
},
{
name: 'eu-central-2',
displayName: 'Europe',
location: 'Zurich',
},
{
name: 'eu-north-1',
displayName: 'Europe',
location: 'Stockholm',
},
{
name: 'eu-south-1',
displayName: 'Europe',
location: 'Milan',
},
{
name: 'eu-south-2',
displayName: 'Europe',
location: 'Spain',
},
{
name: 'eu-west-1',
displayName: 'Europe',
location: 'Ireland',
},
{
name: 'eu-west-2',
displayName: 'Europe',
location: 'London',
},
{
name: 'eu-west-3',
displayName: 'Europe',
location: 'Paris',
},
{
name: 'il-central-1',
displayName: 'Israel',
location: 'Tel Aviv',
},
{
name: 'me-central-1',
displayName: 'Middle East',
location: 'UAE',
},
{
name: 'me-south-1',
displayName: 'Middle East',
location: 'Bahrain',
},
{
name: 'mx-central-1',
displayName: 'Mexico',
location: 'Central',
},
{
name: 'sa-east-1',
displayName: 'South America',
location: 'São Paulo',
},
{
name: 'us-east-1',
displayName: 'US East',
location: 'N. Virginia',
},
{
name: 'us-east-2',
displayName: 'US East',
location: 'Ohio',
},
{
name: 'us-gov-east-1',
displayName: 'US East',
location: 'GovCloud',
},
{
name: 'us-west-1',
displayName: 'US West',
location: 'N. California',
},
{
name: 'us-west-2',
displayName: 'US West',
location: 'Oregon',
},
{
name: 'us-gov-west-1',
displayName: 'US West',
location: 'GovCloud',
},
] as const;
export type AWSRegion = (typeof regions)[number]['name'];
export type AwsCredentialsTypeBase = {
region: AWSRegion;
customEndpoints: boolean;
rekognitionEndpoint?: string;
lambdaEndpoint?: string;
snsEndpoint?: string;
sesEndpoint?: string;
sqsEndpoint?: string;
s3Endpoint?: string;
ssmEndpoint?: string;
};
export type AwsIamCredentialsType = AwsCredentialsTypeBase & {
accessKeyId: string;
secretAccessKey: string;
temporaryCredentials: boolean;
sessionToken?: string;
};
export type AwsAssumeRoleCredentialsType = AwsCredentialsTypeBase & {
assumeRole?: boolean;
roleArn?: string;
externalId?: string;
roleSessionName?: string;
useSystemCredentialsForRole?: boolean;
stsAccessKeyId?: string;
stsSecretAccessKey?: string;
stsSessionToken?: string;
};
export type AwsSecurityHeaders = {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string | undefined;
};
@@ -0,0 +1,752 @@
import { ApplicationError } from 'n8n-workflow';
import type { AwsAssumeRoleCredentialsType, AWSRegion } from './types';
global.fetch = jest.fn();
jest.mock('aws4', () => ({
sign: jest.fn(),
}));
jest.mock('xml2js', () => ({
parseString: jest.fn(),
}));
import { sign } from 'aws4';
import { parseString } from 'xml2js';
import { assumeRole } from './utils';
import * as systemCredentialsUtils from './system-credentials-utils';
describe('assumeRole', () => {
let mockFetch: jest.MockedFunction<typeof fetch>;
let mockSign: jest.MockedFunction<typeof sign>;
let mockParseString: jest.MockedFunction<typeof parseString>;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockSign = sign as jest.MockedFunction<typeof sign>;
mockParseString = parseString as jest.MockedFunction<typeof parseString>;
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockSign.mockImplementation((request: any) => request as any);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
describe('with system credentials', () => {
it('should successfully assume role using system credentials by environment', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: true,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
roleSessionName: 'test-session',
};
const mockSystemCredentials = {
accessKeyId: 'system-access-key',
secretAccessKey: 'system-secret-key',
sessionToken: 'system-session-token',
source: 'environment' as const,
};
jest
.spyOn(systemCredentialsUtils, 'getSystemCredentials')
.mockResolvedValue(mockSystemCredentials);
const mockResponse = {
ok: true,
text: jest.fn().mockResolvedValue(`<?xml version="1.0" encoding="UTF-8"?>
<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>assumed-access-key</AccessKeyId>
<SecretAccessKey>assumed-secret-key</SecretAccessKey>
<SessionToken>assumed-session-token</SessionToken>
</Credentials>
</AssumeRoleResult>
</AssumeRoleResponse>`),
};
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',
},
},
},
});
});
const result = await assumeRole(credentials, 'us-east-1');
expect(result).toEqual({
accessKeyId: 'assumed-access-key',
secretAccessKey: 'assumed-secret-key',
sessionToken: 'assumed-session-token',
});
expect(systemCredentialsUtils.getSystemCredentials).toHaveBeenCalled();
expect(mockSign).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
path: '/',
region: 'us-east-1',
}),
mockSystemCredentials,
);
expect(mockFetch).toHaveBeenCalledWith(
'https://sts.us-east-1.amazonaws.com',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('Action=AssumeRole'),
}),
);
});
it('should successfully assume role using system credentials by instanceMetadata', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: true,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
roleSessionName: 'test-session',
};
const mockSystemCredentials = {
accessKeyId: 'system-access-key',
secretAccessKey: 'system-secret-key',
sessionToken: 'system-session-token',
source: 'instanceMetadata' as const,
};
jest
.spyOn(systemCredentialsUtils, 'getSystemCredentials')
.mockResolvedValue(mockSystemCredentials);
const mockResponse = {
ok: true,
text: jest.fn().mockResolvedValue(`<?xml version="1.0" encoding="UTF-8"?>
<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>assumed-access-key</AccessKeyId>
<SecretAccessKey>assumed-secret-key</SecretAccessKey>
<SessionToken>assumed-session-token</SessionToken>
</Credentials>
</AssumeRoleResult>
</AssumeRoleResponse>`),
};
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',
},
},
},
});
});
const result = await assumeRole(credentials, 'us-east-1');
expect(result).toEqual({
accessKeyId: 'assumed-access-key',
secretAccessKey: 'assumed-secret-key',
sessionToken: 'assumed-session-token',
});
expect(systemCredentialsUtils.getSystemCredentials).toHaveBeenCalled();
expect(mockSign).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
path: '/',
region: 'us-east-1',
}),
mockSystemCredentials,
);
expect(mockFetch).toHaveBeenCalledWith(
'https://sts.us-east-1.amazonaws.com',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('Action=AssumeRole'),
}),
);
});
it('should throw error when system credentials are not available', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: true,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
};
jest.spyOn(systemCredentialsUtils, 'getSystemCredentials').mockResolvedValue(null);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'System AWS credentials are required for role assumption',
);
});
it('should include external ID when provided', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: true,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
roleSessionName: 'test-session',
externalId: 'external-123',
};
const mockSystemCredentials = {
accessKeyId: 'system-access-key',
secretAccessKey: 'system-secret-key',
source: 'environment' as const,
};
jest
.spyOn(systemCredentialsUtils, 'getSystemCredentials')
.mockResolvedValue(mockSystemCredentials);
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('ExternalId=external-123'),
}),
);
});
});
describe('with manual STS credentials', () => {
it('should successfully assume role using manual STS credentials', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: false,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
roleSessionName: 'test-session',
stsAccessKeyId: 'sts-access-key',
stsSecretAccessKey: 'sts-secret-key',
stsSessionToken: 'sts-session-token',
};
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',
},
},
},
});
});
const result = await assumeRole(credentials, 'us-east-1');
expect(result).toEqual({
accessKeyId: 'assumed-access-key',
secretAccessKey: 'assumed-secret-key',
sessionToken: 'assumed-session-token',
});
expect(mockSign).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
path: '/',
region: 'us-east-1',
}),
{
accessKeyId: 'sts-access-key',
secretAccessKey: 'sts-secret-key',
sessionToken: 'sts-session-token',
},
);
});
it('should work without STS session token', 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(mockSign).toHaveBeenCalledWith(expect.anything(), {
accessKeyId: 'sts-access-key',
secretAccessKey: 'sts-secret-key',
sessionToken: undefined,
});
});
it('should throw error when STS access key ID is missing', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: false,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
stsSecretAccessKey: 'sts-secret-key',
};
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'STS Access Key ID is required when not using system credentials',
);
});
it('should throw error when STS access key ID is empty', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: false,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
stsAccessKeyId: ' ',
stsSecretAccessKey: 'sts-secret-key',
};
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'STS Access Key ID is required when not using system credentials',
);
});
it('should throw error when STS secret access key is missing', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: false,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
stsAccessKeyId: 'sts-access-key',
};
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'STS Secret Access Key is required when not using system credentials',
);
});
it('should throw error when STS secret access key is empty', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'us-east-1',
customEndpoints: false,
useSystemCredentialsForRole: false,
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
stsAccessKeyId: 'sts-access-key',
stsSecretAccessKey: ' ',
};
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'STS Secret Access Key is required when not using system credentials',
);
});
it('should trim whitespace from STS credentials', 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 ',
stsSessionToken: ' sts-session-token ',
};
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(mockSign).toHaveBeenCalledWith(expect.anything(), {
accessKeyId: 'sts-access-key',
secretAccessKey: 'sts-secret-key',
sessionToken: 'sts-session-token',
});
});
});
describe('region handling', () => {
it('should use correct endpoint for China regions', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'cn-north-1',
customEndpoints: false,
useSystemCredentialsForRole: false,
roleArn: 'arn:aws-cn: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, 'cn-north-1' as AWSRegion);
expect(mockFetch).toHaveBeenCalledWith(
'https://sts.cn-north-1.amazonaws.com.cn',
expect.any(Object),
);
});
it('should use correct endpoint for standard regions', async () => {
const credentials: AwsAssumeRoleCredentialsType = {
region: 'eu-west-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, 'eu-west-1');
expect(mockFetch).toHaveBeenCalledWith(
'https://sts.eu-west-1.amazonaws.com',
expect.any(Object),
);
});
});
describe('error handling', () => {
it('should throw error when signing fails', 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',
};
mockSign.mockImplementation(() => {
throw new Error('Signing failed');
});
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'Failed to sign STS request',
);
});
it('should throw error when STS request fails', 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: false,
status: 403,
statusText: 'Forbidden',
text: jest.fn().mockResolvedValue('Access denied'),
};
mockFetch.mockResolvedValue(mockResponse as any);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'STS AssumeRole failed: 403 Forbidden - Access denied',
);
});
it('should throw error when XML parsing fails', 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('invalid xml'),
};
mockFetch.mockResolvedValue(mockResponse as any);
mockParseString.mockImplementation((_xml, _options, callback) => {
callback(new Error('XML parsing failed'), null);
});
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow('XML parsing failed');
});
it('should throw error when response has no credentials', 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: {},
},
});
});
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'Invalid response from STS AssumeRole',
);
});
it('should throw error when response structure is invalid', 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, {
InvalidResponse: {},
});
});
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(ApplicationError);
await expect(assumeRole(credentials, 'us-east-1')).rejects.toThrow(
'Invalid response from STS 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',
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(mockSign).toHaveBeenCalledWith(expect.anything(), {
accessKeyId: 'sts-access-key',
secretAccessKey: 'sts-secret-key',
sessionToken: undefined,
});
});
});
});
@@ -0,0 +1,381 @@
import {
ApplicationError,
type IHttpRequestMethods,
isObjectEmpty,
type ICredentialTestRequest,
type IDataObject,
type IHttpRequestOptions,
type IRequestOptions,
} from 'n8n-workflow';
import { parseString } from 'xml2js';
import type { Request } from 'aws4';
import {
AWS_GLOBAL_DOMAIN,
type AwsCredentialsTypeBase,
regions,
type AWSRegion,
type AwsAssumeRoleCredentialsType,
type AwsSecurityHeaders,
} from './types';
import { sign } from 'aws4';
import { getSystemCredentials } from './system-credentials-utils';
/**
* Checks if a request body value should be JSON stringified for AWS requests.
* Returns true for plain objects without Content-Length headers.
*/
function shouldStringifyBody<T>(value: T, headers: IDataObject): boolean {
if (
typeof value === 'object' &&
value !== null &&
!headers['Content-Length'] &&
!headers['content-length'] &&
!Buffer.isBuffer(value)
) {
return true;
}
return false;
}
/**
* Gets the AWS domain for a specific region.
*
* @param region - The AWS region to get the domain for
* @returns The AWS domain for the region, or the global domain if region not found
*/
export function getAwsDomain(region: AWSRegion): string {
return regions.find((r) => r.name === region)?.domain ?? AWS_GLOBAL_DOMAIN;
}
/**
* Parses an AWS service URL to extract the service name and region.
* Some AWS services are global and don't have a region.
*
* @param url - The AWS service URL to parse
* @returns Object containing the service name and region (null for global services)
*
* @see {@link https://docs.aws.amazon.com/general/latest/gr/rande.html#global-endpoints AWS Global Endpoints}
*/
export function parseAwsUrl(url: URL): { region: AWSRegion | null; service: string } {
const hostname = url.hostname;
// Handle both .amazonaws.com and .amazonaws.com.cn domains
const [service, region] = hostname.replace(/\.amazonaws\.com.*$/, '').split('.');
return { service, region };
}
/**
* AWS credentials test configuration for validating AWS credentials.
* Uses the STS GetCallerIdentity action to verify that the provided credentials are valid.
* Automatically handles both standard AWS regions and China regions with appropriate endpoints.
*/
export const awsCredentialsTest: ICredentialTestRequest = {
request: {
baseURL:
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
'={{$credentials.region.startsWith("cn-") ? `https://sts.${$credentials.region}.amazonaws.com.cn` : `https://sts.${$credentials.region}.amazonaws.com`}}',
url: '?Action=GetCallerIdentity&Version=2011-06-15',
method: 'POST',
},
};
/**
* Prepares AWS request options for signing by constructing the proper endpoint URL,
* handling query parameters, and setting up the request body for AWS4 signature.
*
* This function handles multiple scenarios:
* - Custom service endpoints from credentials
* - Default AWS service endpoints
* - URI-based requests (legacy IRequestOptions interface)
* - Form data conversion to URL-encoded format
* - Special handling for STS GetCallerIdentity requests
*
* @param requestOptions - The HTTP request options to modify
* @param credentials - AWS credentials containing potential custom endpoints
* @param path - The API path to append to the endpoint
* @param method - HTTP method for the request
* @param service - AWS service name (e.g., 's3', 'lambda', 'sts')
* @param region - AWS region for the request
* @returns Object containing signing options and the constructed endpoint URL
*/
export function awsGetSignInOptionsAndUpdateRequest(
requestOptions: IHttpRequestOptions,
credentials: AwsCredentialsTypeBase,
path: string,
method: string | undefined,
service: string,
region: AWSRegion,
): { signOpts: Request; url: string } {
let body = requestOptions.body;
let endpoint: URL;
let query = requestOptions.qs?.query as IDataObject;
// ! Workaround as we still use the IRequestOptions interface which uses uri instead of url
// ! To change when we replace the interface with IHttpRequestOptions
const requestWithUri = requestOptions as unknown as IRequestOptions;
if (requestWithUri.uri) {
requestOptions.url = requestWithUri.uri;
endpoint = new URL(requestOptions.url);
if (service === 'sts') {
try {
if (requestWithUri.qs?.Action !== 'GetCallerIdentity') {
query = requestWithUri.qs as IDataObject;
} else {
endpoint.searchParams.set('Action', 'GetCallerIdentity');
endpoint.searchParams.set('Version', '2011-06-15');
}
} catch (err) {
console.error(err);
}
}
const parsed = parseAwsUrl(endpoint);
service = parsed.service;
if (parsed.region) {
region = parsed.region;
}
} else {
if (!requestOptions.baseURL && !requestOptions.url) {
let endpointString: string;
if (service === 'lambda' && credentials.lambdaEndpoint) {
endpointString = credentials.lambdaEndpoint;
} else if (service === 'sns' && credentials.snsEndpoint) {
endpointString = credentials.snsEndpoint;
} else if (service === 'sqs' && credentials.sqsEndpoint) {
endpointString = credentials.sqsEndpoint;
} else if (service === 's3' && credentials.s3Endpoint) {
endpointString = credentials.s3Endpoint;
} else if (service === 'ses' && credentials.sesEndpoint) {
endpointString = credentials.sesEndpoint;
} else if (service === 'rekognition' && credentials.rekognitionEndpoint) {
endpointString = credentials.rekognitionEndpoint;
} else if (service === 'ssm' && credentials.ssmEndpoint) {
endpointString = credentials.ssmEndpoint;
} else if (service) {
const domain = getAwsDomain(region);
endpointString = `https://${service}.${region}.${domain}`;
}
endpoint = new URL(endpointString!.replace('{region}', region) + path);
} else {
// If no endpoint is set, we try to decompose the path and use the default endpoint
const customUrl = new URL(`${requestOptions.baseURL!}${requestOptions.url}${path}`);
const parsed = parseAwsUrl(customUrl);
service = parsed.service;
if (parsed.region) {
region = parsed.region;
}
if (service === 'sts') {
try {
customUrl.searchParams.set('Action', 'GetCallerIdentity');
customUrl.searchParams.set('Version', '2011-06-15');
} catch (err) {
console.error(err);
}
}
endpoint = customUrl;
}
}
if (query && Object.keys(query).length !== 0) {
Object.keys(query).forEach((key) => {
endpoint.searchParams.append(key, query[key] as string);
});
}
if (body && typeof body === 'object' && isObjectEmpty(body)) {
body = '';
}
path = endpoint.pathname + endpoint.search;
// ! aws4.sign *must* have the body to sign, but we might have .form instead of .body
const requestWithForm = requestOptions as unknown as { form?: Record<string, string> };
let bodyContent = body !== '' ? body : undefined;
let contentTypeHeader: string | undefined = undefined;
if (shouldStringifyBody(bodyContent, requestOptions.headers ?? {})) {
bodyContent = JSON.stringify(bodyContent);
}
if (requestWithForm.form) {
const params = new URLSearchParams();
for (const key in requestWithForm.form) {
params.append(key, requestWithForm.form[key]);
}
bodyContent = params.toString();
contentTypeHeader = 'application/x-www-form-urlencoded';
}
const signOpts = {
...requestOptions,
headers: {
...(requestOptions.headers ?? {}),
...(contentTypeHeader && { 'content-type': contentTypeHeader }),
},
host: endpoint.host,
method,
path,
body: bodyContent,
region,
} as unknown as Request;
return { signOpts, url: endpoint.origin + path };
}
/**
* Assumes an AWS IAM role using STS (Security Token Service) and returns temporary credentials.
* This function supports two modes for providing credentials for the STS call:
* 1. Using system credentials (environment variables, instance metadata, etc.)
* 2. Using manually provided STS credentials
*
* @param credentials - The assume role credentials configuration
* @param region - AWS region for the STS endpoint
* @returns Promise resolving to temporary credentials for the assumed role
* @throws {ApplicationError} When credentials are invalid or STS call fails
*
* @see {@link https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html STS AssumeRole API}
*/
export async function assumeRole(
credentials: AwsAssumeRoleCredentialsType,
region: AWSRegion,
): Promise<{
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
}> {
let stsCallCredentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
const useSystemCredentialsForRole = credentials.useSystemCredentialsForRole ?? false;
if (useSystemCredentialsForRole) {
const systemCredentials = await getSystemCredentials();
if (!systemCredentials) {
throw new ApplicationError(
'System AWS credentials are required for role assumption. Please ensure AWS credentials are available via environment variables, instance metadata, or container role.',
);
}
stsCallCredentials = systemCredentials;
} else {
if (!credentials.stsAccessKeyId || credentials.stsAccessKeyId.trim() === '') {
throw new ApplicationError(
'STS Access Key ID is required when not using system credentials.',
);
}
if (!credentials.stsSecretAccessKey || credentials.stsSecretAccessKey.trim() === '') {
throw new ApplicationError(
'STS Secret Access Key is required when not using system credentials.',
);
}
const sessionToken = credentials.stsSessionToken?.trim() || undefined;
stsCallCredentials = {
accessKeyId: credentials.stsAccessKeyId.trim(),
secretAccessKey: credentials.stsSecretAccessKey.trim(),
sessionToken,
};
}
const domain = getAwsDomain(region);
const stsEndpoint = `https://sts.${region}.${domain}`;
const assumeRoleBody = {
RoleArn: credentials.roleArn,
RoleSessionName: credentials.roleSessionName || 'n8n-session',
...(credentials.externalId && { ExternalId: credentials.externalId }),
};
const params = new URLSearchParams({
Action: 'AssumeRole',
Version: '2011-06-15',
RoleArn: assumeRoleBody.RoleArn!,
RoleSessionName: assumeRoleBody.RoleSessionName,
});
if (assumeRoleBody.ExternalId) {
params.append('ExternalId', assumeRoleBody.ExternalId);
}
const bodyContent = params.toString();
const stsUrl = new URL(stsEndpoint);
const signOpts = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
host: stsUrl.host,
method: 'POST',
path: '/',
body: bodyContent,
region,
} as Request;
try {
sign(signOpts, stsCallCredentials);
} catch (err) {
console.error('Failed to sign STS request:', err);
throw new ApplicationError('Failed to sign STS request');
}
const response = await fetch(stsEndpoint, {
method: 'POST',
headers: signOpts.headers as Record<string, string>,
body: bodyContent,
});
if (!response.ok) {
const errorText = await response.text();
throw new ApplicationError(
`STS AssumeRole failed: ${response.status} ${response.statusText} - ${errorText}`,
);
}
const responseText = await response.text();
const responseData = await new Promise<IDataObject>((resolve, reject) => {
parseString(responseText, { explicitArray: false }, (err: any, data: IDataObject) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
const assumeRoleResult = (responseData.AssumeRoleResponse as IDataObject)
?.AssumeRoleResult as IDataObject;
if (!assumeRoleResult?.Credentials) {
throw new ApplicationError('Invalid response from STS AssumeRole');
}
const assumedCredentials = assumeRoleResult.Credentials as IDataObject;
const securityHeaders = {
accessKeyId: assumedCredentials.AccessKeyId as string,
secretAccessKey: assumedCredentials.SecretAccessKey as string,
sessionToken: assumedCredentials.SessionToken as string,
};
return securityHeaders;
}
export function signOptions(
requestOptions: IHttpRequestOptions,
signOpts: Request,
securityHeaders: AwsSecurityHeaders,
url: string,
method?: IHttpRequestMethods,
) {
try {
sign(signOpts, securityHeaders);
} catch (err) {
console.error(err);
}
const options: IHttpRequestOptions = {
...requestOptions,
headers: signOpts.headers,
method,
url,
body: signOpts.body,
qs: undefined, // override since it's already in the url
};
return options;
}
@@ -0,0 +1,11 @@
import type { IHttpRequestOptions, IRequestOptions } from 'n8n-workflow';
export const getUrl = (options: IHttpRequestOptions | IRequestOptions): string => {
if (options.url) {
return new URL(options.url, options.baseURL).toString();
}
if ('uri' in options && options.uri) {
return options.uri;
}
throw new Error('No URL found in request options');
};