From 9dbea7393a9e55edeb5cf9646f5068891e14f84c Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 5 Jun 2024 16:53:45 +0200 Subject: [PATCH] fix: Make AWS credential work with global AWS services (#9631) --- .../nodes-base/credentials/Aws.credentials.ts | 63 +++++--- .../credentials/test/Aws.credentials.test.ts | 142 ++++++++++++++++++ packages/nodes-base/tsconfig.build.json | 2 +- 3 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 packages/nodes-base/credentials/test/Aws.credentials.test.ts diff --git a/packages/nodes-base/credentials/Aws.credentials.ts b/packages/nodes-base/credentials/Aws.credentials.ts index 3b52e47d2..9178faf7c 100644 --- a/packages/nodes-base/credentials/Aws.credentials.ts +++ b/packages/nodes-base/credentials/Aws.credentials.ts @@ -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 { + 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'); diff --git a/packages/nodes-base/credentials/test/Aws.credentials.test.ts b/packages/nodes-base/credentials/test/Aws.credentials.test.ts new file mode 100644 index 000000000..99252a695 --- /dev/null +++ b/packages/nodes-base/credentials/test/Aws.credentials.test.ts @@ -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/'); + }); + }); +}); diff --git a/packages/nodes-base/tsconfig.build.json b/packages/nodes-base/tsconfig.build.json index 499160a1e..3a26457c9 100644 --- a/packages/nodes-base/tsconfig.build.json +++ b/packages/nodes-base/tsconfig.build.json @@ -10,5 +10,5 @@ "nodes/**/*.json", "credentials/translations/**/*.json" ], - "exclude": ["nodes/**/*.test.ts", "test/**"] + "exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"] }