Skip to content

Commit df32263

Browse files
castkeetonian
authored andcommitted
feat(cors): support allow credentials cors configuration (#464)
1 parent 8de5e86 commit df32263

17 files changed

+769
-37
lines changed

samtranslator/model/api/api_generator.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
from samtranslator.translator.arn_generator import ArnGenerator
1414

1515
_CORS_WILDCARD = "'*'"
16-
CorsProperties = namedtuple("_CorsProperties", ["AllowMethods", "AllowHeaders", "AllowOrigin", "MaxAge"])
17-
# Default the Cors Properties to '*' wildcard. Other properties are actually Optional
18-
CorsProperties.__new__.__defaults__ = (None, None, _CORS_WILDCARD, None)
16+
CorsProperties = namedtuple("_CorsProperties", ["AllowMethods", "AllowHeaders", "AllowOrigin", "MaxAge", "AllowCredentials"])
17+
# Default the Cors Properties to '*' wildcard and False AllowCredentials. Other properties are actually Optional
18+
CorsProperties.__new__.__defaults__ = (None, None, _CORS_WILDCARD, None, False)
1919

2020
AuthProperties = namedtuple("_AuthProperties", ["Authorizers", "DefaultAuthorizer"])
2121
AuthProperties.__new__.__defaults__ = (None, None)
@@ -214,10 +214,15 @@ def _add_cors(self):
214214
raise InvalidResourceException(self.logical_id, "Unable to add Cors configuration because "
215215
"'DefinitionBody' does not contain a valid Swagger")
216216

217+
if properties.AllowCredentials is True and properties.AllowOrigin == _CORS_WILDCARD:
218+
raise InvalidResourceException(self.logical_id, "Unable to add Cors configuration because "
219+
"'AllowCredentials' can not be true when "
220+
"'AllowOrigin' is \"'*'\" or not set")
221+
217222
editor = SwaggerEditor(self.definition_body)
218223
for path in editor.iter_on_path():
219224
editor.add_cors(path, properties.AllowOrigin, properties.AllowHeaders, properties.AllowMethods,
220-
max_age=properties.MaxAge)
225+
max_age=properties.MaxAge, allow_credentials=properties.AllowCredentials)
221226

222227
# Assign the Swagger back to template
223228
self.definition_body = editor.swagger

samtranslator/swagger/swagger.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class SwaggerEditor(object):
1616
_X_APIGW_INTEGRATION = 'x-amazon-apigateway-integration'
1717
_X_ANY_METHOD = 'x-amazon-apigateway-any-method'
1818

