mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
fix: Make AWS credential work with global AWS services (#9631)
This commit is contained in:
parent
a946ead46e
commit
9dbea7393a
|
@ -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');
|
||||
|
|
142
packages/nodes-base/credentials/test/Aws.credentials.test.ts
Normal file
142
packages/nodes-base/credentials/test/Aws.credentials.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -10,5 +10,5 @@
|
|||
"nodes/**/*.json",
|
||||
"credentials/translations/**/*.json"
|
||||
],
|
||||
"exclude": ["nodes/**/*.test.ts", "test/**"]
|
||||
"exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue