Skip to content

Commit

Permalink
feat(aws-certificatemanager): add DNSValidatedCertificate
Browse files Browse the repository at this point in the history
Add a new class to `@aws-cdk/aws-certificatemanager` called
`DNSValidatedCertificate`.  This class generates a certificate request
using AWS Certificate Manager and auto-validates the request using the
provided DNS "magic cookie" records.  The user need only supply the
Domain Name of the certificate and a Route 53 Hosted Zone.  A
CloudFormation Custom Resource is used along with the supplied Lambda
function to perform the operation.
  • Loading branch information
otterley committed Feb 19, 2019
1 parent 5dec01a commit 10c4619
Show file tree
Hide file tree
Showing 15 changed files with 2,482 additions and 7 deletions.
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-certificatemanager/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.js
!lib/dns_validated_certificate_handler/*.js
*.js.map
*.d.ts
tsconfig.json
Expand All @@ -13,4 +14,4 @@ dist
coverage
.nycrc
.LAST_PACKAGE
*.snk
*.snk
36 changes: 31 additions & 5 deletions packages/@aws-cdk/aws-certificatemanager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,36 @@
This package provides Constructs for provisioning and referencing certificates which
can be used in CloudFront and ELB.

### Validation
### DNS-validated certificates

If certificates are created as part of a CloudFormation run, the
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.

#### Example

```ts
import { HostedZoneProvider } from '@aws-cdk/aws-route53';
import { DNSValidatedCertificate } from '@aws-cdk/aws-certificatemanager';

const hostedZone = new HostedZoneProvider(this, {
domainName: 'example.com',
privateZone: false
}).findAndImport(this, 'ExampleDotCom');

const certificate = new DNSValidatedCertificate(this, 'TestCertificate', {
domainName: 'test.example.com',
hostedZone: hostedZone
});
```

### Email validation

Otherwise, if certificates are created as part of a CloudFormation run, the
CloudFormation provisioning will not complete until domain ownership for the
certificate is completed. For email validation, this involves receiving an
email on one of a number of predefined domains and following the instructions
Expand All @@ -24,7 +51,7 @@ Because of these blocks, it's probably better to provision your certificates eit
stack from your main service, or provision them manually. In both cases, you'll import the
certificate into your stack afterwards.

### Provisioning
#### Example

Provision a new certificate by creating an instance of `Certificate`. Email validation will be sent
to `example.com`:
Expand Down Expand Up @@ -53,5 +80,4 @@ pass the `Certificate` object between the stacks.

## TODO

- [ ] Custom Resource that can looks up the certificate ARN by domain name by querying ACM.
- [ ] Custom Resource to automate certificate validation through Route53.
- [ ] Custom Resource that can look up the certificate ARN by domain name by querying ACM.
96 changes: 95 additions & 1 deletion packages/@aws-cdk/aws-certificatemanager/lib/certificate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { CustomResource } from '@aws-cdk/aws-cloudformation';
import { PolicyStatement } from '@aws-cdk/aws-iam';
import { AssetCode, Function, Runtime } from '@aws-cdk/aws-lambda';
import { IHostedZone } from '@aws-cdk/aws-route53';
import { Construct, IConstruct, Output } from '@aws-cdk/cdk';
import path = require('path');
import { CfnCertificate } from './certificatemanager.generated';
import { apexDomain } from './util';

Expand Down Expand Up @@ -52,8 +57,16 @@ export interface CertificateProps {
validationDomains?: {[domainName: string]: string};
}

export interface DNSValidatedCertificateProps extends CertificateProps {
/**
* Route 53 Hosted Zone used to perform DNS validation of the request. The zone
* must be authoritative for the domain name specified in the Certificate Request.
*/
hostedZone: IHostedZone;
}

/**
* A certificate managed by Amazon Certificate Manager
* 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
Expand Down Expand Up @@ -135,3 +148,84 @@ class ImportedCertificate extends Construct implements ICertificate {
return this.props;
}
}

/**
* A certificate managed by AWS Certificate Manager. Will be automatically
* validated using DNS validation against the specified Route 53 hosted zone.
*/
export class DNSValidatedCertificate extends Construct implements ICertificate {
public readonly certificateArn: string;
private normalizedZoneName: string;
private hostedZoneId: string;
private domainName: string;

constructor(scope: Construct, id: string, props: DNSValidatedCertificateProps) {
super(scope, id);

this.domainName = props.domainName;
this.normalizedZoneName = props.hostedZone.zoneName;
// Remove trailing `.` from zone name
/* istanbul ignore if */
if (this.normalizedZoneName.endsWith('.')) {
this.normalizedZoneName = this.normalizedZoneName.substring(0, this.normalizedZoneName.length - 1);
}

// Remove any `/hostedzone/` prefix from the Hosted Zone ID
this.hostedZoneId = props.hostedZone.hostedZoneId;
/* istanbul ignore if */
if (this.hostedZoneId.startsWith('/hostedzone/')) {
this.hostedZoneId = this.hostedZoneId.split('/hostedzone/')[1];
}

const requestorFunction = new Function(this, 'CertificateRequestorFunction', {
code: new AssetCode(path.join(__dirname, 'request_handler')),
handler: 'index.certificateRequestHandler',
runtime: Runtime.NodeJS810,
timeout: 15 * 60 // 15 minutes
});
requestorFunction.addToRolePolicy(
new PolicyStatement()
.addActions('acm:RequestCertificate', 'acm:DescribeCertificate', 'acm:DeleteCertificate')
.addResource('*')
);
requestorFunction.addToRolePolicy(
new PolicyStatement()
.addActions('route53:GetChange')
.addResource('*')
);
requestorFunction.addToRolePolicy(
new PolicyStatement()
.addActions('route53:changeResourceRecordSets')
.addResource(`arn:aws:route53:::hostedzone/${this.hostedZoneId}`)
);

const certificate = new CustomResource(this, 'CertificateRequestorResource', {
lambdaProvider: requestorFunction,
properties: {
DomainName: props.domainName,
SubjectAlternativeNames: props.subjectAlternativeNames,
HostedZoneId: this.hostedZoneId
}
});

this.certificateArn = certificate.getAtt('Arn').toString();
}

/**
* Export this certificate from the stack
*/
public export(): CertificateImportProps {
return {
certificateArn: new Output(this, 'Arn', { value: this.certificateArn }).makeImportValue().toString()
};
}

public validate(): string[] {
const errors: string[] = [];
// Ensure the zone name is a parent zone of the certificate domain name
if (!this.domainName.endsWith('.' + this.normalizedZoneName)) {
errors.push(`DNS zone ${this.normalizedZoneName} is not authoritative for certificate domain name ${this.domainName}`);
}
return errors;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "standard"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/

dist
.LAST_PACKAGE
.LAST_BUILD
*.snk
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
8.10.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

dist
.LAST_PACKAGE
.LAST_BUILD
*.snk
Loading

0 comments on commit 10c4619

Please sign in to comment.