fix: Make AWS credential work with global AWS services (#9631)

This commit is contained in:
Elias Meire 2024-06-05 16:53:45 +02:00 committed by GitHub
parent a946ead46e
commit 9dbea7393a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 188 additions and 19 deletions

View file

@ -126,6 +126,28 @@ export const regions = [
] 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;
};
// 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 [service, region] = url.hostname.replace('amazonaws.com', '').split('.');
return { service, region: region as AWSRegion };
}
export class Aws implements ICredentialType {
name = 'aws';
@ -276,18 +298,19 @@ export class Aws implements ICredentialType {
];
async authenticate(
credentials: ICredentialDataDecryptedObject,
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;
let path = (requestOptions.qs?.path as string) ?? '';
const method = requestOptions.method;
let body = requestOptions.body;
let region = credentials.region;
if (requestOptions.qs?._region) {
region = requestOptions.qs._region as string;
region = requestOptions.qs._region as AWSRegion;
delete requestOptions.qs._region;
}
@ -310,36 +333,40 @@ export class Aws implements ICredentialType {
console.log(err);
}
}
service = endpoint.hostname.split('.')[0];
region = endpoint.hostname.split('.')[1];
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 as string;
endpointString = credentials.lambdaEndpoint;
} else if (service === 'sns' && credentials.snsEndpoint) {
endpointString = credentials.snsEndpoint as string;
endpointString = credentials.snsEndpoint;
} else if (service === 'sqs' && credentials.sqsEndpoint) {
endpointString = credentials.sqsEndpoint as string;
endpointString = credentials.sqsEndpoint;
} else if (service === 's3' && credentials.s3Endpoint) {
endpointString = credentials.s3Endpoint as string;
endpointString = credentials.s3Endpoint;
} else if (service === 'ses' && credentials.sesEndpoint) {
endpointString = credentials.sesEndpoint as string;
endpointString = credentials.sesEndpoint;
} else if (service === 'rekognition' && credentials.rekognitionEndpoint) {
endpointString = credentials.rekognitionEndpoint as string;
endpointString = credentials.rekognitionEndpoint;
} else if (service === 'sqs' && credentials.sqsEndpoint) {
endpointString = credentials.sqsEndpoint as string;
endpointString = credentials.sqsEndpoint;
} else if (service) {
endpointString = `https://${service}.${region}.amazonaws.com`;
}
endpoint = new URL(
endpointString!.replace('{region}', region as string) + (path as string),
);
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 ?? ''}`);
service = customUrl.hostname.split('.')[0];
region = customUrl.hostname.split('.')[1];
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');

View file

@ -0,0 +1,142 @@
import { sign, type Request } from 'aws4';
import type { IHttpRequestOptions } from 'n8n-workflow';
import { Aws, type AwsCredentialsType } from '../Aws.credentials';
jest.mock('aws4', () => ({
sign: jest.fn(),
}));
describe('Aws Credential', () => {
const aws = new Aws();
let mockSign: jest.Mock;
beforeEach(() => {
mockSign = sign as unknown as jest.Mock;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should have correct properties', () => {
expect(aws.name).toBe('aws');
expect(aws.displayName).toBe('AWS');
expect(aws.documentationUrl).toBe('aws');
expect(aws.icon).toBe('file:icons/AWS.svg');
expect(aws.properties.length).toBeGreaterThan(0);
expect(aws.test.request.baseURL).toBe('=https://sts.{{$credentials.region}}.amazonaws.com');
expect(aws.test.request.url).toBe('?Action=GetCallerIdentity&Version=2011-06-15');
expect(aws.test.request.method).toBe('POST');
});
describe('authenticate', () => {
const credentials: AwsCredentialsType = {
region: 'eu-central-1',
accessKeyId: 'hakuna',
secretAccessKey: 'matata',
customEndpoints: false,
temporaryCredentials: false,
};
const requestOptions: IHttpRequestOptions = {
qs: {},
body: {},
headers: {},
baseURL: 'https://sts.eu-central-1.amazonaws.com',
url: '?Action=GetCallerIdentity&Version=2011-06-15',
method: 'POST',
returnFullResponse: true,
};
const signOpts: Request & IHttpRequestOptions = {
qs: {},
body: undefined,
headers: {},
baseURL: 'https://sts.eu-central-1.amazonaws.com',
url: '?Action=GetCallerIdentity&Version=2011-06-15',
method: 'POST',
returnFullResponse: true,
host: 'sts.eu-central-1.amazonaws.com',
path: '/?Action=GetCallerIdentity&Version=2011-06-15',
region: 'eu-central-1',
};
const securityHeaders = {
accessKeyId: 'hakuna',
secretAccessKey: 'matata',
sessionToken: undefined,
};
it('should call sign with correct parameters', async () => {
const result = await aws.authenticate(credentials, requestOptions);
expect(mockSign).toHaveBeenCalledWith(signOpts, securityHeaders);
expect(result.method).toBe('POST');
expect(result.url).toBe(
'https://sts.eu-central-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15',
);
});
it('should return correct options with custom endpoint', async () => {
const customEndpoint = 'https://custom.endpoint.com';
const result = await aws.authenticate(
{ ...credentials, customEndpoints: true, snsEndpoint: customEndpoint },
{ ...requestOptions, url: '', baseURL: '', qs: { service: 'sns' } },
);
expect(mockSign).toHaveBeenCalledWith(
{
...signOpts,
baseURL: '',
path: '/',
url: '',
qs: {
service: 'sns',
},
host: 'custom.endpoint.com',
},
securityHeaders,
);
expect(result.method).toBe('POST');
expect(result.url).toBe(`${customEndpoint}/`);
});
it('should return correct options with temporary credentials', async () => {
const result = await aws.authenticate(
{ ...credentials, temporaryCredentials: true, sessionToken: 'test-token' },
requestOptions,
);
expect(mockSign).toHaveBeenCalledWith(signOpts, {
...securityHeaders,
sessionToken: 'test-token',
});
expect(result.method).toBe('POST');
expect(result.url).toBe(
'https://sts.eu-central-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15',
);
});
it('should return correct options for a global AWS service', async () => {
const result = await aws.authenticate(credentials, {
...requestOptions,
url: 'https://iam.amazonaws.com',
baseURL: '',
});
expect(mockSign).toHaveBeenCalledWith(
{
...signOpts,
baseURL: '',
path: '/',
host: 'iam.amazonaws.com',
url: 'https://iam.amazonaws.com',
},
securityHeaders,
);
expect(result.method).toBe('POST');
expect(result.url).toBe('https://iam.amazonaws.com/');
});
});
});

View file

@ -10,5 +10,5 @@
"nodes/**/*.json",
"credentials/translations/**/*.json"
],
"exclude": ["nodes/**/*.test.ts", "test/**"]
"exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"]
}