From 11d62fea741270ab73a111afe9e8ef4991c2acc4 Mon Sep 17 00:00:00 2001 From: vigy02 Date: Thu, 31 Oct 2024 13:49:22 -0700 Subject: [PATCH] feat: Add support for custom Lambda function email senders in Auth construct (#2087) * fix: clearing the .amplify/generated/env/ before synthesis * fix: clearing the .amplify/generated/env/ before synthesis * fix: clearing the .amplify/generated/env/ before synthesis * fix: clearing the .amplify/generated/env/ before synthesis * fix: clearing the .amplify/generated/env/ before synthesis * fix: clearing the .amplify/generated/env/ before synthesis * chore: add changeset * fix: clearing the .amplify/generated/env/ before synthesis * fix: clearing the .amplify/generated/env/ before synthesis * fix: clearing the .amplify/generated/env/ before synthesis * fix: Clear generated env directory before shim generation * fix: Clear generated env directory before shim generation * fix: Clear generated env directory before shim generation * fix: Clear generated env directory before shim generation * chore: add changeset * fix: Clear generated env directory before shim generation * fix: Clear generated env directory before shim generation * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * feat: adding custom lambda function trigger for email * cleanup * chore: removed unwanted changesets * chore: preparing for merge * chore: preparing for merge * chore: preparing for merge * feat: added a functionality to translate auth-props for custom fucntion email * chore: added changeset * chore: added new API * feat: added a functionality to translate auth-props for custom fucntion email * feat: added a test case to cover custom function in backend-auth * feat: added a test case to cover custom function in backend-auth * feat: added a test case to cover custom function in backend-auth * feat: added a test case to cover custom function in backend-auth * feat: added a test case to cover custom function in backend-auth * feat: narrowed down the permission by updating the conditions * Refactored the code * Refactored the code * added a test case for checking lambdaTrigger={} empty condition * added a test case for checking lambdaTrigger={} empty condition * Merge Branch * Merge Branch * Merge Branch * Merge Branch * Merge Branch * Merge Branch * Merge Branch * Merge Branch * Merge Branch * Merge Branch * fixed the code to use addTrigger instead of manually setting up permissions * added KMS Key for customEmailSender * changed KMS key to not read-only * changed the test case to include lambdaArn * add a test case validation for KMS key * fixed the code to use addTrigger instead of manually setting up permissions * added KMS Key for customEmailSender * changed KMS key to not read-only * changed the test case to include lambdaArn * add a test case validation for KMS key * detect transform errors with multiple errors (#2102) * detect transform errors with multiple errors * new method of getting multiple transform errors * Add minify option to defineFunction (#2093) * Add minify option to defineFunction * Add unit tests and e2e tests when set minify option to false * Add changeset * Update API.md * add bundling options * Update .changeset/pink-rockets-dance.md * use optional chaining * include funcNoMinify into function.ts --------- Co-authored-by: Kamil Sobol * upgrade constructs (#2103) * Remove deprecated messages field from event (#2106) * detect generic CFN stack creation errors (#2108) * Fix cdk tests when new dependencies are shipped to npm. (#2107) * Fix cdk tests when new dependencies are shipped to npm. * try this * try this * try this * try this * API changes * Update API changes * Added kmsKeyArn for custom user KMS keys * chore: added changesets and updated API's * chore: added changesets * Added integration tests for customEmailSender * updated the API files to reflect master * feat: added customSenderEmail with types and added exceptions to eslint dict * chore: Updated API * chore: Updated API * chore: updated API * Delete packages/ai-constructs/API.md * chore: Updated API * chore: delete unused file * chore: update changeset * chore: update changeset * chore: Updated API and changeSets * chore: Updated the API from main * API updates to resolve conflicting naming * Updated the types in backend-auth * chore: Updated changesets * Added custom Email handler function and refactored the types of auth-construct * chore: updated API --------- Co-authored-by: Roshane Pascual Co-authored-by: MURAKAMI Masahiko Co-authored-by: Kamil Sobol Co-authored-by: Kamil Sobol --- .changeset/curvy-pans-brake.md | 8 + .eslint_dictionary.json | 3 + packages/auth-construct/API.md | 9 +- packages/auth-construct/src/construct.ts | 72 +++++++-- packages/auth-construct/src/index.ts | 1 + packages/auth-construct/src/types.ts | 17 ++- packages/backend-auth/API.md | 14 +- packages/backend-auth/src/factory.test.ts | 140 ++++++++++++++++++ packages/backend-auth/src/factory.ts | 26 +++- .../backend-auth/src/translate_auth_props.ts | 53 ++++++- packages/backend-auth/src/types.ts | 10 ++ .../data_storage_auth_with_triggers.test.ts | 1 + .../data_storage_auth_with_triggers.ts | 105 ++++++++++++- .../amplify/auth/resource.ts | 9 +- .../amplify/backend.ts | 24 +++ .../func-src/handler_custom_email_sender.ts | 28 ++++ .../amplify/function.ts | 5 + .../amplify/test_factories.ts | 2 + .../hotswap-update-files/function.ts | 5 + 19 files changed, 506 insertions(+), 26 deletions(-) create mode 100644 .changeset/curvy-pans-brake.md create mode 100644 packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/func-src/handler_custom_email_sender.ts diff --git a/.changeset/curvy-pans-brake.md b/.changeset/curvy-pans-brake.md new file mode 100644 index 0000000000..6d6cc9dd7a --- /dev/null +++ b/.changeset/curvy-pans-brake.md @@ -0,0 +1,8 @@ +--- +'@aws-amplify/auth-construct': minor +'@aws-amplify/backend-auth': minor +'@aws-amplify/backend': minor +'@aws-amplify/integration-tests': minor +--- + +Add support for custom Lambda function email senders in Auth construct diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index c987d863b6..399dd952f7 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -40,6 +40,7 @@ "datasync", "debounce", "declarator", + "decrypt", "deployer", "deprecations", "deprecator", @@ -146,6 +147,7 @@ "sigint", "signout", "signup", + "SKey", "sms", "stderr", "stdin", @@ -159,6 +161,7 @@ "synthing", "testname", "testnamebucket", + "testuser", "timestamps", "tmpdir", "todos", diff --git a/packages/auth-construct/API.md b/packages/auth-construct/API.md index 6afb8647cb..f3c895c6ce 100644 --- a/packages/auth-construct/API.md +++ b/packages/auth-construct/API.md @@ -9,6 +9,7 @@ import { AuthResources } from '@aws-amplify/plugin-types'; import { aws_cognito } from 'aws-cdk-lib'; import { BackendOutputStorageStrategy } from '@aws-amplify/plugin-types'; import { Construct } from 'constructs'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; import { NumberAttributeConstraints } from 'aws-cdk-lib/aws-cognito'; import { ResourceProvider } from '@aws-amplify/plugin-types'; import { SecretValue } from 'aws-cdk-lib'; @@ -47,7 +48,7 @@ export type AuthProps = { externalProviders?: ExternalProviderOptions; }; senders?: { - email: Pick; + email: Pick | CustomEmailSender; }; userAttributes?: UserAttributes; multifactor?: MFA; @@ -84,6 +85,12 @@ export type CustomAttributeString = CustomAttributeBase & StringAttributeConstra dataType: 'String'; }; +// @public +export type CustomEmailSender = { + handler: IFunction; + kmsKeyArn?: string; +}; + // @public export type EmailLogin = true | EmailLoginSettings; diff --git a/packages/auth-construct/src/construct.ts b/packages/auth-construct/src/construct.ts index 13c6680b65..84f9455d7b 100644 --- a/packages/auth-construct/src/construct.ts +++ b/packages/auth-construct/src/construct.ts @@ -34,6 +34,7 @@ import { UserPoolIdentityProviderOidc, UserPoolIdentityProviderSaml, UserPoolIdentityProviderSamlMetadataType, + UserPoolOperation, UserPoolProps, } from 'aws-cdk-lib/aws-cognito'; import { FederatedPrincipal, Role } from 'aws-cdk-lib/aws-iam'; @@ -51,6 +52,7 @@ import { StackMetadataBackendOutputStorageStrategy, } from '@aws-amplify/backend-output-storage'; import * as path from 'path'; +import { IKey, Key } from 'aws-cdk-lib/aws-kms'; type DefaultRoles = { auth: Role; unAuth: Role }; type IdentityProviderSetupResult = { @@ -130,6 +132,11 @@ export class AmplifyAuth role: Role; }; } = {}; + /** + * The KMS key used for encrypting custom email sender data. + * This is only set when using a custom email sender. + */ + private customEmailSenderKMSkey: IKey | undefined; /** * Create a new Auth construct with AuthProps. @@ -141,24 +148,39 @@ export class AmplifyAuth props: AuthProps = DEFAULTS.IF_NO_PROPS_PROVIDED ) { super(scope, id); - this.name = props.name ?? ''; this.domainPrefix = props.loginWith.externalProviders?.domainPrefix; - // UserPool this.computedUserPoolProps = this.getUserPoolProps(props); + this.userPool = new cognito.UserPool( this, `${this.name}UserPool`, this.computedUserPoolProps ); + /** + * Configure custom email sender for Cognito User Pool + * Grant necessary permissions for Lambda function to decrypt emails + * and allow Cognito to invoke the Lambda function + */ + if ( + props.senders?.email && + 'handler' in props.senders.email && + this.customEmailSenderKMSkey + ) { + this.customEmailSenderKMSkey.grantDecrypt(props.senders.email.handler); + this.customEmailSenderKMSkey.grantEncrypt(props.senders.email.handler); + this.userPool.addTrigger( + UserPoolOperation.of('customEmailSender'), + props.senders.email.handler + ); + } // UserPool - External Providers (Oauth, SAML, OIDC) and User Pool Domain this.providerSetupResult = this.setupExternalProviders( this.userPool, props.loginWith ); - // UserPool Client const userPoolClient = new cognito.UserPoolClient( this, @@ -478,7 +500,30 @@ export class AmplifyAuth }, { standardAttributes: {}, customAttributes: {} } ); - + /** + * Handle KMS key for custom email sender + * If a custom email sender is provided, we either use the provided KMS key ARN + * or create a new KMS key if one is not provided. + */ + if (props.senders?.email && 'handler' in props.senders.email) { + if (props.senders.email.kmsKeyArn) { + // Use the provided KMS key ARN + this.customEmailSenderKMSkey = Key.fromKeyArn( + this, + `${this.name}CustomSenderKey`, + props.senders.email.kmsKeyArn + ); + } else { + // Create a new KMS key if not provided + this.customEmailSenderKMSkey = new Key( + props.senders.email.handler.stack, + `${this.name}CustomSenderKey`, + { + enableKeyRotation: true, + } + ); + } + } const userPoolProps: UserPoolProps = { signInCaseSensitive: DEFAULTS.SIGN_IN_CASE_SENSITIVE, signInAliases: { @@ -503,15 +548,15 @@ export class AmplifyAuth customAttributes: { ...customAttributes, }, - email: props.senders - ? cognito.UserPoolEmail.withSES({ - fromEmail: props.senders.email.fromEmail, - fromName: props.senders.email.fromName, - replyTo: props.senders.email.replyTo, - sesRegion: Stack.of(this).region, - }) - : undefined, - + email: + props.senders && 'fromEmail' in props.senders.email + ? cognito.UserPoolEmail.withSES({ + fromEmail: props.senders.email.fromEmail, + fromName: props.senders.email.fromName, + replyTo: props.senders.email.replyTo, + sesRegion: Stack.of(this).region, + }) + : undefined, selfSignUpEnabled: DEFAULTS.ALLOW_SELF_SIGN_UP, mfa: mfaMode, mfaMessage: this.getMFAMessage(props.multifactor), @@ -528,6 +573,7 @@ export class AmplifyAuth props.loginWith.email?.userInvitation ) : undefined, + customSenderKmsKey: this.customEmailSenderKMSkey, }; return userPoolProps; }; diff --git a/packages/auth-construct/src/index.ts b/packages/auth-construct/src/index.ts index 13af450f20..85e3aa6c6c 100644 --- a/packages/auth-construct/src/index.ts +++ b/packages/auth-construct/src/index.ts @@ -26,6 +26,7 @@ export { CustomAttributeBoolean, CustomAttributeDateTime, CustomAttributeBase, + CustomEmailSender, } from './types.js'; export { AmplifyAuth } from './construct.js'; export { triggerEvents } from './trigger_events.js'; diff --git a/packages/auth-construct/src/types.ts b/packages/auth-construct/src/types.ts index 5083ffb73c..c3d4ddbbea 100644 --- a/packages/auth-construct/src/types.ts +++ b/packages/auth-construct/src/types.ts @@ -9,6 +9,7 @@ import { UserPoolIdentityProviderSamlMetadata, UserPoolSESOptions, } from 'aws-cdk-lib/aws-cognito'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; export type VerificationEmailWithLink = { /** * The type of verification. Must be one of "CODE" or "LINK". @@ -380,6 +381,14 @@ export type CustomAttribute = export type UserAttributes = StandardAttributes & Record<`custom:${string}`, CustomAttribute>; +/** + * CustomEmailSender type for configuring a custom Lambda function for email sending + */ +export type CustomEmailSender = { + handler: IFunction; + kmsKeyArn?: string; +}; + /** * Input props for the AmplifyAuth construct */ @@ -417,11 +426,15 @@ export type AuthProps = { */ senders?: { /** - * Configure Cognito to send emails from SES + * Configure Cognito to send emails from SES or a custom message trigger * SES configurations enable the use of customized email sender addresses and names + * Custom message triggers enable the use of third-party email providers when sending email notifications to users * @see https://docs.amplify.aws/react/build-a-backend/auth/moving-to-production/#email + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-email-sender.html */ - email: Pick; + email: + | Pick + | CustomEmailSender; }; /** * The set of attributes that are required for every user in the user pool. Read more on attributes here - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html diff --git a/packages/backend-auth/API.md b/packages/backend-auth/API.md index f9b6247ced..a1c07703d7 100644 --- a/packages/backend-auth/API.md +++ b/packages/backend-auth/API.md @@ -5,6 +5,7 @@ ```ts import { AmazonProviderProps } from '@aws-amplify/auth-construct'; +import { AmplifyFunction } from '@aws-amplify/plugin-types'; import { AppleProviderProps } from '@aws-amplify/auth-construct'; import { AuthProps } from '@aws-amplify/auth-construct'; import { AuthResources } from '@aws-amplify/plugin-types'; @@ -16,12 +17,14 @@ import { ExternalProviderOptions } from '@aws-amplify/auth-construct'; import { FacebookProviderProps } from '@aws-amplify/auth-construct'; import { FunctionResources } from '@aws-amplify/plugin-types'; import { GoogleProviderProps } from '@aws-amplify/auth-construct'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; import { OidcProviderProps } from '@aws-amplify/auth-construct'; import { ResourceAccessAcceptor } from '@aws-amplify/plugin-types'; import { ResourceAccessAcceptorFactory } from '@aws-amplify/plugin-types'; import { ResourceProvider } from '@aws-amplify/plugin-types'; import { StackProvider } from '@aws-amplify/plugin-types'; import { TriggerEvent } from '@aws-amplify/auth-construct'; +import { UserPoolSESOptions } from 'aws-cdk-lib/aws-cognito'; // @public export type ActionIam = 'addUserToGroup' | 'createGroup' | 'createUser' | 'deleteGroup' | 'deleteUser' | 'deleteUserAttributes' | 'disableUser' | 'enableUser' | 'forgetDevice' | 'getDevice' | 'getGroup' | 'getUser' | 'listUsers' | 'listUsersInGroup' | 'listGroups' | 'listDevices' | 'listGroupsForUser' | 'removeUserFromGroup' | 'resetUserPassword' | 'setUserMfaPreference' | 'setUserPassword' | 'setUserSettings' | 'updateDeviceStatus' | 'updateGroup' | 'updateUserAttributes'; @@ -36,10 +39,13 @@ export type AmazonProviderFactoryProps = Omit & { +export type AmplifyAuthProps = Expand & { loginWith: Expand; triggers?: Partial>>>; access?: AuthAccessGenerator; + senders?: { + email: Pick | CustomEmailSender; + }; }>; // @public @@ -80,6 +86,12 @@ export type AuthLoginWithFactoryProps = Omit & ResourceAccessAcceptorFactory & StackProvider; +// @public +export type CustomEmailSender = { + handler: ConstructFactory | IFunction; + kmsKeyArn?: string; +}; + // @public export const defineAuth: (props: AmplifyAuthProps) => ConstructFactory; diff --git a/packages/backend-auth/src/factory.test.ts b/packages/backend-auth/src/factory.test.ts index bbf285b7cd..a01c5f5935 100644 --- a/packages/backend-auth/src/factory.test.ts +++ b/packages/backend-auth/src/factory.test.ts @@ -26,6 +26,8 @@ import { import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { AmplifyUserError } from '@aws-amplify/platform-core'; import { CfnFunction } from 'aws-cdk-lib/aws-lambda'; +import { Key } from 'aws-cdk-lib/aws-kms'; +import { CustomEmailSender } from './types.js'; const createStackAndSetContext = (): Stack => { const app = new App(); @@ -355,6 +357,144 @@ void describe('AmplifyAuthFactory', () => { }); }); }); + + void it('sets customEmailSender when function is provided as email sender', () => { + const testFunc = new aws_lambda.Function(stack, 'testFunc', { + code: aws_lambda.Code.fromInline('test placeholder'), + runtime: aws_lambda.Runtime.NODEJS_18_X, + handler: 'index.handler', + }); + const funcStub: ConstructFactory> = { + getInstance: () => { + return { + resources: { + lambda: testFunc, + cfnResources: { + cfnFunction: testFunc.node.findChild('Resource') as CfnFunction, + }, + }, + }; + }, + }; + const customEmailSender: CustomEmailSender = { + handler: funcStub, + }; + resetFactoryCount(); + + const authWithTriggerFactory = defineAuth({ + loginWith: { email: true }, + senders: { email: customEmailSender }, + }); + + const backendAuth = authWithTriggerFactory.getInstance(getInstanceProps); + + const template = Template.fromStack(backendAuth.stack); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + LambdaConfig: { + CustomEmailSender: { + LambdaArn: { + Ref: Match.stringLikeRegexp('testFunc'), + }, + }, + KMSKeyID: { + Ref: Match.stringLikeRegexp('CustomSenderKey'), + }, + }, + }); + }); + void it('ensures empty lambdaTriggers do not remove triggers added elsewhere', () => { + const testFunc = new aws_lambda.Function(stack, 'testFunc', { + code: aws_lambda.Code.fromInline('test placeholder'), + runtime: aws_lambda.Runtime.NODEJS_18_X, + handler: 'index.handler', + }); + const funcStub: ConstructFactory> = { + getInstance: () => { + return { + resources: { + lambda: testFunc, + cfnResources: { + cfnFunction: testFunc.node.findChild('Resource') as CfnFunction, + }, + }, + }; + }, + }; + const customEmailSender: CustomEmailSender = { + handler: funcStub, + }; + resetFactoryCount(); + + const authWithTriggerFactory = defineAuth({ + loginWith: { email: true }, + senders: { email: customEmailSender }, + triggers: { preSignUp: funcStub }, + }); + + const backendAuth = authWithTriggerFactory.getInstance(getInstanceProps); + + const template = Template.fromStack(backendAuth.stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + LambdaConfig: { + PreSignUp: { + Ref: Match.stringLikeRegexp('testFunc'), + }, + CustomEmailSender: { + LambdaArn: { + Ref: Match.stringLikeRegexp('testFunc'), + }, + }, + KMSKeyID: { + Ref: Match.stringLikeRegexp('CustomSenderKey'), + }, + }, + }); + }); + void it('uses provided KMS key ARN and sets up custom email sender', () => { + const customKmsKeyArn = new Key(stack, `CustomSenderKey`, { + enableKeyRotation: true, + }); + const testFunc = new aws_lambda.Function(stack, 'testFunc', { + code: aws_lambda.Code.fromInline('test placeholder'), + runtime: aws_lambda.Runtime.NODEJS_18_X, + handler: 'index.handler', + }); + const funcStub: ConstructFactory> = { + getInstance: () => ({ + resources: { + lambda: testFunc, + cfnResources: { + cfnFunction: testFunc.node.findChild('Resource') as CfnFunction, + }, + }, + }), + }; + const customEmailSender: CustomEmailSender = { + handler: funcStub, + kmsKeyArn: customKmsKeyArn.keyArn, + }; + resetFactoryCount(); + + const authWithTriggerFactory = defineAuth({ + loginWith: { email: true }, + senders: { + email: customEmailSender, + }, + triggers: { preSignUp: funcStub }, + }); + + const backendAuth = authWithTriggerFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + LambdaConfig: { + KMSKeyID: { + Ref: Match.stringLikeRegexp('CustomSenderKey'), + }, + }, + }); + }); }); const upperCaseFirstChar = (str: string) => { diff --git a/packages/backend-auth/src/factory.ts b/packages/backend-auth/src/factory.ts index 02edd4695c..48d411f07d 100644 --- a/packages/backend-auth/src/factory.ts +++ b/packages/backend-auth/src/factory.ts @@ -1,6 +1,10 @@ import * as path from 'path'; import { Policy } from 'aws-cdk-lib/aws-iam'; -import { UserPool, UserPoolOperation } from 'aws-cdk-lib/aws-cognito'; +import { + UserPool, + UserPoolOperation, + UserPoolSESOptions, +} from 'aws-cdk-lib/aws-cognito'; import { AmplifyUserError, TagName } from '@aws-amplify/platform-core'; import { AmplifyAuth, @@ -20,12 +24,16 @@ import { ResourceProvider, StackProvider, } from '@aws-amplify/plugin-types'; -import { translateToAuthConstructLoginWith } from './translate_auth_props.js'; +import { + translateToAuthConstructLoginWith, + translateToAuthConstructSenders, +} from './translate_auth_props.js'; import { authAccessBuilder as _authAccessBuilder } from './access_builder.js'; import { AuthAccessPolicyArbiterFactory } from './auth_access_policy_arbiter.js'; import { AuthAccessGenerator, AuthLoginWithFactoryProps, + CustomEmailSender, Expand, } from './types.js'; import { UserPoolAccessPolicyFactory } from './userpool_access_policy_factory.js'; @@ -36,7 +44,7 @@ export type BackendAuth = ResourceProvider & StackProvider; export type AmplifyAuthProps = Expand< - Omit & { + Omit & { /** * Specify how you would like users to log in. You can choose from email, phone, and even external providers such as LoginWithAmazon. */ @@ -60,6 +68,14 @@ export type AmplifyAuthProps = Expand< * access: (allow) => [allow.resource(groupManager).to(["manageGroups"])] */ access?: AuthAccessGenerator; + /** + * Configure email sender options + */ + senders?: { + email: + | Pick + | CustomEmailSender; + }; } >; @@ -142,6 +158,10 @@ class AmplifyAuthGenerator implements ConstructContainerEntryGenerator { this.props.loginWith, backendSecretResolver ), + senders: translateToAuthConstructSenders( + this.props.senders, + this.getInstanceProps + ), outputStorageStrategy: this.getInstanceProps.outputStorageStrategy, }; if (authProps.loginWith.externalProviders) { diff --git a/packages/backend-auth/src/translate_auth_props.ts b/packages/backend-auth/src/translate_auth_props.ts index f4b20fff37..fad144ef6b 100644 --- a/packages/backend-auth/src/translate_auth_props.ts +++ b/packages/backend-auth/src/translate_auth_props.ts @@ -6,7 +6,10 @@ import { GoogleProviderProps, OidcProviderProps, } from '@aws-amplify/auth-construct'; -import { BackendSecretResolver } from '@aws-amplify/plugin-types'; +import { + BackendSecretResolver, + ConstructFactoryGetInstanceProps, +} from '@aws-amplify/plugin-types'; import { AmazonProviderFactoryProps, AppleProviderFactoryProps, @@ -16,6 +19,8 @@ import { GoogleProviderFactoryProps, OidcProviderFactoryProps, } from './types.js'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; +import { AmplifyAuthProps } from './factory.js'; /** * Translate an Auth factory's loginWith to its Auth construct counterpart. Backend secret fields will be resolved @@ -79,6 +84,52 @@ export const translateToAuthConstructLoginWith = ( return result; }; +/** + * Translates the senders property from AmplifyAuthProps to AuthProps format. + * @param senders - The senders object from AmplifyAuthProps. + * @param getInstanceProps - Properties used to get an instance of the sender. + * @returns The translated senders object in AuthProps format, or undefined if no valid sender is provided. + * @description + * This function handles the translation of the 'senders' property, specifically for email senders. + * If no senders are provided or if there's no email sender, it returns undefined. + * If the email sender has a 'getInstance' method, it retrieves the Lambda function and returns it. + * Otherwise, it returns the email sender as is. + */ +export const translateToAuthConstructSenders = ( + senders: AmplifyAuthProps['senders'] | undefined, + getInstanceProps: ConstructFactoryGetInstanceProps +): AuthProps['senders'] | undefined => { + if (!senders || !senders.email) { + return undefined; + } + + // Handle CustomEmailSender type + if ('handler' in senders.email) { + const lambda: IFunction = + 'getInstance' in senders.email.handler + ? senders.email.handler.getInstance(getInstanceProps).resources.lambda + : senders.email.handler; + + return { + email: { + handler: lambda, + kmsKeyArn: senders.email.kmsKeyArn, + }, + }; + } + + // Handle SES configuration + if ('fromEmail' in senders.email) { + return { + email: senders.email, + }; + } + + // If none of the above, return the email configuration as-is + return { + email: senders.email, + }; +}; const translateAmazonProps = ( backendSecretResolver: BackendSecretResolver, diff --git a/packages/backend-auth/src/types.ts b/packages/backend-auth/src/types.ts index 8b7c018feb..46c199f1a1 100644 --- a/packages/backend-auth/src/types.ts +++ b/packages/backend-auth/src/types.ts @@ -8,6 +8,7 @@ import { OidcProviderProps, } from '@aws-amplify/auth-construct'; import { + AmplifyFunction, BackendSecret, ConstructFactory, ConstructFactoryGetInstanceProps, @@ -15,6 +16,7 @@ import { ResourceAccessAcceptorFactory, ResourceProvider, } from '@aws-amplify/plugin-types'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; /** * This utility allows us to expand nested types in auto complete prompts. @@ -252,3 +254,11 @@ export type ActionIam = | 'updateDeviceStatus' | 'updateGroup' | 'updateUserAttributes'; + +/** + * CustomEmailSender type for configuring a custom Lambda function for email sending + */ +export type CustomEmailSender = { + handler: ConstructFactory | IFunction; + kmsKeyArn?: string; +}; diff --git a/packages/integration-tests/src/test-in-memory/data_storage_auth_with_triggers.test.ts b/packages/integration-tests/src/test-in-memory/data_storage_auth_with_triggers.test.ts index 7570ece6fd..37c7e3c456 100644 --- a/packages/integration-tests/src/test-in-memory/data_storage_auth_with_triggers.test.ts +++ b/packages/integration-tests/src/test-in-memory/data_storage_auth_with_triggers.test.ts @@ -52,6 +52,7 @@ void it('data storage auth with triggers', () => { assertExpectedLogicalIds(templates.defaultNodeFunc, 'AWS::Lambda::Function', [ 'defaultNodeFunctionlambda5C194062', 'echoFunclambdaE17DCA46', + 'funcCustomEmailSenderlambda3CCBA9A6', 'funcNoMinifylambda91CDF3E0', 'funcWithAwsSdklambda5F770AD7', 'funcWithSchedulelambda0B6E4271', diff --git a/packages/integration-tests/src/test-project-setup/data_storage_auth_with_triggers.ts b/packages/integration-tests/src/test-project-setup/data_storage_auth_with_triggers.ts index 893c3bb8ff..d9924bc238 100644 --- a/packages/integration-tests/src/test-project-setup/data_storage_auth_with_triggers.ts +++ b/packages/integration-tests/src/test-project-setup/data_storage_auth_with_triggers.ts @@ -33,6 +33,10 @@ import { import { e2eToolingClientConfig } from '../e2e_tooling_client_config.js'; import isMatch from 'lodash.ismatch'; import { TextWriter, ZipReader } from '@zip.js/zip.js'; +import { + AdminCreateUserCommand, + CognitoIdentityProviderClient, +} from '@aws-sdk/client-cognito-identity-provider'; /** * Creates test projects with data, storage, and auth categories. @@ -68,7 +72,10 @@ export class DataStorageAuthWithTriggerTestProjectCreator private readonly cloudTrailClient: CloudTrailClient = new CloudTrailClient( e2eToolingClientConfig ), - private readonly resourceFinder: DeployedResourcesFinder = new DeployedResourcesFinder() + private readonly resourceFinder: DeployedResourcesFinder = new DeployedResourcesFinder(), + private readonly cognitoClient: CognitoIdentityProviderClient = new CognitoIdentityProviderClient( + e2eToolingClientConfig + ) ) {} createProject = async (e2eProjectDir: string): Promise => { @@ -87,7 +94,8 @@ export class DataStorageAuthWithTriggerTestProjectCreator this.iamClient, this.sqsClient, this.cloudTrailClient, - this.resourceFinder + this.resourceFinder, + this.cognitoClient ); await fs.cp( project.sourceProjectAmplifyDirURL, @@ -154,7 +162,8 @@ class DataStorageAuthWithTriggerTestProject extends TestProjectBase { private readonly iamClient: IAMClient, private readonly sqsClient: SQSClient, private readonly cloudTrailClient: CloudTrailClient, - private readonly resourceFinder: DeployedResourcesFinder + private readonly resourceFinder: DeployedResourcesFinder, + private readonly cognitoClient: CognitoIdentityProviderClient ) { super( name, @@ -262,12 +271,19 @@ class DataStorageAuthWithTriggerTestProject extends TestProjectBase { 'AWS::Lambda::Function', (name) => name.includes('funcNoMinify') ); + const funcCustomEmailSender = + await this.resourceFinder.findByBackendIdentifier( + backendId, + 'AWS::Lambda::Function', + (name) => name.includes('funcCustomEmailSender') + ); assert.equal(defaultNodeLambda.length, 1); assert.equal(node16Lambda.length, 1); assert.equal(funcWithSsm.length, 1); assert.equal(funcWithAwsSdk.length, 1); assert.equal(funcWithSchedule.length, 1); + assert.equal(funcCustomEmailSender.length, 1); const expectedResponse = { s3TestContent: 'this is some test content', @@ -279,7 +295,9 @@ class DataStorageAuthWithTriggerTestProject extends TestProjectBase { await this.checkLambdaResponse(defaultNodeLambda[0], expectedResponse); await this.checkLambdaResponse(node16Lambda[0], expectedResponse); await this.checkLambdaResponse(funcWithSsm[0], 'It is working'); - await this.checkLambdaResponse(funcWithAwsSdk[0], 'It is working'); + + // Custom email sender assertion + await this.assertCustomEmailSenderWorks(backendId); await this.assertScheduleInvokesFunction(backendId); @@ -642,4 +660,83 @@ class DataStorageAuthWithTriggerTestProject extends TestProjectBase { ); } }; + + private assertCustomEmailSenderWorks = async ( + backendId: BackendIdentifier + ) => { + const TIMEOUT_MS = 1000 * 60 * 2; // 2 minutes + const startTime = Date.now(); + const queue = await this.resourceFinder.findByBackendIdentifier( + backendId, + 'AWS::SQS::Queue', + (name) => name.includes('customEmailSenderQueue') + ); + + assert.strictEqual(queue.length, 1, 'Custom email sender queue not found'); + + // Trigger an email sending operation + await this.triggerEmailSending(backendId); + + // Wait for the SQS message + let messageReceived = false; + while (Date.now() - startTime < TIMEOUT_MS && !messageReceived) { + const response = await this.sqsClient.send( + new ReceiveMessageCommand({ + QueueUrl: queue[0], + WaitTimeSeconds: 20, + }) + ); + + if (response.Messages && response.Messages.length > 0) { + messageReceived = true; + // Verify the message content + const messageBody = JSON.parse(response.Messages[0].Body || '{}'); + assert.strictEqual( + messageBody.message, + 'Custom Email Sender is working', + 'Unexpected message content' + ); + + // Delete the message + await this.sqsClient.send( + new DeleteMessageCommand({ + QueueUrl: queue[0], + ReceiptHandle: response.Messages[0].ReceiptHandle!, + }) + ); + } + } + + assert.strictEqual( + messageReceived, + true, + 'Custom email sender was not triggered within the timeout period' + ); + }; + + private triggerEmailSending = async (backendId: BackendIdentifier) => { + const userPoolId = await this.resourceFinder.findByBackendIdentifier( + backendId, + 'AWS::Cognito::UserPool', + () => true + ); + + assert.strictEqual(userPoolId.length, 1, 'User pool not found'); + + const username = `testuser_${Date.now()}@example.com`; + const password = 'TestPassword123!'; + + await this.cognitoClient.send( + new AdminCreateUserCommand({ + UserPoolId: userPoolId[0], + Username: username, + TemporaryPassword: password, + UserAttributes: [ + { Name: 'email', Value: username }, + { Name: 'email_verified', Value: 'true' }, + ], + }) + ); + // The creation of a new user should trigger the custom email sender + }; } diff --git a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/auth/resource.ts b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/auth/resource.ts index 92733f12ec..14c55e503e 100644 --- a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/auth/resource.ts +++ b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/auth/resource.ts @@ -1,5 +1,9 @@ import { defineAuth, secret } from '@aws-amplify/backend'; -import { defaultNodeFunc } from '../function.js'; +import { defaultNodeFunc, funcCustomEmailSender } from '../function.js'; + +const customEmailSenderFunction = { + handler: funcCustomEmailSender, +}; export const auth = defineAuth({ loginWith: { @@ -21,6 +25,9 @@ export const auth = defineAuth({ logoutUrls: ['https://logout.com'], }, }, + senders: { + email: customEmailSenderFunction, + }, triggers: { postConfirmation: defaultNodeFunc, }, diff --git a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/backend.ts b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/backend.ts index d9d298e555..c47ce414bd 100644 --- a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/backend.ts +++ b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/backend.ts @@ -26,3 +26,27 @@ if (scheduleFunctionLambdaRole) { ); } backend.funcWithSchedule.addEnvironment('SQS_QUEUE_URL', queue.queueUrl); + +// Queue setup for customEmailSender + +const customEmailSenderLambda = backend.funcCustomEmailSender.resources.lambda; +const customEmailSenderLambdaRole = customEmailSenderLambda.role; +const customEmailSenderQueueStack = Stack.of(customEmailSenderLambda); +const emailSenderQueue = new Queue( + customEmailSenderQueueStack, + 'amplify-customEmailSenderQueue' +); + +if (customEmailSenderLambdaRole) { + emailSenderQueue.grantSendMessages( + Role.fromRoleArn( + customEmailSenderQueueStack, + 'CustomEmailSenderLambdaExecutionRole', + customEmailSenderLambdaRole.roleArn + ) + ); +} +backend.funcCustomEmailSender.addEnvironment( + 'CUSTOM_EMAIL_SENDER_SQS_QUEUE_URL', + emailSenderQueue.queueUrl +); diff --git a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/func-src/handler_custom_email_sender.ts b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/func-src/handler_custom_email_sender.ts new file mode 100644 index 0000000000..e52be2ea0a --- /dev/null +++ b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/func-src/handler_custom_email_sender.ts @@ -0,0 +1,28 @@ +import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs'; + +/** + * This function asserts that custom email sender function is working properly + */ +export const handler = async () => { + const sqsClient = new SQSClient({ region: process.env.region }); + + const queueUrl = process.env.CUSTOM_EMAIL_SENDER_SQS_QUEUE_URL; + + if (!queueUrl) { + throw new Error('SQS_QUEUE_URL is not set in environment variables'); + } + + const messageBody = JSON.stringify({ + message: 'Custom Email Sender is working', + timeStamp: new Date().toISOString(), + }); + + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: messageBody, + }) + ); + + return 'It is working'; +}; diff --git a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/function.ts b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/function.ts index 7e367aca48..7265fb2ac4 100644 --- a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/function.ts +++ b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/function.ts @@ -58,3 +58,8 @@ export const funcNoMinify = defineFunction({ minify: false, }, }); + +export const funcCustomEmailSender = defineFunction({ + name: 'funcCustomEmailSender', + entry: './func-src/handler_custom_email_sender.ts', +}); diff --git a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/test_factories.ts b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/test_factories.ts index d782b45be1..73ee6b0479 100644 --- a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/test_factories.ts +++ b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/amplify/test_factories.ts @@ -6,6 +6,7 @@ import { node16Func, funcWithSchedule, funcNoMinify, + funcCustomEmailSender, } from './function.js'; import { storage } from './storage/resource.js'; import { auth } from './auth/resource.js'; @@ -20,4 +21,5 @@ export const dataStorageAuthWithTriggers = { funcWithAwsSdk, funcWithSchedule, funcNoMinify, + funcCustomEmailSender, }; diff --git a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/hotswap-update-files/function.ts b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/hotswap-update-files/function.ts index 542465f859..a0abf3e302 100644 --- a/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/hotswap-update-files/function.ts +++ b/packages/integration-tests/src/test-projects/data-storage-auth-with-triggers-ts/hotswap-update-files/function.ts @@ -60,3 +60,8 @@ export const funcNoMinify = defineFunction({ minify: false, }, }); + +export const funcCustomEmailSender = defineFunction({ + name: 'funcCustomEmailSender', + entry: './func-src/handler_custom_email_sender.ts', +});