mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat: AWS Assume role credentials (#20626)
This commit is contained in:
@@ -51,4 +51,8 @@ export class SecurityConfig {
|
||||
*/
|
||||
@Env('N8N_GIT_NODE_DISABLE_BARE_REPOS')
|
||||
disableBareRepos: boolean = false;
|
||||
|
||||
/** Whether to allow access to AWS system credentials, e.g. in awsAssumeRole credentials */
|
||||
@Env('N8N_AWS_SYSTEM_CREDENTIALS_ACCESS_ENABLED')
|
||||
awsSystemCredentialsAccess: boolean = false;
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@ describe('GlobalConfig', () => {
|
||||
contentSecurityPolicyReportOnly: false,
|
||||
disableWebhookHtmlSandboxing: false,
|
||||
disableBareRepos: false,
|
||||
awsSystemCredentialsAccess: false,
|
||||
},
|
||||
executions: {
|
||||
mode: 'regular',
|
||||
|
||||
@@ -1,276 +1,29 @@
|
||||
import type { Request } from 'aws4';
|
||||
import { sign } from 'aws4';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
IDataObject,
|
||||
IHttpRequestOptions,
|
||||
INodeProperties,
|
||||
IRequestOptions,
|
||||
} from 'n8n-workflow';
|
||||
import { isObjectEmpty } from 'n8n-workflow';
|
||||
|
||||
type RegionData = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
location: string;
|
||||
domain?: string;
|
||||
};
|
||||
|
||||
const chinaDomain = 'amazonaws.com.cn';
|
||||
const globalDomain = 'amazonaws.com';
|
||||
|
||||
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: chinaDomain,
|
||||
},
|
||||
{
|
||||
name: 'cn-northwest-1',
|
||||
displayName: 'China',
|
||||
location: 'Ningxia',
|
||||
domain: chinaDomain,
|
||||
},
|
||||
{
|
||||
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 AwsCredentialsType = {
|
||||
region: AWSRegion;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
temporaryCredentials: boolean;
|
||||
customEndpoints: boolean;
|
||||
sessionToken?: string;
|
||||
rekognitionEndpoint?: string;
|
||||
lambdaEndpoint?: string;
|
||||
snsEndpoint?: string;
|
||||
sesEndpoint?: string;
|
||||
sqsEndpoint?: string;
|
||||
s3Endpoint?: string;
|
||||
ssmEndpoint?: string;
|
||||
};
|
||||
|
||||
function getAwsDomain(region: AWSRegion): string {
|
||||
return regions.find((r) => r.name === region)?.domain ?? globalDomain;
|
||||
}
|
||||
|
||||
// Some AWS services are global and don't have a region
|
||||
// https://docs.aws.amazon.com/general/latest/gr/rande.html#global-endpoints
|
||||
// Example: iam.amazonaws.com (global), s3.us-east-1.amazonaws.com (regional)
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
import type { AwsIamCredentialsType, AWSRegion } from './common/aws/types';
|
||||
import {
|
||||
awsCredentialsTest,
|
||||
awsGetSignInOptionsAndUpdateRequest,
|
||||
signOptions,
|
||||
} from './common/aws/utils';
|
||||
import { awsCustomEndpoints, awsRegionProperty } from './common/aws/descriptions';
|
||||
|
||||
export class Aws implements ICredentialType {
|
||||
name = 'aws';
|
||||
|
||||
displayName = 'AWS';
|
||||
displayName = 'AWS (IAM)';
|
||||
|
||||
documentationUrl = 'aws';
|
||||
|
||||
icon = { light: 'file:icons/AWS.svg', dark: 'file:icons/AWS.dark.svg' } as const;
|
||||
|
||||
properties: 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',
|
||||
},
|
||||
awsRegionProperty,
|
||||
{
|
||||
displayName: 'Access Key ID',
|
||||
name: 'accessKeyId',
|
||||
@@ -307,121 +60,17 @@ export class Aws implements ICredentialType {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
...awsCustomEndpoints,
|
||||
];
|
||||
|
||||
async authenticate(
|
||||
rawCredentials: ICredentialDataDecryptedObject,
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IHttpRequestOptions> {
|
||||
const credentials = rawCredentials as AwsCredentialsType;
|
||||
let endpoint: URL;
|
||||
let service = requestOptions.qs?.service as string;
|
||||
let path = (requestOptions.qs?.path as string) ?? '';
|
||||
const credentials = rawCredentials as AwsIamCredentialsType;
|
||||
const service = requestOptions.qs?.service as string;
|
||||
const path = (requestOptions.qs?.path as string) ?? '';
|
||||
const method = requestOptions.method;
|
||||
let body = requestOptions.body;
|
||||
|
||||
let region = credentials.region;
|
||||
if (requestOptions.qs?._region) {
|
||||
@@ -429,114 +78,14 @@ export class Aws implements ICredentialType {
|
||||
delete requestOptions.qs._region;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// body must be a string or a buffer, check if it needs to be stringified
|
||||
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,
|
||||
const { signOpts, url } = awsGetSignInOptionsAndUpdateRequest(
|
||||
requestOptions,
|
||||
credentials,
|
||||
path,
|
||||
body: bodyContent,
|
||||
method,
|
||||
service,
|
||||
region,
|
||||
} as unknown as Request;
|
||||
);
|
||||
|
||||
const securityHeaders = {
|
||||
accessKeyId: `${credentials.accessKeyId}`.trim(),
|
||||
@@ -545,30 +94,9 @@ export class Aws implements ICredentialType {
|
||||
? `${credentials.sessionToken}`.trim()
|
||||
: undefined,
|
||||
};
|
||||
try {
|
||||
sign(signOpts, securityHeaders);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
const options: IHttpRequestOptions = {
|
||||
...requestOptions,
|
||||
headers: signOpts.headers,
|
||||
method,
|
||||
url: endpoint.origin + path,
|
||||
body: signOpts.body,
|
||||
qs: undefined, // override since it's already in the url
|
||||
};
|
||||
|
||||
return options;
|
||||
return signOptions(requestOptions, signOpts, securityHeaders, url, method);
|
||||
}
|
||||
|
||||
test: 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',
|
||||
},
|
||||
};
|
||||
test = awsCredentialsTest;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialType,
|
||||
IHttpRequestOptions,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
import { type AwsAssumeRoleCredentialsType, type AWSRegion } from './common/aws/types';
|
||||
import { awsCustomEndpoints, awsRegionProperty } from './common/aws/descriptions';
|
||||
import {
|
||||
assumeRole,
|
||||
awsCredentialsTest,
|
||||
awsGetSignInOptionsAndUpdateRequest,
|
||||
signOptions,
|
||||
} from './common/aws/utils';
|
||||
|
||||
export class AwsAssumeRole implements ICredentialType {
|
||||
name = 'awsAssumeRole';
|
||||
|
||||
displayName = 'AWS (Assume Role)';
|
||||
|
||||
documentationUrl = 'awsassumerole';
|
||||
|
||||
icon = { light: 'file:icons/AWS.svg', dark: 'file:icons/AWS.dark.svg' } as const;
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
awsRegionProperty,
|
||||
{
|
||||
displayName: 'Use System Credentials',
|
||||
name: 'useSystemCredentialsForRole',
|
||||
description:
|
||||
'Use system credentials (environment variables, container role, etc.) to call STS.AssumeRole. Access to AWS system credentials is disabled by default and must be explicitly enabled. See <a href="https://docs.n8n.io/integrations/credentials/awsassumerole/">documentation</a> for more information.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: {
|
||||
hideOnCloud: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'STS Access Key ID',
|
||||
name: 'stsAccessKeyId',
|
||||
description: 'Access Key ID to use for the STS.AssumeRole call',
|
||||
// eslint-disable-next-line n8n-nodes-base/cred-class-field-type-options-password-missing
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
useSystemCredentialsForRole: [false],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'STS Access Key Secret',
|
||||
name: 'stsSecretAccessKey',
|
||||
description: 'Secret Access Key to use for the STS.AssumeRole call',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
useSystemCredentialsForRole: [false],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'STS Session Token (optional)',
|
||||
name: 'stsSessionToken',
|
||||
description: 'Session Token to use for the STS.AssumeRole call',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
useSystemCredentialsForRole: [false],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Role ARN',
|
||||
name: 'roleArn',
|
||||
description: 'The ARN of the role to assume (e.g., arn:aws:iam::123456789012:role/MyRole)',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'arn:aws:iam::123456789012:role/MyRole',
|
||||
},
|
||||
{
|
||||
displayName: 'External ID',
|
||||
name: 'externalId',
|
||||
description:
|
||||
"External ID for cross-account role assumption (should be required by your role's trust policy)",
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Role Session Name',
|
||||
name: 'roleSessionName',
|
||||
description: 'Name for the role session',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'n8n-session',
|
||||
},
|
||||
...awsCustomEndpoints,
|
||||
];
|
||||
|
||||
async authenticate(
|
||||
decryptedCredentials: ICredentialDataDecryptedObject,
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IHttpRequestOptions> {
|
||||
const credentials = decryptedCredentials as AwsAssumeRoleCredentialsType;
|
||||
const service = requestOptions.qs?.service as string;
|
||||
const path = (requestOptions.qs?.path as string) ?? '';
|
||||
const method = requestOptions.method;
|
||||
|
||||
let region = credentials.region;
|
||||
if (requestOptions.qs?._region) {
|
||||
region = requestOptions.qs._region as AWSRegion;
|
||||
delete requestOptions.qs._region;
|
||||
}
|
||||
|
||||
let finalCredentials = credentials;
|
||||
let securityHeaders: {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
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 };
|
||||
} catch (error) {
|
||||
console.error('Failed to assume role:', error);
|
||||
throw new ApplicationError(
|
||||
`Failed to assume role: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { signOpts, url } = awsGetSignInOptionsAndUpdateRequest(
|
||||
requestOptions,
|
||||
finalCredentials,
|
||||
path,
|
||||
method,
|
||||
service,
|
||||
region,
|
||||
);
|
||||
|
||||
return signOptions(requestOptions, signOpts, securityHeaders, url, method);
|
||||
}
|
||||
|
||||
test = awsCredentialsTest;
|
||||
}
|
||||
@@ -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,546 @@
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
class MockSecurityConfig {
|
||||
awsSystemCredentialsAccess = true;
|
||||
}
|
||||
|
||||
jest.mock('@n8n/di', () => ({
|
||||
Container: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@n8n/config', () => ({
|
||||
SecurityConfig: MockSecurityConfig,
|
||||
}));
|
||||
|
||||
import { Container } from '@n8n/di';
|
||||
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();
|
||||
(Container.get as jest.Mock).mockReturnValue(mockSecurityConfigInstance);
|
||||
|
||||
mockEnvGetter.mockReturnValue(undefined);
|
||||
|
||||
(global.fetch as jest.Mock).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: 'Bearer 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();
|
||||
});
|
||||
});
|
||||
|
||||
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,247 @@
|
||||
import { SecurityConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
type Resolvers = 'environment' | 'podIdentity' | 'containerMetadata' | 'instanceMetadata';
|
||||
type RetrunData = {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string;
|
||||
};
|
||||
|
||||
export const envGetter = (key: string): string | undefined => process.env[key];
|
||||
|
||||
export const credentialsResolver: Record<Resolvers, () => Promise<RetrunData | 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 is available, it includes the Authorization header
|
||||
* as required by AWS for Pod Identity credential endpoints.
|
||||
*
|
||||
* @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 {
|
||||
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(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,676 @@
|
||||
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', 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 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,387 @@
|
||||
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.',
|
||||
);
|
||||
}
|
||||
if (systemCredentials.source !== 'environment') {
|
||||
return {
|
||||
accessKeyId: systemCredentials.accessKeyId,
|
||||
secretAccessKey: systemCredentials.secretAccessKey,
|
||||
sessionToken: systemCredentials.sessionToken as string,
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { sign, type Request } from 'aws4';
|
||||
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
import { Aws, type AwsCredentialsType } from '../Aws.credentials';
|
||||
import { Aws } from '../Aws.credentials';
|
||||
import type { AwsIamCredentialsType } from '../common/aws/types';
|
||||
|
||||
jest.mock('aws4', () => ({
|
||||
sign: jest.fn(),
|
||||
@@ -21,7 +22,7 @@ describe('Aws Credential', () => {
|
||||
|
||||
it('should have correct properties', () => {
|
||||
expect(aws.name).toBe('aws');
|
||||
expect(aws.displayName).toBe('AWS');
|
||||
expect(aws.displayName).toBe('AWS (IAM)');
|
||||
expect(aws.documentationUrl).toBe('aws');
|
||||
expect(aws.icon).toEqual({ light: 'file:icons/AWS.svg', dark: 'file:icons/AWS.dark.svg' });
|
||||
expect(aws.properties.length).toBeGreaterThan(0);
|
||||
@@ -34,7 +35,7 @@ describe('Aws Credential', () => {
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
const credentials: AwsCredentialsType = {
|
||||
const credentials: AwsIamCredentialsType = {
|
||||
region: 'eu-central-1',
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
@@ -183,7 +184,7 @@ describe('Aws Credential', () => {
|
||||
});
|
||||
|
||||
describe('China regions', () => {
|
||||
const chinaCredentials: AwsCredentialsType = {
|
||||
const chinaCredentials: AwsIamCredentialsType = {
|
||||
region: 'cn-north-1',
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { NodeConnectionTypes, NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { awsApiRequestREST } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from './utils';
|
||||
|
||||
export class AwsLambda implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -27,13 +28,9 @@ export class AwsLambda implements INodeType {
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { awsApiRequestSOAP } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from './utils';
|
||||
|
||||
export class AwsSns implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -27,13 +28,9 @@ export class AwsSns implements INodeType {
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { awsApiRequestSOAP } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from './utils';
|
||||
|
||||
export class AwsSnsTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -27,12 +28,7 @@ export class AwsSnsTrigger implements INodeType {
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
@@ -42,6 +38,7 @@ export class AwsSnsTrigger implements INodeType {
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Topic',
|
||||
name: 'topic',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { certificateFields, certificateOperations } from './CertificateDescription';
|
||||
import { awsApiRequestAllItems, awsApiRequestREST } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
export class AwsCertificateManager implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -24,13 +25,9 @@ export class AwsCertificateManager implements INodeType {
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
import { jsonParse, NodeApiError } from 'n8n-workflow';
|
||||
import { getAwsCredentials } from '../GenericFunctions';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -20,7 +21,7 @@ export async function awsApiRequest(
|
||||
query: IDataObject = {},
|
||||
headers?: object,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
@@ -36,7 +37,7 @@ export async function awsApiRequest(
|
||||
} as IHttpRequestOptions;
|
||||
|
||||
try {
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import type {
|
||||
IDataObject,
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { AwsCredentialsType } from '../../../../credentials/Aws.credentials';
|
||||
import type { AwsIamCredentialsType } from '../../../../credentials/common/aws/types';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: ILoadOptionsFunctions | IPollFunctions | IExecuteSingleFunctions,
|
||||
@@ -16,7 +15,7 @@ export async function awsApiRequest(
|
||||
body: string,
|
||||
): Promise<any> {
|
||||
const credentialsType = 'aws';
|
||||
const credentials = await this.getCredentials<AwsCredentialsType>(credentialsType);
|
||||
const credentials = await this.getCredentials<AwsIamCredentialsType>(credentialsType);
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
url: '',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { awsApiRequestREST } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
export class AwsComprehend implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -23,13 +24,9 @@ export class AwsComprehend implements INodeType {
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
import { parseString } from 'xml2js';
|
||||
import { getAwsCredentials } from '../GenericFunctions';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -16,7 +17,7 @@ export async function awsApiRequest(
|
||||
body?: string,
|
||||
headers?: object,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
@@ -29,7 +30,7 @@ export async function awsApiRequest(
|
||||
headers,
|
||||
region: credentials?.region as string,
|
||||
} as IHttpRequestOptions;
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
}
|
||||
|
||||
export async function awsApiRequestREST(
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
decodeItem,
|
||||
simplify,
|
||||
} from './utils';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
export class AwsDynamoDB implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -42,13 +43,9 @@ export class AwsDynamoDB implements INodeType {
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { ApplicationError, deepCopy } from 'n8n-workflow';
|
||||
|
||||
import type { IRequestBody } from './types';
|
||||
import { getAwsCredentials } from '../GenericFunctions';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -20,7 +21,7 @@ export async function awsApiRequest(
|
||||
body?: object | IRequestBody,
|
||||
headers?: object,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
service,
|
||||
@@ -35,7 +36,11 @@ export async function awsApiRequest(
|
||||
|
||||
try {
|
||||
return JSON.parse(
|
||||
(await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions)) as string,
|
||||
(await this.helpers.requestWithAuthentication.call(
|
||||
this,
|
||||
credentialsType,
|
||||
requestOptions,
|
||||
)) as string,
|
||||
);
|
||||
} catch (error) {
|
||||
const statusCode = (error.statusCode || error.cause?.statusCode) as number;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
listenerCertificateOperations,
|
||||
} from './ListenerCertificateDescription';
|
||||
import { loadBalancerFields, loadBalancerOperations } from './LoadBalancerDescription';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
export class AwsElb implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -31,13 +32,9 @@ export class AwsElb implements INodeType {
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import { parseString } from 'xml2js';
|
||||
import { getAwsCredentials } from '../GenericFunctions';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -23,7 +24,7 @@ export async function awsApiRequest(
|
||||
_option: IDataObject = {},
|
||||
_region?: string,
|
||||
) {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
@@ -39,7 +40,7 @@ export async function awsApiRequest(
|
||||
} as IHttpRequestOptions;
|
||||
|
||||
try {
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,31 @@ import type {
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import { parseString as parseXml } from 'xml2js';
|
||||
import type {
|
||||
AwsAssumeRoleCredentialsType,
|
||||
AwsIamCredentialsType,
|
||||
} from '../../credentials/common/aws/types';
|
||||
|
||||
export async function getAwsCredentials(
|
||||
context: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
) {
|
||||
let credentialsType: 'aws' | 'awsAssumeRole' = 'aws';
|
||||
|
||||
try {
|
||||
const authentication = context.getNodeParameter('authentication', 0) as 'iam' | 'assumeRole';
|
||||
|
||||
if (authentication === 'assumeRole') {
|
||||
credentialsType = 'awsAssumeRole';
|
||||
}
|
||||
} catch (error) {
|
||||
context.logger.warn('Could not get authentication type');
|
||||
}
|
||||
|
||||
const credentials: AwsIamCredentialsType | AwsAssumeRoleCredentialsType =
|
||||
await context.getCredentials(credentialsType);
|
||||
|
||||
return { credentials, credentialsType };
|
||||
}
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -18,7 +43,7 @@ export async function awsApiRequest(
|
||||
body?: string,
|
||||
headers?: object,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
service,
|
||||
@@ -32,7 +57,7 @@ export async function awsApiRequest(
|
||||
} as IHttpRequestOptions;
|
||||
|
||||
try {
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject, { parseXml: true });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { awsApiRequestREST, keysTPascalCase } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
export class AwsRekognition implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -23,13 +24,9 @@ export class AwsRekognition implements INodeType {
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
import { parseString } from 'xml2js';
|
||||
import { getAwsCredentials } from '../GenericFunctions';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -22,7 +23,7 @@ export async function awsApiRequest(
|
||||
option: IDataObject = {},
|
||||
_region?: string,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
@@ -39,7 +40,7 @@ export async function awsApiRequest(
|
||||
if (Object.keys(option).length !== 0) {
|
||||
Object.assign(requestOptions, option);
|
||||
}
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
}
|
||||
|
||||
export async function awsApiRequestREST(
|
||||
|
||||
@@ -16,6 +16,8 @@ import { bucketFields, bucketOperations } from './BucketDescription';
|
||||
import { fileFields, fileOperations } from './FileDescription';
|
||||
import { folderFields, folderOperations } from './FolderDescription';
|
||||
import { awsApiRequestREST, awsApiRequestRESTAllItems } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../../utils';
|
||||
import { getAwsCredentials } from '../../GenericFunctions';
|
||||
|
||||
// Minimum size 5MB for multipart upload in S3
|
||||
const UPLOAD_CHUNK_SIZE = 5120 * 1024;
|
||||
@@ -39,13 +41,9 @@ export class AwsS3V2 implements INodeType {
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
@@ -87,13 +85,14 @@ export class AwsS3V2 implements INodeType {
|
||||
let responseData;
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
const { credentials } = await getAwsCredentials(this);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let headers: IDataObject = {};
|
||||
try {
|
||||
if (resource === 'bucket') {
|
||||
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html
|
||||
if (operation === 'create') {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const name = this.getNodeParameter('name', i) as string;
|
||||
const additionalFields = this.getNodeParameter('additionalFields', i);
|
||||
if (additionalFields.acl) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
import { parseString } from 'xml2js';
|
||||
import { getAwsCredentials } from '../../GenericFunctions';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -38,7 +39,9 @@ export async function awsApiRequest(
|
||||
if (Object.keys(option).length !== 0) {
|
||||
Object.assign(requestOptions, option);
|
||||
}
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
const { credentialsType } = await getAwsCredentials(this);
|
||||
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
}
|
||||
|
||||
export async function awsApiRequestREST(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import qs from 'node:querystring';
|
||||
|
||||
import { awsApiRequestSOAP, awsApiRequestSOAPAllItems } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
function setParameter(params: string[], base: string, values: string[]) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
@@ -33,13 +34,9 @@ export class AwsSes implements INodeType {
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import { parseString } from 'xml2js';
|
||||
import { getAwsCredentials } from '../GenericFunctions';
|
||||
|
||||
export async function awsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
@@ -20,7 +21,7 @@ export async function awsApiRequest(
|
||||
body?: string,
|
||||
headers?: object,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
@@ -35,7 +36,7 @@ export async function awsApiRequest(
|
||||
} as IHttpRequestOptions;
|
||||
|
||||
try {
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject, { parseXml: true });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NodeApiError, NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { awsApiRequestSOAP } from '../GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
export class AwsSqs implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -29,13 +30,9 @@ export class AwsSqs implements INodeType {
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
import type { IExpenseDocument } from './GenericFunctions';
|
||||
import { awsApiRequestREST, simplify, validateCredentials } from './GenericFunctions';
|
||||
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
|
||||
|
||||
export class AwsTextract implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -30,13 +31,9 @@ export class AwsTextract implements INodeType {
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: awsNodeCredentials,
|
||||
properties: [
|
||||
awsNodeAuthOptions,
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import { URL } from 'url';
|
||||
import { parseString } from 'xml2js';
|
||||
import { getAwsCredentials } from '../GenericFunctions';
|
||||
|
||||
function getEndpointForService(
|
||||
service: string,
|
||||
@@ -39,7 +40,7 @@ export async function awsApiRequest(
|
||||
body?: string,
|
||||
headers?: object,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('aws');
|
||||
const { credentials, credentialsType } = await getAwsCredentials(this);
|
||||
|
||||
const requestOptions = {
|
||||
qs: {
|
||||
@@ -54,7 +55,7 @@ export async function awsApiRequest(
|
||||
} as IHttpRequestOptions;
|
||||
|
||||
try {
|
||||
return await this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
|
||||
} catch (error) {
|
||||
if (error?.response?.data || error?.response?.body) {
|
||||
const errorMessage = error?.response?.data || error?.response?.body;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { INodeCredentialDescription, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const awsNodeCredentials: INodeCredentialDescription[] = [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['iam'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'awsAssumeRole',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['assumeRole'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
export const awsNodeAuthOptions: INodeProperties = {
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'AWS (IAM)',
|
||||
value: 'iam',
|
||||
},
|
||||
{
|
||||
name: 'AWS (Assume Role)',
|
||||
value: 'assumeRole',
|
||||
},
|
||||
],
|
||||
default: 'iam',
|
||||
};
|
||||
@@ -42,6 +42,7 @@
|
||||
"dist/credentials/AutomizyApi.credentials.js",
|
||||
"dist/credentials/AutopilotApi.credentials.js",
|
||||
"dist/credentials/Aws.credentials.js",
|
||||
"dist/credentials/AwsAssumeRole.credentials.js",
|
||||
"dist/credentials/AzureStorageOAuth2Api.credentials.js",
|
||||
"dist/credentials/AzureStorageSharedKeyApi.credentials.js",
|
||||
"dist/credentials/BambooHrApi.credentials.js",
|
||||
|
||||
Reference in New Issue
Block a user