19+
1920
def __init__(self, doc):
2021
"""
2122
Initialize the class with a swagger dictionary. This class creates a copy of the Swagger and performs all
@@ -118,7 +119,8 @@ def iter_on_path(self):
118119
for path, value in self.paths.items():
119120
yield path
120121

121-
def add_cors(self, path, allowed_origins, allowed_headers=None, allowed_methods=None, max_age=None):
122+
def add_cors(self, path, allowed_origins, allowed_headers=None, allowed_methods=None, max_age=None,
123+
allow_credentials=None):
122124
"""
123125
Add CORS configuration to this path. Specifically, we will add a OPTIONS response config to the Swagger that
124126
will return headers required for CORS. Since SAM uses aws_proxy integration, we cannot inject the headers
@@ -139,6 +141,7 @@ def add_cors(self, path, allowed_origins, allowed_headers=None, allowed_methods=
139141
Value can also be an intrinsic function dict.
140142
:param integer/dict max_age: Maximum duration to cache the CORS Preflight request. Value is set on
141143
Access-Control-Max-Age header. Value can also be an intrinsic function dict.
144+
:param bool/None allow_credentials: Flags whether request is allowed to contain credentials.
142145
:raises ValueError: When values for one of the allowed_* variables is empty
143146
"""
144147

@@ -156,15 +159,19 @@ def add_cors(self, path, allowed_origins, allowed_headers=None, allowed_methods=
156159
# APIGW expects the value to be a "string expression". Hence wrap in another quote. Ex: "'GET,POST,DELETE'"
157160
allowed_methods = "'{}'".format(allowed_methods)
158161

162+
if allow_credentials is not True:
163+
allow_credentials = False
164+
159165
# Add the Options method and the CORS response
160166
self.add_path(path, self._OPTIONS_METHOD)
161167
self.paths[path][self._OPTIONS_METHOD] = self._options_method_response_for_cors(allowed_origins,
162168
allowed_headers,
163169
allowed_methods,
164-
max_age)
170+
max_age,
171+
allow_credentials)
165172

166173
def _options_method_response_for_cors(self, allowed_origins, allowed_headers=None, allowed_methods=None,
167-
max_age=None):
174+
max_age=None, allow_credentials=None):
168175
"""
169176
Returns a Swagger snippet containing configuration for OPTIONS HTTP Method to configure CORS.
170177
@@ -179,6 +186,7 @@ def _options_method_response_for_cors(self, allowed_origins, allowed_headers=Non
179186
Value can also be an intrinsic function dict.
180187
:param integer/dict max_age: Maximum duration to cache the CORS Preflight request. Value is set on
181188
Access-Control-Max-Age header. Value can also be an intrinsic function dict.
189+
:param bool allow_credentials: Flags whether request is allowed to contain credentials.
182190
183191
:return dict: Dictionary containing Options method configuration for CORS
184192
"""
@@ -187,6 +195,7 @@ def _options_method_response_for_cors(self, allowed_origins, allowed_headers=Non
187195
ALLOW_HEADERS = "Access-Control-Allow-Headers"
188196
ALLOW_METHODS = "Access-Control-Allow-Methods"
189197
MAX_AGE = "Access-Control-Max-Age"
198+
ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"
190199
HEADER_RESPONSE = lambda x: "method.response.header."+x
191200

192201
response_parameters = {
@@ -217,6 +226,11 @@ def _options_method_response_for_cors(self, allowed_origins, allowed_headers=Non
217226
# MaxAge can be set to 0, which is a valid value. So explicitly check against None
218227
response_parameters[HEADER_RESPONSE(MAX_AGE)] = max_age
219228
response_headers[MAX_AGE] = {"type": "integer"}
229+
if allow_credentials is True:
230+
# Allow-Credentials only has a valid value of true, it should be omitted otherwise.
231+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
232+
response_parameters[HEADER_RESPONSE(ALLOW_CREDENTIALS)] = "'true'"
233+
response_headers[ALLOW_CREDENTIALS] = {"type": "string"}
220234

221235
return {
222236
"summary": "CORS support",

tests/swagger/test_swagger.py

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
_X_INTEGRATION = "x-amazon-apigateway-integration"
1010
_X_ANY_METHOD = 'x-amazon-apigateway-any-method'
11+
_ALLOW_CREDENTALS_TRUE = "'true'"
1112

1213
class TestSwaggerEditor_init(TestCase):
1314

@@ -308,18 +309,21 @@ def test_must_add_options_to_new_path(self):
308309
allowed_headers = ["headers", "2"]
309310
allowed_methods = {"key": "methods"}
310311
max_age = 60
312+
allow_credentials = True
313+
options_method_response_allow_credentials = True
311314
path = "/foo"
312315
expected = {"some cors": "return value"}
313316

314317
self.editor._options_method_response_for_cors = Mock()
315318
self.editor._options_method_response_for_cors.return_value = expected
316319

317-
self.editor.add_cors(path, allowed_origins, allowed_headers, allowed_methods, max_age)
320+
self.editor.add_cors(path, allowed_origins, allowed_headers, allowed_methods, max_age, allow_credentials)
318321
self.assertEquals(expected, self.editor.swagger["paths"][path]["options"])
319322
self.editor._options_method_response_for_cors.assert_called_with(allowed_origins,
320323
allowed_headers,
321324
allowed_methods,
322-
max_age)
325+
max_age,
326+
options_method_response_allow_credentials)
323327

324328
def test_must_skip_existing_path(self):
325329
path = "/withoptions"
@@ -346,28 +350,33 @@ def test_must_work_for_optional_allowed_headers(self):
346350
allowed_headers = None # No Value
347351
allowed_methods = "methods"
348352
max_age = 60
353+
allow_credentials = True
354+
options_method_response_allow_credentials = True
349355

350356
expected = {"some cors": "return value"}
351357
path = "/foo"
352358

353359
self.editor._options_method_response_for_cors = Mock()
354360
self.editor._options_method_response_for_cors.return_value = expected
355361

356-
self.editor.add_cors(path, allowed_origins, allowed_headers, allowed_methods, max_age)
362+
self.editor.add_cors(path, allowed_origins, allowed_headers, allowed_methods, max_age, allow_credentials)
357363

358364
self.assertEquals(expected, self.editor.swagger["paths"][path]["options"])
359365

360366
self.editor._options_method_response_for_cors.assert_called_with(allowed_origins,
361367
allowed_headers,
362368
allowed_methods,
363-
max_age)
369+
max_age,
370+
options_method_response_allow_credentials)
364371

365372
def test_must_make_default_value_with_optional_allowed_methods(self):
366373

367374
allowed_origins = "origins"
368375
allowed_headers = "headers"
369376
allowed_methods = None # No Value
370377
max_age = 60
378+
allow_credentials = True
379+
options_method_response_allow_credentials = True
371380

372381
default_allow_methods_value = "some default value"
373382
default_allow_methods_value_with_quotes = "'{}'".format(default_allow_methods_value)
@@ -380,7 +389,7 @@ def test_must_make_default_value_with_optional_allowed_methods(self):
380389
self.editor._options_method_response_for_cors = Mock()
381390
self.editor._options_method_response_for_cors.return_value = expected
382391

383-
self.editor.add_cors(path, allowed_origins, allowed_headers, allowed_methods, max_age)
392+
self.editor.add_cors(path, allowed_origins, allowed_headers, allowed_methods, max_age, allow_credentials)
384393

385394
self.assertEquals(expected, self.editor.swagger["paths"][path]["options"])
386395

@@ -389,7 +398,29 @@ def test_must_make_default_value_with_optional_allowed_methods(self):
389398
# Must be called with default value.
390399
# And value must be quoted
391400
default_allow_methods_value_with_quotes,
392-
max_age)
401+
max_age,
402+
options_method_response_allow_credentials)
403+
404+
def test_must_accept_none_allow_credentials(self):
405+
allowed_origins = "origins"
406+
allowed_headers = ["headers", "2"]
407+
allowed_methods = {"key": "methods"}
408+
max_age = 60
409+
allow_credentials = None
410+
options_method_response_allow_credentials = False
411+
path = "/foo"
412+
expected = {"some cors": "return value"}
413+
414+
self.editor._options_method_response_for_cors = Mock()
415+
self.editor._options_method_response_for_cors.return_value = expected
416+
417+
self.editor.add_cors(path, allowed_origins, allowed_headers, allowed_methods, max_age, allow_credentials)
418+
self.assertEquals(expected, self.editor.swagger["paths"][path]["options"])
419+
self.editor._options_method_response_for_cors.assert_called_with(allowed_origins,
420+
allowed_headers,
421+
allowed_methods,
422+
max_age,
423+
options_method_response_allow_credentials)
393424

394425

395426
class TestSwaggerEditor_options_method_response_for_cors(TestCase):
@@ -400,6 +431,7 @@ def test_correct_value_is_returned(self):
400431
methods = {"a": "b"}
401432
origins = [1,2,3]
402433
max_age = 60
434+
allow_credentials = True
403435

404436
expected = {
405437
"summary": "CORS support",
@@ -414,10 +446,11 @@ def test_correct_value_is_returned(self):
414446
"default": {
415447
"statusCode": "200",
416448
"responseParameters": {
449+
"method.response.header.Access-Control-Allow-Credentials": _ALLOW_CREDENTALS_TRUE,
417450
"method.response.header.Access-Control-Allow-Headers": headers,
418451
"method.response.header.Access-Control-Allow-Methods": methods,
419452
"method.response.header.Access-Control-Allow-Origin": origins,
420-
"method.response.header.Access-Control-Max-Age": max_age
453+
"method.response.header.Access-Control-Max-Age": max_age,
421454
},
422455
"responseTemplates": {
423456
"application/json": "{}\n"
@@ -429,6 +462,9 @@ def test_correct_value_is_returned(self):
429462
"200": {
430463
"description": "Default response for CORS method",
431464
"headers": {
465+
"Access-Control-Allow-Credentials": {
466+
"type": "string"
467+
},
432468
"Access-Control-Allow-Headers": {
433469
"type": "string"
434470
},
@@ -446,20 +482,27 @@ def test_correct_value_is_returned(self):
446482
}
447483
}
448484

449-
actual = SwaggerEditor(SwaggerEditor.gen_skeleton())._options_method_response_for_cors(origins, headers, methods, max_age)
485+
actual = SwaggerEditor(SwaggerEditor.gen_skeleton())._options_method_response_for_cors(origins, headers,
486+
methods, max_age,
487+
allow_credentials)
450488
self.assertEquals(expected, actual)
451489

452490
def test_allow_headers_is_skipped_with_no_value(self):
453491
headers = None # No value
454492
methods = "methods"
455493
origins = "origins"
494+
allow_credentials = True
456495

457496
expected = {
497+
"method.response.header.Access-Control-Allow-Credentials": _ALLOW_CREDENTALS_TRUE,
458498
"method.response.header.Access-Control-Allow-Methods": methods,
459-
"method.response.header.Access-Control-Allow-Origin": origins
499+
"method.response.header.Access-Control-Allow-Origin": origins,
460500
}
461501

462502
expected_headers = {
503+
"Access-Control-Allow-Credentials": {
504+
"type": "string"
505+
},
463506
"Access-Control-Allow-Methods": {
464507
"type": "string"
465508
},
@@ -469,7 +512,7 @@ def test_allow_headers_is_skipped_with_no_value(self):
469512
}
470513

471514
options_config = SwaggerEditor(SwaggerEditor.gen_skeleton())._options_method_response_for_cors(
472-
origins, headers, methods)
515+
origins, headers, methods, allow_credentials=allow_credentials)
473516

474517
actual = options_config[_X_INTEGRATION]["responses"]["default"]["responseParameters"]
475518
self.assertEquals(expected, actual)
@@ -479,14 +522,16 @@ def test_allow_methods_is_skipped_with_no_value(self):
479522
headers = "headers"
480523
methods = None # No value
481524
origins = "origins"
525+
allow_credentials = True
482526

483527
expected = {
528+
"method.response.header.Access-Control-Allow-Credentials": _ALLOW_CREDENTALS_TRUE,
484529
"method.response.header.Access-Control-Allow-Headers": headers,
485-
"method.response.header.Access-Control-Allow-Origin": origins
530+
"method.response.header.Access-Control-Allow-Origin": origins,
486531
}
487532

488533
options_config = SwaggerEditor(SwaggerEditor.gen_skeleton())._options_method_response_for_cors(
489-
origins, headers, methods)
534+
origins, headers, methods, allow_credentials=allow_credentials)
490535

491536
actual = options_config[_X_INTEGRATION]["responses"]["default"]["responseParameters"]
492537
self.assertEquals(expected, actual)
@@ -495,14 +540,15 @@ def test_allow_origins_is_not_skipped_with_no_value(self):
495540
headers = None
496541
methods = None
497542
origins = None
543+
allow_credentials = False
498544

499545
expected = {
500546
# We will ALWAYS set AllowOrigin. This is a minimum requirement for CORS
501547
"method.response.header.Access-Control-Allow-Origin": origins
502548
}
503549

504550
options_config = SwaggerEditor(SwaggerEditor.gen_skeleton())._options_method_response_for_cors(
505-
origins, headers, methods)
551+
origins, headers, methods, allow_credentials=allow_credentials)
506552

507553
actual = options_config[_X_INTEGRATION]["responses"]["default"]["responseParameters"]
508554
self.assertEquals(expected, actual)
@@ -512,19 +558,38 @@ def test_max_age_can_be_set_to_zero(self):
512558
methods = "methods"
513559
origins = "origins"
514560
max_age = 0
561+
allow_credentials = True
515562

516563
expected = {
564+
"method.response.header.Access-Control-Allow-Credentials": _ALLOW_CREDENTALS_TRUE,
517565
"method.response.header.Access-Control-Allow-Methods": methods,
518566
"method.response.header.Access-Control-Allow-Origin": origins,
519-
"method.response.header.Access-Control-Max-Age": max_age
567+
"method.response.header.Access-Control-Max-Age": max_age,
520568
}
521569

522570
options_config = SwaggerEditor(SwaggerEditor.gen_skeleton())._options_method_response_for_cors(
523-
origins, headers, methods, max_age)
571+
origins, headers, methods, max_age, allow_credentials)
524572

525573
actual = options_config[_X_INTEGRATION]["responses"]["default"]["responseParameters"]
526574
self.assertEquals(expected, actual)
527575

576+
def test_allow_credentials_is_skipped_with_false_value(self):
577+
headers = "headers"
578+
methods = "methods"
579+
origins = "origins"
580+
allow_credentials = False
581+
582+
expected = {
583+
"method.response.header.Access-Control-Allow-Headers": headers,
584+
"method.response.header.Access-Control-Allow-Methods": methods,
585+
"method.response.header.Access-Control-Allow-Origin": origins,
586+
}
587+
588+
options_config = SwaggerEditor(SwaggerEditor.gen_skeleton())._options_method_response_for_cors(
589+
origins, headers, methods, allow_credentials=allow_credentials)
590+
591+
actual = options_config[_X_INTEGRATION]["responses"]["default"]["responseParameters"]
592+
self.assertEquals(expected, actual)
528593

529594
class TestSwaggerEditor_make_cors_allowed_methods_for_path(TestCase):
530595

tests/translator/input/api_with_cors.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,4 @@ Resources:
6666
AllowMethods: "methods"
6767
AllowHeaders: "headers"
6868
AllowOrigin: "origins"
69+
AllowCredentials: true

0 commit comments

Comments
 (0)