Repository: serverless/serverless (JavaScript) — Feature

Issue:
Add maxAge option for CORS preflight requests in API Gateway
# This is a Feature Proposal

## Description

As a developer using CORS for cross-origin usage of my API, I don't want the extra latency that results from CORS preflight requests happening all the time. I'd like to be able to configure Serverless to set the `Access-Control-Max-Age` header on preflight responses so that they may be cached for as long as the browser will allow.

Some background on the topic:

Serverless Framework has [CORS support](https://serverless.com/framework/docs/providers/aws/events/apigateway#enabling-cors) for controlling access to resources on different origins. Under CORS, non-simple requests can't be made until a "preflight request" is made to check if CORS rules permit the original request; it's an OPTIONS query and the response has a bunch of "Access-Control-*" headers which describe the rules. One of these headers that can be returned from these preflight requests is `Access-Control-Max-Age`: this defines the time in seconds that the results of a preflight request may be cached. Browsers cut this off at varying points (24hrs for Firefox, 10min for Chrome AFAIK), but one preflight request every 10 minutes is substantially better than the default.

To implement, `serverless.yml` would need a `maxAge` option in the `cors` section, like this:

```
cors:
  origin: 'https://mydomain.com'
  maxAge: 86400
  headers:
    - Authorization
    - Content-Type
```

and the corresponding implementation [here](https://github.com/serverless/serverless/blob/master/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js#L25) would need to conditionally set the `Access-Control-Max-Age` setting based on that value.

## Additional Data

* ***Serverless Framework Version you're using***: 1.24.1
* ***Operating System***: Solus
* ***Stack Trace***: N/A
* ***Provider Error messages***: N/A



Discussion:
@pshendry Good catch 👍 . I agree that SLS should support the `max-age` header on CORS preflight calls.


Resolving patch:
diff --git a/docs/providers/aws/events/apigateway.md b/docs/providers/aws/events/apigateway.md
index c5a6f0dac51..c6b8f80418a 100644
--- a/docs/providers/aws/events/apigateway.md
+++ b/docs/providers/aws/events/apigateway.md
@@ -224,6 +224,21 @@ functions:
 
 Configuring the `cors` property sets  [Access-Control-Allow-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin), [Access-Control-Allow-Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers), [Access-Control-Allow-Methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods),[Access-Control-Allow-Credentials](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials) headers in the CORS preflight response.
 
+To enable the `Access-Control-Max-Age` preflight response header, set the `maxAge` property in the `cors` object:
+
+```yml
+functions:
+  hello:
+    handler: handler.hello
+    events:
+      - http:
+          path: hello
+          method: get
+          cors:
+            origin: '*'
+            maxAge: 86400
+```
+
 If you want to use CORS with the lambda-proxy integration, remember to include the `Access-Control-Allow-*` headers in your headers object, like this:
 
 ```javascript
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js
index 167d113fd0a..8b4dc474791 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js
@@ -25,6 +25,16 @@ module.exports = {
         'Access-Control-Allow-Credentials': `'${config.allowCredentials}'`,
       };
 
+      // Enable CORS Max Age usage if set
+      if (_.has(config, 'maxAge')) {
+        if (_.isInteger(config.maxAge) && config.maxAge > 0) {
+          preflightHeaders['Access-Control-Max-Age'] = `'${config.maxAge}'`;
+        } else {
+          const errorMessage = 'maxAge should be an integer over 0';
+          throw new this.serverless.classes.Error(errorMessage);
+        }
+      }
+
       if (_.includes(config.methods, 'ANY')) {
         preflightHeaders['Access-Control-Allow-Methods'] =
           preflightHeaders['Access-Control-Allow-Methods']
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
index 936931cdbe8..6d1b82947bb 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
@@ -61,6 +61,11 @@ module.exports = {
             cors.origin = http.cors.origin || '*';
             cors.allowCredentials = cors.allowCredentials || http.cors.allowCredentials;
 
+            // when merging, last one defined wins
+            if (_.has(http.cors, 'maxAge')) {
+              cors.maxAge = http.cors.maxAge;
+            }
+
             corsPreflight[http.path] = cors;
           }
 
@@ -332,10 +337,15 @@ module.exports = {
       if (cors.methods.indexOf(http.method.toUpperCase()) === NOT_FOUND) {
         cors.methods.push(http.method.toUpperCase());
       }
+      if (_.has(cors, 'maxAge')) {
+        if (!_.isInteger(cors.maxAge) || cors.maxAge < 1) {
+          const errorMessage = 'maxAge should be an integer over 0';
+          throw new this.serverless.classes.Error(errorMessage);
+        }
+      }
     } else {
       cors.methods.push(http.method.toUpperCase());
     }
-
     return cors;
   },
 


Test patch:
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js
index db2d06c876b..64f65624b4c 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js
@@ -67,18 +67,21 @@ describe('#compileCors()', () => {
         headers: ['*'],
         methods: ['OPTIONS', 'PUT'],
         allowCredentials: false,
+        maxAge: 86400,
       },
       'users/create': {
         origins: ['*', 'http://example.com'],
         headers: ['*'],
         methods: ['OPTIONS', 'POST'],
         allowCredentials: true,
+        maxAge: 86400,
       },
       'users/delete': {
         origins: ['*'],
         headers: ['CustomHeaderA', 'CustomHeaderB'],
         methods: ['OPTIONS', 'DELETE'],
         allowCredentials: false,
+        maxAge: 86400,
       },
       'users/any': {
         origins: ['http://example.com'],
@@ -117,6 +120,13 @@ describe('#compileCors()', () => {
           .ResponseParameters['method.response.header.Access-Control-Allow-Credentials']
       ).to.equal('\'true\'');
 
+      expect(
+        awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
+          .Resources.ApiGatewayMethodUsersCreateOptions
+          .Properties.Integration.IntegrationResponses[0]
+          .ResponseParameters['method.response.header.Access-Control-Max-Age']
+      ).to.equal('\'86400\'');
+
       // users/update
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
@@ -139,6 +149,13 @@ describe('#compileCors()', () => {
           .ResponseParameters['method.response.header.Access-Control-Allow-Credentials']
       ).to.equal('\'false\'');
 
+      expect(
+        awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
+          .Resources.ApiGatewayMethodUsersUpdateOptions
+          .Properties.Integration.IntegrationResponses[0]
+          .ResponseParameters['method.response.header.Access-Control-Max-Age']
+      ).to.equal('\'86400\'');
+
       // users/delete
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
@@ -168,6 +185,13 @@ describe('#compileCors()', () => {
           .ResponseParameters['method.response.header.Access-Control-Allow-Credentials']
       ).to.equal('\'false\'');
 
+      expect(
+        awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
+          .Resources.ApiGatewayMethodUsersDeleteOptions
+          .Properties.Integration.IntegrationResponses[0]
+          .ResponseParameters['method.response.header.Access-Control-Max-Age']
+      ).to.equal('\'86400\'');
+
       // users/any
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
@@ -198,4 +222,34 @@ describe('#compileCors()', () => {
       ).to.equal('\'false\'');
     });
   });
+
+  it('should throw error if maxAge is not an integer greater than 0', () => {
+    awsCompileApigEvents.validated.corsPreflight = {
+      'users/update': {
+        origin: 'http://example.com',
+        headers: ['*'],
+        methods: ['OPTIONS', 'PUT'],
+        allowCredentials: false,
+        maxAge: -1,
+      },
+    };
+
+    expect(() => awsCompileApigEvents.compileCors())
+      .to.throw(Error, 'maxAge should be an integer over 0');
+  });
+
+  it('should throw error if maxAge is not an integer', () => {
+    awsCompileApigEvents.validated.corsPreflight = {
+      'users/update': {
+        origin: 'http://example.com',
+        headers: ['*'],
+        methods: ['OPTIONS', 'PUT'],
+        allowCredentials: false,
+        maxAge: 'five',
+      },
+    };
+
+    expect(() => awsCompileApigEvents.compileCors())
+      .to.throw(Error, 'maxAge should be an integer over 0');
+  });
 });
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
index 4e481c9d8b5..6cbbfaac553 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
@@ -643,6 +643,7 @@ describe('#validate()', () => {
                 headers: ['X-Foo-Bar'],
                 origins: ['acme.com'],
                 methods: ['POST', 'OPTIONS'],
+                maxAge: 86400,
               },
             },
           },
@@ -657,10 +658,11 @@ describe('#validate()', () => {
       methods: ['POST', 'OPTIONS'],
       origins: ['acme.com'],
       allowCredentials: false,
+      maxAge: 86400,
     });
   });
 
-  it('should merge all preflight origins, method, headers and allowCredentials for a path', () => {
+  it('should merge all preflight cors options for a path', () => {
     awsCompileApigEvents.serverless.service.functions = {
       first: {
         events: [
@@ -673,6 +675,7 @@ describe('#validate()', () => {
                   'http://example.com',
                 ],
                 allowCredentials: true,
+                maxAge: 10000,
               },
             },
           }, {
@@ -683,6 +686,7 @@ describe('#validate()', () => {
                 origins: [
                   'http://example2.com',
                 ],
+                maxAge: 86400,
               },
             },
           }, {
@@ -717,12 +721,35 @@ describe('#validate()', () => {
       .to.deep.equal(['http://example2.com', 'http://example.com']);
     expect(validated.corsPreflight['users/{id}'].headers)
       .to.deep.equal(['TestHeader2', 'TestHeader']);
+    expect(validated.corsPreflight.users.maxAge)
+      .to.equal(86400);
     expect(validated.corsPreflight.users.allowCredentials)
       .to.equal(true);
     expect(validated.corsPreflight['users/{id}'].allowCredentials)
       .to.equal(false);
   });
 
+  it('should throw an error if the maxAge is not a positive integer', () => {
+    awsCompileApigEvents.serverless.service.functions = {
+      first: {
+        events: [
+          {
+            http: {
+              method: 'POST',
+              path: '/foo/bar',
+              cors: {
+                origin: '*',
+                maxAge: -1,
+              },
+            },
+          },
+        ],
+      },
+    };
+
+    expect(() => awsCompileApigEvents.validate()).to.throw(Error);
+  });
+
   it('should add default statusCode to custom statusCodes', () => {
     awsCompileApigEvents.serverless.service.functions = {
       first: {