Skip to content

Commit 7f45de4

Browse files
authoredDec 6, 2021
feat(cognito): user pool: adds custom sender (Email/SMS) lambda triggers (#17740)
### Motivation I would like to use the Custom Sender triggers in my CDK app. They are supported by raw CF (despite the docs claiming otherwise). I do not wish to use the CfnUserPool construct as I am unable to pass the pool as a reference to other constructs (e.g. API Gateway authorisers/User Pool App Clients/etc.). I do not wish to follow the method recommended by the docs either (using the CLI to update the pool post-deployment), as it is hard to automate (requiring me to fetch the ARNs of my functions and KMS Key, not to mention it overrides other properties of the User Pool), and leaks my stack configuration out from CDK and into some post-deploy script. Please find this PR in support of adding the triggers to CDK. ### Description Adds support for 2 extra lambda triggers (`CustomEmailSender` and `CustomSMSSender`). These triggers have a different format, requiring both function ARN and version (only supported value is `V1_0`). Additionally, specifying either of these requires a `KMSKeyId` be specified as well, which appears as a property of the `LambdaTriggers` property in CF. Additionally, specifying the `CustomSMSSender` requires the `smsRole` be specified (allowing `sns:publish`). Neither of these requirements are enforced in the code, but CF will throw helpful (IMO) errors if they are not satisfied. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent c291c44 commit 7f45de4

File tree

5 files changed

+406
-7
lines changed

5 files changed

+406
-7
lines changed
 

‎packages/@aws-cdk/aws-cognito/lib/user-pool.ts

+78-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IRole, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam';
2+
import { IKey } from '@aws-cdk/aws-kms';
23
import * as lambda from '@aws-cdk/aws-lambda';
34
import { ArnFormat, Duration, IResource, Lazy, Names, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/core';
45
import { Construct } from 'constructs';
@@ -138,6 +139,20 @@ export interface UserPoolTriggers {
138139
*/
139140
readonly verifyAuthChallengeResponse?: lambda.IFunction;
140141

142+
/**
143+
* Amazon Cognito invokes this trigger to send email notifications to users.
144+
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-email-sender.html
145+
* @default - no trigger configured
146+
*/
147+
readonly customEmailSender?: lambda.IFunction
148+
149+
/**
150+
* Amazon Cognito invokes this trigger to send SMS notifications to users.
151+
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-sms-sender.html
152+
* @default - no trigger configured
153+
*/
154+
readonly customSmsSender?: lambda.IFunction
155+
141156
/**
142157
* Index signature
143158
*/
@@ -208,6 +223,18 @@ export class UserPoolOperation {
208223
*/
209224
public static readonly VERIFY_AUTH_CHALLENGE_RESPONSE = new UserPoolOperation('verifyAuthChallengeResponse');
210225

226+
/**
227+
* Amazon Cognito invokes this trigger to send email notifications to users.
228+
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-email-sender.html
229+
*/
230+
public static readonly CUSTOM_EMAIL_SENDER = new UserPoolOperation('customEmailSender');
231+
232+
/**
233+
* Amazon Cognito invokes this trigger to send email notifications to users.
234+
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-sms-sender.html
235+
*/
236+
public static readonly CUSTOM_SMS_SENDER = new UserPoolOperation('customSmsSender');
237+
211238
/** A custom user pool operation */
212239
public static of(name: string): UserPoolOperation {
213240
const lowerCamelCase = name.charAt(0).toLowerCase() + name.slice(1);
@@ -616,6 +643,13 @@ export interface UserPoolProps {
616643
* @default - see defaults on each property of DeviceTracking.
617644
*/
618645
readonly deviceTracking?: DeviceTracking;
646+
647+
/**
648+
* This key will be used to encrypt temporary passwords and authorization codes that Amazon Cognito generates.
649+
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-sender-triggers.html
650+
* @default - no key ID configured
651+
*/
652+
readonly customSenderKmsKey?: IKey;
619653
}
620654

621655
/**
@@ -766,12 +800,37 @@ export class UserPool extends UserPoolBase {
766800

767801
const signIn = this.signInConfiguration(props);
768802

803+
if (props.customSenderKmsKey) {
804+
const kmsKey = props.customSenderKmsKey;
805+
(this.triggers as any).kmsKeyId = kmsKey.keyArn;
806+
}
807+
769808
if (props.lambdaTriggers) {
770809
for (const t of Object.keys(props.lambdaTriggers)) {
771-
const trigger = props.lambdaTriggers[t];
772-
if (trigger !== undefined) {
773-
this.addLambdaPermission(trigger as lambda.IFunction, t);
774-
(this.triggers as any)[t] = (trigger as lambda.IFunction).functionArn;
810+
let trigger: lambda.IFunction | undefined;
811+
switch (t) {
812+
case 'customSmsSender':
813+
case 'customEmailSender':
814+
if (!this.triggers.kmsKeyId) {
815+
throw new Error('you must specify a KMS key if you are using customSmsSender or customEmailSender.');
816+
}
817+
trigger = props.lambdaTriggers[t];
818+
const version = 'V1_0';
819+
if (trigger !== undefined) {
820+
this.addLambdaPermission(trigger as lambda.IFunction, t);
821+
(this.triggers as any)[t] = {
822+
lambdaArn: trigger.functionArn,
823+
lambdaVersion: version,
824+
};
825+
}
826+
break;
827+
default:
828+
trigger = props.lambdaTriggers[t] as lambda.IFunction | undefined;
829+
if (trigger !== undefined) {
830+
this.addLambdaPermission(trigger as lambda.IFunction, t);
831+
(this.triggers as any)[t] = (trigger as lambda.IFunction).functionArn;
832+
}
833+
break;
775834
}
776835
}
777836
}
@@ -848,7 +907,21 @@ export class UserPool extends UserPoolBase {
848907
}
849908

850909
this.addLambdaPermission(fn, operation.operationName);
851-
(this.triggers as any)[operation.operationName] = fn.functionArn;
910+
switch (operation.operationName) {
911+
case 'customEmailSender':
912+
case 'customSmsSender':
913+
if (!this.triggers.kmsKeyId) {
914+
throw new Error('you must specify a KMS key if you are using customSmsSender or customEmailSender.');
915+
}
916+
(this.triggers as any)[operation.operationName] = {
917+
lambdaArn: fn.functionArn,
918+
lambdaVersion: 'V1_0',
919+
};
920+
break;
921+
default:
922+
(this.triggers as any)[operation.operationName] = fn.functionArn;
923+
}
924+
852925
}
853926

854927
private addLambdaPermission(fn: lambda.IFunction, name: string): void {

‎packages/@aws-cdk/aws-cognito/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"dependencies": {
8585
"@aws-cdk/aws-certificatemanager": "0.0.0",
8686
"@aws-cdk/aws-iam": "0.0.0",
87+
"@aws-cdk/aws-kms": "0.0.0",
8788
"@aws-cdk/aws-lambda": "0.0.0",
8889
"@aws-cdk/core": "0.0.0",
8990
"@aws-cdk/custom-resources": "0.0.0",
@@ -94,6 +95,7 @@
9495
"peerDependencies": {
9596
"@aws-cdk/aws-certificatemanager": "0.0.0",
9697
"@aws-cdk/aws-iam": "0.0.0",
98+
"@aws-cdk/aws-kms": "0.0.0",
9799
"@aws-cdk/aws-lambda": "0.0.0",
98100
"@aws-cdk/core": "0.0.0",
99101
"@aws-cdk/custom-resources": "0.0.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
{
2+
"Resources": {
3+
"emailLambdaServiceRole7569D9F6": {
4+
"Type": "AWS::IAM::Role",
5+
"Properties": {
6+
"AssumeRolePolicyDocument": {
7+
"Statement": [
8+
{
9+
"Action": "sts:AssumeRole",
10+
"Effect": "Allow",
11+
"Principal": {
12+
"Service": "lambda.amazonaws.com"
13+
}
14+
}
15+
],
16+
"Version": "2012-10-17"
17+
},
18+
"ManagedPolicyArns": [
19+
{
20+
"Fn::Join": [
21+
"",
22+
[
23+
"arn:",
24+
{
25+
"Ref": "AWS::Partition"
26+
},
27+
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
28+
]
29+
]
30+
}
31+
]
32+
}
33+
},
34+
"emailLambda61F82360": {
35+
"Type": "AWS::Lambda::Function",
36+
"Properties": {
37+
"Code": {
38+
"ZipFile": "exports.handler = function(event, ctx, cb) { console.log(\"Mocked custom email send\");return cb(null, \"success\"); }"
39+
},
40+
"Role": {
41+
"Fn::GetAtt": [
42+
"emailLambdaServiceRole7569D9F6",
43+
"Arn"
44+
]
45+
},
46+
"Handler": "index.handler",
47+
"Runtime": "nodejs14.x"
48+
},
49+
"DependsOn": [
50+
"emailLambdaServiceRole7569D9F6"
51+
]
52+
},
53+
"emailLambdaCustomEmailSenderCognito5E15D907": {
54+
"Type": "AWS::Lambda::Permission",
55+
"Properties": {
56+
"Action": "lambda:InvokeFunction",
57+
"FunctionName": {
58+
"Fn::GetAtt": [
59+
"emailLambda61F82360",
60+
"Arn"
61+
]
62+
},
63+
"Principal": "cognito-idp.amazonaws.com"
64+
}
65+
},
66+
"keyFEDD6EC0": {
67+
"Type": "AWS::KMS::Key",
68+
"Properties": {
69+
"KeyPolicy": {
70+
"Statement": [
71+
{
72+
"Action": "kms:*",
73+
"Effect": "Allow",
74+
"Principal": {
75+
"AWS": {
76+
"Fn::Join": [
77+
"",
78+
[
79+
"arn:",
80+
{
81+
"Ref": "AWS::Partition"
82+
},
83+
":iam::",
84+
{
85+
"Ref": "AWS::AccountId"
86+
},
87+
":root"
88+
]
89+
]
90+
}
91+
},
92+
"Resource": "*"
93+
}
94+
],
95+
"Version": "2012-10-17"
96+
}
97+
},
98+
"UpdateReplacePolicy": "Retain",
99+
"DeletionPolicy": "Retain"
100+
},
101+
"pool056F3F7E": {
102+
"Type": "AWS::Cognito::UserPool",
103+
"Properties": {
104+
"AccountRecoverySetting": {
105+
"RecoveryMechanisms": [
106+
{
107+
"Name": "verified_phone_number",
108+
"Priority": 1
109+
},
110+
{
111+
"Name": "verified_email",
112+
"Priority": 2
113+
}
114+
]
115+
},
116+
"AdminCreateUserConfig": {
117+
"AllowAdminCreateUserOnly": false
118+
},
119+
"AutoVerifiedAttributes": [
120+
"email"
121+
],
122+
"EmailVerificationMessage": "The verification code to your new account is {####}",
123+
"EmailVerificationSubject": "Verify your new account",
124+
"LambdaConfig": {
125+
"CustomEmailSender": {
126+
"LambdaArn": {
127+
"Fn::GetAtt": [
128+
"emailLambda61F82360",
129+
"Arn"
130+
]
131+
},
132+
"LambdaVersion": "V1_0"
133+
},
134+
"KMSKeyID": {
135+
"Fn::GetAtt": [
136+
"keyFEDD6EC0",
137+
"Arn"
138+
]
139+
}
140+
},
141+
"SmsVerificationMessage": "The verification code to your new account is {####}",
142+
"UsernameAttributes": [
143+
"email"
144+
],
145+
"VerificationMessageTemplate": {
146+
"DefaultEmailOption": "CONFIRM_WITH_CODE",
147+
"EmailMessage": "The verification code to your new account is {####}",
148+
"EmailSubject": "Verify your new account",
149+
"SmsMessage": "The verification code to your new account is {####}"
150+
}
151+
},
152+
"UpdateReplacePolicy": "Delete",
153+
"DeletionPolicy": "Delete"
154+
},
155+
"poolclient2623294C": {
156+
"Type": "AWS::Cognito::UserPoolClient",
157+
"Properties": {
158+
"UserPoolId": {
159+
"Ref": "pool056F3F7E"
160+
},
161+
"AllowedOAuthFlows": [
162+
"implicit",
163+
"code"
164+
],
165+
"AllowedOAuthFlowsUserPoolClient": true,
166+
"AllowedOAuthScopes": [
167+
"profile",
168+
"phone",
169+
"email",
170+
"openid",
171+
"aws.cognito.signin.user.admin"
172+
],
173+
"CallbackURLs": [
174+
"https://example.com"
175+
],
176+
"ExplicitAuthFlows": [
177+
"ALLOW_USER_SRP_AUTH",
178+
"ALLOW_REFRESH_TOKEN_AUTH"
179+
],
180+
"SupportedIdentityProviders": [
181+
"COGNITO"
182+
]
183+
}
184+
}
185+
},
186+
"Outputs": {
187+
"UserPoolId": {
188+
"Value": {
189+
"Ref": "pool056F3F7E"
190+
}
191+
},
192+
"ClientId": {
193+
"Value": {
194+
"Ref": "poolclient2623294C"
195+
}
196+
}
197+
}
198+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as kms from '@aws-cdk/aws-kms';
2+
import * as lambda from '@aws-cdk/aws-lambda';
3+
import { App, CfnOutput, RemovalPolicy, Stack } from '@aws-cdk/core';
4+
import { UserPool } from '../lib';
5+
6+
/*
7+
* Stack verification steps
8+
* * Sign up to the created user pool using an email address as the username, and password.
9+
* * Verify the CustomEmailSender lambda was called via logged message in CloudWatch.
10+
*/
11+
const app = new App();
12+
const stack = new Stack(app, 'integ-user-pool-custom-sender');
13+
14+
const customSenderLambda = new lambda.Function(stack, 'emailLambda', {
15+
runtime: lambda.Runtime.NODEJS_14_X,
16+
handler: 'index.handler',
17+
code: lambda.Code.fromInline('exports.handler = function(event, ctx, cb) { console.log("Mocked custom email send");return cb(null, "success"); }'),
18+
});
19+
20+
const userpool = new UserPool(stack, 'pool', {
21+
autoVerify: {
22+
email: true,
23+
},
24+
selfSignUpEnabled: true,
25+
signInAliases: {
26+
email: true,
27+
},
28+
customSenderKmsKey: new kms.Key(stack, 'key'),
29+
lambdaTriggers: {
30+
customEmailSender: customSenderLambda,
31+
},
32+
removalPolicy: RemovalPolicy.DESTROY,
33+
});
34+
35+
const client = userpool.addClient('client', {
36+
authFlows: {
37+
userSrp: true,
38+
},
39+
});
40+
41+
new CfnOutput(stack, 'UserPoolId', {
42+
value: userpool.userPoolId,
43+
});
44+
45+
new CfnOutput(stack, 'ClientId', {
46+
value: client.userPoolClientId,
47+
});

‎packages/@aws-cdk/aws-cognito/test/user-pool.test.ts

+81-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Match, Template } from '@aws-cdk/assertions';
22
import { Role, ServicePrincipal } from '@aws-cdk/aws-iam';
3+
import * as kms from '@aws-cdk/aws-kms';
34
import * as lambda from '@aws-cdk/aws-lambda';
45
import { testDeprecated } from '@aws-cdk/cdk-build-tools';
56
import { CfnParameter, Duration, Stack, Tags } from '@aws-cdk/core';
@@ -331,9 +332,69 @@ describe('User Pool', () => {
331332
});
332333
});
333334

335+
test('custom sender lambda triggers via properties are correctly configured', () => {
336+
// GIVEN
337+
const stack = new Stack();
338+
const kmsKey = fooKey(stack, 'TestKMSKey');
339+
const emailFn = fooFunction(stack, 'customEmailSender');
340+
const smsFn = fooFunction(stack, 'customSmsSender');
341+
342+
// WHEN
343+
new UserPool(stack, 'Pool', {
344+
customSenderKmsKey: kmsKey,
345+
lambdaTriggers: {
346+
customEmailSender: emailFn,
347+
customSmsSender: smsFn,
348+
},
349+
});
350+
351+
// THEN
352+
Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', {
353+
LambdaConfig: {
354+
CustomEmailSender: {
355+
LambdaArn: stack.resolve(emailFn.functionArn),
356+
LambdaVersion: 'V1_0',
357+
},
358+
CustomSMSSender: {
359+
LambdaArn: stack.resolve(smsFn.functionArn),
360+
LambdaVersion: 'V1_0',
361+
},
362+
},
363+
});
364+
Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Permission', {
365+
Action: 'lambda:InvokeFunction',
366+
FunctionName: stack.resolve(emailFn.functionArn),
367+
Principal: 'cognito-idp.amazonaws.com',
368+
});
369+
Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Permission', {
370+
Action: 'lambda:InvokeFunction',
371+
FunctionName: stack.resolve(smsFn.functionArn),
372+
Principal: 'cognito-idp.amazonaws.com',
373+
});
374+
});
375+
376+
test('lambda trigger KMS Key ID via properties is correctly configured', () => {
377+
// GIVEN
378+
const stack = new Stack();
379+
const kmsKey = fooKey(stack, 'TestKMSKey');
380+
381+
// WHEN
382+
new UserPool(stack, 'Pool', {
383+
customSenderKmsKey: kmsKey,
384+
});
385+
386+
// THEN
387+
Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', {
388+
LambdaConfig: {
389+
KMSKeyID: { 'Fn::GetAtt': ['TestKMSKey32509532', 'Arn'] },
390+
},
391+
});
392+
});
393+
334394
test('add* API correctly appends triggers', () => {
335395
// GIVEN
336396
const stack = new Stack();
397+
const kmsKey = fooKey(stack, 'TestKMSKey');
337398

338399
const createAuthChallenge = fooFunction(stack, 'createAuthChallenge');
339400
const customMessage = fooFunction(stack, 'customMessage');
@@ -345,9 +406,13 @@ describe('User Pool', () => {
345406
const preTokenGeneration = fooFunction(stack, 'preTokenGeneration');
346407
const userMigration = fooFunction(stack, 'userMigration');
347408
const verifyAuthChallengeResponse = fooFunction(stack, 'verifyAuthChallengeResponse');
409+
const customEmailSender = fooFunction(stack, 'customEmailSender');
410+
const customSmsSender = fooFunction(stack, 'customSmsSender');
348411

349412
// WHEN
350-
const pool = new UserPool(stack, 'Pool');
413+
const pool = new UserPool(stack, 'Pool', {
414+
customSenderKmsKey: kmsKey,
415+
});
351416
pool.addTrigger(UserPoolOperation.CREATE_AUTH_CHALLENGE, createAuthChallenge);
352417
pool.addTrigger(UserPoolOperation.CUSTOM_MESSAGE, customMessage);
353418
pool.addTrigger(UserPoolOperation.DEFINE_AUTH_CHALLENGE, defineAuthChallenge);
@@ -358,6 +423,8 @@ describe('User Pool', () => {
358423
pool.addTrigger(UserPoolOperation.PRE_TOKEN_GENERATION, preTokenGeneration);
359424
pool.addTrigger(UserPoolOperation.USER_MIGRATION, userMigration);
360425
pool.addTrigger(UserPoolOperation.VERIFY_AUTH_CHALLENGE_RESPONSE, verifyAuthChallengeResponse);
426+
pool.addTrigger(UserPoolOperation.CUSTOM_EMAIL_SENDER, customEmailSender);
427+
pool.addTrigger(UserPoolOperation.CUSTOM_SMS_SENDER, customSmsSender);
361428

362429
// THEN
363430
Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', {
@@ -372,12 +439,20 @@ describe('User Pool', () => {
372439
PreTokenGeneration: stack.resolve(preTokenGeneration.functionArn),
373440
UserMigration: stack.resolve(userMigration.functionArn),
374441
VerifyAuthChallengeResponse: stack.resolve(verifyAuthChallengeResponse.functionArn),
442+
CustomEmailSender: {
443+
LambdaArn: stack.resolve(customEmailSender.functionArn),
444+
LambdaVersion: 'V1_0',
445+
},
446+
CustomSMSSender: {
447+
LambdaArn: stack.resolve(customSmsSender.functionArn),
448+
LambdaVersion: 'V1_0',
449+
},
375450
},
376451
});
377452

378453
[createAuthChallenge, customMessage, defineAuthChallenge, postAuthentication,
379454
postConfirmation, preAuthentication, preSignUp, preTokenGeneration, userMigration,
380-
verifyAuthChallengeResponse].forEach((fn) => {
455+
verifyAuthChallengeResponse, customEmailSender, customSmsSender].forEach((fn) => {
381456
Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Permission', {
382457
Action: 'lambda:InvokeFunction',
383458
FunctionName: stack.resolve(fn.functionArn),
@@ -1700,3 +1775,7 @@ function fooFunction(scope: Construct, name: string): lambda.IFunction {
17001775
handler: 'index.handler',
17011776
});
17021777
}
1778+
1779+
function fooKey(scope: Construct, name: string): kms.Key {
1780+
return new kms.Key(scope, name);
1781+
}

0 commit comments

Comments
 (0)
Please sign in to comment.