Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(certificatemanager): native CloudFormation DNS validated certificate #8552

Merged
merged 16 commits into from
Jul 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 41 additions & 21 deletions packages/@aws-cdk/aws-certificatemanager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,9 @@ After requesting a certificate, you will need to prove that you own the
domain in question before the certificate will be granted. The CloudFormation
deployment will wait until this verification process has been completed.

Because of this wait time, it's better to provision your certificates
either in a separate stack from your main service, or provision them
manually and import them into your CDK application.

The CDK also provides a custom resource which can be used for automatic
validation if the DNS records for the domain are managed through Route53 (see
below).
Because of this wait time, when using manual validation methods, it's better
to provision your certificates either in a separate stack from your main
service, or provision them manually and import them into your CDK application.

### Email validation

Expand All @@ -39,25 +35,49 @@ in the AWS Certificate Manager User Guide.

### DNS validation

DNS-validated certificates are validated by configuring appropriate DNS
records for your domain.
If Amazon Route 53 is your DNS provider for the requested domain, the DNS record can be
created automatically:

```ts
new Certificate(this, 'Certificate', {
domainName: 'hello.example.com',
validation: CertificateValidation.fromDns(myHostedZone), // Route 53 hosted zone
});
```

See [Validate with DNS](https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-validate-dns.html)
Otherwise DNS records must be added manually and the stack will not complete
creating until the records are added.

```ts
new Certificate(this, 'Certificate', {
domainName: 'hello.example.com',
validation: CertificateValidation.fromDns(), // Records must be added manually
});
```

See also [Validate with DNS](https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-validate-dns.html)
in the AWS Certificate Manager User Guide.

### Automatic DNS-validated certificates using Route53
When working with multiple domains, use the `CertificateValidation.fromDnsMultiZone()`:

[multiple domains DNS validation](test/example.dns.lit.ts)

Use the `DnsValidatedCertificate` construct for cross-region certificate creation:

```ts
new DnsValidatedCertificate(this, 'CrossRegionCertificate', {
Comment on lines +65 to +68
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do:

export class CrossRegionDnsValidatedCertificate extends DnsValidatedCertificate {
  constructor(scope: cdk.Construct, id: string, props: DnsValidatedCertificateProps) {
    super(scope, id, props);
  }
}

to get a better naming?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's worth it, especially considering cross-region support is on the roadmap for CloudFormation. Let's just leave it as-is and deprecate it once we can.

domainName: 'hello.example.com',
hostedZone: myHostedZone,
region: 'us-east-1',
});
```

The `DnsValidatedCertificateRequest` class provides a Custom Resource by which
you can request a TLS certificate from AWS Certificate Manager that is
automatically validated using a cryptographically secure DNS record. For this to
work, there must be a Route 53 public zone that is responsible for serving
records under the Domain Name of the requested certificate. For example, if you
request a certificate for `www.example.com`, there must be a Route 53 public
zone `example.com` that provides authoritative records for the domain.
This is useful when deploying a stack in a region other than `us-east-1` with a
certificate for a CloudFront distribution.

Example:
If cross-region is not needed, the recommended solution is to use the
`Certificate` construct which uses a native CloudFormation implementation.

[request a validated certificate example](test/example.dns-validated-request.lit.ts)

### Importing

Expand All @@ -71,4 +91,4 @@ const certificate = Certificate.fromCertificateArn(this, 'Certificate', arn);
### Sharing between Stacks

To share the certificate between stacks in the same CDK application, simply
pass the `Certificate` object between the stacks.
pass the `Certificate` object between the stacks.
191 changes: 157 additions & 34 deletions packages/@aws-cdk/aws-certificatemanager/lib/certificate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as route53 from '@aws-cdk/aws-route53';
import { Construct, IResource, Resource, Token } from '@aws-cdk/core';
import { CfnCertificate } from './certificatemanager.generated';
import { apexDomain } from './util';
Expand Down Expand Up @@ -40,33 +41,132 @@ export interface CertificateProps {
* Has to be a superdomain of the requested domain.
*
* @default - Apex domain is used for every domain that's not overridden.
* @deprecated use `validation` instead.
*/
readonly validationDomains?: {[domainName: string]: string};

/**
* Validation method used to assert domain ownership
*
* @default ValidationMethod.EMAIL
* @deprecated use `validation` instead.
*/
readonly validationMethod?: ValidationMethod;

/**
* How to validate this certifcate
*
* @default CertificateValidation.fromEmail()
*/
readonly validation?: CertificateValidation;
}

/**
* Properties for certificate validation
*/
export interface CertificationValidationProps {
/**
* Validation method
*
* @default ValidationMethod.EMAIL
*/
readonly method?: ValidationMethod;

/**
* Hosted zone to use for DNS validation
*
* @default - use email validation
*/
readonly hostedZone?: route53.IHostedZone;

/**
* A map of hosted zones to use for DNS validation
*
* @default - use `hostedZone`
*/
readonly hostedZones?: { [domainName: string]: route53.IHostedZone };

/**
* Validation domains to use for email validation
*
* @default - Apex domain
*/
readonly validationDomains?: { [domainName: string]: string };
}

