Repository: amplify-education/serverless-domain-manager (ts)

Issue:
Enable Mutual TLS authentication
<!--- Please keep this note for the community --->

### Community Note

* Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request
* Please do not leave "+1" or "me too" comments, they generate extra noise for issue followers and do not help prioritize the request
* If you are interested in working on this issue or have submitted a pull request, please leave a comment

<!--- Thank you for keeping this note for the community --->

## Feature Request

**Is your feature request related to a problem? Please describe.**

API gateway provide us with a method to authenticate http requests using client-server certificates. We are missing this option. Refer to [https://aws.amazon.com/blogs/compute/introducing-mutual-tls-authentication-for-amazon-api-gateway/](https://aws.amazon.com/blogs/compute/introducing-mutual-tls-authentication-for-amazon-api-gateway/)

**Describe the solution you'd like**
A clear and concise description of what you want to happen. Add any considered drawbacks, and what `serverless.yml` would look like:

```yaml
custom:
  customDomain:
    tlsTruststore: 's3://my-bucket/my-trust.pem'
```

**Describe alternatives you've considered**
Move to terraform

**Any Other Info**
If you can, maybe sketch out implementation details? If you've started a PR for this feature, link it here.


Pull request:
Add support for mutual TLS authentication for regional APIs
<!--- Information about referencing Github Issues: https://help.github.com/articles/basic-writing-and-formatting-syntax/#referencing-issues-and-pull-requests --->
Fixes #503

**Description of Issue Fixed**
AWS added support for mutual TLS authentication to API Gateway regional APIs. Current version of **serverless-domain-manager** doesn't support it. This PR aims to solve that issue.

**Changes proposed in this pull request**:

* `CustomDomain` extended with two optional properties: `tlsTruststoreUri` and `tlsTruststoreVersion`. Whenever `tlsTruststoreUri` is given, the plugin now creates domain names with mutual TLS enabled.
* `tlsTruststoreUri` must be an s3 uri. The plugin now checks whether that file exists or not. If the plugin hasn't access to S3 (which may happen for several reasons), then it assumes the file exists and continues without failing.

<!--- Please remember to allow edits from maintainers: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork --->


Patch:
diff --git a/README.md b/README.md
index 95a87cf..99ab68b 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,8 @@ route53:ChangeResourceRecordSets    hostedzone/{HostedZoneId}
 route53:GetHostedZone               *
 route53:ListResourceRecordSets      *
 iam:CreateServiceLinkedRole         arn:aws:iam::${AWS::AccountId}: role/aws-service-role/ops.apigateway.amazonaws.com/AWSServiceRoleForAPIGateway
+s3:ListBucket                       *
+s3:GetObject                        *
 ```
 ### CloudFormation
 Alternatively you can generate an least privileged IAM Managed Policy for deployment with this:
@@ -153,6 +155,8 @@ custom:
 | route53Region | `(none)` | Region to send Route53 services requests to (only applicable if also using route53Profile option) |
 | endpointType | edge | Defines the endpoint type, accepts `regional` or `edge`. |
 | apiType | rest | Defines the api type, accepts `rest`, `http` or `websocket`. |
+| tlsTruststoreUri | `undefined` | An Amazon S3 url that specifies the truststore for mutual TLS authentication, for example `s3://bucket-name/key-name`. The truststore can contain certificates from public or private certificate authorities. Be aware mutual TLS is only available for `regional` APIs. |
+| tlsTruststoreVersion | `undefined` | The version of the S3 object that contains your truststore. To specify a version, you must have versioning enabled for the S3 bucket. |
 | hostedZoneId | | If hostedZoneId is set the route53 record set will be created in the matching zone, otherwise the hosted zone will be figured out from the domainName (hosted zone with matching domain). |
 | hostedZonePrivate | | If hostedZonePrivate is set to `true` then only private hosted zones will be used for route 53 records. If it is set to `false` then only public hosted zones will be used for route53 records. Setting this parameter is specially useful if you have multiple hosted zones with the same domain name (e.g. a public and a private one) |
 | enabled | true | Sometimes there are stages for which is not desired to have custom domain names. This flag allows the developer to disable the plugin for such cases. Accepts either `boolean` or `string` values and defaults to `true` for backwards compatibility. |
diff --git a/src/aws/api-gateway-wrapper.ts b/src/aws/api-gateway-wrapper.ts
index 5f6eaaa..7766114 100644
--- a/src/aws/api-gateway-wrapper.ts
+++ b/src/aws/api-gateway-wrapper.ts
@@ -29,6 +29,7 @@ class APIGatewayWrapper {
 
         // For EDGE domain name or TLS 1.0, create with APIGateway (v1)
         const isEdgeType = domain.endpointType === Globals.endpointTypes.edge;
+        const hasMutualTls = !!domain.tlsTruststoreUri;
         if (isEdgeType || domain.securityPolicy === "TLS_1_0") {
             // Set up parameters
             const params = {
@@ -41,6 +42,16 @@ class APIGatewayWrapper {
                 tags: providerTags,
             };
 
+            if (!isEdgeType && hasMutualTls) {
+                params.mutualTlsAuthentication = {
+                    truststoreUri: domain.tlsTruststoreUri
+                };
+
+                if (domain.tlsTruststoreVersion) {
+                    params.truststoreVersion = domain.tlsTruststoreVersion;
+                }
+            }
+
             // Make API call to create domain
             try {
                 // Creating EDGE domain so use APIGateway (v1) service
@@ -51,7 +62,7 @@ class APIGatewayWrapper {
             }
 
         } else { // For Regional domain name create with ApiGatewayV2
-            const params = {
+            const params: any = {
                 DomainName: domain.givenDomainName,
                 DomainNameConfigurations: [{
                     CertificateArn: domain.certificateArn,
@@ -61,6 +72,16 @@ class APIGatewayWrapper {
                 Tags: providerTags
             };
 
+            if (!isEdgeType && hasMutualTls) {
+                params.MutualTlsAuthentication = {
+                    TruststoreUri: domain.tlsTruststoreUri
+                };
+
+                if (domain.tlsTruststoreVersion) {
+                    params.TruststoreVersion = domain.tlsTruststoreVersion;
+                }
+            }
+
             // Make API call to create domain
             try {
                 // Creating Regional domain so use ApiGatewayV2
diff --git a/src/aws/s3-wrapper.ts b/src/aws/s3-wrapper.ts
new file mode 100644
index 0000000..745d131
--- /dev/null
+++ b/src/aws/s3-wrapper.ts
@@ -0,0 +1,45 @@
+import {S3} from "aws-sdk";
+import {throttledCall} from "../utils";
+import DomainConfig = require("../domain-config");
+import Globals from "../globals";
+
+class S3Wrapper {
+    public s3: S3;
+
+    constructor(credentials: any) {
+        this.s3 = new S3(credentials);
+    }
+
+    /**
+     * * Checks whether the Mutual TLS certificate exists in S3 or not
+     */
+    public async assertTlsCertObjectExists(domain: DomainConfig): Promise<void> {
+        try {
+            const {Bucket, Key} = this.extractBucketAndKey(domain.tlsTruststoreUri);
+            const params: S3.Types.HeadObjectRequest = {Bucket, Key};
+
+            if (domain.tlsTruststoreVersion) {
+                params.VersionId = domain.tlsTruststoreVersion;
+            }
+
+            await throttledCall(this.s3, "headObject", params);
+        } catch (err) {
+            if (err.code !== "AccessDenied") {
+                throw Error(`Could not head S3 object at ${domain.tlsTruststoreUri}.\n${err.message}`);
+            }
+
+            Globals.logWarning(`Unable to check existance of S3 object at ${domain.tlsTruststoreUri} due to\n${err.message}`);
+        }
+    }
+
+    /**
+     * * Extracts Bucket and Key from the given s3 uri
+     */
+    private extractBucketAndKey(uri: string): { Bucket: string; Key: string } {
+        const { hostname, pathname } = new URL(uri);
+
+        return { Bucket: hostname, Key: pathname.substring(1) };
+    }
+}
+
+export = S3Wrapper;
diff --git a/src/domain-config.ts b/src/domain-config.ts
index de5d6bf..d5da9a8 100644
--- a/src/domain-config.ts
+++ b/src/domain-config.ts
@@ -21,6 +21,8 @@ class DomainConfig {
     public route53Region: string | undefined;
     public endpointType: string | undefined;
     public apiType: string | undefined;
+    public tlsTruststoreUri: string | undefined;
+    public tlsTruststoreVersion: string | undefined;
     public hostedZoneId: string | undefined;
     public hostedZonePrivate: boolean | undefined;
     public enabled: boolean | string | undefined;
@@ -78,6 +80,17 @@ class DomainConfig {
         }
         this.apiType = apiTypeToUse;
 
+        const isEdgeType = this.endpointType === Globals.endpointTypes.edge;
+        const hasMutualTls = !!config.tlsTruststoreUri;
+        if (isEdgeType && hasMutualTls) {
+            throw new Error(`${this.endpointType} APIs do not support mutual TLS, remove tlsTruststoreUri or change to a regional API.`);
+        }
+        if (config.tlsTruststoreUri) {
+            this.validateS3Uri(config.tlsTruststoreUri);
+        }
+        this.tlsTruststoreUri = config.tlsTruststoreUri;
+        this.tlsTruststoreVersion = config.tlsTruststoreVersion;
+
         const securityPolicyDefault = config.securityPolicy || Globals.tlsVersions.tls_1_2;
         const tlsVersionToUse = Globals.tlsVersions[securityPolicyDefault.toLowerCase()];
         if (!tlsVersionToUse) {
@@ -106,6 +119,14 @@ class DomainConfig {
             healthCheckId: config.route53Params?.healthCheckId
         }
     }
+
+    private validateS3Uri(uri: string): void {
+        const { protocol, pathname } = new URL(uri);
+
+        if (protocol !== "s3:" && !pathname.substring(1).includes("/")) {
+            throw new Error(`${uri} is not a valid s3 uri, try something like s3://bucket-name/key-name.`);
+        }
+    }
 }
 
 export = DomainConfig;
diff --git a/src/index.ts b/src/index.ts
index 56933a2..30fd13c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,6 +4,7 @@ import ACMWrapper = require("./aws/acm-wrapper");
 import APIGatewayWrapper = require("./aws/api-gateway-wrapper");
 import CloudFormationWrapper = require("./aws/cloud-formation-wrapper");
 import Route53Wrapper = require("./aws/route53-wrapper");
+import S3Wrapper = require("./aws/s3-wrapper");
 import DomainConfig = require("./domain-config");
 import Globals from "./globals";
 import {CustomDomain, ServerlessInstance, ServerlessOptions, ServerlessUtils} from "./types";
@@ -14,6 +15,7 @@ class ServerlessCustomDomain {
     // AWS SDK resources
     public apiGatewayWrapper: APIGatewayWrapper;
     public cloudFormationWrapper: CloudFormationWrapper;
+    public s3Wrapper: S3Wrapper;
 
     // Serverless specific properties
     public serverless: ServerlessInstance;
@@ -168,6 +170,7 @@ class ServerlessCustomDomain {
 
         this.apiGatewayWrapper = new APIGatewayWrapper(credentials);
         this.cloudFormationWrapper = new CloudFormationWrapper(credentials);
+        this.s3Wrapper = new S3Wrapper(credentials);
     }
 
     /**
@@ -190,6 +193,10 @@ class ServerlessCustomDomain {
         const route53 = new Route53Wrapper(domain.route53Profile, domain.route53Region);
         const acm = new ACMWrapper(domain.endpointType);
         try {
+            if (domain.tlsTruststoreUri) {
+                await this.s3Wrapper.assertTlsCertObjectExists(domain);
+            }
+
             if (!domain.domainInfo) {
                 if (!domain.certificateArn) {
                     const searchName = domain.certificateName || domain.givenDomainName;
diff --git a/src/types.ts b/src/types.ts
index 2230186..a1f6e35 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -12,6 +12,8 @@ export interface CustomDomain { // tslint:disable-line
     route53Region: string | undefined;
     endpointType: string | undefined;
     apiType: string | undefined;
+    tlsTruststoreUri: string | undefined;
+    tlsTruststoreVersion: string | undefined;
     hostedZoneId: string | undefined;
     hostedZonePrivate: boolean | undefined;
     enabled: boolean | string | undefined;
@@ -52,6 +54,7 @@ export interface ServerlessInstance { // tslint:disable-line
                 Route53: any,
                 CloudFormation: any,
                 ACM: any,
+                S3: any,
                 config: {
                     httpOptions: HTTPOptions,
                     update(toUpdate: object): void,


Test patch:
diff --git a/test/unit-tests/index.test.ts b/test/unit-tests/index.test.ts
index 55973d3..4bc7b69 100644
--- a/test/unit-tests/index.test.ts
+++ b/test/unit-tests/index.test.ts
@@ -10,6 +10,7 @@ import ServerlessCustomDomain = require("../../src/index");
 import {getAWSPagedResults} from "../../src/utils";
 import Route53Wrapper = require("../../src/aws/route53-wrapper");
 import ACMWrapper = require("../../src/aws/acm-wrapper");
+import S3Wrapper = require("../../src/aws/s3-wrapper");
 
 const expect = chai.expect;
 chai.use(spies);
@@ -58,6 +59,8 @@ const constructPlugin = (customDomainOptions, multiple: boolean = false) => {
         domainName: customDomainOptions.domainName,
         enabled: customDomainOptions.enabled,
         endpointType: customDomainOptions.endpointType,
+        tlsTruststoreUri: customDomainOptions.tlsTruststoreUri,
+        tlsTruststoreVersion: customDomainOptions.tlsTruststoreVersion,
         hostedZoneId: customDomainOptions.hostedZoneId,
         hostedZonePrivate: customDomainOptions.hostedZonePrivate,
         route53Profile: customDomainOptions.route53Profile,
@@ -88,6 +91,7 @@ const constructPlugin = (customDomainOptions, multiple: boolean = false) => {
                     CloudFormation: aws.CloudFormation,
                     Route53: aws.Route53,
                     SharedIniFileCredentials: aws.SharedIniFileCredentials,
+                    S3: aws.S3,
                     config: {
                         httpOptions: {
                             timeout: 5000,
@@ -568,6 +572,107 @@ describe("Custom Domain Plugin", () => {
         });
     });
 
+    describe("Check Mutual TLS certificate existance in S3", () => {
+        it("Should check existance of certificate in S3", async () => {
+            AWS.mock("S3", "headObject", (params, callback) => {
+                callback(null, params);
+            });
+            const options = {
+                domainName: "test_domain",
+                endpointType: "regional",
+                tlsTruststoreUri: 's3://test_bucket/test_key'
+            };
+            const plugin = constructPlugin(options);
+            plugin.initializeVariables();
+
+            const s3Wrapper = new S3Wrapper({});
+            const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain);
+
+            const spy = chai.spy.on(s3Wrapper.s3, "headObject");
+            await s3Wrapper.assertTlsCertObjectExists(dc);
+            const expectedParams = {
+                Bucket: 'test_bucket',
+                Key: 'test_key'
+            }
+            expect(spy).to.have.been.called.with(expectedParams);
+        });
+
+        it("Should check existance of a concrete certificate version in S3", async () => {
+            AWS.mock("S3", "headObject", (params, callback) => {
+                callback(null, params);
+            });
+            const options = {
+                domainName: "test_domain",
+                endpointType: "regional",
+                tlsTruststoreUri: 's3://test_bucket/test_key',
+                tlsTruststoreVersion: 'test_version'
+            };
+            const plugin = constructPlugin(options);
+            plugin.initializeVariables();
+
+            const s3Wrapper = new S3Wrapper({});
+            const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain);
+
+            const spy = chai.spy.on(s3Wrapper.s3, "headObject");
+            await s3Wrapper.assertTlsCertObjectExists(dc);
+            const expectedParams = {
+                Bucket: 'test_bucket',
+                Key: 'test_key',
+                VersionId: 'test_version'
+            }
+            expect(spy).to.have.been.called.with(expectedParams);
+        });
+
+        it('should fail when the mutual TLS certificate is not stored in S3', async () => {
+            AWS.mock("S3", "headObject", (params, callback) => {
+                // @ts-ignore
+                callback({code: 'NotFound'}, null);
+            });
+            const options = {
+                domainName: "test_domain",
+                endpointType: "regional",
+                tlsTruststoreUri: 's3://test_bucket/test_key'
+            };
+            const plugin = constructPlugin(options);
+            plugin.initializeVariables();
+
+            const s3Wrapper = new S3Wrapper({});
+            const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain);
+
+            try {
+                await s3Wrapper.assertTlsCertObjectExists(dc);
+            } catch(e) {
+                expect(e.message).to.contain('Could not head S3 object');
+            }
+        });
+
+        it("Should not fail due to lack of S3 permissions", async () => {
+            AWS.mock("S3", "headObject", (params, callback) => {
+                // @ts-ignore
+                callback({code: 'AccessDenied'}, null);
+            });
+            const options = {
+                domainName: "test_domain",
+                endpointType: "regional",
+                tlsTruststoreUri: 's3://test_bucket/test_key'
+            };
+            const plugin = constructPlugin(options);
+            plugin.initializeVariables();
+
+            const s3Wrapper = new S3Wrapper({});
+            const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain);
+
+            let err;
+            try {
+                await s3Wrapper.assertTlsCertObjectExists(dc);
+            } catch(e) {
+                err = e;
+            } finally {
+                expect(err).to.equal(undefined);
+            }
+        });
+    });
+
     describe("Create a New Domain Name", () => {
         it("Get a given certificate by given domain name ", async () => {
             AWS.mock("ACM", "listCertificates", certTestData);
@@ -709,6 +814,80 @@ describe("Custom Domain Plugin", () => {
             expect(spy).to.have.been.called.with(expectedParams);
         });
 
+        it("Create a domain name with mutual TLS authentication", async () => {
+            AWS.mock("APIGateway", "createDomainName", (params, callback) => {
+                callback(null, {distributionDomainName: "foo", securityPolicy: "TLS_1_0"});
+            });
+
+            const plugin = constructPlugin({
+                domainName: "test_domain",
+                endpointType: "regional",
+                securityPolicy: "tls_1_0",
+                tlsTruststoreUri: "s3://bucket-name/key-name"
+            });
+            plugin.initializeVariables();
+            plugin.initAWSResources();
+
+            const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain);
+            dc.certificateArn = "fake_cert";
+
+            const spy = chai.spy.on(plugin.apiGatewayWrapper.apiGateway, "createDomainName");
+            await plugin.apiGatewayWrapper.createCustomDomain(dc);
+            const expectedParams = {
+                domainName: dc.givenDomainName,
+                endpointConfiguration: {
+                    types: [dc.endpointType],
+                },
+                mutualTlsAuthentication: {
+                    truststoreUri: dc.tlsTruststoreUri
+                },
+                securityPolicy: dc.securityPolicy,
+                tags: {
+                    ...plugin.serverless.service.provider.stackTags,
+                    ...plugin.serverless.service.provider.tags,
+                },
+                regionalCertificateArn: dc.certificateArn
+            }
+            expect(spy).to.have.been.called.with(expectedParams);
+        });
+
+        it("Create an HTTP domain name with mutual TLS authentication", async () => {
+            AWS.mock("ApiGatewayV2", "createDomainName", (params, callback) => {
+                callback(null, params);
+            });
+
+            const plugin = constructPlugin({
+                domainName: "test_domain",
+                endpointType: "regional",
+                apiType: "http",
+                tlsTruststoreUri: "s3://bucket-name/key-name"
+            });
+            plugin.initializeVariables();
+            plugin.initAWSResources();
+
+            const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain);
+            dc.certificateArn = "fake_cert";
+
+            const spy = chai.spy.on(plugin.apiGatewayWrapper.apiGatewayV2, "createDomainName");
+            await plugin.apiGatewayWrapper.createCustomDomain(dc);
+            const expectedParams = {
+                DomainName: dc.givenDomainName,
+                DomainNameConfigurations: [{
+                    CertificateArn: dc.certificateArn,
+                    EndpointType: dc.endpointType,
+                    SecurityPolicy: dc.securityPolicy
+                }],
+                MutualTlsAuthentication: {
+                    TruststoreUri: dc.tlsTruststoreUri
+                },
+                Tags: {
+                    ...plugin.serverless.service.provider.stackTags,
+                    ...plugin.serverless.service.provider.tags,
+                }
+            }
+            expect(spy).to.have.been.called.with(expectedParams);
+        });
+
         it("Create new A and AAAA Alias Records", async () => {
             AWS.mock("Route53", "listHostedZones", (params, callback) => {
                 // @ts-ignore
@@ -1623,6 +1802,29 @@ describe("Custom Domain Plugin", () => {
             expect(consoleOutput[0]).to.contain("test message");
         });
 
+        it('should fail when the mutual TLS certificate is not stored in S3', async () => {
+            AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => {
+                callback(null, {DomainName: "test_domain", DomainNameConfigurations: [{HostedZoneId: "test_id"}]});
+            });
+            AWS.mock("S3", "headObject", (params, callback) => {
+                // @ts-ignore
+                callback({code: 'NotFound'}, null);
+            });
+            const plugin = constructPlugin({
+                domainName: "test_domain",
+                endpointType: "regional",
+                tlsTruststoreUri: 's3://test_bucket/test_key'
+            });
+            plugin.initializeVariables();
+            plugin.initAWSResources();
+
+            try {
+                await plugin.createDomains();
+            } catch(e) {
+                expect(e.message).to.contain('Could not head S3 object');
+            }
+        });
+
         afterEach(() => {
             AWS.restore();
             consoleOutput = [];
@@ -1780,6 +1982,32 @@ describe("Custom Domain Plugin", () => {
             expect(errored).to.equal(true);
         });
 
+        it("Should throw an Error when mutual TLS is enabled for edge APIs", async () => {
+            const plugin = constructPlugin({endpointType: "edge", tlsTruststoreUri: "s3://bucket-name/key-name"});
+
+            let errored = false;
+            try {
+                await plugin.hookWrapper(null);
+            } catch (err) {
+                errored = true;
+                expect(err.message).to.equal(`EDGE APIs do not support mutual TLS, remove tlsTruststoreUri or change to a regional API.`);
+            }
+            expect(errored).to.equal(true);
+        });
+
+        it("Should throw an Error when mutual TLS uri is not an S3 uri", async () => {
+            const plugin = constructPlugin({endpointType: "regional", tlsTruststoreUri: "http://example.com"});
+
+            let errored = false;
+            try {
+                await plugin.hookWrapper(null);
+            } catch (err) {
+                errored = true;
+                expect(err.message).to.equal(`http://example.com is not a valid s3 uri, try something like s3://bucket-name/key-name.`);
+            }
+            expect(errored).to.equal(true);
+        });
+
         afterEach(() => {
             consoleOutput = [];
         });