diff --git a/packages/@aws-cdk/aws-cognito/README.md b/packages/@aws-cdk/aws-cognito/README.md index ee9a2b93ace4b..e16a1ccaef3e6 100644 --- a/packages/@aws-cdk/aws-cognito/README.md +++ b/packages/@aws-cdk/aws-cognito/README.md @@ -37,6 +37,7 @@ This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aw - [Lambda Triggers](#lambda-triggers) - [Import](#importing-user-pools) - [App Clients](#app-clients) + - [Domains](#domains) ## User Pools @@ -398,3 +399,34 @@ pool.addClient('app-client', { } }); ``` + +### Domains + +After setting up an [app client](#app-clients), the address for the user pool's sign-up and sign-in webpages can be +configured using domains. There are two ways to set up a domain - either the Amazon Cognito hosted domain can be chosen +with an available domain prefix, or a custom domain name can be chosen. The custom domain must be one that is already +owned, and whose certificate is registered in AWS Certificate Manager. + +The following code sets up a user pool domain in Amazon Cognito hosted domain with the prefix 'my-awesome-app' - + +```ts +const pool = new UserPool(this, 'Pool'); +pool.addDomain('domain', { + domain: UserPoolDomainType.cognitoDomain({ + domainPrefix: 'my-awesome-app', + }), +}); +``` + +On the other hand, the following code sets up a user pool domain and use your own custom domain - + +```ts +const domainCert = new acm.Certificate.fromCertificateArn(this, 'domainCert', certificateArn); +const pool = new UserPool(this, 'Pool'); +pool.addDomain('domain', { + domain: UserPoolDomainType.customDomain({ + domainPrefix: 'my-awesome-app', + certificate: domainCert, + }), +}); +``` \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/lib/index.ts b/packages/@aws-cdk/aws-cognito/lib/index.ts index d19f4fc1882cf..c7f8ba6547ceb 100644 --- a/packages/@aws-cdk/aws-cognito/lib/index.ts +++ b/packages/@aws-cdk/aws-cognito/lib/index.ts @@ -2,4 +2,5 @@ export * from './cognito.generated'; export * from './user-pool'; export * from './user-pool-attr'; -export * from './user-pool-client'; \ No newline at end of file +export * from './user-pool-client'; +export * from './user-pool-domain'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts index 639de30001655..cde6bf72191ca 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts @@ -146,7 +146,7 @@ export class OAuthScope { } /** - * Properties for the UserPoolClient construct + * Options to create a UserPoolClient */ export interface UserPoolClientOptions { /** diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-domain.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-domain.ts new file mode 100644 index 0000000000000..b1518861e2fbb --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-domain.ts @@ -0,0 +1,129 @@ +import { ICertificate } from '@aws-cdk/aws-certificatemanager'; +import { Construct, IResource, Resource } from '@aws-cdk/core'; +import { AwsCustomResource, AwsCustomResourcePolicy, AwsSdkCall, PhysicalResourceId } from '@aws-cdk/custom-resources'; +import { CfnUserPoolDomain } from './cognito.generated'; +import { IUserPool } from './user-pool'; + +/** + * Represents a user pool domain. + */ +export interface IUserPoolDomain extends IResource { + /** + * The domain that was specified to be created. + * If `customDomain` was selected, this holds the full domain name that was specified. + * If the `cognitoDomain` was used, it contains the prefix to the Cognito hosted domain. + * @attribute + */ + readonly domainName: string; +} + +/** + * Options while specifying custom domain + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-add-custom-domain.html + */ +export interface CustomDomainOptions { + /** + * The custom domain name that you would like to associate with this User Pool. + */ + readonly domainName: string; + + /** + * The certificate to associate with this domain. + */ + readonly certificate: ICertificate; +} + +/** + * Options while specifying a cognito prefix domain. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-assign-domain-prefix.html + */ +export interface CognitoDomainOptions { + /** + * The prefix to the Cognito hosted domain name that will be associated with the user pool. + */ + readonly domainPrefix: string; +} + +/** + * Options to create a UserPoolDomain + */ +export interface UserPoolDomainOptions { + /** + * Associate a custom domain with your user pool + * Either `customDomain` or `cognitoDomain` must be specified. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-add-custom-domain.html + * @default - not set if `cognitoDomain` is specified, otherwise, throws an error. + */ + readonly customDomain?: CustomDomainOptions; + + /** + * Associate a cognito prefix domain with your user pool + * Either `customDomain` or `cognitoDomain` must be specified. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-assign-domain-prefix.html + * @default - not set if `customDomain` is specified, otherwise, throws an error. + */ + readonly cognitoDomain?: CognitoDomainOptions; +} + +/** + * Props for UserPoolDomain construct + */ +export interface UserPoolDomainProps extends UserPoolDomainOptions { + /** + * The user pool to which this domain should be associated. + */ + readonly userPool: IUserPool; +} + +/** + * Define a user pool domain + */ +export class UserPoolDomain extends Resource implements IUserPoolDomain { + public readonly domainName: string; + + constructor(scope: Construct, id: string, props: UserPoolDomainProps) { + super(scope, id); + + if (!!props.customDomain === !!props.cognitoDomain) { + throw new Error('One of, and only one of, cognitoDomain or customDomain must be specified'); + } + + if (props.cognitoDomain?.domainPrefix && !/^[a-z0-9-]+$/.test(props.cognitoDomain.domainPrefix)) { + throw new Error('domainPrefix for cognitoDomain can contain only lowercase alphabets, numbers and hyphens'); + } + + const domainName = props.cognitoDomain?.domainPrefix || props.customDomain?.domainName!; + const resource = new CfnUserPoolDomain(this, 'Resource', { + userPoolId: props.userPool.userPoolId, + domain: domainName, + customDomainConfig: props.customDomain ? { certificateArn: props.customDomain.certificate.certificateArn } : undefined, + }); + + this.domainName = resource.ref; + } + + /** + * The domain name of the CloudFront distribution associated with the user pool domain. + */ + public get cloudFrontDomainName(): string { + const sdkCall: AwsSdkCall = { + service: 'CognitoIdentityServiceProvider', + action: 'describeUserPoolDomain', + parameters: { + Domain: this.domainName, + }, + physicalResourceId: PhysicalResourceId.of(this.domainName), + }; + const customResource = new AwsCustomResource(this, 'CloudFrontDomainName', { + resourceType: 'Custom::UserPoolCloudFrontDomainName', + onCreate: sdkCall, + onUpdate: sdkCall, + policy: AwsCustomResourcePolicy.fromSdkCalls({ + // DescribeUserPoolDomain only supports access level '*' + // https://docs.aws.amazon.com/IAM/latest/UserGuide/list_amazoncognitouserpools.html#amazoncognitouserpools-actions-as-permissions + resources: [ '*' ], + }), + }); + return customResource.getResponseField('DomainDescription.CloudFrontDistribution'); + } +} diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts index 8d73b9aa1f04a..b8ba8176be0a9 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts @@ -4,6 +4,7 @@ import { Construct, Duration, IResource, Lazy, Resource, Stack } from '@aws-cdk/ import { CfnUserPool } from './cognito.generated'; import { ICustomAttribute, RequiredAttributes } from './user-pool-attr'; import { IUserPoolClient, UserPoolClient, UserPoolClientOptions } from './user-pool-client'; +import { UserPoolDomain, UserPoolDomainOptions } from './user-pool-domain'; /** * The different ways in which users of this pool can sign up or sign in. @@ -658,6 +659,10 @@ export class UserPool extends Resource implements IUserPool { (this.triggers as any)[operation.operationName] = fn.functionArn; } + /** + * Add a new app client to this user pool. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-client-apps.html + */ public addClient(id: string, options?: UserPoolClientOptions): IUserPoolClient { return new UserPoolClient(this, id, { userPool: this, @@ -665,6 +670,17 @@ export class UserPool extends Resource implements IUserPool { }); } + /** + * Associate a domain to this user pool. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-assign-domain.html + */ + public addDomain(id: string, options: UserPoolDomainOptions): UserPoolDomain { + return new UserPoolDomain(this, id, { + userPool: this, + ...options, + }); + } + private addLambdaPermission(fn: lambda.IFunction, name: string): void { const capitalize = name.charAt(0).toUpperCase() + name.slice(1); fn.addPermission(`${capitalize}Cognito`, { diff --git a/packages/@aws-cdk/aws-cognito/package.json b/packages/@aws-cdk/aws-cognito/package.json index 120d28fb32dd3..e5306e9881b24 100644 --- a/packages/@aws-cdk/aws-cognito/package.json +++ b/packages/@aws-cdk/aws-cognito/package.json @@ -72,16 +72,20 @@ "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "constructs": "^3.0.2" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "constructs": "^3.0.2" }, "jest": {}, @@ -91,7 +95,8 @@ "awslint": { "exclude": [ "attribute-tag:@aws-cdk/aws-cognito.UserPoolClient.userPoolClientName", - "resource-attribute:@aws-cdk/aws-cognito.UserPoolClient.userPoolClientClientSecret" + "resource-attribute:@aws-cdk/aws-cognito.UserPoolClient.userPoolClientClientSecret", + "props-physical-name:@aws-cdk/aws-cognito.UserPoolDomainProps" ] }, "stability": "experimental", diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explcit-props.expected.json b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.expected.json similarity index 100% rename from packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explcit-props.expected.json rename to packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.expected.json diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explcit-props.ts b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.ts similarity index 100% rename from packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explcit-props.ts rename to packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.ts diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-domain-cfdist.expected.json b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-domain-cfdist.expected.json new file mode 100644 index 0000000000000..b1e726042529a --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-domain-cfdist.expected.json @@ -0,0 +1,254 @@ +{ + "Resources": { + "UserPoolsmsRole4EA729DD": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "integuserpooldomaincfdistUserPool17475E8A" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "cognito-idp.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "sns-publish" + } + ] + } + }, + "UserPool6BA7E5F2": { + "Type": "AWS::Cognito::UserPool", + "Properties": { + "AdminCreateUserConfig": { + "AllowAdminCreateUserOnly": true + }, + "EmailVerificationMessage": "Hello {username}, Your verification code is {####}", + "EmailVerificationSubject": "Verify your new account", + "SmsConfiguration": { + "ExternalId": "integuserpooldomaincfdistUserPool17475E8A", + "SnsCallerArn": { + "Fn::GetAtt": [ + "UserPoolsmsRole4EA729DD", + "Arn" + ] + } + }, + "SmsVerificationMessage": "The verification code to your new account is {####}", + "VerificationMessageTemplate": { + "DefaultEmailOption": "CONFIRM_WITH_CODE", + "EmailMessage": "Hello {username}, Your verification code is {####}", + "EmailSubject": "Verify your new account", + "SmsMessage": "The verification code to your new account is {####}" + } + } + }, + "UserPoolDomainD0EA232A": { + "Type": "AWS::Cognito::UserPoolDomain", + "Properties": { + "Domain": "cdk-integ-user-pool-domain", + "UserPoolId": { + "Ref": "UserPool6BA7E5F2" + } + } + }, + "UserPoolDomainCloudFrontDomainNameE213E594": { + "Type": "Custom::UserPoolCloudFrontDomainName", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd22872D164C4C", + "Arn" + ] + }, + "Create": { + "service": "CognitoIdentityServiceProvider", + "action": "describeUserPoolDomain", + "parameters": { + "Domain": { + "Ref": "UserPoolDomainD0EA232A" + } + }, + "physicalResourceId": { + "id": { + "Ref": "UserPoolDomainD0EA232A" + } + } + }, + "Update": { + "service": "CognitoIdentityServiceProvider", + "action": "describeUserPoolDomain", + "parameters": { + "Domain": { + "Ref": "UserPoolDomainD0EA232A" + } + }, + "physicalResourceId": { + "id": { + "Ref": "UserPoolDomainD0EA232A" + } + } + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleDefaultPolicyD28E1A5E": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "cognito-idp:DescribeUserPoolDomain", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleDefaultPolicyD28E1A5E", + "Roles": [ + { + "Ref": "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + } + ] + } + }, + "AWS679f53fac002430cb0da5b7982bd22872D164C4C": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5bS3BucketA3488101" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5bS3VersionKey23A2E46C" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5bS3VersionKey23A2E46C" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2", + "Arn" + ] + }, + "Runtime": "nodejs12.x", + "Timeout": 120 + }, + "DependsOn": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleDefaultPolicyD28E1A5E", + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + ] + } + }, + "Outputs": { + "Domain": { + "Value": { + "Ref": "UserPoolDomainD0EA232A" + } + }, + "CloudFrontDomainName": { + "Value": { + "Fn::GetAtt": [ + "UserPoolDomainCloudFrontDomainNameE213E594", + "DomainDescription.CloudFrontDistribution" + ] + } + } + }, + "Parameters": { + "AssetParameters0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5bS3BucketA3488101": { + "Type": "String", + "Description": "S3 bucket for asset \"0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5b\"" + }, + "AssetParameters0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5bS3VersionKey23A2E46C": { + "Type": "String", + "Description": "S3 key for asset version \"0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5b\"" + }, + "AssetParameters0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5bArtifactHashF6409D44": { + "Type": "String", + "Description": "Artifact hash for asset \"0317970d7c7695dbb9076b70f5eaa0a840dabe9a56c3389439ae5018b5a4cc5b\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-domain-cfdist.ts b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-domain-cfdist.ts new file mode 100644 index 0000000000000..abde60a7cd12c --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-domain-cfdist.ts @@ -0,0 +1,26 @@ +import { App, CfnOutput, Stack } from '@aws-cdk/core'; +import { UserPool } from '../lib'; + +/* + * Stack verification steps: + * * Verify that the CloudFrontDistribution stack output is of the format 'xxxxxxxxxxxxxx.cloudfront.net' + */ + +const app = new App(); +const stack = new Stack(app, 'integ-user-pool-domain-cfdist'); + +const userpool = new UserPool(stack, 'UserPool'); + +const domain = userpool.addDomain('Domain', { + cognitoDomain: { + domainPrefix: 'cdk-integ-user-pool-domain', + }, +}); + +new CfnOutput(stack, 'Domain', { + value: domain.domainName, +}); + +new CfnOutput(stack, 'CloudFrontDomainName', { + value: domain.cloudFrontDomainName, +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.expected.json b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.expected.json index 04093296c5700..82f29c93ead24 100644 --- a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.expected.json +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.expected.json @@ -838,6 +838,15 @@ "SmsMessage": "verification sms message from the integ test. Code is {####}." } } + }, + "myuserpoolmyuserpooldomainEE1E11AF": { + "Type": "AWS::Cognito::UserPoolDomain", + "Properties": { + "Domain": "myawesomeapp", + "UserPoolId": { + "Ref": "myuserpool01998219" + } + } } }, "Outputs": { @@ -845,6 +854,23 @@ "Value": { "Ref": "myuserpool01998219" } + }, + "cognitoDomainName": { + "Value": { + "Fn::Join": [ + "", + [ + { + "Ref": "myuserpoolmyuserpooldomainEE1E11AF" + }, + ".auth.", + { + "Ref": "AWS::Region" + }, + ".amazoncognito.com" + ] + ] + } } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.ts b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.ts index 9f35ba7405d82..262fbb8670638 100644 --- a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.ts +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-explicit-props.ts @@ -69,10 +69,20 @@ const userpool = new UserPool(stack, 'myuserpool', { }, }); +const cognitoDomain = userpool.addDomain('myuserpooldomain', { + cognitoDomain: { + domainPrefix: 'myawesomeapp', + }, +}); + new CfnOutput(stack, 'userpoolId', { value: userpool.userPoolId, }); +new CfnOutput(stack, 'cognitoDomainName', { + value: `${cognitoDomain.domainName}.auth.${stack.region}.amazoncognito.com`, +}); + function dummyTrigger(name: string): IFunction { return new Function(stack, name, { functionName: name, diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool-domain.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool-domain.test.ts new file mode 100644 index 0000000000000..8aa2a7972732b --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/test/user-pool-domain.test.ts @@ -0,0 +1,128 @@ +import '@aws-cdk/assert/jest'; +import { Certificate } from '@aws-cdk/aws-certificatemanager'; +import { Stack } from '@aws-cdk/core'; +import { UserPool, UserPoolDomain } from '../lib'; + +describe('User Pool Client', () => { + test('custom domain name', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + // WHEN + const certificate = Certificate.fromCertificateArn(stack, 'cert', + 'arn:aws:acm:eu-west-1:0123456789:certificate/7ec3e4ac-808a-4649-b805-66ae02346ad8'); + new UserPoolDomain(stack, 'Domain', { + userPool: pool, + customDomain: { + domainName: 'test-domain.example.com', + certificate, + }, + }); + + // THEN + expect(stack).toHaveResource('AWS::Cognito::UserPoolDomain', { + UserPoolId: stack.resolve(pool.userPoolId), + Domain: 'test-domain.example.com', + CustomDomainConfig: { + CertificateArn: 'arn:aws:acm:eu-west-1:0123456789:certificate/7ec3e4ac-808a-4649-b805-66ae02346ad8', + }, + }); + }); + + test('cognito domain prefix', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + // WHEN + new UserPoolDomain(stack, 'Domain', { + userPool: pool, + cognitoDomain: { + domainPrefix: 'cognito-domain-prefix', + }, + }); + + // THEN + expect(stack).toHaveResource('AWS::Cognito::UserPoolDomain', { + UserPoolId: stack.resolve(pool.userPoolId), + Domain: 'cognito-domain-prefix', + }); + }); + + test('fails when neither cognitoDomain nor customDomain are specified', () => { + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + const certificate = Certificate.fromCertificateArn(stack, 'cert', + 'arn:aws:acm:eu-west-1:0123456789:certificate/7ec3e4ac-808a-4649-b805-66ae02346ad8'); + + expect(() => new UserPoolDomain(stack, 'Domain', { + userPool: pool, + cognitoDomain: { + domainPrefix: 'cognito-domain-prefix', + }, + customDomain: { + domainName: 'mydomain.com', + certificate, + }, + })).toThrow(/cognitoDomain or customDomain must be specified/); + }); + + test('fails when both cognitoDomain and customDomain are specified', () => { + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + expect(() => new UserPoolDomain(stack, 'Domain', { + userPool: pool, + })).toThrow(/cognitoDomain or customDomain must be specified/); + }); + + test('fails when domainPrefix has characters outside the allowed charset', () => { + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + expect(() => pool.addDomain('Domain1', { + cognitoDomain: { domainPrefix: 'domain.prefix' }, + })).toThrow(/lowercase alphabets, numbers and hyphens/); + expect(() => pool.addDomain('Domain2', { + cognitoDomain: { domainPrefix: 'Domain-Prefix' }, + })).toThrow(/lowercase alphabets, numbers and hyphens/); + expect(() => pool.addDomain('Domain3', { + cognitoDomain: { domainPrefix: 'dómäin-prefix' }, + })).toThrow(/lowercase alphabets, numbers and hyphens/); + }); + + test('custom resource is added when cloudFrontDistribution method is called', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + const domain = pool.addDomain('Domain', { + cognitoDomain: { + domainPrefix: 'cognito-domain-prefix', + }, + }); + + // WHEN + const cfDomainName = domain.cloudFrontDomainName; + + // THEN + expect(stack.resolve(cfDomainName)).toEqual({ + 'Fn::GetAtt': [ + 'PoolDomainCloudFrontDomainName340BF87E', + 'DomainDescription.CloudFrontDistribution', + ], + }); + + expect(stack).toHaveResource('Custom::UserPoolCloudFrontDomainName'); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Action: 'cognito-idp:DescribeUserPoolDomain', + Effect: 'Allow', + Resource: '*', + }], + Version: '2012-10-17', + }, + }); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53-targets/README.md b/packages/@aws-cdk/aws-route53-targets/README.md index a3679ddfc8bff..e3e8f99a0e5ba 100644 --- a/packages/@aws-cdk/aws-route53-targets/README.md +++ b/packages/@aws-cdk/aws-route53-targets/README.md @@ -48,7 +48,7 @@ This library contains Route53 Alias Record targets for: target: route53.RecordTarget.fromAlias(new alias.InterfaceVpcEndpointTarget(interfaceVpcEndpoint)) }); ``` -* S3 Bucket WebSite: +* S3 Bucket Website: **Important:** The Bucket name must strictly match the full DNS name. See [the Developer Guide](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/getting-started.html) for more info. @@ -69,5 +69,12 @@ See [the Developer Guide](https://docs.aws.amazon.com/Route53/latest/DeveloperGu target: route53.RecordTarget.fromAlias(new alias.BucketWebsiteTarget(bucket)), }); ``` +* User pool domain + ```ts + new route53.ARecord(this, 'AliasRecord', { + zone, + target: route53.RecordTarget.fromAlias(new alias.UserPoolDomainTarget(domain)), + }); + ``` See the documentation of `@aws-cdk/aws-route53` for more information. diff --git a/packages/@aws-cdk/aws-route53-targets/lib/cloudfront-target.ts b/packages/@aws-cdk/aws-route53-targets/lib/cloudfront-target.ts index a09a926ad7230..bb765702f5c87 100644 --- a/packages/@aws-cdk/aws-route53-targets/lib/cloudfront-target.ts +++ b/packages/@aws-cdk/aws-route53-targets/lib/cloudfront-target.ts @@ -1,22 +1,22 @@ import * as cloudfront from '@aws-cdk/aws-cloudfront'; import * as route53 from '@aws-cdk/aws-route53'; -/** - * The hosted zone Id if using an alias record in Route53. - * This value never changes. - */ -const CLOUDFRONT_ZONE_ID = 'Z2FDTNDATAQYW2'; - /** * Use a CloudFront Distribution as an alias record target */ export class CloudFrontTarget implements route53.IAliasRecordTarget { + /** + * The hosted zone Id if using an alias record in Route53. + * This value never changes. + */ + public static readonly CLOUDFRONT_ZONE_ID = 'Z2FDTNDATAQYW2'; + constructor(private readonly distribution: cloudfront.CloudFrontWebDistribution) { } public bind(_record: route53.IRecordSet): route53.AliasRecordTargetConfig { return { - hostedZoneId: CLOUDFRONT_ZONE_ID, + hostedZoneId: CloudFrontTarget.CLOUDFRONT_ZONE_ID, dnsName: this.distribution.domainName, }; } diff --git a/packages/@aws-cdk/aws-route53-targets/lib/index.ts b/packages/@aws-cdk/aws-route53-targets/lib/index.ts index 673f2e881f27d..6df1bd67d6037 100644 --- a/packages/@aws-cdk/aws-route53-targets/lib/index.ts +++ b/packages/@aws-cdk/aws-route53-targets/lib/index.ts @@ -4,3 +4,4 @@ export * from './classic-load-balancer-target'; export * from './cloudfront-target'; export * from './load-balancer-target'; export * from './interface-vpc-endpoint-target'; +export * from './userpool-domain'; diff --git a/packages/@aws-cdk/aws-route53-targets/lib/userpool-domain.ts b/packages/@aws-cdk/aws-route53-targets/lib/userpool-domain.ts new file mode 100644 index 0000000000000..f3b9f0a550ff1 --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/lib/userpool-domain.ts @@ -0,0 +1,18 @@ +import { UserPoolDomain } from '@aws-cdk/aws-cognito'; +import { AliasRecordTargetConfig, IAliasRecordTarget, IRecordSet } from '@aws-cdk/aws-route53'; +import { CloudFrontTarget } from './cloudfront-target'; + +/** + * Use a user pool domain as an alias record target + */ +export class UserPoolDomainTarget implements IAliasRecordTarget { + constructor(private readonly domain: UserPoolDomain) { + } + + public bind(_record: IRecordSet): AliasRecordTargetConfig { + return { + dnsName: this.domain.cloudFrontDomainName, + hostedZoneId: CloudFrontTarget.CLOUDFRONT_ZONE_ID, + }; + } +} diff --git a/packages/@aws-cdk/aws-route53-targets/package.json b/packages/@aws-cdk/aws-route53-targets/package.json index 43a960edb20fd..e80557c257da3 100644 --- a/packages/@aws-cdk/aws-route53-targets/package.json +++ b/packages/@aws-cdk/aws-route53-targets/package.json @@ -88,6 +88,7 @@ "dependencies": { "@aws-cdk/aws-apigateway": "0.0.0", "@aws-cdk/aws-cloudfront": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-elasticloadbalancing": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", @@ -102,6 +103,7 @@ "peerDependencies": { "@aws-cdk/aws-apigateway": "0.0.0", "@aws-cdk/aws-cloudfront": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-elasticloadbalancing": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", diff --git a/packages/@aws-cdk/aws-route53-targets/test/userpool-domain.test.ts b/packages/@aws-cdk/aws-route53-targets/test/userpool-domain.test.ts new file mode 100644 index 0000000000000..9c8655a0e2e69 --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/test/userpool-domain.test.ts @@ -0,0 +1,33 @@ +import '@aws-cdk/assert/jest'; +import { UserPool, UserPoolDomain } from '@aws-cdk/aws-cognito'; +import { ARecord, PublicHostedZone, RecordTarget } from '@aws-cdk/aws-route53'; +import { Stack } from '@aws-cdk/core'; +import { UserPoolDomainTarget } from '../lib'; + +test('use user pool domain as record target', () => { + // GIVEN + const stack = new Stack(); + const zone = new PublicHostedZone(stack, 'HostedZone', { zoneName: 'test.public' }); + const userPool = new UserPool(stack, 'UserPool'); + const domain = new UserPoolDomain(stack, 'UserPoolDomain', { + userPool, + cognitoDomain: { domainPrefix: 'domain-prefix' }, + }); + + // WHEN + new ARecord(zone, 'Alias', { + zone, + target: RecordTarget.fromAlias(new UserPoolDomainTarget(domain)), + }); + + // THEN + expect(stack).toHaveResource('AWS::Route53::RecordSet', { + AliasTarget: { + DNSName: { 'Fn::GetAtt': [ + 'UserPoolDomainCloudFrontDomainName0B254952', + 'DomainDescription.CloudFrontDistribution', + ] }, + HostedZoneId: 'Z2FDTNDATAQYW2', + }, + }); +}); \ No newline at end of file