From 9b17e45173f51844551b906a137f9c626eb207f0 Mon Sep 17 00:00:00 2001 From: heindrickdumdum Date: Thu, 20 Jun 2024 00:11:51 -0400 Subject: [PATCH 1/5] feat: FastAPI contrib --- .../django/lti1p3_tool_config/models.py | 6 +-- pylti1p3/contrib/fastapi/__init__.py | 0 pylti1p3/contrib/fastapi/cookie.py | 37 +++++++++++++++++++ .../fastapi/launch_data_storage/__init__.py | 0 .../fastapi/launch_data_storage/cache.py | 9 +++++ pylti1p3/contrib/fastapi/message_launch.py | 33 +++++++++++++++++ pylti1p3/contrib/fastapi/oidc_login.py | 37 +++++++++++++++++++ pylti1p3/contrib/fastapi/redirect.py | 34 +++++++++++++++++ pylti1p3/contrib/fastapi/request.py | 24 ++++++++++++ pylti1p3/contrib/fastapi/session.py | 5 +++ pylti1p3/contrib/flask/cookie.py | 16 ++++---- pylti1p3/tool_config/abstract.py | 12 +++--- tests/test_resource_link.py | 6 +-- 13 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 pylti1p3/contrib/fastapi/__init__.py create mode 100644 pylti1p3/contrib/fastapi/cookie.py create mode 100644 pylti1p3/contrib/fastapi/launch_data_storage/__init__.py create mode 100644 pylti1p3/contrib/fastapi/launch_data_storage/cache.py create mode 100644 pylti1p3/contrib/fastapi/message_launch.py create mode 100644 pylti1p3/contrib/fastapi/oidc_login.py create mode 100644 pylti1p3/contrib/fastapi/redirect.py create mode 100644 pylti1p3/contrib/fastapi/request.py create mode 100644 pylti1p3/contrib/fastapi/session.py diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/models.py b/pylti1p3/contrib/django/lti1p3_tool_config/models.py index 6e5ff73..617768c 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/models.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/models.py @@ -170,9 +170,9 @@ def to_dict(self): "auth_audience": self.auth_audience, "key_set_url": self.key_set_url, "key_set": json.loads(self.key_set) if self.key_set else None, - "deployment_ids": json.loads(self.deployment_ids) - if self.deployment_ids - else [], + "deployment_ids": ( + json.loads(self.deployment_ids) if self.deployment_ids else [] + ), } return data diff --git a/pylti1p3/contrib/fastapi/__init__.py b/pylti1p3/contrib/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pylti1p3/contrib/fastapi/cookie.py b/pylti1p3/contrib/fastapi/cookie.py new file mode 100644 index 0000000..4150ae5 --- /dev/null +++ b/pylti1p3/contrib/fastapi/cookie.py @@ -0,0 +1,37 @@ +from pylti1p3.cookie import CookieService + + +class FastAPICookieService(CookieService): + _request = None + _cookie_data_to_set = None + + def __init__(self, request): + self._request = request + self._cookie_data_to_set = {} + + def _get_key(self, key): + return self._cookie_prefix + "-" + key + + def get_cookie(self, name): + return self._request.cookies.get(self._get_key(name)) + + def set_cookie(self, name, value, exp=3600): + self._cookie_data_to_set[self._get_key(name)] = { + "value": value, + "exp": exp, + } + + def update_response(self, response): + is_secure = self._request.url.is_secure + for key, cookie_data in self._cookie_data_to_set.items(): + cookie_kwargs = { + "key": key, + "value": cookie_data["value"], + "max_age": cookie_data["exp"], + "secure": is_secure, + "path": "/", + "httponly": True, + } + if is_secure: + cookie_kwargs["samesite"] = "None" + response.set_cookie(**cookie_kwargs) diff --git a/pylti1p3/contrib/fastapi/launch_data_storage/__init__.py b/pylti1p3/contrib/fastapi/launch_data_storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pylti1p3/contrib/fastapi/launch_data_storage/cache.py b/pylti1p3/contrib/fastapi/launch_data_storage/cache.py new file mode 100644 index 0000000..7c7bbd8 --- /dev/null +++ b/pylti1p3/contrib/fastapi/launch_data_storage/cache.py @@ -0,0 +1,9 @@ +from pylti1p3.launch_data_storage.cache import CacheDataStorage + + +class FastAPICacheDataStorage(CacheDataStorage): + _cache = None + + def __init__(self, cache, **kwargs): + self._cache = cache + super().__init__(cache, **kwargs) diff --git a/pylti1p3/contrib/fastapi/message_launch.py b/pylti1p3/contrib/fastapi/message_launch.py new file mode 100644 index 0000000..1cb152e --- /dev/null +++ b/pylti1p3/contrib/fastapi/message_launch.py @@ -0,0 +1,33 @@ +from pylti1p3.message_launch import MessageLaunch + +from .cookie import FastAPICookieService +from .session import FastAPISessionService + + +class FastAPIMessageLaunch(MessageLaunch): + def __init__( + self, + request, + tool_config, + session_service=None, + cookie_service=None, + launch_data_storage=None, + requests_session=None, + ): + cookie_service = ( + cookie_service if cookie_service else FastAPICookieService(request) + ) + session_service = ( + session_service if session_service else FastAPISessionService(request) + ) + super().__init__( + request, + tool_config, + session_service, + cookie_service, + launch_data_storage, + requests_session, + ) + + def _get_request_param(self, key): + return self._request.get_param(key) diff --git a/pylti1p3/contrib/fastapi/oidc_login.py b/pylti1p3/contrib/fastapi/oidc_login.py new file mode 100644 index 0000000..9fb44f4 --- /dev/null +++ b/pylti1p3/contrib/fastapi/oidc_login.py @@ -0,0 +1,37 @@ +from fastapi.responses import HTMLResponse + +from pylti1p3.oidc_login import OIDCLogin + +from .cookie import FastAPICookieService +from .redirect import FastAPIRedirect +from .session import FastAPISessionService + + +class FastAPIOIDCLogin(OIDCLogin): + def __init__( + self, + request, + tool_config, + session_service=None, + cookie_service=None, + launch_data_storage=None, + ): + cookie_service = ( + cookie_service if cookie_service else FastAPICookieService(request) + ) + session_service = ( + session_service if session_service else FastAPISessionService(request) + ) + super().__init__( + request, + tool_config, + session_service, + cookie_service, + launch_data_storage, + ) + + def get_redirect(self, url): + return FastAPIRedirect(url, self._cookie_service) + + def get_response(self, html): + return HTMLResponse(content=html) diff --git a/pylti1p3/contrib/fastapi/redirect.py b/pylti1p3/contrib/fastapi/redirect.py new file mode 100644 index 0000000..bbdb1ab --- /dev/null +++ b/pylti1p3/contrib/fastapi/redirect.py @@ -0,0 +1,34 @@ +from fastapi.responses import HTMLResponse, RedirectResponse + +from pylti1p3.redirect import Redirect + + +class FastAPIRedirect(Redirect): + _location = None + _cookie_service = None + + def __init__(self, location, cookie_service=None): + super().__init__() + self._location = location + self._cookie_service = cookie_service + + def do_redirect(self): + return self._process_response(RedirectResponse(self._location)) + + def do_js_redirect(self): + return self._process_response( + HTMLResponse( + f'' + ) + ) + + def set_redirect_url(self, location): + self._location = location + + def get_redirect_url(self): + return self._location + + def _process_response(self, response): + if self._cookie_service: + self._cookie_service.update_response(response) + return response diff --git a/pylti1p3/contrib/fastapi/request.py b/pylti1p3/contrib/fastapi/request.py new file mode 100644 index 0000000..bc40b5d --- /dev/null +++ b/pylti1p3/contrib/fastapi/request.py @@ -0,0 +1,24 @@ +from pylti1p3.request import Request + + +class FastAPIRequest(Request): + _session = None + + def __init__(self, request): + super().__init__() + self._request = request + + @property + def session(self): + return self._request.session + + async def get_param(self, key): + if self._request.method == "GET": + return self._request.query_params.get(key, None) + return (await self._request.form()).get(key) + + def get_cookie(self, key): + return self._request.cookies.get(key, None) + + def is_secure(self): + return self._request.url.is_secure diff --git a/pylti1p3/contrib/fastapi/session.py b/pylti1p3/contrib/fastapi/session.py new file mode 100644 index 0000000..ff36d33 --- /dev/null +++ b/pylti1p3/contrib/fastapi/session.py @@ -0,0 +1,5 @@ +from pylti1p3.session import SessionService + + +class FastAPISessionService(SessionService): + pass diff --git a/pylti1p3/contrib/flask/cookie.py b/pylti1p3/contrib/flask/cookie.py index 775855f..b9973fc 100644 --- a/pylti1p3/contrib/flask/cookie.py +++ b/pylti1p3/contrib/flask/cookie.py @@ -20,14 +20,14 @@ def set_cookie(self, name, value, exp=3600): def update_response(self, response): for key, cookie_data in self._cookie_data_to_set.items(): - cookie_kwargs = dict( - key=key, - value=cookie_data["value"], - max_age=cookie_data["exp"], - secure=self._request.is_secure(), - path="/", - httponly=True, - ) + cookie_kwargs = { + "key": key, + "value": cookie_data["value"], + "max_age": cookie_data["exp"], + "secure": self._request.is_secure(), + "path": "/", + "httponly": True, + } if self._request.is_secure(): cookie_kwargs["samesite"] = "None" diff --git a/pylti1p3/tool_config/abstract.py b/pylti1p3/tool_config/abstract.py index e97d07e..84847fc 100644 --- a/pylti1p3/tool_config/abstract.py +++ b/pylti1p3/tool_config/abstract.py @@ -39,14 +39,14 @@ def check_iss_has_many_clients(self, iss: str) -> bool: return iss_type == IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER def set_iss_has_one_client(self, iss: str): - self.issuers_relation_types[ - iss - ] = IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + self.issuers_relation_types[iss] = ( + IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + ) def set_iss_has_many_clients(self, iss: str): - self.issuers_relation_types[ - iss - ] = IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER + self.issuers_relation_types[iss] = ( + IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER + ) def find_registration(self, iss: str, *args, **kwargs) -> Registration: """ diff --git a/tests/test_resource_link.py b/tests/test_resource_link.py index cb669c4..602ba7f 100644 --- a/tests/test_resource_link.py +++ b/tests/test_resource_link.py @@ -293,9 +293,9 @@ def _get_data_with_invalid_deployment( def _get_data_with_invalid_message(self, *args): # pylint: disable=unused-argument message_launch_data = self.expected_message_launch_data.copy() - message_launch_data[ - "https://purl.imsglobal.org/spec/lti/claim/version" - ] = "1.2.0" + message_launch_data["https://purl.imsglobal.org/spec/lti/claim/version"] = ( + "1.2.0" + ) return message_launch_data def test_res_link_launch_invalid_nonce(self): From 0d9620c6506631212c63f4c4835c1ae3034b32dd Mon Sep 17 00:00:00 2001 From: heindrickdumdum Date: Thu, 20 Jun 2024 04:14:13 -0400 Subject: [PATCH 2/5] feat: Update FastAPI request --- pylti1p3/contrib/fastapi/__init__.py | 7 +++++++ pylti1p3/contrib/fastapi/request.py | 19 +++++++++++++++---- tox.ini | 1 + 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pylti1p3/contrib/fastapi/__init__.py b/pylti1p3/contrib/fastapi/__init__.py index e69de29..298c7e0 100644 --- a/pylti1p3/contrib/fastapi/__init__.py +++ b/pylti1p3/contrib/fastapi/__init__.py @@ -0,0 +1,7 @@ +# flake8: noqa +from .cookie import FastAPICookieService +from .launch_data_storage.cache import FastAPICacheDataStorage +from .message_launch import FastAPIMessageLaunch +from .oidc_login import FastAPIOIDCLogin +from .request import FastAPIRequest +from .session import FastAPISessionService diff --git a/pylti1p3/contrib/fastapi/request.py b/pylti1p3/contrib/fastapi/request.py index bc40b5d..5096024 100644 --- a/pylti1p3/contrib/fastapi/request.py +++ b/pylti1p3/contrib/fastapi/request.py @@ -2,20 +2,31 @@ class FastAPIRequest(Request): - _session = None + _request = None + _form_data = None + + def __init__(self, request, form_data=None): + """ + Parameters: + request: FastAPI request + form_data: form data from FastAPI request + To get form data from FastAPI request, must use async method. + As we don't use async functions here, form data must be provided from outside. + """ - def __init__(self, request): super().__init__() + self._request = request + self._form_data = form_data or {} @property def session(self): return self._request.session - async def get_param(self, key): + def get_param(self, key): if self._request.method == "GET": return self._request.query_params.get(key, None) - return (await self._request.form()).get(key) + return self._form_data.get(key) def get_cookie(self, key): return self._request.cookies.get(key, None) diff --git a/tox.ini b/tox.ini index 51058bd..143ad67 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ deps = black coverage django + fastapi flake8 flask jwcrypto From efb8837d48ad166cb506cb9c864c97f5c62a5ebe Mon Sep 17 00:00:00 2001 From: heindrickdumdum Date: Thu, 20 Jun 2024 10:15:31 -0400 Subject: [PATCH 3/5] fix: Fix FastAPI cookie --- pylti1p3/contrib/fastapi/cookie.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylti1p3/contrib/fastapi/cookie.py b/pylti1p3/contrib/fastapi/cookie.py index 4150ae5..4c20fa8 100644 --- a/pylti1p3/contrib/fastapi/cookie.py +++ b/pylti1p3/contrib/fastapi/cookie.py @@ -13,7 +13,7 @@ def _get_key(self, key): return self._cookie_prefix + "-" + key def get_cookie(self, name): - return self._request.cookies.get(self._get_key(name)) + return self._request.get_cookie(self._get_key(name)) def set_cookie(self, name, value, exp=3600): self._cookie_data_to_set[self._get_key(name)] = { @@ -22,7 +22,7 @@ def set_cookie(self, name, value, exp=3600): } def update_response(self, response): - is_secure = self._request.url.is_secure + is_secure = self._request.is_secure() for key, cookie_data in self._cookie_data_to_set.items(): cookie_kwargs = { "key": key, From 568329a790832b31e8d8f077feee6b926cd9d467 Mon Sep 17 00:00:00 2001 From: heindrickdumdum Date: Thu, 20 Jun 2024 23:37:19 -0400 Subject: [PATCH 4/5] feat: Change FastAPI redirect status code to 302 --- pylti1p3/contrib/fastapi/redirect.py | 2 +- pylti1p3/contrib/fastapi/request.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pylti1p3/contrib/fastapi/redirect.py b/pylti1p3/contrib/fastapi/redirect.py index bbdb1ab..30f47d0 100644 --- a/pylti1p3/contrib/fastapi/redirect.py +++ b/pylti1p3/contrib/fastapi/redirect.py @@ -13,7 +13,7 @@ def __init__(self, location, cookie_service=None): self._cookie_service = cookie_service def do_redirect(self): - return self._process_response(RedirectResponse(self._location)) + return self._process_response(RedirectResponse(self._location, status_code=302)) def do_js_redirect(self): return self._process_response( diff --git a/pylti1p3/contrib/fastapi/request.py b/pylti1p3/contrib/fastapi/request.py index 5096024..4e5e37c 100644 --- a/pylti1p3/contrib/fastapi/request.py +++ b/pylti1p3/contrib/fastapi/request.py @@ -5,7 +5,7 @@ class FastAPIRequest(Request): _request = None _form_data = None - def __init__(self, request, form_data=None): + def __init__(self, request, form_data): """ Parameters: request: FastAPI request @@ -17,7 +17,7 @@ def __init__(self, request, form_data=None): super().__init__() self._request = request - self._form_data = form_data or {} + self._form_data = form_data @property def session(self): From 0fdc22cf9e60661eb09722e8dc1d3c7f0c21cd91 Mon Sep 17 00:00:00 2001 From: heindrickdumdum Date: Fri, 21 Jun 2024 00:06:11 -0400 Subject: [PATCH 5/5] chore: Update FastAPI response cookie --- pylti1p3/contrib/fastapi/cookie.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pylti1p3/contrib/fastapi/cookie.py b/pylti1p3/contrib/fastapi/cookie.py index 4c20fa8..8afb64e 100644 --- a/pylti1p3/contrib/fastapi/cookie.py +++ b/pylti1p3/contrib/fastapi/cookie.py @@ -31,6 +31,7 @@ def update_response(self, response): "secure": is_secure, "path": "/", "httponly": True, + "samesite": None, } if is_secure: cookie_kwargs["samesite"] = "None"