├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky └── commit-msg ├── .npmignore ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── lambda-packages └── dns-validated-domain-identity-handler │ ├── jest.config.js │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── handlers │ │ ├── base.ts │ │ ├── create.ts │ │ ├── delete.ts │ │ ├── index.ts │ │ └── update.ts │ ├── index.ts │ ├── record.ts │ ├── util.ts │ └── verifier.ts │ ├── test │ ├── handlers │ │ ├── base.test.ts │ │ ├── create.test.ts │ │ ├── delete.test.ts │ │ └── update.test.ts │ ├── helper.ts │ ├── index.test.ts │ ├── record.test.ts │ ├── util.test.ts │ └── verifier.test.ts │ ├── tsconfig.json │ └── tslint.json ├── package-lock.json ├── package.json ├── renovate.json ├── src └── dns-validated-domain-identity.ts ├── test └── dns-validated-domain-identitiy.test.ts ├── tsconfig.json └── tslint.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: workflow 2 | on: [push, pull_request] 3 | jobs: 4 | job: 5 | runs-on: ubuntu-latest 6 | timeout-minutes: 10 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | fetch-depth: 0 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: 'lts/*' 14 | - name: Setup (Module) 15 | run: npm ci 16 | - name: Setup (Lambda Package) 17 | working-directory: lambda-packages/dns-validated-domain-identity-handler 18 | run: npm ci 19 | - name: Lint (Module) 20 | run: npm run lint 21 | - name: Lint (Lambda Package) 22 | working-directory: lambda-packages/dns-validated-domain-identity-handler 23 | run: npm run lint 24 | - name: Build (Module) 25 | run: npm run build 26 | - name: Build (Lambda Package) 27 | working-directory: lambda-packages/dns-validated-domain-identity-handler 28 | run: npm run build 29 | - name: Test (Module) 30 | run: npm test 31 | - name: Test (Lambda Package) 32 | working-directory: lambda-packages/dns-validated-domain-identity-handler 33 | run: npm test 34 | - name: Publish 35 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 36 | run: npx semantic-release 37 | env: 38 | HUSKY: 0 # disable commitlint checks 39 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # package directories 61 | node_modules 62 | jspm_packages 63 | 64 | # Test Artifacts 65 | output 66 | 67 | # Build Caches 68 | .rpt2_cache 69 | BrowserStackLocal* 70 | 71 | # Built files 72 | *.tgz 73 | /dist 74 | /browser 75 | /types 76 | /output 77 | 78 | dist 79 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .github 3 | *.tgz 4 | *.log 5 | src 6 | docs 7 | test 8 | integration-test 9 | assets 10 | examples 11 | 12 | tsconfig* 13 | tslint* 14 | 15 | node_modules 16 | 17 | jest.config.js 18 | renovate.json 19 | .releaserc.json 20 | lambda-packages/**/*.d.ts 21 | lambda-packages/**/*.map 22 | lambda-packages/**/package-lock.json 23 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@prescott/semantic-release-config" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.2.0](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v2.1.0...v2.2.0) (2024-01-13) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **release:** bypass commitlint hooks to prevent publish failure ([31f3f13](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/31f3f1334bb8321ea8a55822aa9874a9ffd741d1)) 7 | 8 | 9 | ### Features 10 | 11 | * update to Node.js v20 ([#82](https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues/82)) ([dbb14bb](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/dbb14bbb202f325717879567822915d8a88d0159)) 12 | 13 | # [2.0.0](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.1.0...v2.0.0) (2022-02-22) 14 | 15 | 16 | ### Features 17 | 18 | * migrate to CDK v2 ([c01e581](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/c01e581d2fbf0ddb58f4e90dbb67ef644d0097bc)), closes [#37](https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues/37) 19 | 20 | 21 | ### BREAKING CHANGES 22 | 23 | * Requires CDK v2 24 | 25 | # [1.1.0](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.0.6...v1.1.0) (2022-02-22) 26 | 27 | 28 | ### Features 29 | 30 | * add `identityArn` property ([6c1b938](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/6c1b938a61feba472c22b3336cc9de5e7f1f2894)), closes [#8](https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues/8) 31 | * update requestor function to use Node.js 14 ([71decd1](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/71decd18c9529f3c046038dee295d7da7e4f8288)) 32 | 33 | ## [1.0.6](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.0.5...v1.0.6) (2021-09-30) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * refactor [#30](https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues/30) with FQDN usage for Route53 record name ([ace6085](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/ace6085456b9740bf5e2faa4d6e1d2d6218a0c24)) 39 | * respect existing TXT records ([#31](https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues/31)) ([8635dda](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/8635ddaab94c2e83cbfae344b9403050b6356688)) 40 | 41 | ## [1.0.5](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.0.4...v1.0.5) (2021-05-08) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * **delete-record:** removing the ommitting of required parameters from DELETE record ([#16](https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues/16)) ([af62caa](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/af62caabe18b36f7f5f2bca1b1c1e7ac96c080b3)) 47 | 48 | ## [1.0.4](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.0.3...v1.0.4) (2020-07-06) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * **iam:** fix missing IAM action for setting DKIM enabled ([ec9325f](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/ec9325ffc27d874128bd4e6a4b7aea6d224daad5)) 54 | 55 | ## [1.0.3](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.0.2...v1.0.3) (2020-06-28) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * **iam:** fix invalid IAM action name for querying SES Domain Validation ([d693380](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/d693380dab2f83c9906e2e9922ab9539f0434c7e)) 61 | 62 | ## [1.0.2](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.0.1...v1.0.2) (2020-06-12) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * **deps:** fix invalid CDK version in peer dependency list ([85e7d96](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/85e7d969ca06eac696349a2fa6b7d88ed53ffeca)) 68 | 69 | ## [1.0.1](https://github.com/mooyoul/aws-cdk-ses-domain-identity/compare/v1.0.0...v1.0.1) (2020-06-12) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * **iam:** add addtional required SES IAM actions ([ca41f94](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/ca41f94e62e2a271e367d748be8b0c5a8efce882)) 75 | * **typing:** fix broken types location ([9162f52](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/9162f52793ee45e78dc09f9804f12eb315859322)) 76 | 77 | # 1.0.0 (2020-06-09) 78 | 79 | 80 | ### Features 81 | 82 | * initial commit ([c6ca45e](https://github.com/mooyoul/aws-cdk-ses-domain-identity/commit/c6ca45e9d153fa6b7e68c2f71f045e4926e0f4ee)) 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | Copyright © 2020 MooYeol Prescott Lee, http://debug.so 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the “Software”), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SES Domain Identity Construct for AWS CDK 2 | 3 | [![Build Status](https://github.com/mooyoul/aws-cdk-ses-domain-identity/actions/workflows/main.yml/badge.svg)](https://github.com/mooyoul/aws-cdk-ses-domain-identity/actions) 4 | [![Semantic Release enabled](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 5 | [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com/) 6 | [![MIT license](http://img.shields.io/badge/license-MIT-blue.svg)](http://mooyoul.mit-license.org/) 7 | 8 | This package provides Constructs for provisioning & validating SES Domain Identity which can be used in SES. 9 | 10 | Inspired from [Automatic DNS-validated certificates using Route 53 of `aws-cdk-lib/aws-certificatemanager` package.](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-certificatemanager-readme.html) 11 | 12 | This package automatically validates SES Domain Identity like `aws-cdk-lib/aws-certificatemanager` does. 13 | 14 | ----- 15 | 16 | ## About CDK Compatibility 17 | 18 | Now `aws-cdk-ses-domain-identity` has been migrated to CDK v2. 19 | The major version of `aws-cdk-ses-domain-identity` matches to compatible CDK version. 20 | 21 | - For CDK v1 users: Use 1.x.x version 22 | - `npm i aws-cdk-ses-domain-identity@1 --save` 23 | - For CDK v2 users: Use 2.x.x version 24 | - `npm i aws-cdk-ses-domain-identity@latest --save` 25 | - or `npm i aws-cdk-ses-domain-identity@2 --save` 26 | 27 | ## Example 28 | 29 | ```typescript 30 | import * as route53 from "aws-cdk-lib/aws-route53"; 31 | import { DnsValidatedDomainIdentity } from "aws-cdk-ses-domain-identity"; 32 | 33 | // ... (truncated) 34 | const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', { 35 | domainName: 'example.com', 36 | privateZone: false, 37 | }); 38 | 39 | const identity = new DnsValidatedDomainIdentity(this, 'DomainIdentity', { 40 | domainName: 'example.com', 41 | dkim: true, 42 | region: 'us-east-1', 43 | hostedZone, 44 | }); 45 | // ... (truncated) 46 | ``` 47 | 48 | ## Constructs 49 | 50 | ### DnsValidatedDomainIdentity 51 | 52 | #### Initializer 53 | 54 | ```typescript 55 | new DnsValidatedDomainIdentity(scope: Construct, id: string, props?: DnsValidatedDomainIdentityProps) 56 | ``` 57 | 58 | #### Construct Props 59 | 60 | ```typescript 61 | interface DnsValidatedDomainIdentityProps { 62 | /** 63 | * Fully-qualified domain name to request a domain identity for. 64 | */ 65 | readonly domainName: string; 66 | 67 | /** 68 | * Whether to configure DKIM on domain identity. 69 | * @default true 70 | */ 71 | readonly dkim?: boolean; 72 | 73 | /** 74 | * Route 53 Hosted Zone used to perform DNS validation of the request. The zone 75 | * must be authoritative for the domain name specified in the Domain Identity Request. 76 | */ 77 | readonly hostedZone: route53.IHostedZone; 78 | /** 79 | * AWS region that will validate the domain identity. This is needed especially 80 | * for domain identity used for AWS SES services, which require the region 81 | * to be one of SES supported regions. 82 | * 83 | * @default the region the stack is deployed in. 84 | */ 85 | readonly region?: string; 86 | 87 | /** 88 | * Role to use for the custom resource that creates the validated domain identity 89 | * 90 | * @default - A new role will be created 91 | */ 92 | readonly customResourceRole?: iam.IRole; 93 | } 94 | ``` 95 | 96 | #### Properties 97 | 98 | | Name | Type | Description | 99 | |-------------|--------|---------------------------------| 100 | | identityArn | string | The ARN of the domain identity. | 101 | 102 | 103 | ## License 104 | 105 | [MIT](LICENSE) 106 | 107 | See full license on [mooyoul.mit-license.org](http://mooyoul.mit-license.org/) 108 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/test"], 3 | testMatch: ["**/*.test.ts"], 4 | transform: { 5 | "^.+\\.tsx?$": "ts-jest" 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/test"], 3 | testMatch: ["**/*.test.ts"], 4 | transform: { 5 | "^.+\\.tsx?$": "ts-jest" 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dns-validated-domain-identity-handler", 3 | "version": "1.0.0", 4 | "description": "Lambda handler for provisioning AWS SES Domain Identity", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "devDependencies": { 8 | "@prescott/tslint-preset": "1.0.1", 9 | "@types/aws-lambda": "8.10.149", 10 | "@types/jest": "29.5.14", 11 | "@types/nock": "11.1.0", 12 | "@types/node": "^20.11.0", 13 | "@types/sinon": "17.0.4", 14 | "aws-sdk": "2.1692.0", 15 | "jest": "29.7.0", 16 | "nock": "14.0.5", 17 | "sinon": "20.0.0", 18 | "ts-jest": "29.3.4", 19 | "ts-node": "10.9.2", 20 | "tslint": "6.1.3", 21 | "typescript": "^5.3.3" 22 | }, 23 | "scripts": { 24 | "clean": "rm -rf dist", 25 | "prebuild": "npm run clean", 26 | "build": "tsc -p tsconfig.json", 27 | "prepublishOnly": "npm run build", 28 | "test": "jest", 29 | "lint": "tslint -c tslint.json '{src,test}/**/*.ts'" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/mooyoul/aws-cdk-ses-domain-identity.git" 34 | }, 35 | "keywords": [ 36 | "aws", 37 | "aws-cdk", 38 | "aws-cdk-construct", 39 | "aws-ses", 40 | "ses" 41 | ], 42 | "author": "MooYeol Prescott Lee ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues" 46 | }, 47 | "homepage": "https://github.com/mooyoul/aws-cdk-ses-domain-identity#readme", 48 | "overrides": { 49 | "@prescott/tslint-preset": { 50 | "typescript": "$typescript" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/handlers/base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CloudFormationCustomResourceEvent, CloudFormationCustomResourceEventCommon, 3 | CloudFormationCustomResourceFailedResponse, 4 | CloudFormationCustomResourceResponse, 5 | CloudFormationCustomResourceSuccessResponse, 6 | } from "aws-lambda"; 7 | import * as https from "https"; 8 | 9 | type ConsumeEventResult = { 10 | physicalResourceId: CloudFormationCustomResourceResponse["PhysicalResourceId"]; 11 | data: CloudFormationCustomResourceResponse["Data"]; 12 | }; 13 | 14 | type EventBase = CloudFormationCustomResourceEventCommon & { PhysicalResourceId?: string }; 15 | 16 | export abstract class CustomResourceHandler { 17 | public constructor( 18 | protected readonly event: T, 19 | ) {} 20 | 21 | public async handleEvent() { 22 | try { 23 | const { physicalResourceId, data } = await this.consumeEvent(); 24 | 25 | console.log(`Notifying success response...`); // tslint:disable-line 26 | 27 | const response: CloudFormationCustomResourceSuccessResponse = { 28 | Status: "SUCCESS", 29 | PhysicalResourceId: physicalResourceId, 30 | StackId: this.event.StackId, 31 | RequestId: this.event.RequestId, 32 | LogicalResourceId: this.event.LogicalResourceId, 33 | Data: data, 34 | }; 35 | await this.notify(response); 36 | 37 | return response; 38 | } catch (e) { 39 | console.error("Failed to provision resource!", e.stack); // tslint:disable-line 40 | 41 | const response: CloudFormationCustomResourceFailedResponse = { 42 | Status: "FAILED", 43 | Reason: e.message, 44 | PhysicalResourceId: this.event.PhysicalResourceId ?? "Unknown", 45 | StackId: this.event.StackId, 46 | RequestId: this.event.RequestId, 47 | LogicalResourceId: this.event.LogicalResourceId, 48 | Data: {}, 49 | }; 50 | await this.notify(response); 51 | 52 | return response; 53 | } 54 | } 55 | 56 | protected abstract consumeEvent(): Promise; 57 | 58 | private notify(response: CloudFormationCustomResourceResponse) { 59 | return new Promise((resolve, reject) => { 60 | const bufBody = Buffer.from(JSON.stringify(response), "utf8"); 61 | 62 | const req = https.request(this.event.ResponseURL, { 63 | method: "PUT", 64 | headers: { 65 | "Content-Type": "application/json", 66 | "Content-Length": bufBody.length, 67 | }, 68 | }); 69 | 70 | function onError(e: Error) { 71 | req.removeListener("response", onResponse); 72 | reject(e); 73 | } 74 | 75 | function onResponse() { 76 | req.removeListener("error", onError) 77 | .destroy(); 78 | 79 | resolve(); 80 | } 81 | 82 | req.once("error", onError) 83 | .once("response", onResponse) 84 | .end(bufBody); 85 | }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/handlers/create.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationCustomResourceCreateEvent } from "aws-lambda"; 2 | import { Verifier } from "../verifier"; 3 | import { CustomResourceHandler } from "./base"; 4 | 5 | export class CreateCustomResourceHandler extends CustomResourceHandler { 6 | public async consumeEvent() { 7 | const verifier = Verifier.from(this.event.ResourceProperties); 8 | 9 | await verifier.verifyIdentity(); 10 | if (this.event.ResourceProperties.DKIM) { 11 | await verifier.enableDKIM(); 12 | } 13 | 14 | return { 15 | physicalResourceId: verifier.domainName, 16 | data: {}, 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/handlers/delete.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationCustomResourceDeleteEvent } from "aws-lambda"; 2 | import { Verifier } from "../verifier"; 3 | import { CustomResourceHandler } from "./base"; 4 | 5 | export class DeleteCustomResourceHandler extends CustomResourceHandler { 6 | public async consumeEvent() { 7 | // If the resource didn't create correctly, the physical resource ID won't be 8 | // the requested domain name. so don't try to delete it in that case. 9 | if (this.event.PhysicalResourceId === this.event.ResourceProperties.DomainName) { 10 | const verifier = Verifier.from(this.event.ResourceProperties); 11 | 12 | if (this.event.ResourceProperties.DKIM) { 13 | await verifier.disableDKIM(); 14 | } 15 | 16 | await verifier.revokeIdentity(); 17 | } 18 | 19 | return { 20 | // Keep Physical Resource ID 21 | physicalResourceId: this.event.PhysicalResourceId, 22 | data: {}, 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./base"; 2 | export * from "./create"; 3 | export * from "./delete"; 4 | export * from "./update"; 5 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/handlers/update.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationCustomResourceUpdateEvent } from "aws-lambda"; 2 | import { Verifier } from "../verifier"; 3 | import { CustomResourceHandler } from "./base"; 4 | 5 | export class UpdateCustomResourceHandler extends CustomResourceHandler { 6 | public async consumeEvent() { 7 | const dnsChanged = this.hasChanged("DomainName") || this.hasChanged("HostedZoneId"); 8 | const dkimChanged = this.hasChanged("DKIM"); 9 | 10 | // If DNS is changed, Verify changed domain first and then revoke old one 11 | if (dnsChanged) { 12 | const newVerifier = Verifier.from(this.event.ResourceProperties); 13 | const oldVerifier = Verifier.from(this.event.OldResourceProperties); 14 | 15 | await newVerifier.verifyIdentity(true); 16 | if (this.event.ResourceProperties.DKIM) { 17 | await newVerifier.enableDKIM(true); 18 | } 19 | 20 | if (this.event.OldResourceProperties.DKIM) { 21 | await oldVerifier.disableDKIM(); 22 | } 23 | 24 | await oldVerifier.revokeIdentity(); 25 | 26 | return { 27 | // Update Physical Resource ID 28 | physicalResourceId: newVerifier.domainName, 29 | data: {}, 30 | }; 31 | } 32 | 33 | if (dkimChanged) { 34 | const verifier = Verifier.from(this.event.ResourceProperties); 35 | 36 | if (this.event.ResourceProperties.DKIM) { 37 | await verifier.enableDKIM(true); 38 | } else { 39 | await verifier.disableDKIM(); 40 | } 41 | } 42 | 43 | return { 44 | // Keep Physical Resource ID 45 | physicalResourceId: this.event.PhysicalResourceId, 46 | data: {}, 47 | }; 48 | } 49 | 50 | private hasChanged(name: string): boolean { 51 | return this.event.OldResourceProperties[name] !== this.event.ResourceProperties[name]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/index.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationCustomResourceEvent } from "aws-lambda"; 2 | 3 | import { 4 | CreateCustomResourceHandler, 5 | CustomResourceHandler, 6 | DeleteCustomResourceHandler, 7 | UpdateCustomResourceHandler, 8 | } from "./handlers"; 9 | 10 | export async function identityRequestHandler(event: CloudFormationCustomResourceEvent) { 11 | const handler: CustomResourceHandler = (() => { 12 | switch (event.RequestType) { 13 | case "Create": 14 | return new CreateCustomResourceHandler(event); 15 | case "Update": 16 | return new UpdateCustomResourceHandler(event); 17 | case "Delete": 18 | return new DeleteCustomResourceHandler(event); 19 | } 20 | })(); 21 | 22 | return await handler.handleEvent(); 23 | } 24 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/record.ts: -------------------------------------------------------------------------------- 1 | import type { Change, ResourceRecord, ResourceRecordSet } from "aws-sdk/clients/route53"; 2 | 3 | const DEFAULT_VERIFICATION_RECORD_TTL = 1800; // 30 minutes 4 | 5 | export class Record { 6 | public static forIdentity(domainName: string, records: string[]) { 7 | return new this(`_amazonses.${domainName}.`, "TXT", new Set(records)); 8 | } 9 | 10 | public static forDKIM(domainName: string, token: string) { 11 | return new this( 12 | `${token}._domainkey.${domainName}.`, 13 | "CNAME", 14 | new Set([`${token}.dkim.amazonses.com`]), 15 | ); 16 | } 17 | 18 | public static fromResourceRecordSet(resource: ResourceRecordSet) { 19 | switch (resource.Type) { 20 | case "CNAME": 21 | return new this( 22 | resource.Name, 23 | resource.Type, 24 | new Set(resource.ResourceRecords!.map((record) => record.Value)), 25 | ); 26 | case "TXT": 27 | return new this( 28 | resource.Name, 29 | resource.Type, 30 | new Set(resource.ResourceRecords!.map((record) => JSON.parse(record.Value))), 31 | ); 32 | default: 33 | throw new Error("Unsupported ResourceRecord Type"); 34 | } 35 | } 36 | 37 | public constructor( 38 | public readonly name: string, 39 | public readonly type: "CNAME" | "TXT", 40 | public readonly values: Set, 41 | public readonly ttl: number = DEFAULT_VERIFICATION_RECORD_TTL, 42 | ) {} 43 | 44 | public get size() { 45 | return this.values.size; 46 | } 47 | 48 | public has(value: string) { 49 | return this.values.has(value); 50 | } 51 | 52 | public add(value: string) { 53 | this.values.add(value); 54 | } 55 | 56 | public remove(value: string) { 57 | this.values.delete(value); 58 | } 59 | 60 | public action(type: "CREATE" | "UPSERT" | "DELETE"): Change { 61 | return { 62 | Action: type, 63 | ResourceRecordSet: { 64 | Name: this.name, 65 | Type: this.type, 66 | ResourceRecords: this.serialize(), 67 | TTL: this.ttl, 68 | }, 69 | }; 70 | } 71 | 72 | private serialize(): ResourceRecord[] { 73 | switch (this.type) { 74 | case "CNAME": 75 | return Array.from(this.values).map((value) => ({ Value: value })); 76 | case "TXT": 77 | return Array.from(this.values).map((value) => ({ Value: JSON.stringify(value) })); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/util.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 2 | 3 | export async function waitFor( 4 | poller: () => Promise, 5 | tester: (state: State) => boolean, 6 | options: { 7 | maxAttempts: number; 8 | delay: number; 9 | failureMessage?: string; 10 | }, 11 | ): Promise { 12 | const { maxAttempts } = options; 13 | const delayInMs = options.delay * 1000; 14 | 15 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 16 | try { 17 | const state = await poller(); 18 | 19 | if (tester(state)) { 20 | return state; 21 | } 22 | } catch (e) { 23 | // Swallow errors and continue to next attempt 24 | } 25 | 26 | await sleep(delayInMs * (attempt ** 2)); 27 | } 28 | 29 | throw new Error(options.failureMessage ?? "Maximum attempts exceeded"); 30 | } 31 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/src/verifier.ts: -------------------------------------------------------------------------------- 1 | import * as Route53 from "aws-sdk/clients/route53"; 2 | import * as SES from "aws-sdk/clients/ses"; 3 | import { Record } from "./record"; 4 | import { waitFor } from "./util"; 5 | 6 | type CFNCustomResourceProps = { 7 | [key: string]: any; 8 | }; 9 | 10 | type Wait = { 11 | delay: number; 12 | maxAttempts: number; 13 | }; 14 | 15 | const DEFAULT_WAIT: Wait = { delay: 30, maxAttempts: 30 }; 16 | 17 | // tslint:disable:no-console 18 | export class Verifier { 19 | public static from(props: CFNCustomResourceProps) { 20 | return new this(props.DomainName, props.HostedZoneId, props.Region); 21 | } 22 | 23 | private readonly route53 = new Route53({ region: this.region }); 24 | private readonly ses = new SES({ region: this.region }); 25 | 26 | public constructor( 27 | public readonly domainName: string, 28 | public readonly hostedZoneId: string, 29 | public readonly region: string, 30 | ) {} 31 | 32 | public async verifyIdentity(upsert: boolean = false) { 33 | console.log("Verifying Domain for %s", this.domainName); 34 | const identityToken = await this.requestIdentityToken(); 35 | 36 | console.log("Getting current identity record"); 37 | const currentIdentityRecord = await this.getCurrentIdentityRecord(); 38 | 39 | if (currentIdentityRecord) { 40 | console.log("Found existing TXT record, adding value to verify domain in zone %s", this.hostedZoneId); 41 | } else { 42 | console.log("Creating a TXT record for verifying domain into zone %s", this.hostedZoneId); 43 | } 44 | 45 | const shouldUpsert = currentIdentityRecord || upsert; 46 | const record = currentIdentityRecord ?? Record.forIdentity(this.domainName, []); 47 | record.add(identityToken); 48 | 49 | const changeId = await this.changeRecords([ 50 | record.action(shouldUpsert ? "UPSERT" : "CREATE"), 51 | ]); 52 | 53 | console.log("Waiting for DNS records to commit..."); 54 | await this.waitForRecordChange(changeId); 55 | 56 | console.log("Waiting for domain verification..."); 57 | await this.waitForIdentityVerified(); 58 | } 59 | 60 | public async enableDKIM(upsert: boolean = false) { 61 | console.log("Enabling DKIM for %s", this.domainName); 62 | const tokens = await this.requestDKIMTokens(); 63 | 64 | console.log("Creating %d DNS records for verifying DKIM into zone %s", tokens.length, this.hostedZoneId); 65 | const changeId = await this.changeRecords(tokens.map((token) => 66 | Record.forDKIM(this.domainName, token).action(upsert ? "UPSERT" : "CREATE"), 67 | )); 68 | 69 | console.log("Waiting for DNS records to commit..."); 70 | await this.waitForRecordChange(changeId); 71 | 72 | console.log("Waiting for DKIM verification..."); 73 | await this.waitForDKIMVerified(); 74 | } 75 | 76 | public async revokeIdentity() { 77 | console.log("Getting current verification state for domain %s", this.domainName); 78 | const identity = await this.describeIdentity(); 79 | 80 | console.log("Revoking verification for domain %s", this.domainName); 81 | await this.ses.deleteIdentity({ 82 | Identity: this.domainName, 83 | }).promise(); 84 | 85 | const identityRecord = await this.getCurrentIdentityRecord(); 86 | if (!identityRecord) { 87 | console.log("Identity Record does not exist (Maybe drifted?). skipping identity record unprovisioning..."); 88 | return; 89 | } 90 | 91 | identityRecord.remove(identity.token); 92 | 93 | // If the record contained only the validation value, delete the record. 94 | // Otherwise just update the record with the value removed. 95 | if (identityRecord.size === 0) { 96 | console.log("Deleting DNS Records used for domain verification..."); 97 | await this.changeRecords([ 98 | identityRecord.action("DELETE"), 99 | ]); 100 | } else { 101 | console.log("Updating DNS Records to remove the value used for domain verification..."); 102 | await this.changeRecords([ 103 | identityRecord.action("UPSERT"), 104 | ]); 105 | } 106 | 107 | } 108 | 109 | public async disableDKIM() { 110 | console.log("Getting current DKIM state for domain %s", this.domainName); 111 | const dkim = await this.describeDKIM(); 112 | 113 | console.log("Disabling DKIM for domain %s", this.domainName); 114 | await this.ses.setIdentityDkimEnabled({ 115 | Identity: this.domainName, 116 | DkimEnabled: false, 117 | }).promise(); 118 | 119 | console.log("Deleting DNS Records used for DKIM verification..."); 120 | await this.changeRecords(dkim.tokens.map((token) => 121 | Record.forDKIM(this.domainName, token).action("DELETE"), 122 | )); 123 | } 124 | 125 | private async changeRecords( 126 | changes: Route53.Change[], 127 | wait: Wait = DEFAULT_WAIT, 128 | ) { 129 | const change = await this.route53.changeResourceRecordSets({ 130 | HostedZoneId: this.hostedZoneId, 131 | ChangeBatch: { 132 | Changes: changes, 133 | }, 134 | }).promise(); 135 | 136 | return change.ChangeInfo.Id; 137 | } 138 | 139 | private async requestIdentityToken() { 140 | const res = await this.ses.verifyDomainIdentity({ 141 | Domain: this.domainName, 142 | }).promise(); 143 | 144 | return res.VerificationToken; 145 | } 146 | 147 | private async requestDKIMTokens() { 148 | const res = await this.ses.verifyDomainDkim({ 149 | Domain: this.domainName, 150 | }).promise(); 151 | 152 | return res.DkimTokens; 153 | } 154 | 155 | private async describeIdentity() { 156 | const res = await this.ses.getIdentityVerificationAttributes({ 157 | Identities: [this.domainName], 158 | }).promise(); 159 | 160 | const attr = res.VerificationAttributes[this.domainName]; 161 | 162 | if (!attr?.VerificationToken) { 163 | throw new Error("There are no identity for given domain"); 164 | } 165 | 166 | return { 167 | status: attr.VerificationStatus, 168 | token: attr.VerificationToken, 169 | }; 170 | } 171 | 172 | private async describeDKIM() { 173 | const res = await this.ses.getIdentityDkimAttributes({ 174 | Identities: [this.domainName], 175 | }).promise(); 176 | 177 | const attr = res.DkimAttributes[this.domainName]; 178 | if (!attr?.DkimEnabled) { 179 | throw new Error("DKIM is not configured for given domain"); 180 | } 181 | 182 | return { 183 | status: attr.DkimVerificationStatus, 184 | tokens: attr.DkimTokens || [], 185 | }; 186 | } 187 | 188 | private async getCurrentIdentityRecord(): Promise { 189 | const identity = Record.forIdentity(this.domainName, []); 190 | 191 | const res = await this.route53.listResourceRecordSets({ 192 | HostedZoneId: this.hostedZoneId, 193 | StartRecordType: identity.type, 194 | StartRecordName: identity.name, 195 | }).promise(); 196 | 197 | const first = res.ResourceRecordSets[0]; 198 | return first?.Name === identity.name && first?.Type === identity.type 199 | ? Record.fromResourceRecordSet(first) 200 | : null; 201 | } 202 | 203 | private async waitForRecordChange( 204 | changeId: string, 205 | wait: Wait = DEFAULT_WAIT, 206 | ) { 207 | await this.route53.waitFor("resourceRecordSetsChanged", { 208 | Id: changeId, 209 | // Wait up to 5 minutes 210 | $waiter: wait, 211 | }).promise(); 212 | } 213 | 214 | private async waitForIdentityVerified( 215 | wait: Wait = DEFAULT_WAIT, 216 | ) { 217 | await this.ses.waitFor("identityExists", { 218 | Identities: [this.domainName], 219 | // Wait up to 5 minutes 220 | $waiter: wait, 221 | }).promise(); 222 | } 223 | 224 | private async waitForDKIMVerified( 225 | wait: Wait = DEFAULT_WAIT, 226 | ) { 227 | await waitFor( 228 | () => this.describeDKIM(), 229 | (state) => state.status === "Success", 230 | { 231 | ...wait, 232 | failureMessage: "Failed to verify DKIM status", 233 | }, 234 | ); 235 | } 236 | } 237 | // tslint:enable:no-console 238 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/handlers/base.test.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationCustomResourceEvent } from "aws-lambda"; 2 | import * as nock from "nock"; 3 | 4 | import { sandbox } from "../helper"; 5 | 6 | import { CustomResourceHandler } from "../../src/handlers/base"; 7 | 8 | class SuccessfulCustomResourceHandler extends CustomResourceHandler { 9 | protected async consumeEvent() { 10 | return { 11 | physicalResourceId: "id", 12 | data: { 13 | Name: "Value", 14 | }, 15 | }; 16 | } 17 | } 18 | 19 | // tslint:disable-next-line:max-classes-per-file 20 | class FailureCustomResourceHandler extends CustomResourceHandler { 21 | protected async consumeEvent(): Promise { 22 | throw new Error("MOCKED ERROR"); 23 | } 24 | } 25 | 26 | 27 | describe(CustomResourceHandler.name, () => { 28 | let event: CloudFormationCustomResourceEvent; 29 | beforeEach(() => { 30 | event = { 31 | StackId: "StackId", 32 | RequestId: "RequestId", 33 | LogicalResourceId: "LogicalResourceId", 34 | RequestType: "Fake", 35 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 36 | } as any; 37 | }); 38 | 39 | describe("#handleEvent", () => { 40 | it("should report success if consumeEvent was resolved", async () => { 41 | const request = nock("https://s3.amazonaws.com") 42 | .put("/bucket/key", (body) => body.Status === "SUCCESS") 43 | .reply(200); 44 | 45 | const handler = new SuccessfulCustomResourceHandler(event); 46 | const res = await handler.handleEvent(); 47 | expect(request.isDone()).toBeTruthy(); 48 | expect(res).toEqual({ 49 | Status: "SUCCESS", 50 | PhysicalResourceId: "id", 51 | StackId: "StackId", 52 | RequestId: "RequestId", 53 | LogicalResourceId: "LogicalResourceId", 54 | Data: { Name: "Value" }, 55 | }); 56 | }); 57 | 58 | it("should report failure if consumeEvent thrown an error", async () => { 59 | const request = nock("https://s3.amazonaws.com") 60 | .put("/bucket/key", (body) => body.Status === "FAILED") 61 | .reply(200); 62 | 63 | const handler = new FailureCustomResourceHandler(event); 64 | const res = await handler.handleEvent(); 65 | expect(request.isDone()).toBeTruthy(); 66 | expect(res).toEqual({ 67 | Status: "FAILED", 68 | Reason: "MOCKED ERROR", 69 | PhysicalResourceId: "Unknown", 70 | StackId: "StackId", 71 | RequestId: "RequestId", 72 | LogicalResourceId: "LogicalResourceId", 73 | Data: {}, 74 | }); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/handlers/create.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import { sandbox } from "../helper"; 3 | 4 | import { Verifier } from "../../src/verifier"; 5 | 6 | import { CreateCustomResourceHandler } from "../../src/handlers/create"; 7 | 8 | describe(CreateCustomResourceHandler.name, () => { 9 | describe("#consumeEvent", () => { 10 | let verifyIdentityFake: sinon.SinonSpy; 11 | let revokeIdentityFake: sinon.SinonSpy; 12 | let enableDKIMFake: sinon.SinonSpy; 13 | let disableDKIMFake: sinon.SinonSpy; 14 | 15 | beforeEach(() => { 16 | verifyIdentityFake = sinon.fake.resolves(undefined); 17 | revokeIdentityFake = sinon.fake.resolves(undefined); 18 | enableDKIMFake = sinon.fake.resolves(undefined); 19 | disableDKIMFake = sinon.fake.resolves(undefined); 20 | 21 | sandbox.mock(Verifier) 22 | .expects("from") 23 | .returns({ 24 | domainName: "example.com", 25 | verifyIdentity: verifyIdentityFake, 26 | revokeIdentity: revokeIdentityFake, 27 | enableDKIM: enableDKIMFake, 28 | disableDKIM: disableDKIMFake, 29 | }); 30 | }); 31 | 32 | it("should verify identity", async () => { 33 | const handler = new CreateCustomResourceHandler({ 34 | StackId: "StackId", 35 | RequestId: "RequestId", 36 | LogicalResourceId: "LogicalResourceId", 37 | RequestType: "Create", 38 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 39 | ResourceProperties: { 40 | DomainName: "example.com", 41 | HostedZoneId: "HOSTED_ZONE_ID", 42 | Region: "us-east-1", 43 | DKIM: false, 44 | ServiceToken: "SERVICE_TOKEN", 45 | }, 46 | } as any); 47 | 48 | const res = await handler.consumeEvent(); 49 | 50 | expect(res).toEqual({ 51 | physicalResourceId: "example.com", 52 | data: {}, 53 | }); 54 | 55 | sinon.assert.calledOnce(verifyIdentityFake); 56 | sinon.assert.notCalled(enableDKIMFake); 57 | sinon.assert.notCalled(revokeIdentityFake); 58 | sinon.assert.notCalled(disableDKIMFake); 59 | }); 60 | 61 | it("should verify identity", async () => { 62 | const handler = new CreateCustomResourceHandler({ 63 | StackId: "StackId", 64 | RequestId: "RequestId", 65 | LogicalResourceId: "LogicalResourceId", 66 | RequestType: "Create", 67 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 68 | ResourceProperties: { 69 | DomainName: "example.com", 70 | HostedZoneId: "HOSTED_ZONE_ID", 71 | Region: "us-east-1", 72 | DKIM: true, 73 | ServiceToken: "SERVICE_TOKEN", 74 | }, 75 | } as any); 76 | 77 | const res = await handler.consumeEvent(); 78 | expect(res).toEqual({ 79 | physicalResourceId: "example.com", 80 | data: {}, 81 | }); 82 | 83 | sinon.assert.calledOnce(verifyIdentityFake); 84 | sinon.assert.calledOnce(enableDKIMFake); 85 | sinon.assert.notCalled(revokeIdentityFake); 86 | sinon.assert.notCalled(disableDKIMFake); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/handlers/delete.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import { sandbox } from "../helper"; 3 | 4 | import { Verifier } from "../../src/verifier"; 5 | 6 | import { DeleteCustomResourceHandler } from "../../src/handlers/delete"; 7 | 8 | describe(DeleteCustomResourceHandler.name, () => { 9 | describe("#consumeEvent", () => { 10 | let verifyIdentityFake: sinon.SinonSpy; 11 | let revokeIdentityFake: sinon.SinonSpy; 12 | let enableDKIMFake: sinon.SinonSpy; 13 | let disableDKIMFake: sinon.SinonSpy; 14 | 15 | beforeEach(() => { 16 | verifyIdentityFake = sinon.fake.resolves(undefined); 17 | revokeIdentityFake = sinon.fake.resolves(undefined); 18 | enableDKIMFake = sinon.fake.resolves(undefined); 19 | disableDKIMFake = sinon.fake.resolves(undefined); 20 | 21 | sandbox.stub(Verifier, "from") 22 | .returns({ 23 | verifyIdentity: verifyIdentityFake, 24 | revokeIdentity: revokeIdentityFake, 25 | enableDKIM: enableDKIMFake, 26 | disableDKIM: disableDKIMFake, 27 | } as any); 28 | }); 29 | 30 | it("should revoke identity", async () => { 31 | const handler = new DeleteCustomResourceHandler({ 32 | StackId: "StackId", 33 | RequestId: "RequestId", 34 | PhysicalResourceId: "example.com", 35 | LogicalResourceId: "LogicalResourceId", 36 | RequestType: "Delete", 37 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 38 | ResourceProperties: { 39 | DomainName: "example.com", 40 | HostedZoneId: "HOSTED_ZONE_ID", 41 | Region: "us-east-1", 42 | DKIM: false, 43 | ServiceToken: "SERVICE_TOKEN", 44 | }, 45 | } as any); 46 | 47 | const res = await handler.consumeEvent(); 48 | expect(res).toEqual({ 49 | physicalResourceId: "example.com", 50 | data: {}, 51 | }); 52 | 53 | sinon.assert.notCalled(verifyIdentityFake); 54 | sinon.assert.notCalled(enableDKIMFake); 55 | sinon.assert.calledOnce(revokeIdentityFake); 56 | sinon.assert.notCalled(disableDKIMFake); 57 | }); 58 | 59 | it("should revoke identity and DKIM", async () => { 60 | const handler = new DeleteCustomResourceHandler({ 61 | StackId: "StackId", 62 | RequestId: "RequestId", 63 | PhysicalResourceId: "example.com", 64 | LogicalResourceId: "LogicalResourceId", 65 | RequestType: "Delete", 66 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 67 | ResourceProperties: { 68 | DomainName: "example.com", 69 | HostedZoneId: "HOSTED_ZONE_ID", 70 | Region: "us-east-1", 71 | DKIM: true, 72 | ServiceToken: "SERVICE_TOKEN", 73 | }, 74 | } as any); 75 | 76 | await handler.consumeEvent(); 77 | 78 | sinon.assert.notCalled(verifyIdentityFake); 79 | sinon.assert.notCalled(enableDKIMFake); 80 | sinon.assert.calledOnce(revokeIdentityFake); 81 | sinon.assert.calledOnce(disableDKIMFake); 82 | }); 83 | 84 | it("should skip if resource creation was failed before", async () => { 85 | const handler = new DeleteCustomResourceHandler({ 86 | StackId: "StackId", 87 | RequestId: "RequestId", 88 | PhysicalResourceId: "Unknown", 89 | LogicalResourceId: "LogicalResourceId", 90 | RequestType: "Delete", 91 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 92 | ResourceProperties: { 93 | DomainName: "example.com", 94 | HostedZoneId: "HOSTED_ZONE_ID", 95 | Region: "us-east-1", 96 | DKIM: true, 97 | ServiceToken: "SERVICE_TOKEN", 98 | }, 99 | } as any); 100 | 101 | 102 | const res = await handler.consumeEvent(); 103 | expect(res).toEqual({ 104 | physicalResourceId: "Unknown", 105 | data: {}, 106 | }); 107 | 108 | sinon.assert.notCalled(verifyIdentityFake); 109 | sinon.assert.notCalled(enableDKIMFake); 110 | sinon.assert.notCalled(revokeIdentityFake); 111 | sinon.assert.notCalled(disableDKIMFake); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/handlers/update.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import { sandbox } from "../helper"; 3 | 4 | import { Verifier } from "../../src/verifier"; 5 | 6 | import { UpdateCustomResourceHandler } from "../../src/handlers/update"; 7 | 8 | describe(UpdateCustomResourceHandler.name, () => { 9 | describe("#consumeEvent", () => { 10 | let verifyIdentityFake: sinon.SinonSpy; 11 | let revokeIdentityFake: sinon.SinonSpy; 12 | let enableDKIMFake: sinon.SinonSpy; 13 | let disableDKIMFake: sinon.SinonSpy; 14 | 15 | beforeEach(() => { 16 | verifyIdentityFake = sinon.fake.resolves(undefined); 17 | revokeIdentityFake = sinon.fake.resolves(undefined); 18 | enableDKIMFake = sinon.fake.resolves(undefined); 19 | disableDKIMFake = sinon.fake.resolves(undefined); 20 | 21 | sandbox.mock(Verifier) 22 | .expects("from") 23 | .atLeast(1) 24 | .atMost(2) 25 | .callsFake((props) => ({ 26 | domainName: props.DomainName, 27 | verifyIdentity: verifyIdentityFake, 28 | revokeIdentity: revokeIdentityFake, 29 | enableDKIM: enableDKIMFake, 30 | disableDKIM: disableDKIMFake, 31 | })); 32 | }); 33 | 34 | it("should re-verify identity on DomainName change", async () => { 35 | const handler = new UpdateCustomResourceHandler({ 36 | StackId: "StackId", 37 | RequestId: "RequestId", 38 | PhysicalResourceId: "example.com", 39 | LogicalResourceId: "LogicalResourceId", 40 | RequestType: "Update", 41 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 42 | ResourceProperties: { 43 | DomainName: "example2.com", 44 | HostedZoneId: "HOSTED_ZONE_ID", 45 | Region: "us-east-1", 46 | DKIM: false, 47 | }, 48 | OldResourceProperties: { 49 | DomainName: "example.com", 50 | HostedZoneId: "HOSTED_ZONE_ID", 51 | Region: "us-east-1", 52 | DKIM: false, 53 | }, 54 | } as any); 55 | 56 | const res = await handler.consumeEvent(); 57 | expect(res).toEqual({ 58 | physicalResourceId: "example2.com", 59 | data: {}, 60 | }); 61 | 62 | sinon.assert.calledOnce(verifyIdentityFake); 63 | sinon.assert.notCalled(enableDKIMFake); 64 | sinon.assert.calledOnce(revokeIdentityFake); 65 | sinon.assert.notCalled(disableDKIMFake); 66 | }); 67 | 68 | it("should re-verify identity on HostedZone change", async () => { 69 | const handler = new UpdateCustomResourceHandler({ 70 | StackId: "StackId", 71 | RequestId: "RequestId", 72 | PhysicalResourceId: "example.com", 73 | LogicalResourceId: "LogicalResourceId", 74 | RequestType: "Update", 75 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 76 | ResourceProperties: { 77 | DomainName: "example.com", 78 | HostedZoneId: "HOSTED_ZONE_ID_2", 79 | Region: "us-east-1", 80 | DKIM: false, 81 | }, 82 | OldResourceProperties: { 83 | DomainName: "example.com", 84 | HostedZoneId: "HOSTED_ZONE_ID", 85 | Region: "us-east-1", 86 | DKIM: false, 87 | }, 88 | } as any); 89 | 90 | const res = await handler.consumeEvent(); 91 | expect(res).toEqual({ 92 | physicalResourceId: "example.com", 93 | data: {}, 94 | }); 95 | 96 | sinon.assert.calledOnce(verifyIdentityFake); 97 | sinon.assert.notCalled(enableDKIMFake); 98 | sinon.assert.calledOnce(revokeIdentityFake); 99 | sinon.assert.notCalled(disableDKIMFake); 100 | }); 101 | 102 | it("should enable DKIM if enabled", async () => { 103 | const handler = new UpdateCustomResourceHandler({ 104 | StackId: "StackId", 105 | RequestId: "RequestId", 106 | PhysicalResourceId: "example.com", 107 | LogicalResourceId: "LogicalResourceId", 108 | RequestType: "Update", 109 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 110 | ResourceProperties: { 111 | DomainName: "example.com", 112 | HostedZoneId: "HOSTED_ZONE_ID", 113 | Region: "us-east-1", 114 | DKIM: true, 115 | }, 116 | OldResourceProperties: { 117 | DomainName: "example.com", 118 | HostedZoneId: "HOSTED_ZONE_ID", 119 | Region: "us-east-1", 120 | DKIM: false, 121 | }, 122 | } as any); 123 | 124 | const res = await handler.consumeEvent(); 125 | expect(res).toEqual({ 126 | physicalResourceId: "example.com", 127 | data: {}, 128 | }); 129 | 130 | sinon.assert.notCalled(verifyIdentityFake); 131 | sinon.assert.calledOnce(enableDKIMFake); 132 | sinon.assert.notCalled(revokeIdentityFake); 133 | sinon.assert.notCalled(disableDKIMFake); 134 | }); 135 | 136 | it("should disable DKIM if disabled", async () => { 137 | const handler = new UpdateCustomResourceHandler({ 138 | StackId: "StackId", 139 | RequestId: "RequestId", 140 | PhysicalResourceId: "example.com", 141 | 142 | LogicalResourceId: "LogicalResourceId", 143 | RequestType: "Update", 144 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 145 | ResourceProperties: { 146 | DomainName: "example.com", 147 | HostedZoneId: "HOSTED_ZONE_ID", 148 | Region: "us-east-1", 149 | DKIM: false, 150 | }, 151 | OldResourceProperties: { 152 | DomainName: "example.com", 153 | HostedZoneId: "HOSTED_ZONE_ID", 154 | Region: "us-east-1", 155 | DKIM: true, 156 | }, 157 | } as any); 158 | 159 | const res = await handler.consumeEvent(); 160 | expect(res).toEqual({ 161 | physicalResourceId: "example.com", 162 | data: {}, 163 | }); 164 | 165 | sinon.assert.notCalled(verifyIdentityFake); 166 | sinon.assert.notCalled(enableDKIMFake); 167 | sinon.assert.notCalled(revokeIdentityFake); 168 | sinon.assert.calledOnce(disableDKIMFake); 169 | }); 170 | 171 | it("should update everything", async () => { 172 | const handler = new UpdateCustomResourceHandler({ 173 | StackId: "StackId", 174 | RequestId: "RequestId", 175 | LogicalResourceId: "LogicalResourceId", 176 | RequestType: "Update", 177 | ResponseURL: "https://s3.amazonaws.com/bucket/key", 178 | ResourceProperties: { 179 | DomainName: "example2.com", 180 | HostedZoneId: "HOSTED_ZONE_ID2", 181 | Region: "us-east-1", 182 | DKIM: true, 183 | }, 184 | OldResourceProperties: { 185 | DomainName: "example.com", 186 | HostedZoneId: "HOSTED_ZONE_ID", 187 | Region: "us-east-1", 188 | DKIM: false, 189 | }, 190 | } as any); 191 | 192 | const res = await handler.consumeEvent(); 193 | expect(res).toEqual({ 194 | physicalResourceId: "example2.com", 195 | data: {}, 196 | }); 197 | 198 | sinon.assert.calledOnce(verifyIdentityFake); 199 | sinon.assert.calledOnce(enableDKIMFake); 200 | sinon.assert.calledOnce(revokeIdentityFake); 201 | sinon.assert.notCalled(disableDKIMFake); 202 | }); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/helper.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | 3 | export const sandbox = sinon.createSandbox(); 4 | 5 | afterEach(() => { 6 | sandbox.verifyAndRestore(); 7 | }); 8 | 9 | export function stubAWSAPI( 10 | Service: new (...args: any[]) => T, 11 | method: keyof T, 12 | fake: sinon.SinonSpy, 13 | ) { 14 | const service = new Service(); 15 | const proto = Object.getPrototypeOf(service); 16 | 17 | return sandbox.stub(proto, method) 18 | .callsFake((...args: any[]) => { 19 | return { 20 | promise: () => Promise.resolve(fake(...args)), 21 | }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import { sandbox } from "./helper"; 3 | 4 | import { identityRequestHandler } from "../src"; 5 | import { 6 | CreateCustomResourceHandler, 7 | DeleteCustomResourceHandler, 8 | UpdateCustomResourceHandler, 9 | } from "../src/handlers"; 10 | 11 | describe("Handler", () => { 12 | let handleEventFake: sinon.SinonStub; 13 | 14 | describe("when RequestType = Create", () => { 15 | beforeEach(() => { 16 | handleEventFake = sandbox.stub(CreateCustomResourceHandler.prototype, "handleEvent"); 17 | handleEventFake.resolves({ value: "create" }); 18 | }); 19 | 20 | it("should use CreateCustomResourceHandler", async () => { 21 | const event = { 22 | RequestType: "Create", 23 | } as any; 24 | 25 | const res = await identityRequestHandler(event); 26 | 27 | sinon.assert.calledOnce(handleEventFake); 28 | expect(res).toEqual({ value: "create" }); 29 | }); 30 | }); 31 | 32 | describe("when RequestType = Update", () => { 33 | beforeEach(() => { 34 | handleEventFake = sandbox.stub(UpdateCustomResourceHandler.prototype, "handleEvent"); 35 | handleEventFake.resolves({ value: "update" }); 36 | }); 37 | 38 | it("should use UpdateCustomResourceHandler", async () => { 39 | const event = { 40 | RequestType: "Update", 41 | } as any; 42 | 43 | const res = await identityRequestHandler(event); 44 | 45 | sinon.assert.calledOnce(handleEventFake); 46 | 47 | expect(res).toEqual({ value: "update" }); 48 | }); 49 | }); 50 | 51 | describe("when RequestType = Delete", () => { 52 | beforeEach(() => { 53 | handleEventFake = sandbox.stub(DeleteCustomResourceHandler.prototype, "handleEvent"); 54 | handleEventFake.resolves({ value: "delete" }); 55 | }); 56 | 57 | it("should use DeleteCustomResourceHandler", async () => { 58 | const event = { 59 | RequestType: "Delete", 60 | } as any; 61 | 62 | const res = await identityRequestHandler(event); 63 | 64 | sinon.assert.calledOnce(handleEventFake); 65 | 66 | expect(res).toEqual({ value: "delete" }); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/record.test.ts: -------------------------------------------------------------------------------- 1 | import { Record } from "../src/record"; 2 | 3 | describe(Record.name, () => { 4 | describe("static #forIdentity", () => { 5 | it("should return a new instance of Record", () => { 6 | const value = Record.forIdentity("example.com", ["token"]); 7 | expect(value).toBeInstanceOf(Record); 8 | expect(value).toMatchObject({ 9 | name: "_amazonses.example.com.", 10 | type: "TXT", 11 | values: new Set(["token"]), 12 | ttl: 1800, 13 | }); 14 | }); 15 | }); 16 | 17 | describe("static #forDKIM", () => { 18 | it("should return a new instance of Record", () => { 19 | const value = Record.forDKIM("example.com", "token"); 20 | expect(value).toBeInstanceOf(Record); 21 | expect(value).toMatchObject({ 22 | name: "token._domainkey.example.com.", 23 | type: "CNAME", 24 | values: new Set(["token.dkim.amazonses.com"]), 25 | ttl: 1800, 26 | }); 27 | }); 28 | }); 29 | 30 | describe("#action", () => { 31 | let record: Record; 32 | 33 | beforeEach(() => { 34 | record = new Record( 35 | "example.com", 36 | "CNAME", 37 | new Set(["target.example.com"]), 38 | 1234, 39 | ); 40 | }); 41 | 42 | it("should return Route53 Change for CREATE type", () => { 43 | expect(record.action("CREATE")).toEqual({ 44 | Action: "CREATE", 45 | ResourceRecordSet: { 46 | Name: "example.com", 47 | Type: "CNAME", 48 | ResourceRecords: [{ Value: "target.example.com" }], 49 | TTL: 1234, 50 | }, 51 | }); 52 | }); 53 | 54 | it("should return Route53 Change for UPSERT type", () => { 55 | expect(record.action("UPSERT")).toEqual({ 56 | Action: "UPSERT", 57 | ResourceRecordSet: { 58 | Name: "example.com", 59 | Type: "CNAME", 60 | ResourceRecords: [{ Value: "target.example.com" }], 61 | TTL: 1234, 62 | }, 63 | }); 64 | }); 65 | 66 | it("should return Route53 Change for DELETE type", () => { 67 | expect(record.action("DELETE")).toEqual({ 68 | Action: "DELETE", 69 | ResourceRecordSet: { 70 | Name: "example.com", 71 | Type: "CNAME", 72 | TTL: 1234, 73 | ResourceRecords: [ 74 | { Value: "target.example.com" }, 75 | ], 76 | }, 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/util.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | 3 | import { waitFor } from "../src/util"; 4 | 5 | describe("Utils", () => { 6 | describe(waitFor.name, () => { 7 | let pollerStub: sinon.SinonStub; 8 | 9 | beforeEach(() => { 10 | pollerStub = sinon.stub(); 11 | pollerStub.rejects(new Error("MOCKED")); 12 | pollerStub.onCall(3).resolves({ state: 1 }); 13 | pollerStub.onCall(4).resolves({ state: 2, name: "foo" }); 14 | }); 15 | 16 | it("should retry", async () => { 17 | const testerStub = sinon.stub(); 18 | testerStub.returns(false); 19 | testerStub.onCall(1).returns(true); 20 | 21 | await waitFor(pollerStub, testerStub, { 22 | maxAttempts: 10, 23 | delay: 0, 24 | }); 25 | 26 | expect(pollerStub.callCount).toEqual(5); 27 | expect(testerStub.callCount).toEqual(2); 28 | }); 29 | 30 | it("should throw error when maximum attempts exceeded", async () => { 31 | await expect( 32 | waitFor(pollerStub, (value: any) => value.state === 2, { 33 | maxAttempts: 3, 34 | delay: 0, 35 | }), 36 | ).rejects.toThrow("Maximum attempts exceeded"); 37 | 38 | expect(pollerStub.callCount).toEqual(3); 39 | }); 40 | 41 | it("should return state from poller if succeeded", async () => { 42 | const res = await waitFor(pollerStub, (value: any) => value.state === 2, { 43 | maxAttempts: 10, 44 | delay: 0, 45 | }); 46 | 47 | expect(res).toEqual({ state: 2, name: "foo" }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/test/verifier.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | 3 | import * as Route53 from "aws-sdk/clients/route53"; 4 | import * as SES from "aws-sdk/clients/ses"; 5 | import { sandbox, stubAWSAPI } from "./helper"; 6 | 7 | import { Verifier } from "../src/verifier"; 8 | 9 | describe(Verifier.name, () => { 10 | describe("static #from", () => { 11 | it("should return instance of Verifier", () => { 12 | const value = Verifier.from({ 13 | DomainName: "example.com", 14 | HostedZoneId: "FAKE", 15 | Region: "us-east-1", 16 | } as any); 17 | 18 | expect(value).toBeInstanceOf(Verifier); 19 | expect(value).toMatchObject({ 20 | domainName: "example.com", 21 | hostedZoneId: "FAKE", 22 | region: "us-east-1", 23 | }); 24 | }); 25 | }); 26 | 27 | let verifier: Verifier; 28 | beforeEach(() => { 29 | verifier = new Verifier("example.com", "HOSTED_ZONE_ID", "us-east-1"); 30 | }); 31 | 32 | describe("#verifyIdentity", () => { 33 | let verifyDomainIdentityFake: sinon.SinonSpy; 34 | let changeResourceRecordSetsFake: sinon.SinonSpy; 35 | let listResourceRecordSetsFake: sinon.SinonSpy; 36 | let waitForResourceRecordSetsChangedFake: sinon.SinonSpy; 37 | let waitForIdentityExistsFake: sinon.SinonSpy; 38 | 39 | beforeEach(() => { 40 | verifyDomainIdentityFake = sinon.fake.resolves({ 41 | VerificationToken: "token", 42 | }); 43 | 44 | changeResourceRecordSetsFake = sinon.fake.resolves({ 45 | ChangeInfo: { 46 | Id: "fake-id", 47 | }, 48 | }); 49 | 50 | listResourceRecordSetsFake = sinon.fake.resolves({ 51 | ResourceRecordSets: [], // No records found 52 | IsTruncated: false, 53 | MaxItems: "100", 54 | }); 55 | 56 | waitForResourceRecordSetsChangedFake = sinon.fake.resolves({ 57 | ChangeInfo: { 58 | Id: "fake-id", 59 | Status: "INSYNC", 60 | }, 61 | }); 62 | 63 | waitForIdentityExistsFake = sinon.fake.resolves({ 64 | VerificationAttributes: { 65 | ["example.com"]: { 66 | VerificationStatus: "Success", 67 | VerificationToken: "token", 68 | }, 69 | }, 70 | }); 71 | 72 | stubAWSAPI(SES, "verifyDomainIdentity", verifyDomainIdentityFake); 73 | stubAWSAPI(Route53, "changeResourceRecordSets", changeResourceRecordSetsFake); 74 | stubAWSAPI(Route53, "listResourceRecordSets", listResourceRecordSetsFake); 75 | stubAWSAPI(Route53, "waitFor", waitForResourceRecordSetsChangedFake); 76 | stubAWSAPI(SES, "waitFor", waitForIdentityExistsFake); 77 | }); 78 | 79 | it("should verify identity", async () => { 80 | await verifier.verifyIdentity(); 81 | 82 | sinon.assert.calledOnce(verifyDomainIdentityFake); 83 | sinon.assert.calledWith(verifyDomainIdentityFake, sinon.match({ 84 | Domain: "example.com", 85 | })); 86 | 87 | 88 | sinon.assert.calledOnce(changeResourceRecordSetsFake); 89 | sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({ 90 | HostedZoneId: "HOSTED_ZONE_ID", 91 | ChangeBatch: { 92 | Changes: [{ 93 | Action: "CREATE", 94 | ResourceRecordSet: { 95 | Name: "_amazonses.example.com.", 96 | Type: "TXT", 97 | ResourceRecords: [{ 98 | Value: `"token"`, 99 | }], 100 | TTL: 1800, 101 | }, 102 | }], 103 | }, 104 | })); 105 | 106 | sinon.assert.calledOnce(waitForResourceRecordSetsChangedFake); 107 | sinon.assert.calledWith(waitForResourceRecordSetsChangedFake, "resourceRecordSetsChanged", sinon.match({ 108 | Id: "fake-id", 109 | })); 110 | 111 | sinon.assert.calledOnce(waitForIdentityExistsFake); 112 | sinon.assert.calledWith(waitForIdentityExistsFake, "identityExists", sinon.match({ 113 | Identities: ["example.com"], 114 | })); 115 | }); 116 | 117 | it("should upsert Route53 Record if upsert = true", async () => { 118 | await verifier.verifyIdentity(true); 119 | 120 | sinon.assert.calledOnce(changeResourceRecordSetsFake); 121 | sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({ 122 | ChangeBatch: { 123 | Changes: [{ 124 | Action: "UPSERT", 125 | ResourceRecordSet: { 126 | Name: "_amazonses.example.com.", 127 | Type: "TXT", 128 | ResourceRecords: [{ 129 | Value: `"token"`, 130 | }], 131 | TTL: 1800, 132 | }, 133 | }], 134 | }, 135 | })); 136 | }); 137 | }); 138 | 139 | describe("#revokeIdentity", () => { 140 | let getIdentityVerificationAttributesFake: sinon.SinonStub; 141 | let deleteIdentityFake: sinon.SinonSpy; 142 | let changeResourceRecordSetsFake: sinon.SinonSpy; 143 | let listResourceRecordSetsFake: sinon.SinonSpy; 144 | 145 | beforeEach(() => { 146 | getIdentityVerificationAttributesFake = sinon.stub(); 147 | getIdentityVerificationAttributesFake.resolves({ 148 | VerificationAttributes: { 149 | ["example.com"]: { 150 | VerificationStatus: "Success", 151 | VerificationToken: "token", 152 | }, 153 | }, 154 | }); 155 | 156 | deleteIdentityFake = sinon.fake.resolves({}); 157 | 158 | changeResourceRecordSetsFake = sinon.fake.resolves({ 159 | ChangeInfo: { 160 | Id: "fake-id", 161 | }, 162 | }); 163 | 164 | listResourceRecordSetsFake = sinon.fake.resolves({ 165 | ResourceRecordSets: [{ 166 | Name: "_amazonses.example.com.", 167 | Type: "TXT", 168 | ResourceRecords: [{ 169 | Value: "\"token\"", 170 | }], 171 | }], 172 | IsTruncated: false, 173 | MaxItems: "100", 174 | }); 175 | 176 | stubAWSAPI(SES, "getIdentityVerificationAttributes", getIdentityVerificationAttributesFake); 177 | stubAWSAPI(SES, "deleteIdentity", deleteIdentityFake); 178 | stubAWSAPI(Route53, "changeResourceRecordSets", changeResourceRecordSetsFake); 179 | stubAWSAPI(Route53, "listResourceRecordSets", listResourceRecordSetsFake); 180 | }); 181 | 182 | it("should delete identity and delete route53 record", async () => { 183 | await verifier.revokeIdentity(); 184 | 185 | sinon.assert.calledOnce(getIdentityVerificationAttributesFake); 186 | sinon.assert.calledWith(getIdentityVerificationAttributesFake, sinon.match({ 187 | Identities: ["example.com"], 188 | })); 189 | 190 | sinon.assert.calledOnce(deleteIdentityFake); 191 | sinon.assert.calledWith(deleteIdentityFake, sinon.match({ 192 | Identity: "example.com", 193 | })); 194 | 195 | sinon.assert.calledOnce(changeResourceRecordSetsFake); 196 | sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({ 197 | HostedZoneId: "HOSTED_ZONE_ID", 198 | ChangeBatch: { 199 | Changes: [{ 200 | Action: "DELETE", 201 | ResourceRecordSet: { 202 | Name: "_amazonses.example.com.", 203 | Type: "TXT", 204 | TTL: 1800, 205 | ResourceRecords: [], 206 | }, 207 | }], 208 | }, 209 | })); 210 | }); 211 | 212 | it("should throw Error if given domain hasn't been verified", async () => { 213 | getIdentityVerificationAttributesFake.onFirstCall().resolves({ 214 | VerificationAttributes: {}, 215 | }); 216 | 217 | await expect(() => verifier.revokeIdentity()).rejects.toThrow("There are no identity for given domain"); 218 | }); 219 | }); 220 | 221 | describe("#enableDKIM", () => { 222 | let verifyDomainDkimFake: sinon.SinonSpy; 223 | let changeResourceRecordSetsFake: sinon.SinonSpy; 224 | let waitForResourceRecordSetsChangedFake: sinon.SinonSpy; 225 | let getIdentityDkimAttributesFake: sinon.SinonStub; 226 | 227 | beforeEach(() => { 228 | verifyDomainDkimFake = sinon.fake.resolves({ 229 | DkimTokens: ["foo", "bar", "baz"], 230 | }); 231 | 232 | changeResourceRecordSetsFake = sinon.fake.resolves({ 233 | ChangeInfo: { 234 | Id: "fake-id", 235 | }, 236 | }); 237 | 238 | waitForResourceRecordSetsChangedFake = sinon.fake.resolves({ 239 | ChangeInfo: { 240 | Id: "fake-id", 241 | Status: "INSYNC", 242 | }, 243 | }); 244 | 245 | getIdentityDkimAttributesFake = sinon.stub(); 246 | getIdentityDkimAttributesFake.resolves({ 247 | DkimAttributes: { 248 | ["example.com"]: { 249 | DkimEnabled: true, 250 | DkimVerificationStatus: "Success", 251 | DkimTokens: ["foo", "bar", "baz"], 252 | }, 253 | }, 254 | }); 255 | 256 | stubAWSAPI(SES, "verifyDomainDkim", verifyDomainDkimFake); 257 | stubAWSAPI(Route53, "changeResourceRecordSets", changeResourceRecordSetsFake); 258 | stubAWSAPI(Route53, "waitFor", waitForResourceRecordSetsChangedFake); 259 | stubAWSAPI(SES, "getIdentityDkimAttributes", getIdentityDkimAttributesFake); 260 | }); 261 | 262 | it("should enable DKIM and create Route53 Records", async () => { 263 | await verifier.enableDKIM(); 264 | 265 | sinon.assert.calledOnce(verifyDomainDkimFake); 266 | sinon.assert.calledWith(verifyDomainDkimFake, sinon.match({ 267 | Domain: "example.com", 268 | })); 269 | 270 | 271 | sinon.assert.calledOnce(changeResourceRecordSetsFake); 272 | sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({ 273 | HostedZoneId: "HOSTED_ZONE_ID", 274 | ChangeBatch: { 275 | Changes: [{ 276 | Action: "CREATE", 277 | ResourceRecordSet: { 278 | Name: "foo._domainkey.example.com.", 279 | Type: "CNAME", 280 | ResourceRecords: [{ 281 | Value: "foo.dkim.amazonses.com", 282 | }], 283 | TTL: 1800, 284 | }, 285 | }, { 286 | Action: "CREATE", 287 | ResourceRecordSet: { 288 | Name: "bar._domainkey.example.com.", 289 | Type: "CNAME", 290 | ResourceRecords: [{ 291 | Value: "bar.dkim.amazonses.com", 292 | }], 293 | TTL: 1800, 294 | }, 295 | }, { 296 | Action: "CREATE", 297 | ResourceRecordSet: { 298 | Name: "baz._domainkey.example.com.", 299 | Type: "CNAME", 300 | ResourceRecords: [{ 301 | Value: "baz.dkim.amazonses.com", 302 | }], 303 | TTL: 1800, 304 | }, 305 | }], 306 | }, 307 | })); 308 | 309 | sinon.assert.calledOnce(waitForResourceRecordSetsChangedFake); 310 | sinon.assert.calledWith(waitForResourceRecordSetsChangedFake, "resourceRecordSetsChanged", sinon.match({ 311 | Id: "fake-id", 312 | })); 313 | 314 | sinon.assert.calledOnce(getIdentityDkimAttributesFake); 315 | sinon.assert.calledWith(getIdentityDkimAttributesFake, sinon.match({ 316 | Identities: ["example.com"], 317 | })); 318 | }); 319 | 320 | it("should upsert Route53 Records if upsert = true", async () => { 321 | await verifier.enableDKIM(true); 322 | 323 | sinon.assert.calledOnce(changeResourceRecordSetsFake); 324 | sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({ 325 | ChangeBatch: { 326 | Changes: [{ 327 | Action: "UPSERT", 328 | ResourceRecordSet: { 329 | Name: "foo._domainkey.example.com.", 330 | Type: "CNAME", 331 | ResourceRecords: [{ 332 | Value: "foo.dkim.amazonses.com", 333 | }], 334 | TTL: 1800, 335 | }, 336 | }, { 337 | Action: "UPSERT", 338 | ResourceRecordSet: { 339 | Name: "bar._domainkey.example.com.", 340 | Type: "CNAME", 341 | ResourceRecords: [{ 342 | Value: "bar.dkim.amazonses.com", 343 | }], 344 | TTL: 1800, 345 | }, 346 | }, { 347 | Action: "UPSERT", 348 | ResourceRecordSet: { 349 | Name: "baz._domainkey.example.com.", 350 | Type: "CNAME", 351 | ResourceRecords: [{ 352 | Value: "baz.dkim.amazonses.com", 353 | }], 354 | TTL: 1800, 355 | }, 356 | }], 357 | }, 358 | })); 359 | }); 360 | 361 | it("should throw Error if given domain hasn't been verified", async () => { 362 | getIdentityDkimAttributesFake.onFirstCall().resolves({ 363 | DkimAttributes: {}, 364 | }); 365 | 366 | await expect(() => verifier.disableDKIM()).rejects.toThrow("DKIM is not configured for given domain"); 367 | }); 368 | }); 369 | 370 | describe("#disableDKIM", () => { 371 | let getIdentityDkimAttributesFake: sinon.SinonStub; 372 | let setIdentityDkimEnabledFake: sinon.SinonSpy; 373 | let changeResourceRecordSetsFake: sinon.SinonSpy; 374 | 375 | beforeEach(() => { 376 | getIdentityDkimAttributesFake = sinon.stub(); 377 | getIdentityDkimAttributesFake.resolves({ 378 | DkimAttributes: { 379 | ["example.com"]: { 380 | DkimEnabled: true, 381 | DkimVerificationStatus: "Success", 382 | DkimTokens: ["foo", "bar", "baz"], 383 | }, 384 | }, 385 | }); 386 | 387 | setIdentityDkimEnabledFake = sinon.fake.resolves({}); 388 | changeResourceRecordSetsFake = sinon.fake.resolves({ 389 | ChangeInfo: { 390 | Id: "fake-id", 391 | }, 392 | }); 393 | 394 | stubAWSAPI(SES, "getIdentityDkimAttributes", getIdentityDkimAttributesFake); 395 | stubAWSAPI(SES, "setIdentityDkimEnabled", setIdentityDkimEnabledFake); 396 | stubAWSAPI(Route53, "changeResourceRecordSets", changeResourceRecordSetsFake); 397 | }); 398 | 399 | it("should disable DKIM and delete Route53 Records", async () => { 400 | await verifier.disableDKIM(); 401 | 402 | sinon.assert.calledOnce(getIdentityDkimAttributesFake); 403 | sinon.assert.calledWith(getIdentityDkimAttributesFake, sinon.match({ 404 | Identities: ["example.com"], 405 | })); 406 | 407 | sinon.assert.calledOnce(setIdentityDkimEnabledFake); 408 | sinon.assert.calledWith(setIdentityDkimEnabledFake, sinon.match({ 409 | Identity: "example.com", 410 | DkimEnabled: false, 411 | })); 412 | 413 | sinon.assert.calledOnce(changeResourceRecordSetsFake); 414 | sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({ 415 | HostedZoneId: "HOSTED_ZONE_ID", 416 | ChangeBatch: { 417 | Changes: [{ 418 | Action: "DELETE", 419 | ResourceRecordSet: { 420 | Name: "foo._domainkey.example.com.", 421 | Type: "CNAME", 422 | TTL: 1800, 423 | ResourceRecords: [ 424 | { Value: "foo.dkim.amazonses.com"}, 425 | ], 426 | }, 427 | }, { 428 | Action: "DELETE", 429 | ResourceRecordSet: { 430 | Name: "bar._domainkey.example.com.", 431 | Type: "CNAME", 432 | TTL: 1800, 433 | ResourceRecords: [ 434 | { Value: "bar.dkim.amazonses.com"}, 435 | ], 436 | }, 437 | }, { 438 | Action: "DELETE", 439 | ResourceRecordSet: { 440 | Name: "baz._domainkey.example.com.", 441 | Type: "CNAME", 442 | TTL: 1800, 443 | ResourceRecords: [ 444 | { Value: "baz.dkim.amazonses.com"}, 445 | ], 446 | }, 447 | }], 448 | }, 449 | })); 450 | }); 451 | 452 | it("should throw Error if given domain hasn't been verified", async () => { 453 | getIdentityDkimAttributesFake.onFirstCall().resolves({ 454 | DkimAttributes: {}, 455 | }); 456 | 457 | await expect(() => verifier.disableDKIM()).rejects.toThrow("DKIM is not configured for given domain"); 458 | }); 459 | }); 460 | }); 461 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "noImplicitAny": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "strictNullChecks": true 11 | }, 12 | "include": [ 13 | "src/**/*.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /lambda-packages/dns-validated-domain-identity-handler/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@prescott/tslint-preset"], 3 | "rules": { 4 | "quotemark": [true, "double"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-cdk-ses-domain-identity", 3 | "version": "2.2.0", 4 | "description": "Constructs for provisioning and referencing domain identities which can be used in SES RuleSets and Actions Construct.", 5 | "main": "dist/dns-validated-domain-identity.js", 6 | "types": "dist/dns-validated-domain-identity.d.ts", 7 | "peerDependencies": { 8 | "aws-cdk-lib": "^2.0.0", 9 | "constructs": "^10.0.0" 10 | }, 11 | "devDependencies": { 12 | "@prescott/commitlint-preset": "1.0.9", 13 | "@prescott/semantic-release-config": "1.0.17", 14 | "@prescott/tslint-preset": "1.0.1", 15 | "@types/jest": "29.5.14", 16 | "@types/node": "^20.11.0", 17 | "aws-cdk-lib": "2.200.1", 18 | "constructs": "10.4.2", 19 | "husky": "9.1.7", 20 | "jest": "29.7.0", 21 | "semantic-release": "24.2.5", 22 | "ts-jest": "29.3.4", 23 | "ts-node": "10.9.2", 24 | "tslint": "6.1.3", 25 | "typescript": "^5.3.3" 26 | }, 27 | "scripts": { 28 | "prepare": "husky install", 29 | "clean": "rm -rf dist", 30 | "prebuild": "npm run clean", 31 | "build": "tsc -p tsconfig.json", 32 | "prepublishOnly": "npm run build && cd lambda-packages/dns-validated-domain-identity-handler && npm run build", 33 | "test": "jest", 34 | "lint": "tslint -c tslint.json '{src,test}/**/*.ts'" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/mooyoul/aws-cdk-ses-domain-identity.git" 39 | }, 40 | "keywords": [ 41 | "aws", 42 | "aws-cdk", 43 | "aws-cdk-construct", 44 | "aws-ses", 45 | "ses" 46 | ], 47 | "author": "MooYeol Prescott Lee ", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/mooyoul/aws-cdk-ses-domain-identity/issues" 51 | }, 52 | "homepage": "https://github.com/mooyoul/aws-cdk-ses-domain-identity#readme", 53 | "config": { 54 | "commitizen": { 55 | "path": "cz-conventional-changelog" 56 | } 57 | }, 58 | "commitlint": { 59 | "extends": [ 60 | "@prescott/commitlint-preset" 61 | ] 62 | }, 63 | "overrides": { 64 | "@prescott/tslint-preset": { 65 | "typescript": "$typescript" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>mooyoul/node-standard//renovate-config/default", 4 | "github>mooyoul/node-standard//renovate-config/nodejs20", 5 | "github>mooyoul/node-standard//renovate-config/semantic-commit" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/dns-validated-domain-identity.ts: -------------------------------------------------------------------------------- 1 | import { CustomResource, Duration, Resource, Stack, Token } from "aws-cdk-lib"; 2 | import * as iam from "aws-cdk-lib/aws-iam"; 3 | import * as lambda from "aws-cdk-lib/aws-lambda"; 4 | import * as route53 from "aws-cdk-lib/aws-route53"; 5 | import { Construct } from "constructs"; 6 | import * as path from "path"; 7 | 8 | /** 9 | * Properties to create a DNS-validated Domain Identity of AWS SES Service. 10 | * 11 | * @experimental 12 | */ 13 | export interface DnsValidatedDomainIdentityProps { 14 | /** 15 | * Fully-qualified domain name to request a domain identity for. 16 | */ 17 | readonly domainName: string; 18 | 19 | /** 20 | * Whether to configure DKIM on domain identity. 21 | * @default true 22 | */ 23 | readonly dkim?: boolean; 24 | 25 | /** 26 | * Route 53 Hosted Zone used to perform DNS validation of the request. The zone 27 | * must be authoritative for the domain name specified in the Domain Identity Request. 28 | */ 29 | readonly hostedZone: route53.IHostedZone; 30 | /** 31 | * AWS region that will validate the domain identity. This is needed especially 32 | * for domain identity used for AWS SES services, which require the region 33 | * to be one of SES supported regions. 34 | * 35 | * @default the region the stack is deployed in. 36 | */ 37 | readonly region?: string; 38 | 39 | /** 40 | * Role to use for the custom resource that creates the validated domain identity 41 | * 42 | * @default - A new role will be created 43 | */ 44 | readonly customResourceRole?: iam.IRole; 45 | } 46 | 47 | /** 48 | * A domain identity managed by AWS SES. Will be automatically 49 | * validated using DNS validation against the specified Route 53 hosted zone. 50 | */ 51 | export class DnsValidatedDomainIdentity extends Resource { 52 | public readonly domainName: string; 53 | public readonly dkim: boolean; 54 | public readonly identityArn: string; 55 | 56 | private readonly hostedZoneId: string; 57 | private readonly normalizedZoneName: string; 58 | 59 | public constructor(scope: Construct, id: string, props: DnsValidatedDomainIdentityProps) { 60 | super(scope, id); 61 | 62 | const stack = Stack.of(this); 63 | 64 | const region = props.region ?? stack.region; 65 | const accountId = stack.account; 66 | 67 | this.domainName = props.domainName; 68 | this.dkim = props.dkim ?? false; 69 | this.identityArn = `arn:aws:ses:${region}:${accountId}:identity/${this.domainName}`; 70 | this.normalizedZoneName = props.hostedZone.zoneName; 71 | // Remove trailing `.` from zone name 72 | if (this.normalizedZoneName.endsWith(".")) { 73 | this.normalizedZoneName = this.normalizedZoneName.substring(0, this.normalizedZoneName.length - 1); 74 | } 75 | 76 | // Remove any `/hostedzone/` prefix from the Hosted Zone ID 77 | this.hostedZoneId = props.hostedZone.hostedZoneId.replace(/^\/hostedzone\//, ""); 78 | 79 | const requestorFunction = new lambda.Function(this, "DomainIdentityRequestorFunction", { 80 | code: lambda.Code.fromAsset(path.resolve(__dirname, "..", "lambda-packages", "dns-validated-domain-identity-handler", "dist")), 81 | handler: "index.identityRequestHandler", 82 | runtime: lambda.Runtime.NODEJS_20_X, 83 | memorySize: 128, 84 | timeout: Duration.minutes(15), 85 | role: props.customResourceRole, 86 | }); 87 | requestorFunction.addToRolePolicy(new iam.PolicyStatement({ 88 | actions: [ 89 | "ses:GetIdentityVerificationAttributes", 90 | "ses:GetIdentityDkimAttributes", 91 | "ses:SetIdentityDkimEnabled", 92 | "ses:VerifyDomainIdentity", 93 | "ses:VerifyDomainDkim", 94 | "ses:ListIdentities", 95 | "ses:DeleteIdentity", 96 | ], 97 | resources: ["*"], 98 | })); 99 | requestorFunction.addToRolePolicy(new iam.PolicyStatement({ 100 | actions: ["route53:GetChange"], 101 | resources: ["*"], 102 | })); 103 | requestorFunction.addToRolePolicy(new iam.PolicyStatement({ 104 | actions: [ 105 | "route53:changeResourceRecordSets", 106 | "route53:ListResourceRecordSets", 107 | ], 108 | resources: [`arn:${Stack.of(requestorFunction).partition}:route53:::hostedzone/${this.hostedZoneId}`], 109 | })); 110 | 111 | const identity = new CustomResource(this, "IdentityRequestorResource", { 112 | serviceToken: requestorFunction.functionArn, 113 | properties: { 114 | DomainName: this.domainName, 115 | HostedZoneId: this.hostedZoneId, 116 | Region: region, 117 | DKIM: props.dkim, 118 | }, 119 | }); 120 | 121 | this.node.addValidation({ 122 | validate: (): string[] => { 123 | const errors: string[] = []; 124 | // Ensure the zone name is a parent zone of the certificate domain name 125 | if (!Token.isUnresolved(this.normalizedZoneName) && 126 | this.domainName !== this.normalizedZoneName && 127 | !this.domainName.endsWith("." + this.normalizedZoneName)) { 128 | errors.push(`DNS zone ${this.normalizedZoneName} is not authoritative for SES identity domain name ${this.domainName}`); 129 | } 130 | 131 | return errors; 132 | }, 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /test/dns-validated-domain-identitiy.test.ts: -------------------------------------------------------------------------------- 1 | import { App, Stack, Token } from "aws-cdk-lib"; 2 | import { Template } from "aws-cdk-lib/assertions"; 3 | import * as iam from "aws-cdk-lib/aws-iam"; 4 | import { HostedZone, PublicHostedZone } from "aws-cdk-lib/aws-route53"; 5 | import { DnsValidatedDomainIdentity } from "../src/dns-validated-domain-identity"; 6 | 7 | describe(DnsValidatedDomainIdentity.name, () => { 8 | it("creates CloudFormation Custom Resource", () => { 9 | const stack = new Stack(); 10 | 11 | const exampleDotComZone = new PublicHostedZone(stack, "ExampleDotCom", { 12 | zoneName: "example.com", 13 | }); 14 | 15 | // tslint:disable-next-line:no-unused-expression 16 | new DnsValidatedDomainIdentity(stack, "DomainIdentity", { 17 | domainName: "test.example.com", 18 | hostedZone: exampleDotComZone, 19 | }); 20 | 21 | Template.fromStack(stack).hasResourceProperties("AWS::CloudFormation::CustomResource", { 22 | DomainName: "test.example.com", 23 | ServiceToken: { 24 | "Fn::GetAtt": [ 25 | "DomainIdentityDomainIdentityRequestorFunction700A5CBC", 26 | "Arn", 27 | ], 28 | }, 29 | HostedZoneId: { 30 | Ref: "ExampleDotCom4D1B83AA", 31 | }, 32 | }); 33 | 34 | Template.fromStack(stack).hasResourceProperties("AWS::Lambda::Function", { 35 | Handler: "index.identityRequestHandler", 36 | Runtime: "nodejs20.x", 37 | MemorySize: 128, 38 | Timeout: 900, 39 | }); 40 | 41 | Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", { 42 | PolicyName: "DomainIdentityDomainIdentityRequestorFunctionServiceRoleDefaultPolicy9D23D5BE", 43 | Roles: [ 44 | { 45 | Ref: "DomainIdentityDomainIdentityRequestorFunctionServiceRoleD8F10EBD", 46 | }, 47 | ], 48 | PolicyDocument: { 49 | Version: "2012-10-17", 50 | Statement: [ 51 | { 52 | Action: [ 53 | "ses:GetIdentityVerificationAttributes", 54 | "ses:GetIdentityDkimAttributes", 55 | "ses:SetIdentityDkimEnabled", 56 | "ses:VerifyDomainIdentity", 57 | "ses:VerifyDomainDkim", 58 | "ses:ListIdentities", 59 | "ses:DeleteIdentity", 60 | ], 61 | Effect: "Allow", 62 | Resource: "*", 63 | }, 64 | { 65 | Action: "route53:GetChange", 66 | Effect: "Allow", 67 | Resource: "*", 68 | }, 69 | { 70 | Action: [ 71 | "route53:changeResourceRecordSets", 72 | "route53:ListResourceRecordSets", 73 | ], 74 | Effect: "Allow", 75 | Resource: { 76 | "Fn::Join": [ 77 | "", 78 | [ 79 | "arn:", 80 | { Ref: "AWS::Partition" }, 81 | ":route53:::hostedzone/", 82 | { Ref: "ExampleDotCom4D1B83AA" }, 83 | ], 84 | ], 85 | }, 86 | }, 87 | ], 88 | }, 89 | }); 90 | }); 91 | 92 | it("adds validation error on domain mismatch", () => { 93 | const stack = new Stack(); 94 | 95 | const helloDotComZone = new PublicHostedZone(stack, "HelloDotCom", { 96 | zoneName: "hello.com", 97 | }); 98 | 99 | // tslint:disable-next-line:no-unused-expression 100 | new DnsValidatedDomainIdentity(stack, "DomainIdentity", { 101 | domainName: "example.com", 102 | hostedZone: helloDotComZone, 103 | }); 104 | 105 | expect(() => Template.fromStack(stack)) 106 | .toThrow(/DNS zone hello.com is not authoritative for SES identity domain name example.com/); 107 | }); 108 | 109 | it("does not try to validate unresolved tokens", () => { 110 | const stack = new Stack(); 111 | 112 | const helloDotComZone = new PublicHostedZone(stack, "HelloDotCom", { 113 | zoneName: Token.asString("hello.com"), 114 | }); 115 | 116 | // tslint:disable-next-line:no-unused-expression 117 | new DnsValidatedDomainIdentity(stack, "DomainIdentity", { 118 | domainName: "hello.com", 119 | hostedZone: helloDotComZone, 120 | }); 121 | 122 | expect(() => Template.fromStack(stack)).not.toThrow(); 123 | }); 124 | 125 | it("works with imported zone", () => { 126 | // GIVEN 127 | const app = new App(); 128 | const stack = new Stack(app, "Stack", { 129 | env: { account: "12345678", region: "us-blue-5" }, 130 | }); 131 | const imported = HostedZone.fromLookup(stack, "ExampleDotCom", { 132 | domainName: "mydomain.com", 133 | }); 134 | 135 | // WHEN 136 | // tslint:disable-next-line:no-unused-expression 137 | new DnsValidatedDomainIdentity(stack, "DomainIdentity", { 138 | domainName: "mydomain.com", 139 | hostedZone: imported, 140 | }); 141 | 142 | // THEN 143 | Template.fromStack(stack).hasResourceProperties("AWS::CloudFormation::CustomResource", { 144 | ServiceToken: { 145 | "Fn::GetAtt": [ 146 | "DomainIdentityDomainIdentityRequestorFunction700A5CBC", 147 | "Arn", 148 | ], 149 | }, 150 | DomainName: "mydomain.com", 151 | HostedZoneId: "DUMMY", 152 | }); 153 | }); 154 | 155 | it("works with imported role", () => { 156 | // GIVEN 157 | const app = new App(); 158 | const stack = new Stack(app, "Stack", { 159 | env: { account: "12345678", region: "us-blue-5" }, 160 | }); 161 | const helloDotComZone = new PublicHostedZone(stack, "HelloDotCom", { 162 | zoneName: "hello.com", 163 | }); 164 | const role = iam.Role.fromRoleArn(stack, "Role", "arn:aws:iam::account-id:role/role-name"); 165 | 166 | // WHEN 167 | // tslint:disable-next-line:no-unused-expression 168 | new DnsValidatedDomainIdentity(stack, "DomainIdentity", { 169 | domainName: "hello.com", 170 | hostedZone: helloDotComZone, 171 | customResourceRole: role, 172 | }); 173 | 174 | // THEN 175 | Template.fromStack(stack).hasResourceProperties("AWS::Lambda::Function", { 176 | Role: "arn:aws:iam::account-id:role/role-name", 177 | }); 178 | }); 179 | 180 | it("exposes properties related to identity", () => { 181 | const app = new App(); 182 | const stack = new Stack(app, "Stack", { 183 | env: { account: "12345678", region: "us-blue-5" }, 184 | }); 185 | 186 | const helloDotComZone = new PublicHostedZone(stack, "HelloDotCom", { 187 | zoneName: "example.com", 188 | }); 189 | 190 | const identity = new DnsValidatedDomainIdentity(stack, "DomainIdentity", { 191 | domainName: "test.example.com", 192 | hostedZone: helloDotComZone, 193 | }); 194 | 195 | expect(identity.identityArn).toEqual("arn:aws:ses:us-blue-5:12345678:identity/test.example.com"); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "noImplicitAny": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "strictNullChecks": true 11 | }, 12 | "include": [ 13 | "src/**/*.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@prescott/tslint-preset"], 3 | "rules": { 4 | "quotemark": [true, "double"] 5 | } 6 | } 7 | --------------------------------------------------------------------------------