mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -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;
|
] as const;
|
||||||
|
|
||||||
export type AWSRegion = (typeof regions)[number]['name'];
|
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 {
|
export class Aws implements ICredentialType {
|
||||||
name = 'aws';
|
name = 'aws';
|
||||||
|
@ -276,18 +298,19 @@ export class Aws implements ICredentialType {
|
||||||
];
|
];
|
||||||
|
|
||||||
async authenticate(
|
async authenticate(
|
||||||
credentials: ICredentialDataDecryptedObject,
|
rawCredentials: ICredentialDataDecryptedObject,
|
||||||
requestOptions: IHttpRequestOptions,
|
requestOptions: IHttpRequestOptions,
|
||||||
): Promise<IHttpRequestOptions> {
|
): Promise<IHttpRequestOptions> {
|
||||||
|
const credentials = rawCredentials as AwsCredentialsType;
|
||||||
let endpoint: URL;
|
let endpoint: URL;
|
||||||
let service = requestOptions.qs?.service as string;
|
let service = requestOptions.qs?.service as string;
|
||||||
let path = requestOptions.qs?.path;
|
let path = (requestOptions.qs?.path as string) ?? '';
|
||||||
const method = requestOptions.method;
|
const method = requestOptions.method;
|
||||||
let body = requestOptions.body;
|
let body = requestOptions.body;
|
||||||
|
|
||||||
let region = credentials.region;
|
let region = credentials.region;
|
||||||
if (requestOptions.qs?._region) {
|
if (requestOptions.qs?._region) {
|
||||||
region = requestOptions.qs._region as string;
|
region = requestOptions.qs._region as AWSRegion;
|
||||||
delete requestOptions.qs._region;
|
delete requestOptions.qs._region;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -310,36 +333,40 @@ export class Aws implements ICredentialType {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service = endpoint.hostname.split('.')[0];
|
const parsed = parseAwsUrl(endpoint);
|
||||||
region = endpoint.hostname.split('.')[1];
|
service = parsed.service;
|
||||||
|
if (parsed.region) {
|
||||||
|
region = parsed.region;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!requestOptions.baseURL && !requestOptions.url) {
|
if (!requestOptions.baseURL && !requestOptions.url) {
|
||||||
let endpointString: string;
|
let endpointString: string;
|
||||||
if (service === 'lambda' && credentials.lambdaEndpoint) {
|
if (service === 'lambda' && credentials.lambdaEndpoint) {
|
||||||
endpointString = credentials.lambdaEndpoint as string;
|
endpointString = credentials.lambdaEndpoint;
|
||||||
} else if (service === 'sns' && credentials.snsEndpoint) {
|
} else if (service === 'sns' && credentials.snsEndpoint) {
|
||||||
endpointString = credentials.snsEndpoint as string;
|
endpointString = credentials.snsEndpoint;
|
||||||
} else if (service === 'sqs' && credentials.sqsEndpoint) {
|
} else if (service === 'sqs' && credentials.sqsEndpoint) {
|
||||||
endpointString = credentials.sqsEndpoint as string;
|
endpointString = credentials.sqsEndpoint;
|
||||||
} else if (service === 's3' && credentials.s3Endpoint) {
|
} else if (service === 's3' && credentials.s3Endpoint) {
|
||||||
endpointString = credentials.s3Endpoint as string;
|
endpointString = credentials.s3Endpoint;
|
||||||
} else if (service === 'ses' && credentials.sesEndpoint) {
|
} else if (service === 'ses' && credentials.sesEndpoint) {
|
||||||
endpointString = credentials.sesEndpoint as string;
|
endpointString = credentials.sesEndpoint;
|
||||||
} else if (service === 'rekognition' && credentials.rekognitionEndpoint) {
|
} else if (service === 'rekognition' && credentials.rekognitionEndpoint) {
|
||||||
endpointString = credentials.rekognitionEndpoint as string;
|
endpointString = credentials.rekognitionEndpoint;
|
||||||
} else if (service === 'sqs' && credentials.sqsEndpoint) {
|
} else if (service === 'sqs' && credentials.sqsEndpoint) {
|
||||||
endpointString = credentials.sqsEndpoint as string;
|
endpointString = credentials.sqsEndpoint;
|
||||||
} else if (service) {
|
} else if (service) {
|
||||||
endpointString = `https://${service}.${region}.amazonaws.com`;
|
endpointString = `https://${service}.${region}.amazonaws.com`;
|
||||||
}
|
}
|
||||||
endpoint = new URL(
|
endpoint = new URL(endpointString!.replace('{region}', region) + path);
|
||||||
endpointString!.replace('{region}', region as string) + (path as string),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// If no endpoint is set, we try to decompose the path and use the default endpoint
|
// 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 customUrl = new URL(`${requestOptions.baseURL!}${requestOptions.url}${path}`);
|
||||||
service = customUrl.hostname.split('.')[0];
|
const parsed = parseAwsUrl(customUrl);
|
||||||
region = customUrl.hostname.split('.')[1];
|
service = parsed.service;
|
||||||
|
if (parsed.region) {
|
||||||
|
region = parsed.region;
|
||||||
|
}
|
||||||
if (service === 'sts') {
|
if (service === 'sts') {
|
||||||
try {
|
try {
|
||||||
customUrl.searchParams.set('Action', 'GetCallerIdentity');
|
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",
|
"nodes/**/*.json",
|
||||||
"credentials/translations/**/*.json"
|
"credentials/translations/**/*.json"
|
||||||
],
|
],
|
||||||
"exclude": ["nodes/**/*.test.ts", "test/**"]
|
"exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue