diff --git a/packages/nodes-base/credentials/Aws.credentials.ts b/packages/nodes-base/credentials/Aws.credentials.ts index 9170657a4f..5bed9cb9cb 100644 --- a/packages/nodes-base/credentials/Aws.credentials.ts +++ b/packages/nodes-base/credentials/Aws.credentials.ts @@ -354,6 +354,7 @@ export class Aws implements ICredentialType { } else if (service === 'rekognition' && credentials.rekognitionEndpoint) { endpointString = credentials.rekognitionEndpoint; } else if (service === 'sqs' && credentials.sqsEndpoint) { + //ToDo: should we remove the duplicate? endpointString = credentials.sqsEndpoint; } else if (service) { endpointString = `https://${service}.${region}.amazonaws.com`; diff --git a/packages/nodes-base/nodes/Aws/Cognito/AwsCognito.node.json b/packages/nodes-base/nodes/Aws/Cognito/AwsCognito.node.json new file mode 100644 index 0000000000..8929c461ef --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Cognito/AwsCognito.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.awsCognito", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/aws/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.awscognito/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Aws/Cognito/AwsCognito.node.ts b/packages/nodes-base/nodes/Aws/Cognito/AwsCognito.node.ts new file mode 100644 index 0000000000..ecabba3f59 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Cognito/AwsCognito.node.ts @@ -0,0 +1,80 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; + +import { userOperations, userFields, userPoolOperations, userPoolFields } from './descriptions'; +import { presendStringifyBody, searchUserPools } from './GenericFunctions'; + +export class AwsCognito implements INodeType { + description: INodeTypeDescription = { + displayName: 'AWS Cognito', + name: 'awsCognito', + icon: 'file:cognito.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interacts with Amazon Cognito', + defaults: { name: 'AWS Cognito' }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + hints: [ + // ToDo: Add hints + // { + // message: 'Please select a parameter in the options to modify the post', + // displayCondition: + // '={{$parameter["resource"] === "user" && $parameter["operation"] === "update" && Object.keys($parameter["additionalOptions"]).length === 0}}', + // whenToDisplay: 'always', + // location: 'outputPane', + // type: 'warning', + // }, + ], + credentials: [ + { + name: 'aws', + required: true, + }, + ], + requestDefaults: { + baseURL: '=https://cognito-idp.{{$credentials.region}}.amazonaws.com', + url: '', + json: true, + headers: { + 'Content-Type': 'application/x-amz-json-1.1', + }, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + default: 'user', + routing: { + send: { + preSend: [presendStringifyBody], + }, + }, + options: [ + { + name: 'User', + value: 'user', + }, + { + name: 'User Pool', + value: 'userPool', + }, + ], + }, + ...userPoolOperations, + ...userPoolFields, + ...userOperations, + ...userFields, + ], + }; + + methods = { + listSearch: { + searchUserPools, + // Todo: Add more search methods + }, + }; +} diff --git a/packages/nodes-base/nodes/Aws/Cognito/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/Cognito/GenericFunctions.ts new file mode 100644 index 0000000000..e95193ba2c --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Cognito/GenericFunctions.ts @@ -0,0 +1,253 @@ +import type { + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, + IPollFunctions, + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + INodeExecutionData, + IN8nHttpFullResponse, + JsonObject, + DeclarativeRestApiSettings, + IExecutePaginationFunctions, +} from 'n8n-workflow'; +import { ApplicationError, NodeApiError, NodeOperationError } from 'n8n-workflow'; + +/* Function which helps while developing the node */ +// ToDo: Remove before completing the pull request +export async function presendTest( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + console.log('requestOptions', requestOptions); + return requestOptions; +} + +/* + * Helper function which stringifies the body before sending the request. + * It is added to the routing property in the "resource" parameter thus for all requests. + */ +export async function presendStringifyBody( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + if (requestOptions.body) { + requestOptions.body = JSON.stringify(requestOptions.body); + } + return requestOptions; +} + +export async function presendFilter( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + const filterAttribute = additionalFields.filterAttribute as string; + let filterType = additionalFields.filterType as string; + const filterValue = additionalFields.filterValue as string; + + if (filterAttribute && filterType && filterValue) { + // Convert the filterType to the format the API expects + const filterTypeMapping: { [key: string]: string } = { + exactMatch: '=', + startsWith: '^=', + }; + filterType = filterTypeMapping[filterType] || filterType; + + // Parse the body if it's a string to add the new property + let body: IDataObject; + if (typeof requestOptions.body === 'string') { + try { + body = JSON.parse(requestOptions.body) as IDataObject; + } catch (error) { + throw new NodeOperationError(this.getNode(), 'Failed to parse requestOptions body'); + } + } else { + body = requestOptions.body as IDataObject; + } + + requestOptions.body = JSON.stringify({ + ...body, + Filter: `${filterAttribute} ${filterType} "${filterValue}"`, + }); + + console.log('requestOptions with filter', requestOptions); // ToDo: Remove + } else { + // ToDo: Return warning that all three parameters are needed, don't throw an error but don't send the request + console.log('no filter is added', requestOptions); // ToDo: Remove + } + + return requestOptions; +} + +/* Helper function to handle pagination */ +const possibleRootProperties = ['Users']; // Root properties that can be returned by the list operations of the API +// ToDo: Test if pagination works +export async function handlePagination( + this: IExecutePaginationFunctions, + resultOptions: DeclarativeRestApiSettings.ResultOptions, +): Promise { + const aggregatedResult: IDataObject[] = []; + let nextPageToken: string | undefined; + const returnAll = this.getNodeParameter('returnAll') as boolean; + let limit = 60; + if (!returnAll) { + limit = this.getNodeParameter('limit') as number; + resultOptions.maxResults = limit; + } + resultOptions.paginate = true; + + do { + if (nextPageToken) { + // For different responses the pagination token might differ. ToDo: Ensure this code works for all endpoints. + resultOptions.options.body = { + ...(resultOptions.options.body as IDataObject), + PaginationToken: nextPageToken, + } as IDataObject; + } + + const responseData = await this.makeRoutingRequest(resultOptions); + + for (const page of responseData) { + for (const prop of possibleRootProperties) { + if (page.json[prop]) { + const currentData = page.json[prop] as IDataObject[]; + aggregatedResult.push(...currentData); + } + } + + if (!returnAll && aggregatedResult.length >= limit) { + return aggregatedResult.slice(0, limit).map((item) => ({ json: item })); + } + + // For different responses the pagination token might differ. ToDo: Ensure this code works for all endpoints. + nextPageToken = page.json.PaginationToken as string | undefined; + } + } while (nextPageToken); + + return aggregatedResult.map((item) => ({ json: item })); +} + +/* Helper functions to handle errors */ + +// ToDo: Handle errors when something is not found. Answer the questions "what happened?" and "how to fix it?". +export async function handleErrorsDeleteUser( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const post = this.getNodeParameter('user', undefined) as IDataObject; + + // Provide a user-friendly error message + if (post && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The user you are deleting could not be found. Adjust the "user" parameter setting to delete the post correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +/* Helper function used in listSearch methods */ +export async function awsRequest( + this: ILoadOptionsFunctions | IPollFunctions, + opts: IHttpRequestOptions, +): Promise { + const region = (await this.getCredentials('aws')).region as string; + + const requestOptions: IHttpRequestOptions = { + ...opts, + baseURL: `https://cognito-idp.${region}.amazonaws.com`, + json: true, + headers: { + 'Content-Type': 'application/x-amz-json-1.1', + ...opts.headers, + }, + }; + + try { + return (await this.helpers.requestWithAuthentication.call( + this, + 'aws', + requestOptions, + )) as IDataObject; + } catch (error) { + // ToDo: Check if this error handling is correct/needed. It is taken from another AWS node. + const statusCode = (error.statusCode || error.cause?.statusCode) as number; + let errorMessage = (error.response?.body?.message || + error.response?.body?.Message || + error.message) as string; + + if (statusCode === 403) { + if (errorMessage === 'The security token included in the request is invalid.') { + throw new ApplicationError('The AWS credentials are not valid!', { level: 'warning' }); + } else if ( + errorMessage.startsWith( + 'The request signature we calculated does not match the signature you provided', + ) + ) { + throw new ApplicationError('The AWS credentials are not valid!', { level: 'warning' }); + } + } + + if (error.cause?.error) { + try { + errorMessage = error.cause?.error?.message as string; + } catch (ex) {} + } + + throw new ApplicationError(`AWS error response [${statusCode}]: ${errorMessage}`, { + level: 'warning', + }); + } +} + +/* listSearch methods */ + +export async function searchUserPools( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const opts: IHttpRequestOptions = { + url: '', // the base url is set in "awsRequest" + method: 'POST', + headers: { + 'X-Amz-Target': 'AWSCognitoIdentityProviderService.ListUserPools', + }, + body: JSON.stringify({ + MaxResults: 60, // the maximum number by documentation is 60 + NextToken: paginationToken ?? undefined, + }), + }; + const responseData: IDataObject = await awsRequest.call(this, opts); + + const userPools = responseData.UserPools as Array<{ Name: string; Id: string }>; + + const results: INodeListSearchItems[] = userPools + .map((a) => ({ + name: a.Name, + value: a.Id, + })) + .filter( + (a) => + !filter || + a.name.toLowerCase().includes(filter.toLowerCase()) || + a.value.toLowerCase().includes(filter.toLowerCase()), + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.NextToken }; // ToDo: Test if pagination for the search methods works +} diff --git a/packages/nodes-base/nodes/Aws/Cognito/cognito.svg b/packages/nodes-base/nodes/Aws/Cognito/cognito.svg new file mode 100644 index 0000000000..816b57db99 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Cognito/cognito.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Aws/Cognito/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Aws/Cognito/descriptions/UserDescription.ts new file mode 100644 index 0000000000..8f48268074 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Cognito/descriptions/UserDescription.ts @@ -0,0 +1,186 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { handlePagination, presendFilter, presendTest } from '../GenericFunctions'; + +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getAll', + displayOptions: { show: { resource: ['user'] } }, + options: [ + { + name: 'Get Many', + value: 'getAll', + action: 'List the existing users', + routing: { + send: { + preSend: [presendTest], // ToDo: Remove this line before completing the pull request + paginate: true, + }, + // ToDo: Test with pagination (ideally we need 4+ users in the user pool) + // operations: { pagination: handlePagination }, // Responsible for pagination and number of results returned + request: { + method: 'POST', + headers: { + 'X-Amz-Target': 'AWSCognitoIdentityProviderService.ListUsers', + }, + qs: { + pageSize: + '={{ $parameter["limit"] ? ($parameter["limit"] < 60 ? $parameter["limit"] : 60) : 60 }}', // The API allows maximum 60 results per page + }, + }, + }, + }, + ], + }, +]; + +export const userFields: INodeProperties[] = [ + { + displayName: 'User Pool ID', + name: 'userPoolId', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The user pool ID that the users are in', // ToDo: Improve description + displayOptions: { show: { resource: ['user'], operation: ['getAll'] } }, + routing: { send: { type: 'body', property: 'UserPoolId' } }, + modes: [ + { + displayName: 'From list', // ToDo: Fix error when selecting this option + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUserPools', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + hint: 'Enter the user pool ID', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w-]+_[0-9a-zA-Z]+$', + errorMessage: 'The ID must follow the pattern "xxxxxx_xxxxxxxxxxx"', + }, + }, + ], + placeholder: 'e.g. eu-central-1_ab12cdefgh', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { show: { resource: ['user'], operation: ['getAll'] } }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { + minValue: 1, + maxValue: 60, + }, + default: 60, + description: 'Max number of results to return', + displayOptions: { show: { resource: ['user'], operation: ['getAll'], returnAll: [false] } }, + }, + { + displayName: 'Additional Fields', // ToDo: Test additional parameters with the API + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { resource: ['user'], operation: ['getAll'] } }, + options: [ + { + displayName: 'Attributes To Get', + name: 'attributesToGet', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + default: {}, + placeholder: 'Add Attribute', + description: + 'The attributes to return in the response. They can be only required attributes in your user pool, or in conjunction with Filter.' + + 'Amazon Cognito returns an error if not all users in the results have set a value for the attribute you request.' + + "Attributes that you can't filter on, including custom attributes, must have a value set in every " + + 'user profile before an AttributesToGet parameter returns results. e.g. ToDo', // ToDo: Improve description + options: [ + { + name: 'metadataValues', + displayName: 'Metadata', + values: [ + { + displayName: 'Attribute', + name: 'attribute', + type: 'string', + default: '', + description: 'The attribute name to return', + }, + ], + }, + ], + routing: { + send: { + type: 'body', + property: 'AttributesToGet', + value: '={{ $value.metadataValues.map(attribute => attribute.attribute) }}', + }, + }, + }, + { + displayName: 'Filter Attribute', + name: 'filterAttribute', + type: 'options', + default: 'username', + description: 'The attribute to search for', + options: [ + { name: 'Cognito User Status', value: 'cognito:user_status' }, + { name: 'Email', value: 'email' }, + { name: 'Family Name', value: 'family_name' }, + { name: 'Given Name', value: 'given_name' }, + { name: 'Name', value: 'name' }, + { name: 'Phone Number', value: 'phone_number' }, + { name: 'Preferred Username', value: 'preferred_username' }, + { name: 'Status (Enabled)', value: 'status' }, + { name: 'Sub', value: 'sub' }, + { name: 'Username', value: 'username' }, + ], + }, + { + displayName: 'Filter Type', + name: 'filterType', + type: 'options', + default: 'exactMatch', + description: 'The matching strategy of the filter', + options: [ + { name: 'Exact Match', value: 'exactMatch' }, + { name: 'Starts With', value: 'startsWith' }, + ], + }, + { + displayName: 'Filter Value', + name: 'filterValue', + type: 'string', + default: '', + description: 'The value of the attribute to search for', + routing: { + send: { + preSend: [presendFilter], + }, + }, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Aws/Cognito/descriptions/UserPoolDescription.ts b/packages/nodes-base/nodes/Aws/Cognito/descriptions/UserPoolDescription.ts new file mode 100644 index 0000000000..8660ea3d1f --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Cognito/descriptions/UserPoolDescription.ts @@ -0,0 +1,77 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { presendTest } from '../GenericFunctions'; + +export const userPoolOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['userPool'] } }, + options: [ + { + name: 'Get', + value: 'get', + action: 'Describe the configuration of a user pool', + routing: { + send: { + preSend: [presendTest], // ToDo: Remove this line before completing the pull request + }, + request: { + method: 'POST', + headers: { + 'X-Amz-Target': 'AWSCognitoIdentityProviderService.DescribeUserPool', + }, + }, + }, + }, + ], + default: 'get', + }, +]; + +export const userPoolFields: INodeProperties[] = [ + { + displayName: 'User Pool ID', + name: 'userPoolId', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The ID of the user pool', + displayOptions: { show: { resource: ['userPool'], operation: ['get'] } }, + routing: { + send: { + type: 'body', + property: 'UserPoolId', + }, + }, + modes: [ + { + displayName: 'From list', // ToDo: Fix error when selecting this option + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUserPools', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + hint: 'Enter the user pool ID', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w-]+_[0-9a-zA-Z]+$', + errorMessage: 'The ID must follow the pattern "xxxxxx_xxxxxxxxxxx"', + }, + }, + ], + placeholder: 'e.g. eu-central-1_ab12cdefgh', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Aws/Cognito/descriptions/index.ts b/packages/nodes-base/nodes/Aws/Cognito/descriptions/index.ts new file mode 100644 index 0000000000..fd8ad92d27 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Cognito/descriptions/index.ts @@ -0,0 +1,2 @@ +export * from './UserDescription'; +export * from './UserPoolDescription'; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index f81ce3b248..e1a987d269 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -413,6 +413,7 @@ "dist/nodes/Aws/AwsSns.node.js", "dist/nodes/Aws/AwsSnsTrigger.node.js", "dist/nodes/Aws/CertificateManager/AwsCertificateManager.node.js", + "dist/nodes/Aws/Cognito/AwsCognito.node.js", "dist/nodes/Aws/Comprehend/AwsComprehend.node.js", "dist/nodes/Aws/DynamoDB/AwsDynamoDB.node.js", "dist/nodes/Aws/ELB/AwsElb.node.js",