From 9895cf0166a1f13a73d87ed7d621315171eafd35 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 16:22:58 +0200 Subject: [PATCH 01/22] feat(event_handler): add support for OpenAPI security --- .../event_handler/api_gateway.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 29601247b48..968a3ed7d5f 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -83,9 +83,11 @@ Contact, License, OpenAPI, + SecurityScheme, Server, Tag, ) + from aws_lambda_powertools.event_handler.openapi.oauth2 import OAuth2Config from aws_lambda_powertools.event_handler.openapi.params import Dependant from aws_lambda_powertools.event_handler.openapi.types import ( TypeModelOrEnum, @@ -282,6 +284,7 @@ def __init__( tags: Optional[List[str]], operation_id: Optional[str], include_in_schema: bool, + security: Optional[List[Dict[str, List[str]]]], middlewares: Optional[List[Callable[..., Response]]], ): """ @@ -317,6 +320,8 @@ def __init__( The OpenAPI operationId for this route include_in_schema: bool Whether or not to include this route in the OpenAPI schema + security: List[Dict[str, List[str]]], optional + The OpenAPI security for this route middlewares: Optional[List[Callable[..., Response]]] The list of route middlewares to be called in order. """ @@ -339,6 +344,7 @@ def __init__( self.response_description = response_description self.tags = tags or [] self.include_in_schema = include_in_schema + self.security = security self.middlewares = middlewares or [] self.operation_id = operation_id or self._generate_operation_id() @@ -486,6 +492,10 @@ def _get_openapi_path( ) parameters.extend(operation_params) + # Add security if present + if self.security: + operation["security"] = self.security + # Add the parameters to the OpenAPI operation if parameters: all_parameters = {(param["in"], param["name"]): param for param in parameters} @@ -885,6 +895,7 @@ def route( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): raise NotImplementedError() @@ -943,6 +954,7 @@ def get( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): """Get route decorator with GET `method` @@ -980,6 +992,7 @@ def lambda_handler(event, context): tags, operation_id, include_in_schema, + security, middlewares, ) @@ -996,6 +1009,7 @@ def post( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): """Post route decorator with POST `method` @@ -1034,6 +1048,7 @@ def lambda_handler(event, context): tags, operation_id, include_in_schema, + security, middlewares, ) @@ -1050,6 +1065,7 @@ def put( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): """Put route decorator with PUT `method` @@ -1088,6 +1104,7 @@ def lambda_handler(event, context): tags, operation_id, include_in_schema, + security, middlewares, ) @@ -1104,6 +1121,7 @@ def delete( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): """Delete route decorator with DELETE `method` @@ -1141,6 +1159,7 @@ def lambda_handler(event, context): tags, operation_id, include_in_schema, + security, middlewares, ) @@ -1157,6 +1176,7 @@ def patch( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable]] = None, ): """Patch route decorator with PATCH `method` @@ -1197,6 +1217,7 @@ def lambda_handler(event, context): tags, operation_id, include_in_schema, + security, middlewares, ) @@ -1419,6 +1440,8 @@ def get_openapi_schema( terms_of_service: Optional[str] = None, contact: Optional["Contact"] = None, license_info: Optional["License"] = None, + security_schemes: Optional[Dict[str, "SecurityScheme"]] = None, + security: Optional[List[Dict[str, List[str]]]] = None, ) -> "OpenAPI": """ Returns the OpenAPI schema as a pydantic model. @@ -1445,6 +1468,10 @@ def get_openapi_schema( The contact information for the exposed API. license_info: License, optional The license information for the exposed API. + security_schemes: Dict[str, "SecurityScheme"]], optional + A declaration of the security schemes available to be used in the specification. + security: List[Dict[str, List[str]]], optional + A declaration of which security mechanisms are applied globally across the API. Returns ------- @@ -1498,6 +1525,16 @@ def get_openapi_schema( # with an url value of /. output["servers"] = [Server(url="/")] + if security: + if not security_schemes: + raise ValueError("security_schemes must be provided if security is provided") + + # Check if all keys in security are present in the security_schemes + if not all(key in security_schemes for sec in security for key in sec): + raise ValueError("Some security schemes not found in security_schemes") + + output["security"] = security + components: Dict[str, Dict[str, Any]] = {} paths: Dict[str, Dict[str, Any]] = {} operation_ids: Set[str] = set() @@ -1534,6 +1571,8 @@ def get_openapi_schema( if definitions: components["schemas"] = {k: definitions[k] for k in sorted(definitions)} + if security_schemes: + components["securitySchemes"] = security_schemes if components: output["components"] = components if tags: @@ -1556,6 +1595,8 @@ def get_openapi_json_schema( terms_of_service: Optional[str] = None, contact: Optional["Contact"] = None, license_info: Optional["License"] = None, + security_schemes: Optional[Dict[str, "SecurityScheme"]] = None, + security: Optional[List[Dict[str, List[str]]]] = None, ) -> str: """ Returns the OpenAPI schema as a JSON serializable dict @@ -1582,6 +1623,10 @@ def get_openapi_json_schema( The contact information for the exposed API. license_info: License, optional The license information for the exposed API. + security_schemes: Dict[str, "SecurityScheme"]], optional + A declaration of the security schemes available to be used in the specification. + security: List[Dict[str, List[str]]], optional + A declaration of which security mechanisms are applied globally across the API. Returns ------- @@ -1602,6 +1647,8 @@ def get_openapi_json_schema( terms_of_service=terms_of_service, contact=contact, license_info=license_info, + security_schemes=security_schemes, + security=security, ), by_alias=True, exclude_none=True, @@ -1623,6 +1670,7 @@ def enable_swagger( contact: Optional["Contact"] = None, license_info: Optional["License"] = None, swagger_base_url: Optional[str] = None, + oauth2: Optional["OAuth2Config"] = None, middlewares: Optional[List[Callable[..., Response]]] = None, compress: bool = False, ): @@ -1655,6 +1703,8 @@ def enable_swagger( The license information for the exposed API. swagger_base_url: str, optional The base url for the swagger UI. If not provided, we will serve a recent version of the Swagger UI. + oauth2: OAuth2Config, optional + The OAuth2 configuration for the Swagger UI. middlewares: List[Callable[..., Response]], optional List of middlewares to be used for the swagger route. compress: bool, default = False @@ -1719,6 +1769,7 @@ def swagger_handler(): swagger_js, swagger_css, swagger_base_url, + oauth2, ) return Response( @@ -1741,6 +1792,7 @@ def route( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): """Route decorator includes parameter `method`""" @@ -1767,6 +1819,7 @@ def register_resolver(func: Callable): tags, operation_id, include_in_schema, + security, middlewares, ) @@ -2318,6 +2371,7 @@ def route( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): # NOTE: see #1552 for more context. @@ -2334,6 +2388,7 @@ def route( tags, operation_id, include_in_schema, + security, middlewares, ) From 14479d92f991b3696762455ce719229ba686ba01 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 16:26:14 +0200 Subject: [PATCH 02/22] fix: remove unused code --- aws_lambda_powertools/event_handler/api_gateway.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 968a3ed7d5f..9a6bde15551 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -87,7 +87,6 @@ Server, Tag, ) - from aws_lambda_powertools.event_handler.openapi.oauth2 import OAuth2Config from aws_lambda_powertools.event_handler.openapi.params import Dependant from aws_lambda_powertools.event_handler.openapi.types import ( TypeModelOrEnum, @@ -1670,7 +1669,6 @@ def enable_swagger( contact: Optional["Contact"] = None, license_info: Optional["License"] = None, swagger_base_url: Optional[str] = None, - oauth2: Optional["OAuth2Config"] = None, middlewares: Optional[List[Callable[..., Response]]] = None, compress: bool = False, ): @@ -1703,8 +1701,6 @@ def enable_swagger( The license information for the exposed API. swagger_base_url: str, optional The base url for the swagger UI. If not provided, we will serve a recent version of the Swagger UI. - oauth2: OAuth2Config, optional - The OAuth2 configuration for the Swagger UI. middlewares: List[Callable[..., Response]], optional List of middlewares to be used for the swagger route. compress: bool, default = False @@ -1769,7 +1765,6 @@ def swagger_handler(): swagger_js, swagger_css, swagger_base_url, - oauth2, ) return Response( From b4e45f69b541152db31f4d0990b2c10cee253db9 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 16:49:37 +0200 Subject: [PATCH 03/22] chore: add tests for security schemes --- .../event_handler/openapi/models.py | 1 + .../test_openapi_security_schemes.py | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tests/functional/event_handler/test_openapi_security_schemes.py diff --git a/aws_lambda_powertools/event_handler/openapi/models.py b/aws_lambda_powertools/event_handler/openapi/models.py index ace398ec532..4124f34656d 100644 --- a/aws_lambda_powertools/event_handler/openapi/models.py +++ b/aws_lambda_powertools/event_handler/openapi/models.py @@ -447,6 +447,7 @@ class SecurityBase(BaseModel): class Config: extra = "allow" + allow_population_by_field_name = True class APIKeyIn(Enum): diff --git a/tests/functional/event_handler/test_openapi_security_schemes.py b/tests/functional/event_handler/test_openapi_security_schemes.py new file mode 100644 index 00000000000..dc785ba56d0 --- /dev/null +++ b/tests/functional/event_handler/test_openapi_security_schemes.py @@ -0,0 +1,112 @@ +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.event_handler.openapi.models import ( + APIKey, + APIKeyIn, + HTTPBearer, + OAuth2, + OAuthFlowImplicit, + OAuthFlows, + OpenIdConnect, +) + + +def test_openapi_security_scheme_api_key(): + app = APIGatewayRestResolver() + + @app.get("/") + def handler(): + raise NotImplementedError() + + schema = app.get_openapi_schema( + security_schemes={ + "apiKey": APIKey(name="X-API-KEY", description="API Key", in_=APIKeyIn.header), + }, + ) + + security_schemes = schema.components.securitySchemes + assert security_schemes is not None + + assert "apiKey" in security_schemes + api_key_scheme = security_schemes["apiKey"] + assert api_key_scheme.type_.value == "apiKey" + assert api_key_scheme.name == "X-API-KEY" + assert api_key_scheme.description == "API Key" + assert api_key_scheme.in_.value == "header" + + +def test_openapi_security_scheme_http(): + app = APIGatewayRestResolver() + + @app.get("/") + def handler(): + raise NotImplementedError() + + schema = app.get_openapi_schema( + security_schemes={ + "bearerAuth": HTTPBearer( + description="JWT Token", + bearerFormat="JWT", + ), + }, + ) + + security_schemes = schema.components.securitySchemes + assert security_schemes is not None + + assert "bearerAuth" in security_schemes + http_scheme = security_schemes["bearerAuth"] + assert http_scheme.type_.value == "http" + assert http_scheme.scheme == "bearer" + assert http_scheme.bearerFormat == "JWT" + + +def test_openapi_security_scheme_oauth2(): + app = APIGatewayRestResolver() + + @app.get("/") + def handler(): + raise NotImplementedError() + + schema = app.get_openapi_schema( + security_schemes={ + "oauth2": OAuth2( + flows=OAuthFlows( + implicit=OAuthFlowImplicit( + authorizationUrl="https://example.com/oauth2/authorize", + ), + ), + ), + }, + ) + + security_schemes = schema.components.securitySchemes + assert security_schemes is not None + + assert "oauth2" in security_schemes + oauth2_scheme = security_schemes["oauth2"] + assert oauth2_scheme.type_.value == "oauth2" + assert oauth2_scheme.flows.implicit.authorizationUrl == "https://example.com/oauth2/authorize" + + +def test_openapi_security_scheme_open_id_connect(): + app = APIGatewayRestResolver() + + @app.get("/") + def handler(): + raise NotImplementedError() + + schema = app.get_openapi_schema( + security_schemes={ + "openIdConnect": OpenIdConnect( + openIdConnectUrl="https://example.com/oauth2/authorize", + ), + }, + ) + + security_schemes = schema.components.securitySchemes + assert security_schemes is not None + + assert "openIdConnect" in security_schemes + open_id_connect_scheme = security_schemes["openIdConnect"] + assert open_id_connect_scheme.type_.value == "openIdConnect" + assert open_id_connect_scheme.openIdConnectUrl == "https://example.com/oauth2/authorize" From de84d109309aa70cb35c2f28283c83f987e8c721 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 16:55:51 +0200 Subject: [PATCH 04/22] fix: tests --- .../event_handler/api_gateway.py | 2 ++ .../event_handler/bedrock_agent.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 9a6bde15551..31ff5172320 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -2266,6 +2266,7 @@ def route( tags: Optional[List[str]] = None, operation_id: Optional[str] = None, include_in_schema: bool = True, + security: Optional[List[Dict[str, List[str]]]] = None, middlewares: Optional[List[Callable[..., Any]]] = None, ): def register_route(func: Callable): @@ -2287,6 +2288,7 @@ def register_route(func: Callable): frozen_tags, operation_id, include_in_schema, + security, ) # Collate Middleware for routes diff --git a/aws_lambda_powertools/event_handler/bedrock_agent.py b/aws_lambda_powertools/event_handler/bedrock_agent.py index 0ce0f3ff725..4d1a6096f32 100644 --- a/aws_lambda_powertools/event_handler/bedrock_agent.py +++ b/aws_lambda_powertools/event_handler/bedrock_agent.py @@ -102,6 +102,8 @@ def get( # type: ignore[override] include_in_schema: bool = True, middlewares: Optional[List[Callable[..., Any]]] = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + security = None + return super(BedrockAgentResolver, self).get( rule, cors, @@ -114,6 +116,7 @@ def get( # type: ignore[override] tags, operation_id, include_in_schema, + security, middlewares, ) @@ -134,6 +137,8 @@ def post( # type: ignore[override] include_in_schema: bool = True, middlewares: Optional[List[Callable[..., Any]]] = None, ): + security = None + return super().post( rule, cors, @@ -146,6 +151,7 @@ def post( # type: ignore[override] tags, operation_id, include_in_schema, + security, middlewares, ) @@ -166,6 +172,8 @@ def put( # type: ignore[override] include_in_schema: bool = True, middlewares: Optional[List[Callable[..., Any]]] = None, ): + security = None + return super().put( rule, cors, @@ -178,6 +186,7 @@ def put( # type: ignore[override] tags, operation_id, include_in_schema, + security, middlewares, ) @@ -198,6 +207,8 @@ def patch( # type: ignore[override] include_in_schema: bool = True, middlewares: Optional[List[Callable]] = None, ): + security = None + return super().patch( rule, cors, @@ -210,6 +221,7 @@ def patch( # type: ignore[override] tags, operation_id, include_in_schema, + security, middlewares, ) @@ -230,6 +242,8 @@ def delete( # type: ignore[override] include_in_schema: bool = True, middlewares: Optional[List[Callable[..., Any]]] = None, ): + security = None + return super().delete( rule, cors, @@ -242,6 +256,7 @@ def delete( # type: ignore[override] tags, operation_id, include_in_schema, + security, middlewares, ) From 6b6d7cf7f856fa3235b7e6b355da543fc0ed971f Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 17:04:25 +0200 Subject: [PATCH 05/22] chore: add more tests --- .../event_handler/test_openapi_security.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/functional/event_handler/test_openapi_security.py diff --git a/tests/functional/event_handler/test_openapi_security.py b/tests/functional/event_handler/test_openapi_security.py new file mode 100644 index 00000000000..7120a815edd --- /dev/null +++ b/tests/functional/event_handler/test_openapi_security.py @@ -0,0 +1,62 @@ +import pytest + +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.event_handler.openapi.models import APIKey, APIKeyIn + + +def test_openapi_top_level_security(): + app = APIGatewayRestResolver() + + @app.get("/") + def handler(): + raise NotImplementedError() + + schema = app.get_openapi_schema( + security_schemes={ + "apiKey": APIKey(name="X-API-KEY", description="API Key", in_=APIKeyIn.header), + }, + security=[{"apiKey": []}], + ) + + security = schema.security + assert security is not None + + assert len(security) == 1 + assert security[0] == {"apiKey": []} + + +def test_openapi_top_level_security_missing(): + app = APIGatewayRestResolver() + + @app.get("/") + def handler(): + raise NotImplementedError() + + with pytest.raises(ValueError): + app.get_openapi_schema( + security=[{"apiKey": []}], + ) + + +def test_openapi_operation_security(): + app = APIGatewayRestResolver() + + @app.get("/", security=[{"apiKey": []}]) + def handler(): + raise NotImplementedError() + + schema = app.get_openapi_schema( + security_schemes={ + "apiKey": APIKey(name="X-API-KEY", description="API Key", in_=APIKeyIn.header), + }, + ) + + security = schema.security + assert security is None + + operation = schema.paths["/"].get + security = operation.security + assert security is not None + + assert len(security) == 1 + assert security[0] == {"apiKey": []} From 9e980eb6232ae64ef0ab109cff493ef3f402880c Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 17:16:59 +0200 Subject: [PATCH 06/22] feat: add oauth2 to swagger-ui --- .../event_handler/api_gateway.py | 13 +++++++++ .../event_handler/openapi/swagger_ui/html.py | 26 +++++++++++++++--- .../openapi/swagger_ui/oauth2.py | 27 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 31ff5172320..6fd6bd22ed1 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -88,6 +88,7 @@ Tag, ) from aws_lambda_powertools.event_handler.openapi.params import Dependant + from aws_lambda_powertools.event_handler.openapi.swagger_ui.oauth2 import OAuth2Config from aws_lambda_powertools.event_handler.openapi.types import ( TypeModelOrEnum, ) @@ -1671,6 +1672,9 @@ def enable_swagger( swagger_base_url: Optional[str] = None, middlewares: Optional[List[Callable[..., Response]]] = None, compress: bool = False, + security_schemes: Optional[Dict[str, "SecurityScheme"]] = None, + security: Optional[List[Dict[str, List[str]]]] = None, + oauth2Config: Optional["OAuth2Config"] = None, ): """ Returns the OpenAPI schema as a JSON serializable dict @@ -1705,6 +1709,12 @@ def enable_swagger( List of middlewares to be used for the swagger route. compress: bool, default = False Whether or not to enable gzip compression swagger route. + security_schemes: Dict[str, "SecurityScheme"], optional + A declaration of the security schemes available to be used in the specification. + security: List[Dict[str, List[str]]], optional + A declaration of which security mechanisms are applied globally across the API. + oauth2Config: OAuth2Config, optional + The OAuth2 configuration for the Swagger UI. """ from aws_lambda_powertools.event_handler.openapi.compat import model_json from aws_lambda_powertools.event_handler.openapi.models import Server @@ -1736,6 +1746,8 @@ def swagger_handler(): terms_of_service=terms_of_service, contact=contact, license_info=license_info, + security_schemes=security_schemes, + security=security, ) # The .replace(' str: +from typing import Optional + +from aws_lambda_powertools.event_handler.openapi.swagger_ui.oauth2 import OAuth2Config + + +def generate_swagger_html( + spec: str, + path: str, + swagger_js: str, + swagger_css: str, + swagger_base_url: str, + oauth2: Optional[OAuth2Config], +) -> str: """ Generate Swagger UI HTML page @@ -8,10 +20,14 @@ def generate_swagger_html(spec: str, path: str, swagger_js: str, swagger_css: st The OpenAPI spec path: str The path to the Swagger documentation - js_url: str + swagger_js: str The URL to the Swagger UI JavaScript file - css_url: str + swagger_css: str The URL to the Swagger UI CSS file + swagger_base_url: str + The base URL for Swagger UI + oauth2: OAuth2Config, optional + The OAuth2 configuration. """ # If Swagger base URL is present, generate HTML content with linked CSS and JavaScript files @@ -23,6 +39,9 @@ def generate_swagger_html(spec: str, path: str, swagger_js: str, swagger_css: st swagger_css_content = f"" swagger_js_content = f"" + # Prepare oauth2 config + oauth2_content = f"ui.initOAuth({oauth2.json(exclude_none=True, exclude_unset=True)});" if oauth2 else "" + return f""" @@ -65,6 +84,7 @@ def generate_swagger_html(spec: str, path: str, swagger_js: str, swagger_css: st var ui = SwaggerUIBundle(swaggerUIOptions) ui.specActions.updateUrl('{path}?format=json'); + {oauth2_content} """.strip() diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py new file mode 100644 index 00000000000..9fbc834f4ef --- /dev/null +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py @@ -0,0 +1,27 @@ +from typing import Dict, Sequence + +from pydantic import BaseModel, Field + +from aws_lambda_powertools.event_handler.openapi.pydantic_loader import PYDANTIC_V2 + + +# Based on https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/ +class OAuth2Config(BaseModel): + clientId: str = Field(alias="client_id") + realm: str + appName: str = Field(alias="app_name") + scopes: Sequence[str] = Field(default=[]) + additionalQueryStringParams: Dict[str, str] = Field(alias="additional_query_string_params", default={}) + useBasicAuthenticationWithAccessCodeGrant: bool = Field( + alias="use_basic_authentication_with_access_code_grant", + default=False, + ) + usePkceWithAuthorizationCodeGrant: bool = Field(alias="use_pkce_with_authorization_code_grant", default=False) + + if PYDANTIC_V2: + model_config = {"extra": "allow"} + else: + + class Config: + extra = "allow" + allow_population_by_field_name = True From b619e19d2257eb7e75362a0dbb1d0b08e2374cbe Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 17:20:03 +0200 Subject: [PATCH 07/22] fix: reduce complexity and branches --- .../event_handler/api_gateway.py | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 6fd6bd22ed1..eca2ec7e229 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1485,24 +1485,11 @@ def get_openapi_schema( get_definitions, ) from aws_lambda_powertools.event_handler.openapi.models import OpenAPI, PathItem, Server, Tag - from aws_lambda_powertools.event_handler.openapi.pydantic_loader import PYDANTIC_V2 from aws_lambda_powertools.event_handler.openapi.types import ( COMPONENT_REF_TEMPLATE, ) - # Pydantic V2 has no support for OpenAPI schema 3.0 - if PYDANTIC_V2 and not openapi_version.startswith("3.1"): - warnings.warn( - "You are using Pydantic v2, which is incompatible with OpenAPI schema 3.0. Forcing OpenAPI 3.1", - stacklevel=2, - ) - openapi_version = "3.1.0" - elif not PYDANTIC_V2 and not openapi_version.startswith("3.0"): - warnings.warn( - "You are using Pydantic v1, which is incompatible with OpenAPI schema 3.1. Forcing OpenAPI 3.0", - stacklevel=2, - ) - openapi_version = "3.0.3" + openapi_version = self._determine_openapi_version(openapi_version) # Start with the bare minimum required for a valid OpenAPI schema info: Dict[str, Any] = {"title": title, "version": version} @@ -1582,6 +1569,25 @@ def get_openapi_schema( return OpenAPI(**output) + @staticmethod + def _determine_openapi_version(openapi_version): + from aws_lambda_powertools.event_handler.openapi.pydantic_loader import PYDANTIC_V2 + + # Pydantic V2 has no support for OpenAPI schema 3.0 + if PYDANTIC_V2 and not openapi_version.startswith("3.1"): + warnings.warn( + "You are using Pydantic v2, which is incompatible with OpenAPI schema 3.0. Forcing OpenAPI 3.1", + stacklevel=2, + ) + openapi_version = "3.1.0" + elif not PYDANTIC_V2 and not openapi_version.startswith("3.0"): + warnings.warn( + "You are using Pydantic v1, which is incompatible with OpenAPI schema 3.1. Forcing OpenAPI 3.0", + stacklevel=2, + ) + openapi_version = "3.0.3" + return openapi_version + def get_openapi_json_schema( self, *, From a56c2e49b29de5482f4938efdd65abda9788aaa3 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 10 Apr 2024 17:23:37 +0200 Subject: [PATCH 08/22] fix: renamed variable --- aws_lambda_powertools/event_handler/api_gateway.py | 6 +++--- .../event_handler/openapi/swagger_ui/html.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index eca2ec7e229..abfd77b9383 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1680,7 +1680,7 @@ def enable_swagger( compress: bool = False, security_schemes: Optional[Dict[str, "SecurityScheme"]] = None, security: Optional[List[Dict[str, List[str]]]] = None, - oauth2Config: Optional["OAuth2Config"] = None, + oauth2_config: Optional["OAuth2Config"] = None, ): """ Returns the OpenAPI schema as a JSON serializable dict @@ -1719,7 +1719,7 @@ def enable_swagger( A declaration of the security schemes available to be used in the specification. security: List[Dict[str, List[str]]], optional A declaration of which security mechanisms are applied globally across the API. - oauth2Config: OAuth2Config, optional + oauth2_config: OAuth2Config, optional The OAuth2 configuration for the Swagger UI. """ from aws_lambda_powertools.event_handler.openapi.compat import model_json @@ -1783,7 +1783,7 @@ def swagger_handler(): swagger_js, swagger_css, swagger_base_url, - oauth2Config, + oauth2_config, ) return Response( diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py index c6df69eb7d2..8d5f24ffcb2 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py @@ -9,7 +9,7 @@ def generate_swagger_html( swagger_js: str, swagger_css: str, swagger_base_url: str, - oauth2: Optional[OAuth2Config], + oauth2_config: Optional[OAuth2Config], ) -> str: """ Generate Swagger UI HTML page @@ -26,7 +26,7 @@ def generate_swagger_html( The URL to the Swagger UI CSS file swagger_base_url: str The base URL for Swagger UI - oauth2: OAuth2Config, optional + oauth2_config: OAuth2Config, optional The OAuth2 configuration. """ @@ -40,7 +40,9 @@ def generate_swagger_html( swagger_js_content = f"" # Prepare oauth2 config - oauth2_content = f"ui.initOAuth({oauth2.json(exclude_none=True, exclude_unset=True)});" if oauth2 else "" + oauth2_content = ( + f"ui.initOAuth({oauth2_config.json(exclude_none=True, exclude_unset=True)});" if oauth2_config else "" + ) return f""" From e82e54e082a4e0e173143b2360f880e0b0583f9c Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 11 Apr 2024 13:25:08 +0200 Subject: [PATCH 09/22] fix: complexity --- .../event_handler/api_gateway.py | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index abfd77b9383..b3d3597f3e7 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1484,7 +1484,7 @@ def get_openapi_schema( get_compat_model_name_map, get_definitions, ) - from aws_lambda_powertools.event_handler.openapi.models import OpenAPI, PathItem, Server, Tag + from aws_lambda_powertools.event_handler.openapi.models import OpenAPI, PathItem, Tag from aws_lambda_powertools.event_handler.openapi.types import ( COMPONENT_REF_TEMPLATE, ) @@ -1504,23 +1504,12 @@ def get_openapi_schema( info.update({field: value for field, value in optional_fields.items() if value}) - output: Dict[str, Any] = {"openapi": openapi_version, "info": info} - if servers: - output["servers"] = servers - else: - # If the servers property is not provided, or is an empty array, the default value would be a Server Object - # with an url value of /. - output["servers"] = [Server(url="/")] - - if security: - if not security_schemes: - raise ValueError("security_schemes must be provided if security is provided") - - # Check if all keys in security are present in the security_schemes - if not all(key in security_schemes for sec in security for key in sec): - raise ValueError("Some security schemes not found in security_schemes") - - output["security"] = security + output: Dict[str, Any] = { + "openapi": openapi_version, + "info": info, + "servers": self._get_openapi_servers(servers), + "security": self._get_openapi_security(security, security_schemes), + } components: Dict[str, Dict[str, Any]] = {} paths: Dict[str, Dict[str, Any]] = {} @@ -1569,6 +1558,31 @@ def get_openapi_schema( return OpenAPI(**output) + @staticmethod + def _get_openapi_servers(servers: Optional[List["Server"]]) -> List["Server"]: + from aws_lambda_powertools.event_handler.openapi.models import Server + + # If the 'servers' property is not provided or is an empty array, + # the default behavior is to return a Server Object with a URL value of "/". + return servers if servers else [Server(url="/")] + + @staticmethod + def _get_openapi_security( + security: Optional[List[Dict[str, List[str]]]], + security_schemes: Optional[Dict[str, "SecurityScheme"]], + ) -> Optional[List[Dict[str, List[str]]]]: + if security: + if not security_schemes: + raise ValueError("security_schemes must be provided if security is provided") + + # Check if all keys in security are present in the security_schemes + if not all(key in security_schemes for sec in security for key in sec): + raise ValueError("Some security schemes not found in security_schemes") + + return security + else: + return None + @staticmethod def _determine_openapi_version(openapi_version): from aws_lambda_powertools.event_handler.openapi.pydantic_loader import PYDANTIC_V2 From 54d2253fed7944eb80501bcf9c78a166998f4dba Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 11 Apr 2024 14:12:07 +0200 Subject: [PATCH 10/22] fix: pydantic v2 tests --- aws_lambda_powertools/event_handler/openapi/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/openapi/models.py b/aws_lambda_powertools/event_handler/openapi/models.py index 4124f34656d..04345ddaad7 100644 --- a/aws_lambda_powertools/event_handler/openapi/models.py +++ b/aws_lambda_powertools/event_handler/openapi/models.py @@ -441,7 +441,7 @@ class SecurityBase(BaseModel): description: Optional[str] = None if PYDANTIC_V2: - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} else: From 1847e4dd0c1fda161970c26866276b990f75471d Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Mon, 15 Apr 2024 12:12:03 +0200 Subject: [PATCH 11/22] feat: add oauth2 to webui --- .../event_handler/api_gateway.py | 22 +++- .../openapi/swagger_ui/__init__.py | 15 +++ .../event_handler/openapi/swagger_ui/html.py | 7 +- .../openapi/swagger_ui/oauth2.py | 119 +++++++++++++++++- 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index b3d3597f3e7..7ea91b38c53 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -34,7 +34,6 @@ from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError from aws_lambda_powertools.event_handler.openapi.constants import DEFAULT_API_VERSION, DEFAULT_OPENAPI_VERSION from aws_lambda_powertools.event_handler.openapi.exceptions import RequestValidationError -from aws_lambda_powertools.event_handler.openapi.swagger_ui.html import generate_swagger_html from aws_lambda_powertools.event_handler.openapi.types import ( COMPONENT_REF_PREFIX, METHODS_WITH_BODY, @@ -88,7 +87,9 @@ Tag, ) from aws_lambda_powertools.event_handler.openapi.params import Dependant - from aws_lambda_powertools.event_handler.openapi.swagger_ui.oauth2 import OAuth2Config + from aws_lambda_powertools.event_handler.openapi.swagger_ui.oauth2 import ( + OAuth2Config, + ) from aws_lambda_powertools.event_handler.openapi.types import ( TypeModelOrEnum, ) @@ -1738,9 +1739,25 @@ def enable_swagger( """ from aws_lambda_powertools.event_handler.openapi.compat import model_json from aws_lambda_powertools.event_handler.openapi.models import Server + from aws_lambda_powertools.event_handler.openapi.swagger_ui import ( + generate_oauth2_redirect_html, + generate_swagger_html, + ) @self.get(path, middlewares=middlewares, include_in_schema=False, compress=compress) def swagger_handler(): + query_params = self.current_event.query_string_parameters or {} + + # Check for query parameters; if "format" is specified as "oauth2-redirect", + # send the oauth2-redirect HTML stanza so OAuth2 can be used + # Source: https://github.com/swagger-api/swagger-ui/blob/master/dist/oauth2-redirect.html + if query_params.get("format") == "oauth2-redirect": + return Response( + status_code=200, + content_type="text/html", + body=generate_oauth2_redirect_html(), + ) + base_path = self._get_base_path() if swagger_base_url: @@ -1783,7 +1800,6 @@ def swagger_handler(): # Check for query parameters; if "format" is specified as "json", # respond with the JSON used in the OpenAPI spec # Example: https://www.example.com/swagger?format=json - query_params = self.current_event.query_string_parameters or {} if query_params.get("format") == "json": return Response( status_code=200, diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py index e69de29bb2d..abb077b58a3 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py @@ -0,0 +1,15 @@ +from aws_lambda_powertools.event_handler.openapi.swagger_ui.html import ( + generate_swagger_html, +) +from aws_lambda_powertools.event_handler.openapi.swagger_ui.oauth2 import ( + OAuth2Config, + OAuth2UnsafeConfig, + generate_oauth2_redirect_html, +) + +__all__ = [ + "generate_swagger_html", + "generate_oauth2_redirect_html", + "OAuth2Config", + "OAuth2UnsafeConfig", +] diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py index 8d5f24ffcb2..32af1cc7763 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py @@ -66,6 +66,9 @@ def generate_swagger_html( {swagger_js_content} + + + """.strip() From 6836e851b895f4ad6c666974f230f514fd5fb1a4 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Mon, 15 Apr 2024 16:43:52 +0200 Subject: [PATCH 12/22] feat: add docs --- docs/core/event_handler/api_gateway.md | 52 ++++++++++++++++--- .../src/security_schemes_global.py | 44 ++++++++++++++++ .../src/security_schemes_per_operation.py | 43 +++++++++++++++ .../src/swagger_with_oauth2.py | 45 ++++++++++++++++ 4 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 examples/event_handler_rest/src/security_schemes_global.py create mode 100644 examples/event_handler_rest/src/security_schemes_per_operation.py create mode 100644 examples/event_handler_rest/src/swagger_with_oauth2.py diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 1ca54ac57b4..5c88c6329a3 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -991,6 +991,18 @@ To implement these customizations, include extra parameters when defining your r --8<-- "examples/event_handler_rest/src/customizing_api_operations.py" ``` +#### Customizing OpenAPI metadata + +--8<-- "docs/core/event_handler/_openapi_customization_metadata.md" + +Include extra parameters when exporting your OpenAPI specification to apply these customizations: + +=== "customizing_api_metadata.py" + + ```python hl_lines="25-31" + --8<-- "examples/event_handler_rest/src/customizing_api_metadata.py" + ``` + #### Customizing Swagger UI ???+note "Customizing the Swagger metadata" @@ -1014,16 +1026,44 @@ Below is an example configuration for serving Swagger UI from a custom path or C --8<-- "examples/event_handler_rest/src/customizing_swagger_middlewares.py" ``` -#### Customizing OpenAPI metadata +#### Security schemes ---8<-- "docs/core/event_handler/_openapi_customization_metadata.md" +???-info "Does Powertools implement any of the security schemes?" + No. Powertools adds support for generating OpenAPI documentation with security schemes, but it doesn't implement any of the security schemes itself. -Include extra parameters when exporting your OpenAPI specification to apply these customizations: +OpenAPI uses the term security scheme for [authentication and authorization schemes](https://swagger.io/docs/specification/authentication/){target="_blank"}. +When you're describing your API, declare security schemes at the top level, and reference them globally or per operation. -=== "customizing_api_metadata.py" +=== "Global OpenAPI security schemes" - ```python hl_lines="25-31" - --8<-- "examples/event_handler_rest/src/customizing_api_metadata.py" + ```python title="security_schemes_global.py" hl_lines="32-42" + --8<-- "examples/event_handler_rest/src/security_schemes_global.py" + ``` + + 1. Using the oauth security scheme defined earlier, scoped to the "admin" role. + +=== "Per Operation security" + + ```python title="security_schemes_per_operation.py" hl_lines="17 32-41" + --8<-- "examples/event_handler_rest/src/security_schemes_per_operation.py" + ``` + + 1. Using the oauth security scheme defined bellow, scoped to the "admin" role. + +OpenAPI 3 lets you describe APIs protected using the following security schemes: + +| Security Scheme | Type | Description | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [HTTP auth](https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml){target="_blank"} | `HTTPBase` | HTTP authentication schemes using the Authorization header (e.g: [Basic auth](https://swagger.io/docs/specification/authentication/basic-authentication/){target="_blank"}, [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/){target="_blank"}) | +| [API keys](https://swagger.io/docs/specification/authentication/api-keys/https://swagger.io/docs/specification/authentication/api-keys/){target="_blank"} (e.g: query strings, cookies) | `APIKey` | API keys in headers, query strings or [cookies](https://swagger.io/docs/specification/authentication/cookie-authentication/){target="_blank"}. | +| [OAuth 2](https://swagger.io/docs/specification/authentication/oauth2/){target="_blank"} | `OAuth2` | Authorization protocol that gives an API client limited access to user data on a web server. | +| [OpenID Connect Discovery](https://swagger.io/docs/specification/authentication/openid-connect-discovery/){target="_blank"} | `OpenIdConnect` | Identity layer built [on top of the OAuth 2.0 protocol](https://openid.net/developers/how-connect-works/){target="_blank"} and supported by some OAuth 2.0. | + +???-note "Using OAuth2 with the Swagger UI?" + You can use the `OAuth2Config` option to configure a default OAuth2 app on the generated Swagger UI. + + ```python hl_lines="10 15-18 22" + --8<-- "examples/event_handler_rest/src/swagger_with_oauth2.py" ``` ### Custom serializer diff --git a/examples/event_handler_rest/src/security_schemes_global.py b/examples/event_handler_rest/src/security_schemes_global.py new file mode 100644 index 00000000000..3a3ef5ce6f4 --- /dev/null +++ b/examples/event_handler_rest/src/security_schemes_global.py @@ -0,0 +1,44 @@ +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import ( + APIGatewayRestResolver, +) +from aws_lambda_powertools.event_handler.openapi.models import ( + OAuth2, + OAuthFlowAuthorizationCode, + OAuthFlows, +) + +tracer = Tracer() +logger = Logger() + +app = APIGatewayRestResolver(enable_validation=True) + + +@app.get("/") +def helloworld() -> dict: + return {"hello": "world"} + + +@logger.inject_lambda_context +@tracer.capture_lambda_handler +def lambda_handler(event, context): + return app.resolve(event, context) + + +if __name__ == "__main__": + print( + app.get_openapi_json_schema( + title="My API", + security_schemes={ + "oauth": OAuth2( + flows=OAuthFlows( + authorizationCode=OAuthFlowAuthorizationCode( + authorizationUrl="https://xxx.amazoncognito.com/oauth2/authorize", + tokenUrl="https://xxx.amazoncognito.com/oauth2/token", + ), + ), + ), + }, + security=[{"oauth": ["admin"]}], # (1)! + ), + ) diff --git a/examples/event_handler_rest/src/security_schemes_per_operation.py b/examples/event_handler_rest/src/security_schemes_per_operation.py new file mode 100644 index 00000000000..66770a787c7 --- /dev/null +++ b/examples/event_handler_rest/src/security_schemes_per_operation.py @@ -0,0 +1,43 @@ +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import ( + APIGatewayRestResolver, +) +from aws_lambda_powertools.event_handler.openapi.models import ( + OAuth2, + OAuthFlowAuthorizationCode, + OAuthFlows, +) + +tracer = Tracer() +logger = Logger() + +app = APIGatewayRestResolver(enable_validation=True) + + +@app.get("/", security=[{"oauth": ["admin"]}]) # (1)! +def helloworld() -> dict: + return {"hello": "world"} + + +@logger.inject_lambda_context +@tracer.capture_lambda_handler +def lambda_handler(event, context): + return app.resolve(event, context) + + +if __name__ == "__main__": + print( + app.get_openapi_json_schema( + title="My API", + security_schemes={ + "oauth": OAuth2( + flows=OAuthFlows( + authorizationCode=OAuthFlowAuthorizationCode( + authorizationUrl="https://xxx.amazoncognito.com/oauth2/authorize", + tokenUrl="https://xxx.amazoncognito.com/oauth2/token", + ), + ), + ), + }, + ), + ) diff --git a/examples/event_handler_rest/src/swagger_with_oauth2.py b/examples/event_handler_rest/src/swagger_with_oauth2.py new file mode 100644 index 00000000000..4a2a86cdd40 --- /dev/null +++ b/examples/event_handler_rest/src/swagger_with_oauth2.py @@ -0,0 +1,45 @@ +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import ( + APIGatewayRestResolver, +) +from aws_lambda_powertools.event_handler.openapi.models import ( + OAuth2, + OAuthFlowAuthorizationCode, + OAuthFlows, +) +from aws_lambda_powertools.event_handler.openapi.swagger_ui import OAuth2Config + +tracer = Tracer() +logger = Logger() + +oauth2 = OAuth2Config( + client_id="xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + app_name="OAuth2 app", +) + +app = APIGatewayRestResolver(enable_validation=True) +app.enable_swagger( + oauth2_config=oauth2, + security_schemes={ + "oauth": OAuth2( + flows=OAuthFlows( + authorizationCode=OAuthFlowAuthorizationCode( + authorizationUrl="https://xxx.amazoncognito.com/oauth2/authorize", + tokenUrl="https://xxx.amazoncognito.com/oauth2/token", + ), + ), + ), + }, + security=[{"oauth": []}], +) + + +@app.get("/") +def hello() -> str: + return "world" + + +@logger.inject_lambda_context +@tracer.capture_lambda_handler +def lambda_handler(event, context): + return app.resolve(event, context) From ed1c24164db1fe49f91f53c449fc86b78f940071 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Mon, 15 Apr 2024 17:12:05 +0200 Subject: [PATCH 13/22] fix: mypy errors --- .../event_handler/openapi/swagger_ui/oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py index b8aad962757..a5f2fe2583d 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py @@ -16,7 +16,7 @@ class OAuth2Config(BaseModel): clientId: str = Field(alias="client_id") # The realm in which the OAuth2 application is registered. Optional. - realm: Optional[str] + realm: Optional[str] = Field(default=None) # The name of the OAuth2 application appName: str = Field(alias="app_name") From 8ab83be724f4aa7676fbbc2f71ccfc31e820e252 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Mon, 15 Apr 2024 17:23:46 +0200 Subject: [PATCH 14/22] fix: added source of swagger redirect --- .../event_handler/openapi/swagger_ui/oauth2.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py index a5f2fe2583d..f7ac2137f75 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py @@ -58,6 +58,8 @@ class OAuth2UnsafeConfig(OAuth2Config): def generate_oauth2_redirect_html() -> str: """ Generates the HTML content for the OAuth2 redirect page. + + Source: https://github.com/swagger-api/swagger-ui/blob/master/dist/oauth2-redirect.html """ return """ From 8c2818d39f210ac28cd8e58473725fb15f41399e Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 17 Apr 2024 11:29:51 +0200 Subject: [PATCH 15/22] fix: add example and moved oauth2 config --- .../openapi/swagger_ui/__init__.py | 2 - .../openapi/swagger_ui/oauth2.py | 24 ++++---- .../sam/swagger_ui_oauth2_template.yaml | 28 ++++++++++ .../src/swagger_ui_oauth2.py | 55 +++++++++++++++++++ 4 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml create mode 100644 examples/event_handler_rest/src/swagger_ui_oauth2.py diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py index abb077b58a3..bc6eda8abb3 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/__init__.py @@ -3,7 +3,6 @@ ) from aws_lambda_powertools.event_handler.openapi.swagger_ui.oauth2 import ( OAuth2Config, - OAuth2UnsafeConfig, generate_oauth2_redirect_html, ) @@ -11,5 +10,4 @@ "generate_swagger_html", "generate_oauth2_redirect_html", "OAuth2Config", - "OAuth2UnsafeConfig", ] diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py index f7ac2137f75..e3a827814b6 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py @@ -1,9 +1,10 @@ # ruff: noqa: E501 from typing import Dict, Optional, Sequence -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator from aws_lambda_powertools.event_handler.openapi.pydantic_loader import PYDANTIC_V2 +from aws_lambda_powertools.shared.functions import powertools_dev_is_set # Based on https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/ @@ -15,6 +16,10 @@ class OAuth2Config(BaseModel): # The client ID for the OAuth2 application clientId: str = Field(alias="client_id") + # The client secret for the OAuth2 application. This is sensitive information and requires the explicit presence + # of the POWERTOOLS_DEV environment variable. + clientSecret: Optional[str] = Field(alias="client_secret", default=None) + # The realm in which the OAuth2 application is registered. Optional. realm: Optional[str] = Field(default=None) @@ -44,15 +49,14 @@ class Config: extra = "allow" allow_population_by_field_name = True - -class OAuth2UnsafeConfig(OAuth2Config): - """ - This class extends the OAuth2Config class and includes the client secret. - This class NEVER BE USED IN PRODUCTION as it will expose sensitive information. - """ - - # The client secret for the OAuth2 application. This is sensitive information. - clientSecret: str = Field(alias="client_secret") + @validator("clientSecret", always=True) + def client_secret_only_on_dev(cls, v: Optional[str]) -> Optional[str]: + if v and not powertools_dev_is_set(): + raise ValueError( + "cannot use client_secret without POWERTOOLS_DEV mode. See " + "https://docs.powertools.aws.dev/lambda/python/latest/#optimizing-for-non-production-environments", + ) + return v def generate_oauth2_redirect_html() -> str: diff --git a/examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml b/examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml new file mode 100644 index 00000000000..1b530fb9911 --- /dev/null +++ b/examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml @@ -0,0 +1,28 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: Sample SAM Template for Oauth2 Cognito User Pool + Swagger UI + +Globals: + Function: + Timeout: 5 + Runtime: python3.12 + Tracing: Active + Environment: + Variables: + LOG_LEVEL: INFO + POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1 + POWERTOOLS_LOGGER_LOG_EVENT: true + POWERTOOLS_SERVICE_NAME: example + +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: hello_world/ + Handler: swagger_ui_oauth2.lambda_handler + Events: + AnyApiEvent: + Type: Api + Properties: + Path: /{proxy+} # Send requests on any path to the lambda function + Method: ANY # Send requests using any http method to the lambda function diff --git a/examples/event_handler_rest/src/swagger_ui_oauth2.py b/examples/event_handler_rest/src/swagger_ui_oauth2.py new file mode 100644 index 00000000000..9dccee07e82 --- /dev/null +++ b/examples/event_handler_rest/src/swagger_ui_oauth2.py @@ -0,0 +1,55 @@ +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import ( + APIGatewayRestResolver, + Response, +) +from aws_lambda_powertools.event_handler.openapi.models import ( + OAuth2, + OAuthFlowAuthorizationCode, + OAuthFlows, +) +from aws_lambda_powertools.event_handler.openapi.swagger_ui import OAuth2Config + +tracer = Tracer() +logger = Logger() + +oauth2 = OAuth2Config( + client_id="your_oauth2_client_id", + client_secret="your_oauth2_secret", + app_name="OAuth2 Test", +) + +app = APIGatewayRestResolver(enable_validation=True) + +# NOTE: for this to work, your OAuth2 redirect url needs to precisely follow this format: +# https://.execute-api..amazonaws.com//swagger?format=oauth2-redirect +app.enable_swagger( + oauth2_config=oauth2, + security_schemes={ + "oauth": OAuth2( + flows=OAuthFlows( + authorizationCode=OAuthFlowAuthorizationCode( + authorizationUrl="https://your-cognito-domain.eu-central-1.amazoncognito.com/oauth2/authorize", + tokenUrl="https://your-cognito-domain.eu-central-1.amazoncognito.com/oauth2/token", + ), + ), + ), + }, + security=[{"oauth": []}], +) + + +@app.get("/") +def helloworld() -> Response[dict]: + logger.info("Hello, World!") + return Response( + status_code=200, + body={"message": "Hello, World"}, + content_type="application/json", + ) + + +@logger.inject_lambda_context(log_event=True) +@tracer.capture_lambda_handler +def lambda_handler(event, context): + return app.resolve(event, context) From 3c253e9e05313c3fe5953b49cb0b3237972dafba Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 17 Apr 2024 13:21:10 +0200 Subject: [PATCH 16/22] Update aws_lambda_powertools/event_handler/api_gateway.py Co-authored-by: Leandro Damascena Signed-off-by: Ruben Fonseca --- .../event_handler/api_gateway.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 7ea91b38c53..87433b020d5 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1572,17 +1572,17 @@ def _get_openapi_security( security: Optional[List[Dict[str, List[str]]]], security_schemes: Optional[Dict[str, "SecurityScheme"]], ) -> Optional[List[Dict[str, List[str]]]]: - if security: - if not security_schemes: - raise ValueError("security_schemes must be provided if security is provided") + if not security: + return None - # Check if all keys in security are present in the security_schemes - if not all(key in security_schemes for sec in security for key in sec): - raise ValueError("Some security schemes not found in security_schemes") + if not security_schemes: + raise ValueError("security_schemes must be provided if security is provided") - return security - else: - return None + # Check if all keys in security are present in the security_schemes + if any(key not in security_schemes for sec in security for key in sec): + raise ValueError("Some security schemes not found in security_schemes") + + return security @staticmethod def _determine_openapi_version(openapi_version): From 2756a12371ac27dfc91c855f6d9aef39855a6a21 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 17 Apr 2024 13:25:07 +0200 Subject: [PATCH 17/22] Update docs/core/event_handler/api_gateway.md Co-authored-by: Leandro Damascena Signed-off-by: Ruben Fonseca --- docs/core/event_handler/api_gateway.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 5c88c6329a3..aaf9352ebc0 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -1029,7 +1029,7 @@ Below is an example configuration for serving Swagger UI from a custom path or C #### Security schemes ???-info "Does Powertools implement any of the security schemes?" - No. Powertools adds support for generating OpenAPI documentation with security schemes, but it doesn't implement any of the security schemes itself. + No. Powertools adds support for generating OpenAPI documentation with [security schemes](https://swagger.io/docs/specification/authentication/), but it doesn't implement any of the security schemes itself, so you must implement the security mechanisms separately. OpenAPI uses the term security scheme for [authentication and authorization schemes](https://swagger.io/docs/specification/authentication/){target="_blank"}. When you're describing your API, declare security schemes at the top level, and reference them globally or per operation. From d2e58f9137aeed201fc5ab8e6e7061ea4bbac8cf Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 17 Apr 2024 13:26:02 +0200 Subject: [PATCH 18/22] Apply suggestions from code review Co-authored-by: Leandro Damascena Signed-off-by: Ruben Fonseca --- .../event_handler/openapi/swagger_ui/html.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py index 32af1cc7763..8b748d9338a 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py @@ -21,9 +21,9 @@ def generate_swagger_html( path: str The path to the Swagger documentation swagger_js: str - The URL to the Swagger UI JavaScript file + Swagger UI JavaScript source code or URL swagger_css: str - The URL to the Swagger UI CSS file + Swagger UI CSS source code or URL swagger_base_url: str The base URL for Swagger UI oauth2_config: OAuth2Config, optional From dadf08ac3d787714c13a546e1100b2a1bb745949 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 18 Apr 2024 10:16:32 +0200 Subject: [PATCH 19/22] fix: make client id optional too since it can be filled in Swagger UI --- .../event_handler/openapi/swagger_ui/oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py index e3a827814b6..51cc1182296 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py @@ -14,7 +14,7 @@ class OAuth2Config(BaseModel): """ # The client ID for the OAuth2 application - clientId: str = Field(alias="client_id") + clientId: Optional[str] = Field(alias="client_id", default=None) # The client secret for the OAuth2 application. This is sensitive information and requires the explicit presence # of the POWERTOOLS_DEV environment variable. From fb9f76700d1de800b364003170b2613648d9b5b7 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 18 Apr 2024 10:17:45 +0200 Subject: [PATCH 20/22] chore: add swagger example --- .../sam/swagger_ui_oauth2_template.yaml | 58 ++++++++++++++++++- .../src/swagger_ui_oauth2.py | 20 +++---- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml b/examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml index 1b530fb9911..629fa02f88b 100644 --- a/examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml +++ b/examples/event_handler_rest/sam/swagger_ui_oauth2_template.yaml @@ -18,11 +18,67 @@ Resources: HelloWorldFunction: Type: AWS::Serverless::Function Properties: - CodeUri: hello_world/ + CodeUri: ../src Handler: swagger_ui_oauth2.lambda_handler + Environment: + Variables: + COGNITO_USER_POOL_DOMAIN: !Ref UserPoolDomain Events: AnyApiEvent: Type: Api Properties: Path: /{proxy+} # Send requests on any path to the lambda function Method: ANY # Send requests using any http method to the lambda function + + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: PowertoolsUserPool + Policies: + PasswordPolicy: + MinimumLength: 8 + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + RequireUppercase: true + + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: PowertoolsClient + UserPoolId: !Ref CognitoUserPool + GenerateSecret: true + RefreshTokenValidity: 30 + ExplicitAuthFlows: + - ALLOW_USER_PASSWORD_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + SupportedIdentityProviders: + - COGNITO + CallbackURLs: + # NOTE: for this to work, your OAuth2 redirect url needs to precisely follow this format: + # https://.execute-api..amazonaws.com//swagger?format=oauth2-redirect + - !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/${ServerlessRestApi.Stage}/swagger?format=oauth2-redirect" + AllowedOAuthFlows: + - code + AllowedOAuthScopes: + - openid + - email + - profile + - aws.cognito.signin.user.admin + AllowedOAuthFlowsUserPoolClient: true + + UserPoolDomain: + Type: AWS::Cognito::UserPoolDomain + Properties: + Domain: powertools-swagger-oauth2 + UserPoolId: !Ref CognitoUserPool + +Outputs: + HelloWorldApiUrl: + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/${ServerlessRestApi.Stage}/swagger" + + CognitoOAuthClientId: + Value: !GetAtt CognitoUserPoolClient.ClientId + + CognitoDomain: + Value: !Ref UserPoolDomain diff --git a/examples/event_handler_rest/src/swagger_ui_oauth2.py b/examples/event_handler_rest/src/swagger_ui_oauth2.py index 9dccee07e82..1dc7f173735 100644 --- a/examples/event_handler_rest/src/swagger_ui_oauth2.py +++ b/examples/event_handler_rest/src/swagger_ui_oauth2.py @@ -1,3 +1,5 @@ +import os + from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import ( APIGatewayRestResolver, @@ -13,24 +15,20 @@ tracer = Tracer() logger = Logger() -oauth2 = OAuth2Config( - client_id="your_oauth2_client_id", - client_secret="your_oauth2_secret", - app_name="OAuth2 Test", -) +region = os.getenv("AWS_REGION") +cognito_domain = os.getenv("COGNITO_USER_POOL_DOMAIN") app = APIGatewayRestResolver(enable_validation=True) - -# NOTE: for this to work, your OAuth2 redirect url needs to precisely follow this format: -# https://.execute-api..amazonaws.com//swagger?format=oauth2-redirect app.enable_swagger( - oauth2_config=oauth2, + # NOTE: for this to work, your OAuth2 redirect url needs to precisely follow this format: + # https://.execute-api..amazonaws.com//swagger?format=oauth2-redirect + oauth2_config=OAuth2Config(app_name="OAuth2 Test"), security_schemes={ "oauth": OAuth2( flows=OAuthFlows( authorizationCode=OAuthFlowAuthorizationCode( - authorizationUrl="https://your-cognito-domain.eu-central-1.amazoncognito.com/oauth2/authorize", - tokenUrl="https://your-cognito-domain.eu-central-1.amazoncognito.com/oauth2/token", + authorizationUrl=f"https://{cognito_domain}.auth.{region}.amazoncognito.com/oauth2/authorize", + tokenUrl=f"https://{cognito_domain}.auth.{region}.amazoncognito.com/oauth2/token", ), ), ), From dba2499a8faffbf5614e4083498425210f04d364 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 18 Apr 2024 10:58:22 +0200 Subject: [PATCH 21/22] chore: added warning when using client_secret --- .../openapi/swagger_ui/oauth2.py | 8 ++++++ .../event_handler/test_openapi_swagger.py | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py index 51cc1182296..cd825268b5a 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py @@ -1,4 +1,5 @@ # ruff: noqa: E501 +import warnings from typing import Dict, Optional, Sequence from pydantic import BaseModel, Field, validator @@ -56,6 +57,13 @@ def client_secret_only_on_dev(cls, v: Optional[str]) -> Optional[str]: "cannot use client_secret without POWERTOOLS_DEV mode. See " "https://docs.powertools.aws.dev/lambda/python/latest/#optimizing-for-non-production-environments", ) + else: + warnings.warn( + "OAuth2Config is using client_secret and POWERTOOLS_DEV is set. This reveals sensitive information. " + "DO NOT USE THIS OUTSIDE LOCAL DEVELOPMENT", + stacklevel=2, + ) + return v diff --git a/tests/functional/event_handler/test_openapi_swagger.py b/tests/functional/event_handler/test_openapi_swagger.py index 82c9b4874d0..11ec0cf24da 100644 --- a/tests/functional/event_handler/test_openapi_swagger.py +++ b/tests/functional/event_handler/test_openapi_swagger.py @@ -1,7 +1,11 @@ import json +import warnings from typing import Dict +import pytest + from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.event_handler.openapi.swagger_ui import OAuth2Config from tests.functional.utils import load_event LOAD_GW_EVENT = load_event("apiGatewayProxyEvent.json") @@ -112,3 +116,26 @@ def test_openapi_swagger_with_rest_api_stage(): result = app(event, {}) assert result["statusCode"] == 200 assert "ui.specActions.updateUrl('/prod/swagger?format=json')" in result["body"] + + +def test_openapi_swagger_oauth2_without_powertools_dev(): + with pytest.raises(ValueError) as exc: + OAuth2Config(app_name="OAuth2 app", client_id="client_id", client_secret="verysecret") + + assert "cannot use client_secret without POWERTOOLS_DEV mode" in str(exc.value) + + +def test_openapi_swagger_oauth2_with_powertools_dev(monkeypatch): + monkeypatch.setenv("POWERTOOLS_DEV", "1") + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("default") + + OAuth2Config(app_name="OAuth2 app", client_id="client_id", client_secret="verysecret") + + assert str(w[-1].message) == ( + "OAuth2Config is using client_secret and POWERTOOLS_DEV is set. This reveals sensitive information. " + "DO NOT USE THIS OUTSIDE LOCAL DEVELOPMENT" + ) + + monkeypatch.delenv("POWERTOOLS_DEV") From a67ce51c59e7cc4b997c0a7ae0c8c4a887dd2f32 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 18 Apr 2024 11:03:55 +0200 Subject: [PATCH 22/22] fix: logic --- .../event_handler/openapi/swagger_ui/oauth2.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py index cd825268b5a..29250ae0056 100644 --- a/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py +++ b/aws_lambda_powertools/event_handler/openapi/swagger_ui/oauth2.py @@ -52,7 +52,10 @@ class Config: @validator("clientSecret", always=True) def client_secret_only_on_dev(cls, v: Optional[str]) -> Optional[str]: - if v and not powertools_dev_is_set(): + if not v: + return None + + if not powertools_dev_is_set(): raise ValueError( "cannot use client_secret without POWERTOOLS_DEV mode. See " "https://docs.powertools.aws.dev/lambda/python/latest/#optimizing-for-non-production-environments", @@ -63,8 +66,7 @@ def client_secret_only_on_dev(cls, v: Optional[str]) -> Optional[str]: "DO NOT USE THIS OUTSIDE LOCAL DEVELOPMENT", stacklevel=2, ) - - return v + return v def generate_oauth2_redirect_html() -> str: