diff --git a/.changeset/stupid-dodos-agree.md b/.changeset/stupid-dodos-agree.md new file mode 100644 index 00000000000..8d11c56470e --- /dev/null +++ b/.changeset/stupid-dodos-agree.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/auth-construct': patch +--- + +feat(auth): support custom domain for external identity provider diff --git a/packages/auth-construct/src/construct.test.ts b/packages/auth-construct/src/construct.test.ts index b514b6d242d..26be685a5b0 100644 --- a/packages/auth-construct/src/construct.test.ts +++ b/packages/auth-construct/src/construct.test.ts @@ -1389,6 +1389,98 @@ void describe('Auth construct', () => { ], }); }); + + describe('creates custom domain when custom domain options are present', () => { + const testHostedZoneName = 'test-zone-name'; + const testHostedZoneId = 'test-zone-id'; + const testCustomDomainName = 'test-custom-domain-name'; + + let template: Template; + let userPoolCustomDomainRefValue = ''; + let userPoolCloudFrontDomainNameRefValue = ''; + + beforeEach(() => { + new AmplifyAuth(stack, 'test', { + name: 'test_name', + loginWith: { + email: true, + externalProviders: { + google: { + clientId: googleClientId, + clientSecret: SecretValue.unsafePlainText(googleClientSecret), + }, + scopes: ['EMAIL', 'PROFILE'], + callbackUrls: ['http://callback.com'], + logoutUrls: ['http://logout.com'], + customDomainOptions: { + hostedZone: { + zoneName: testHostedZoneName, + hostedZoneId: testHostedZoneId, + }, + domainName: testCustomDomainName, + }, + domainPrefix: 'test-domain-prefix', + }, + }, + }); + template = Template.fromStack(stack); + }); + + void it('creates certificate for the custom domain', () => { + template.hasResourceProperties('AWS::CertificateManager::Certificate', { + DomainName: testCustomDomainName, + DomainValidationOptions: [ + { + DomainName: testCustomDomainName, + HostedZoneId: testHostedZoneId, + }, + ], + }); + }); + + void it('creates custom domain for the user pool', () => { + const userPoolCustomDomains = Object.entries( + template.findResources('AWS::Cognito::UserPoolDomain', { + Properties: { + Domain: testCustomDomainName, + }, + }), + ); + assert.equal(userPoolCustomDomains.length, 1); + userPoolCustomDomainRefValue = userPoolCustomDomains[0][0]; + }); + + void it('creates cloud front domain name for the user pool', () => { + const cloudFrontDomainNames = Object.entries( + template.findResources('Custom::UserPoolCloudFrontDomainName'), + ); + assert.equal(cloudFrontDomainNames.length, 1); + userPoolCloudFrontDomainNameRefValue = cloudFrontDomainNames[0][0]; + }); + + void it('creates dns record for the custom domain', () => { + template.hasResourceProperties('AWS::Route53::RecordSet', { + AliasTarget: { + DNSName: { + 'Fn::GetAtt': [ + userPoolCloudFrontDomainNameRefValue, + 'DomainDescription.CloudFrontDistribution', + ], + }, + }, + HostedZoneId: testHostedZoneId, + Type: 'A', + Name: `${testCustomDomainName}.${testHostedZoneName}.`, + }); + }); + + void it('uses custom domain name for outputs', () => { + const outputs = template.findOutputs('*'); + assert.deepEqual(outputs['oauthCognitoDomain']['Value'], { + Ref: userPoolCustomDomainRefValue, + }); + }); + }); }); void describe('storeOutput strategy', () => { diff --git a/packages/auth-construct/src/construct.ts b/packages/auth-construct/src/construct.ts index 61672e83f44..8969bcbeae3 100644 --- a/packages/auth-construct/src/construct.ts +++ b/packages/auth-construct/src/construct.ts @@ -44,6 +44,7 @@ import { AttributeMapping, AuthProps, CustomAttribute, + CustomDomainOptions, CustomSmsSender, EmailLoginSettings, ExternalProviderOptions, @@ -57,6 +58,12 @@ import { import * as path from 'path'; import { IKey, Key } from 'aws-cdk-lib/aws-kms'; import { CDKContextKey } from '@aws-amplify/platform-core'; +import { ARecord, HostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; +import { + Certificate, + CertificateValidation, +} from 'aws-cdk-lib/aws-certificatemanager'; +import { UserPoolDomainTarget } from 'aws-cdk-lib/aws-route53-targets'; type DefaultRoles = { auth: Role; unAuth: Role }; type IdentityProviderSetupResult = { @@ -873,6 +880,38 @@ export class AmplifyAuth return undefined; }; + private setupCustomDomain = ( + userPool: UserPool, + customDomainOptions: CustomDomainOptions, + ) => { + const hostedZone = HostedZone.fromHostedZoneAttributes( + this, + `${this.name}HostedZone`, + customDomainOptions.hostedZone, + ); + + const certificate = new Certificate(this, `${this.name}Certificate`, { + domainName: customDomainOptions.domainName, + validation: CertificateValidation.fromDns(hostedZone), + }); + + const customDomain = userPool.addDomain( + `${this.name}UserPoolCustomDomain`, + { + customDomain: { + domainName: customDomainOptions.domainName, + certificate, + }, + }, + ); + + new ARecord(this, `${this.name}ARecord`, { + zone: hostedZone, + recordName: customDomainOptions.domainName, + target: RecordTarget.fromAlias(new UserPoolDomainTarget(customDomain)), + }); + }; + /** * Setup External Providers (OAuth/OIDC/SAML) and related settings * such as OAuth settings and User Pool Domains @@ -1074,6 +1113,11 @@ export class AmplifyAuth ); } + // Generate a custom domain if custom domain options are specified + if (external.customDomainOptions) { + this.setupCustomDomain(this.userPool, external.customDomainOptions); + } + // oauth settings for the UserPool client result.oAuthSettings = { callbackUrls: external.callbackUrls, @@ -1142,6 +1186,35 @@ export class AmplifyAuth return result; }; + private getDomainName = (): string => { + const userPoolDomain = this.resources.userPool.node.tryFindChild( + `${this.name}UserPoolDomain`, + ); + if (!userPoolDomain) { + return ''; + } + if (!(userPoolDomain instanceof UserPoolDomain)) { + throw Error('Could not find UserPoolDomain resource in the stack.'); + } + return `${userPoolDomain.domainName}.auth.${ + Stack.of(this).region + }.amazoncognito.com`; + }; + + private getCustomDomainName = (): string => { + const userPoolCustomDomain = this.resources.userPool.node.tryFindChild( + `${this.name}UserPoolCustomDomain`, + ); + + if (!userPoolCustomDomain) { + return ''; + } + if (!(userPoolCustomDomain instanceof UserPoolDomain)) { + throw Error('Could not find UserPoolCustomDomain resource in the stack.'); + } + return userPoolCustomDomain.domainName; + }; + /** * Get sign-in policy configuration for passwordless authentication. */ @@ -1413,18 +1486,13 @@ export class AmplifyAuth output.oauthCognitoDomain = Lazy.string({ produce: () => { - const userPoolDomain = this.resources.userPool.node.tryFindChild( - `${this.name}UserPoolDomain`, - ); - if (!userPoolDomain) { - return ''; - } - if (!(userPoolDomain instanceof UserPoolDomain)) { - throw Error('Could not find UserPoolDomain resource in the stack.'); + const customDomainName = this.getCustomDomainName(); + + if (customDomainName === '') { + return this.getDomainName(); } - return `${userPoolDomain.domainName}.auth.${ - Stack.of(this).region - }.amazoncognito.com`; + + return customDomainName; }, }); diff --git a/packages/auth-construct/src/types.ts b/packages/auth-construct/src/types.ts index b5ea5daaeb2..ba137b59f2a 100644 --- a/packages/auth-construct/src/types.ts +++ b/packages/auth-construct/src/types.ts @@ -10,6 +10,7 @@ import { UserPoolSESOptions, } from 'aws-cdk-lib/aws-cognito'; import { IFunction } from 'aws-cdk-lib/aws-lambda'; +import { HostedZoneAttributes } from 'aws-cdk-lib/aws-route53'; export type VerificationEmailWithLink = { /** * The type of verification. Must be one of "CODE" or "LINK". @@ -309,6 +310,11 @@ export type SamlProviderProps = Omit< }; } & IdentityProviderProps; +export type CustomDomainOptions = { + hostedZone: HostedZoneAttributes; + domainName: string; +}; + /** * External provider options. */ @@ -374,6 +380,8 @@ export type ExternalProviderOptions = { * List of allowed logout URLs for the identity providers. */ logoutUrls: string[]; + + customDomainOptions?: CustomDomainOptions; }; /**