diff --git a/.coveragerc b/.coveragerc index 398ff08af..c6d6ba42b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,5 @@ [run] branch = True +[report] +exclude_lines = + raise NotImplementedError.* diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 66dfc9e72..1a8acdc57 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,8 @@ Next Release (TBD) (`#311 `__) * Fix content type validation when charset is provided (`#306 `__) +* Add back custom authorizer support + (`#322 `__) 0.8.0 diff --git a/README.rst b/README.rst index 637cde6b1..791c16010 100644 --- a/README.rst +++ b/README.rst @@ -944,6 +944,7 @@ Tutorial: Using Custom Authentication AWS API Gateway routes can be authenticated in multiple ways: - API Key +- Cognito User Pools - Custom Auth Handler API Key @@ -960,21 +961,45 @@ Only requests sent with a valid `X-Api-Key` header will be accepted. Using Amazon Cognito User Pools ------------------------------- -To integrate with cognito user pools, you can use the ``define_authorizer`` method -on the ``app`` object. +To integrate with cognito user pools, you can use the +``CognitoUserPoolAuthorizer`` object: .. code-block:: python - @app.route('/user-pools', methods=['GET'], authorizer_name='MyPool') + authorizer = CognitoUserPoolAuthorizer( + 'MyPool', header='Authorization', + provider_arns=['arn:aws:cognito:...:userpool/name']) + + @app.route('/user-pools', methods=['GET'], authorizer=authorizer) def authenticated(): return {"secure": True} - app.define_authorizer( - name='MyPool', - header='Authorization', - auth_type='cognito_user_pools', - provider_arns=['arn:aws:cognito:...:userpool/name'] - ) + +Note, earlier versions of chalice also have an ``app.define_authorizer`` +method as well as an ``authorizer_name`` argument on the ``@app.route(...)`` +method. This approach is deprecated in favor of ``CognitoUserPoolAuthorizer`` +and the ``authorizer`` argument in the ``@app.route(...)`` method. +``app.define_authorizer`` will be removed in future versions of chalice. + + +Using Custom Authorizers +------------------------ + +To integrate with custom authorizers, you can use the ``CustomAuthorizer`` method +on the ``app`` object. You'll need to set the ``authorizer_uri`` +to the URI of your lambda function. + +.. code-block:: python + + authorizer = CustomAuthorizer( + 'MyCustomAuth', header='Authorization', + authorizer_uri=('arn:aws:apigateway:region:lambda:path/2015-03-01' + '/functions/arn:aws:lambda:region:account-id:' + 'function:FunctionName/invocations')) + + @app.route('/custom-auth', methods=['GET'], authorizer=authorizer) + def authenticated(): + return {"secure": True} Tutorial: Local Mode diff --git a/chalice/__init__.py b/chalice/__init__.py index db3836562..335a6912b 100644 --- a/chalice/__init__.py +++ b/chalice/__init__.py @@ -1,7 +1,8 @@ from chalice.app import Chalice from chalice.app import ( ChaliceViewError, BadRequestError, UnauthorizedError, ForbiddenError, - NotFoundError, ConflictError, TooManyRequestsError, Response, CORSConfig + NotFoundError, ConflictError, TooManyRequestsError, Response, CORSConfig, + CustomAuthorizer, CognitoUserPoolAuthorizer ) __version__ = '0.8.0' diff --git a/chalice/app.py b/chalice/app.py index cae30375f..586f51534 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -5,6 +5,7 @@ import json import traceback import decimal +import warnings from collections import Mapping # Implementation note: This file is intended to be a standalone file @@ -100,6 +101,67 @@ def __repr__(self): return 'CaseInsensitiveMapping(%s)' % repr(self._dict) +class Authorizer(object): + name = '' + + def to_swagger(self): + raise NotImplementedError("to_swagger") + + +class CognitoUserPoolAuthorizer(Authorizer): + + _AUTH_TYPE = 'cognito_user_pools' + + def __init__(self, name, provider_arns, header='Authorization'): + self.name = name + self._header = header + if not isinstance(provider_arns, list): + # This class is used directly by users so we're + # adding some validation to help them troubleshoot + # potential issues. + raise TypeError( + "provider_arns should be a list of ARNs, received: %s" + % provider_arns) + self._provider_arns = provider_arns + + def to_swagger(self): + return { + 'in': 'header', + 'type': 'apiKey', + 'name': self._header, + 'x-amazon-apigateway-authtype': self._AUTH_TYPE, + 'x-amazon-apigateway-authorizer': { + 'type': self._AUTH_TYPE, + 'providerARNs': self._provider_arns, + } + } + + +class CustomAuthorizer(Authorizer): + + _AUTH_TYPE = 'custom' + + def __init__(self, name, authorizer_uri, ttl_seconds=300, + header='Authorization'): + self.name = name + self._header = header + self._authorizer_uri = authorizer_uri + self._ttl_seconds = ttl_seconds + + def to_swagger(self): + return { + 'in': 'header', + 'type': 'apiKey', + 'name': self._header, + 'x-amazon-apigateway-authtype': self._AUTH_TYPE, + 'x-amazon-apigateway-authorizer': { + 'type': 'token', + 'authorizerUri': self._authorizer_uri, + 'authorizerResultTtlInSeconds': self._ttl_seconds, + } + } + + class CORSConfig(object): """A cors configuration to attach to a route.""" @@ -204,7 +266,7 @@ class RouteEntry(object): def __init__(self, view_function, view_name, path, methods, authorizer_name=None, api_key_required=None, content_types=None, - cors=False): + cors=False, authorizer=None): self.view_function = view_function self.view_name = view_name self.uri_pattern = path @@ -225,6 +287,7 @@ def __init__(self, view_function, view_name, path, methods, elif cors is False: cors = None self.cors = cors + self.authorizer = authorizer def _parse_view_args(self): if '{' not in self.uri_pattern: @@ -284,12 +347,14 @@ def authorizers(self): return self._authorizers.copy() def define_authorizer(self, name, header, auth_type, provider_arns=None): - # TODO: double check remaining authorizers. This only handles - # cognito_user_pools. + warnings.warn( + "define_authorizer() is deprecated and will be removed in future " + "versions of chalice. Please use CognitoUserPoolAuthorizer(...) " + "instead", PendingDeprecationWarning) self._authorizers[name] = { 'header': header, 'auth_type': auth_type, - 'provider_arns': provider_arns + 'provider_arns': provider_arns, } def route(self, path, **kwargs): @@ -302,6 +367,7 @@ def _add_route(self, path, view_func, **kwargs): name = kwargs.pop('name', view_func.__name__) methods = kwargs.pop('methods', ['GET']) authorizer_name = kwargs.pop('authorizer_name', None) + authorizer = kwargs.pop('authorizer', None) api_key_required = kwargs.pop('api_key_required', None) content_types = kwargs.pop('content_types', ['application/json']) cors = kwargs.pop('cors', False) @@ -319,7 +385,7 @@ def _add_route(self, path, view_func, **kwargs): "URL paths must be unique." % path) entry = RouteEntry(view_func, name, path, methods, authorizer_name, api_key_required, - content_types, cors) + content_types, cors, authorizer) self.routes[path] = entry def __call__(self, event, context): diff --git a/chalice/app.pyi b/chalice/app.pyi index e3003e7e6..693f76f8c 100644 --- a/chalice/app.pyi +++ b/chalice/app.pyi @@ -1,4 +1,4 @@ -from typing import Dict, List, Any, Callable, Union +from typing import Dict, List, Any, Callable, Union, Optional class ChaliceError(Exception): ... class ChaliceViewError(ChaliceError): @@ -14,6 +14,18 @@ class TooManyRequestsError(ChaliceViewError): ... ALL_ERRORS = ... # type: List[ChaliceViewError] + +class Authorizer: + name = ... # type: str + def to_swagger(self) -> Dict[str, Any]: ... + + +class CognitoUserPoolAuthorizer(Authorizer): ... + + +class CustomAuthorizer(Authorizer): ... + + class CORSConfig: allow_origin = ... # type: str allow_headers = ... # type: str @@ -63,6 +75,7 @@ class RouteEntry(object): methods = ... # type: List[str] uri_pattern = ... # type: str authorizer_name = ... # type: str + authorizer = ... # type: Optional[Authorizer] api_key_required = ... # type: bool content_types = ... # type: List[str] view_args = ... # type: List[str] diff --git a/chalice/deploy/swagger.py b/chalice/deploy/swagger.py index ccbe14ebb..80e84744a 100644 --- a/chalice/deploy/swagger.py +++ b/chalice/deploy/swagger.py @@ -2,7 +2,7 @@ from typing import Any, List, Dict # noqa -from chalice.app import Chalice, RouteEntry # noqa +from chalice.app import Chalice, RouteEntry, Authorizer # noqa class SwaggerGenerator(object): @@ -44,15 +44,24 @@ def _add_route_paths(self, api, app): current = self._generate_route_method(view) if 'security' in current: self._add_to_security_definition( - current['security'], api, app.authorizers) + current['security'], api, app.authorizers, view) swagger_for_path[http_method.lower()] = current if view.cors is not None: self._add_preflight_request(view, swagger_for_path) - def _add_to_security_definition(self, security, api_config, authorizers): - # type: (Any, Dict[str, Any], Dict[str, Any]) -> None + def _generate_security_from_auth_obj(self, api_config, authorizer): + # type: (Dict[str, Any], Authorizer) -> None + config = authorizer.to_swagger() + api_config.setdefault( + 'securityDefinitions', {})[authorizer.name] = config + + def _add_to_security_definition(self, security, + api_config, authorizers, view): + # type: (Any, Dict[str, Any], Dict[str, Any], RouteEntry) -> None + if view.authorizer is not None: + self._generate_security_from_auth_obj(api_config, view.authorizer) + return for auth in security: - # TODO: Add validation checks for unknown auth references. name = list(auth.keys())[0] if name == 'api_key': # This is just the api_key_required=True config @@ -62,8 +71,29 @@ def _add_to_security_definition(self, security, api_config, authorizers): 'in': 'header', } # type: Dict[str, Any] else: + # This whole section is deprecated and will + # eventually be removed. This handles the + # authorizers that come in via app.define_authorizer(...) + # The only supported type in this method is + # 'cognito_user_pools'. Everything else goes through the + # preferred ``view.authorizer``. + if name not in authorizers: + error_msg = ( + "The authorizer '%s' is not defined. " + "Use app.define_authorizer(...) to define an " + "authorizer." % (name) + ) + if authorizers: + error_msg += ( + ' Defined authorizers in this app: %s' % + ', '.join(authorizers)) + raise ValueError(error_msg) authorizer_config = authorizers[name] auth_type = authorizer_config['auth_type'] + if auth_type != 'cognito_user_pools': + raise ValueError( + "Unknown auth type: '%s', must be " + "'cognito_user_pools'" % (auth_type,)) swagger_snippet = { 'in': 'header', 'type': 'apiKey', @@ -94,6 +124,8 @@ def _generate_route_method(self, view): current['security'] = [{'api_key': []}] if view.authorizer_name: current['security'] = [{view.authorizer_name: []}] + if view.authorizer: + current['security'] = [{view.authorizer.name: []}] return current def _generate_precanned_responses(self): diff --git a/docs/source/upgrading.rst b/docs/source/upgrading.rst index 0798da249..bf61c7fa3 100644 --- a/docs/source/upgrading.rst +++ b/docs/source/upgrading.rst @@ -7,6 +7,65 @@ interested in the high level changes, see the `CHANGELOG.rst `__) file. +.. _v0-8-1: + +0.8.1 +----- + +The 0.8.1 changed the preferred way of specifying authorizers for view +functions. You now specify either an instance of +``chalice.CognitoUserPoolAuthorizer`` or ``chalice.CustomAuthorizer`` +to an ``@app.route()`` function using the ``authorizer`` argument. + +Deprecated: + +.. code-block:: python + + @app.route('/user-pools', methods=['GET'], authorizer_name='MyPool') + def authenticated(): + return {"secure": True} + + app.define_authorizer( + name='MyPool', + header='Authorization', + auth_type='cognito_user_pools', + provider_arns=['arn:aws:cognito:...:userpool/name'] + ) + +Equivalent, and preferred way + +.. code-block:: python + + from chalice import CognitoUserPoolAuthorizer + + authorizer = CognitoUserPoolAuthorizer( + 'MyPool', header='Authorization', + provider_arns=['arn:aws:cognito:...:userpool/name']) + + @app.route('/user-pools', methods=['GET'], authorizer=authorizer) + def authenticated(): + return {"secure": True} + + +The ``define_authorizer`` is still available, but is now deprecated and will +be removed in future versions of chalice. You can also use the new +``authorizer`` argument to provider a ``CustomAuthorizer``: + + +.. code-block:: python + + from chalice import CustomAuthorizer + + authorizer = CustomAuthorizer( + 'MyCustomAuth', header='Authorization', + authorizer_uri=('arn:aws:apigateway:region:lambda:path/2015-03-01' + '/functions/arn:aws:lambda:region:account-id:' + 'function:FunctionName/invocations')) + + @app.route('/custom-auth', methods=['GET'], authorizer=authorizer) + def authenticated(): + return {"secure": True} + .. _v0-7-0: diff --git a/tests/unit/deploy/test_swagger.py b/tests/unit/deploy/test_swagger.py index 8ffb94cfc..3378fd8a6 100644 --- a/tests/unit/deploy/test_swagger.py +++ b/tests/unit/deploy/test_swagger.py @@ -1,8 +1,11 @@ from chalice.deploy.swagger import SwaggerGenerator from chalice import CORSConfig +from chalice.app import CustomAuthorizer, CognitoUserPoolAuthorizer +import pytest from pytest import fixture + @fixture def swagger_gen(): return SwaggerGenerator(region='us-west-2', @@ -210,10 +213,10 @@ def foo(name): } -def test_can_add_authorizers(sample_app, swagger_gen): +def test_can_add_cognito_authorizers(sample_app, swagger_gen): @sample_app.route('/api-key-required', authorizer_name='MyUserPool') - def foo(name): + def foo(): return {} # Doesn't matter if you define the authorizer before @@ -239,3 +242,110 @@ def foo(name): 'providerARNs': ['arn:aws:cog:r:1:userpool/name'] } } + + +def test_unknown_auth_raises_error(sample_app, swagger_gen): + @sample_app.route('/unknown', authorizer_name='Unknown') + def foo(): + return {} + + sample_app.define_authorizer( + 'Unknown', header='Authorization', + auth_type='unknown-type') + + with pytest.raises(ValueError): + swagger_gen.generate_swagger(sample_app) + + +def test_reference_auth_without_defining(sample_app, swagger_gen): + @sample_app.route('/unknown', authorizer_name='NeverDefined') + def foo(): + return {} + + with pytest.raises(ValueError): + swagger_gen.generate_swagger(sample_app) + + +def test_reference_auth_with_other_auth_defined(sample_app, swagger_gen): + @sample_app.route('/api-key-required', + authorizer_name='Unknown') + def foo(): + return {} + + # Doesn't matter if you define the authorizer before + # it's referenced. + sample_app.define_authorizer( + name='MyUserPool', + header='Authorization', + auth_type='cognito_user_pools', + provider_arns=['arn:aws:cog:r:1:userpool/name'] + ) + + with pytest.raises(ValueError): + swagger_gen.generate_swagger(sample_app) + + +def test_can_use_authorizer_object(sample_app, swagger_gen): + authorizer = CustomAuthorizer( + 'MyAuth', authorizer_uri='auth-uri', header='Authorization') + @sample_app.route('/auth', authorizer=authorizer) + def auth(): + return {'foo': 'bar'} + + doc = swagger_gen.generate_swagger(sample_app) + single_method = doc['paths']['/auth']['get'] + assert single_method.get('security') == [{'MyAuth': []}] + security_definitions = doc['securityDefinitions'] + assert 'MyAuth' in security_definitions + assert security_definitions['MyAuth'] == { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header', + 'x-amazon-apigateway-authtype': 'custom', + 'x-amazon-apigateway-authorizer': { + 'authorizerUri': 'auth-uri', + 'type': 'token', + 'authorizerResultTtlInSeconds': 300 + } + } + + +def test_can_use_cognito_auth_object(sample_app, swagger_gen): + authorizer = CognitoUserPoolAuthorizer('MyUserPool', + header='Authorization', + provider_arns=['myarn']) + @sample_app.route('/api-key-required', authorizer=authorizer) + def foo(): + return {} + + doc = swagger_gen.generate_swagger(sample_app) + single_method = doc['paths']['/api-key-required']['get'] + assert single_method.get('security') == [{'MyUserPool': []}] + assert 'securityDefinitions' in doc + assert doc['securityDefinitions'].get('MyUserPool') == { + 'in': 'header', + 'type': 'apiKey', + 'name': 'Authorization', + 'x-amazon-apigateway-authtype': 'cognito_user_pools', + 'x-amazon-apigateway-authorizer': { + 'type': 'cognito_user_pools', + 'providerARNs': ['myarn'] + } + } + + +def test_auth_defined_for_multiple_methods(sample_app, swagger_gen): + authorizer = CognitoUserPoolAuthorizer('MyUserPool', + header='Authorization', + provider_arns=['myarn']) + @sample_app.route('/pool1', authorizer=authorizer) + def foo(): + return {} + + @sample_app.route('/pool2', authorizer=authorizer) + def bar(): + return {} + + doc = swagger_gen.generate_swagger(sample_app) + assert 'securityDefinitions' in doc + assert len(doc['securityDefinitions']) == 1 diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 9344d447d..90e4a1fcc 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -543,3 +543,41 @@ def test_json_body_available_when_content_type_matches(content_type, is_json): assert request.json_body == {'json': 'body'} else: assert request.json_body is None + + +def test_can_serialize_cognito_auth(): + auth = app.CognitoUserPoolAuthorizer( + 'Name', provider_arns=['Foo'], header='Authorization') + assert auth.to_swagger() == { + 'in': 'header', + 'type': 'apiKey', + 'name': 'Authorization', + 'x-amazon-apigateway-authtype': 'cognito_user_pools', + 'x-amazon-apigateway-authorizer': { + 'type': 'cognito_user_pools', + 'providerARNs': ['Foo'], + } + } + + +def test_typecheck_list_type(): + with pytest.raises(TypeError): + app.CognitoUserPoolAuthorizer('Name', 'Authorization', + provider_arns='foo') + + +def test_can_serialize_custom_authorizer(): + auth = app.CustomAuthorizer( + 'Name', 'myuri', ttl_seconds=10, header='NotAuth' + ) + assert auth.to_swagger() == { + 'in': 'header', + 'type': 'apiKey', + 'name': 'NotAuth', + 'x-amazon-apigateway-authtype': 'custom', + 'x-amazon-apigateway-authorizer': { + 'type': 'token', + 'authorizerUri': 'myuri', + 'authorizerResultTtlInSeconds': 10, + } + }