/**
* How to validate a certificate
*/
export class CertificateValidation {
/**
* Validate the certifcate with DNS
*
* IMPORTANT: If `hostedZone` is not specified, DNS records must be added
* manually and the stack will not complete creating until the records are
* added.
*
* @param hostedZone the hosted zone where DNS records must be created
*/
public static fromDns(hostedZone?: route53.IHostedZone) {
return new CertificateValidation({
method: ValidationMethod.DNS,
hostedZone,
});
}

/**
* Validate the certifcate with automatically created DNS records in multiple
* Amazon Route 53 hosted zones.
*
* @param hostedZones a map of hosted zones where DNS records must be created
* for the domains in the certificate
*/
public static fromDnsMultiZone(hostedZones: { [domainName: string]: route53.IHostedZone }) {
return new CertificateValidation({
method: ValidationMethod.DNS,
hostedZones,
});
}

/**
* Validate the certifcate with Email
*
* IMPORTANT: if you are creating a certificate as part of your stack, the stack
* will not complete creating until you read and follow the instructions in the
* email that you will receive.
*
* ACM will send validation emails to the following addresses:
*
* [email protected]
* [email protected]
* [email protected]
* [email protected]
* [email protected]
*
* For every domain that you register.
*
* @param validationDomains a map of validation domains to use for domains in the certificate
*/
public static fromEmail(validationDomains?: { [domainName: string]: string }) {
return new CertificateValidation({
method: ValidationMethod.EMAIL,
validationDomains,
});
}

/**
* The validation method
*/
public readonly method: ValidationMethod;

/** @param props Certification validation properties */
private constructor(public readonly props: CertificationValidationProps) {
this.method = props.method ?? ValidationMethod.EMAIL;
}
}

/**
* A certificate managed by AWS Certificate Manager
*
* IMPORTANT: if you are creating a certificate as part of your stack, the stack
* will not complete creating until you read and follow the instructions in the
* email that you will receive.
*
* ACM will send validation emails to the following addresses:
*
* [email protected]
* [email protected]
* [email protected]
* [email protected]
* [email protected]
*
* For every domain that you register.
*/
export class Certificate extends Resource implements ICertificate {

Expand All @@ -89,33 +189,27 @@ export class Certificate extends Resource implements ICertificate {
constructor(scope: Construct, id: string, props: CertificateProps) {
super(scope, id);

let validation: CertificateValidation;
if (props.validation) {
validation = props.validation;
} else { // Deprecated props
if (props.validationMethod === ValidationMethod.DNS) {
validation = CertificateValidation.fromDns();
} else {
validation = CertificateValidation.fromEmail(props.validationDomains);
}
}

const allDomainNames = [props.domainName].concat(props.subjectAlternativeNames || []);

const cert = new CfnCertificate(this, 'Resource', {
domainName: props.domainName,
subjectAlternativeNames: props.subjectAlternativeNames,
domainValidationOptions: allDomainNames.map(domainValidationOption),
validationMethod: props.validationMethod,
domainValidationOptions: renderDomainValidation(validation, allDomainNames),
validationMethod: validation.method,
});

this.certificateArn = cert.ref;

/**
* Return the domain validation options for the given domain
*
* Closes over props.
*/
function domainValidationOption(domainName: string): CfnCertificate.DomainValidationOptionProperty {
let validationDomain = props.validationDomains && props.validationDomains[domainName];
if (validationDomain === undefined) {
if (Token.isUnresolved(domainName)) {
throw new Error('When using Tokens for domain names, \'validationDomains\' needs to be supplied');
}
validationDomain = apexDomain(domainName);
}

return { domainName, validationDomain };
}
}
}

Expand All @@ -136,4 +230,33 @@ export enum ValidationMethod {
* @see https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-validate-dns.html
*/
DNS = 'DNS',
}
}

// tslint:disable-next-line:max-line-length
function renderDomainValidation(validation: CertificateValidation, domainNames: string[]): CfnCertificate.DomainValidationOptionProperty[] | undefined {
const domainValidation: CfnCertificate.DomainValidationOptionProperty[] = [];

switch (validation.method) {
case ValidationMethod.DNS:
for (const domainName of domainNames) {
const hostedZone = validation.props.hostedZones?.[domainName] ?? validation.props.hostedZone;
if (hostedZone) {
domainValidation.push({ domainName, hostedZoneId: hostedZone.hostedZoneId });
}
}
break;
case ValidationMethod.EMAIL:
for (const domainName of domainNames) {
const validationDomain = validation.props.validationDomains?.[domainName];
if (!validationDomain && Token.isUnresolved(domainName)) {
throw new Error('When using Tokens for domain names, \'validationDomains\' needs to be supplied');
}
domainValidation.push({ domainName, validationDomain: validationDomain ?? apexDomain(domainName) });
}
break;
default:
throw new Error(`Unknown validation method ${validation.method}`);
}

return domainValidation.length !== 0 ? domainValidation : undefined;
}
36 changes: 36 additions & 0 deletions packages/@aws-cdk/aws-certificatemanager/test/example.dns.lit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as route53 from '@aws-cdk/aws-route53';
import { App, CfnOutput, Construct, Stack } from '@aws-cdk/core';
import * as acm from '../lib';

class AcmStack extends Stack {
constructor(scope: Construct, id: string) {
super(scope, id);
/// !show
const exampleCom = new route53.HostedZone(this, 'ExampleCom', {
zoneName: 'example.com',
});

const exampleNet = new route53.HostedZone(this, 'ExampelNet', {
zoneName: 'example.net',
});

const cert = new acm.Certificate(this, 'Certificate', {
domainName: 'test.example.com',
subjectAlternativeNames: ['cool.example.com', 'test.example.net'],
validation: acm.CertificateValidation.fromDnsMultiZone({
'text.example.com': exampleCom,
'cool.example.com': exampleCom,
'test.example.net': exampleNet,
}),
});
/// !hide

new CfnOutput(this, 'Output', {
value: cert.certificateArn,
});
}
}

const app = new App();
new AcmStack(app, 'AcmStack');
app.synth();
Loading