From 963b9e990a596ff7db26039871bae8845945ca8b Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Fri, 3 Apr 2026 19:25:50 +1100 Subject: [PATCH 1/9] add validation and support Python 3.8 --- .../django_platform/django_platform/views.py | 4 + lti1p3platform/ltiplatform.py | 151 +- lti1p3platform/message_launch.py | 17 +- lti1p3platform/registration.py | 39 +- lti1p3platform/utils.py | 3 +- poetry.lock | 1684 ++++++++--------- pyproject.toml | 20 +- tests/platform_config.py | 2 + tests/test_platform_conf.py | 1 + 9 files changed, 1035 insertions(+), 886 deletions(-) diff --git a/examples/django_platform/django_platform/views.py b/examples/django_platform/django_platform/views.py index bd042f4..fcb1b61 100644 --- a/examples/django_platform/django_platform/views.py +++ b/examples/django_platform/django_platform/views.py @@ -42,6 +42,10 @@ def init_platform_config(self, platform_settings: t.Dict[str, t.Any]) -> None: .set_platform_private_key(platform_settings["private_key"]) ) + access_token_url = platform_settings.get("access_token_url") + if access_token_url: + registration.set_access_token_url(access_token_url) + self._registration = registration def get_registration_by_params(self, **kwargs: t.Any) -> Registration: diff --git a/lti1p3platform/ltiplatform.py b/lti1p3platform/ltiplatform.py index aa03ed7..994db3e 100644 --- a/lti1p3platform/ltiplatform.py +++ b/lti1p3platform/ltiplatform.py @@ -4,6 +4,8 @@ import typing as t import base64 import json +import ipaddress +from urllib.parse import urlparse from typing_extensions import TypedDict import requests @@ -51,7 +53,8 @@ class LTI1P3PlatformConfAbstract(ABC): def __init__(self, **kwargs: t.Any) -> None: self._jwt: t.Dict[str, t.Any] = {} - self._jwt_verify_options: t.Dict[str, t.Any] = {"verify_aud": False} + self._used_tool_jtis: t.Dict[str, int] = {} + self._used_tool_nonces: t.Dict[str, int] = {} self.init_platform_config(**kwargs) @@ -96,6 +99,28 @@ def fetch_public_key(self, key_set_url: str) -> JWKS: """ Fetch public key from url """ + parsed_url = urlparse(key_set_url) + if parsed_url.scheme != "https": + raise InvalidKeySetUrl + + hostname = parsed_url.hostname or "" + if hostname in {"localhost"}: + raise InvalidKeySetUrl + + try: + host_ip = ipaddress.ip_address(hostname) + if ( + host_ip.is_private + or host_ip.is_loopback + or host_ip.is_link_local + or host_ip.is_reserved + or host_ip.is_multicast + ): + raise InvalidKeySetUrl + except ValueError: + # Hostname is not an IP literal. Continue. + pass + try: resp = requests.get(key_set_url, timeout=5) except requests.exceptions.RequestException as exc: @@ -124,7 +149,7 @@ def get_tool_key_set(self) -> JWKS: assert ( tool_key_set_url is not None ), "If public_key_set is not set, public_set_url should be set" - if tool_key_set_url.startswith(("http://", "https://")): + if tool_key_set_url.startswith("https://"): tool_key_set = self.fetch_public_key(tool_key_set_url) self._registration.set_tool_key_set(tool_key_set) else: @@ -186,7 +211,9 @@ def get_tool_public_key(self) -> bytes: # Could not find public key with a matching kid and alg. raise LtiException("Unable to find public key") - def tool_validate_and_decode(self, jwt_token_string: str) -> t.Dict[str, t.Any]: + def tool_validate_and_decode( + self, jwt_token_string: str, audience: str + ) -> t.Dict[str, t.Any]: self.validate_jwt_format(jwt_token_string) public_key = self.get_tool_public_key() @@ -195,9 +222,85 @@ def tool_validate_and_decode(self, jwt_token_string: str) -> t.Dict[str, t.Any]: jwt_token_string, public_key, algorithms=["RS256"], - options=self._jwt_verify_options, + options={"verify_aud": True}, + audience=audience, ) + def _is_token_replay(self, jti: str, exp: int) -> bool: + now = int(time.time()) + + # Best-effort cleanup of expired JTIs. + expired_jtis = [key for key, expires in self._used_tool_jtis.items() if expires < now] + for key in expired_jtis: + del self._used_tool_jtis[key] + + if jti in self._used_tool_jtis: + return True + + self._used_tool_jtis[jti] = exp + return False + + def _is_nonce_replay(self, nonce: str, exp: int) -> bool: + now = int(time.time()) + + expired_nonces = [ + key for key, expires in self._used_tool_nonces.items() if expires < now + ] + for key in expired_nonces: + del self._used_tool_nonces[key] + + if nonce in self._used_tool_nonces: + return True + + self._used_tool_nonces[nonce] = exp + return False + + def _validate_tool_access_token_assertion( + self, decoded_assertion: t.Dict[str, t.Any], expected_audience: str + ) -> None: + assert self._registration is not None, "Registration not yet set" + + required_claims = ["iss", "sub", "aud", "iat", "exp", "jti"] + for required_claim in required_claims: + if required_claim not in decoded_assertion: + raise MissingRequiredClaim( + f"The required claim {required_claim} is missing from client_assertion JWT." + ) + + client_id = self._registration.get_client_id() + if not client_id: + raise LtiException("Client ID is not set") + + print(decoded_assertion) + if decoded_assertion["iss"] != client_id: + raise LtiException("Invalid client_assertion iss") + + if decoded_assertion["sub"] != client_id: + raise LtiException("Invalid client_assertion sub") + + aud_claim = decoded_assertion.get("aud") + if isinstance(aud_claim, str): + aud_values = [aud_claim] + elif isinstance(aud_claim, list): + aud_values = aud_claim + else: + raise LtiException("Invalid client_assertion aud") + + if expected_audience not in aud_values: + raise LtiException("Invalid client_assertion audience") + + now = int(time.time()) + iat = int(decoded_assertion["iat"]) + exp = int(decoded_assertion["exp"]) + if iat > now + 60: + raise LtiException("Invalid client_assertion iat") + if exp <= now: + raise LtiException("Invalid client_assertion exp") + + jti = str(decoded_assertion["jti"]) + if self._is_token_replay(jti, exp): + raise LtiException("Replay detected for client_assertion jti") + def get_access_token( self, token_request_data: t.Dict[str, t.Any] ) -> AccessTokenResponse: @@ -239,8 +342,20 @@ def get_access_token( if token_request_data["grant_type"] != "client_credentials": raise UnsupportedGrantType() + if token_request_data["client_assertion_type"] != ( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + ): + raise LtiException("Invalid client_assertion_type") + + expected_audience = self._registration.get_access_token_url() + if not expected_audience: + raise LtiException("No expected audience configured") + # Validate JWT token - self.tool_validate_and_decode(token_request_data["client_assertion"]) + decoded_assertion = self.tool_validate_and_decode( + token_request_data["client_assertion"], audience=expected_audience + ) + self._validate_tool_access_token_assertion(decoded_assertion, expected_audience) # Check scopes and only return valid and supported ones valid_scopes = [] @@ -281,8 +396,25 @@ def validate_deeplinking_resp( self, token_request_data: t.Dict[str, t.Any] ) -> t.List[t.Dict[str, t.Any]]: jwt_token_string = token_request_data["JWT"] + assert self._registration is not None, "Registration not yet set" + + expected_audience = self._registration.get_iss() + assert expected_audience is not None + + deep_link_response = self.tool_validate_and_decode( + jwt_token_string, audience=expected_audience + ) - deep_link_response = self.tool_validate_and_decode(jwt_token_string) + nonce = deep_link_response.get("nonce") + if not nonce: + raise LtiDeepLinkingResponseException("Token nonce is missing") + + exp = deep_link_response.get("exp") + if not exp: + raise LtiDeepLinkingResponseException("Token exp is missing") + + if self._is_nonce_replay(str(nonce), int(exp)): + raise LtiDeepLinkingResponseException("Replay detected for token nonce") # Check the response is a Deep Linking response type message_type = deep_link_response.get( @@ -328,7 +460,9 @@ def validate_token( public_key = self._registration.get_platform_public_key() assert public_key is not None - token_contents = Registration.decode_and_verify(token, public_key) + token_contents = Registration.decode_and_verify( + token, public_key, audience=audience + ) if token_contents.get("iss") != self._registration.get_iss(): raise LtiException("Invalid issuer") @@ -336,9 +470,6 @@ def validate_token( if "exp" in token_contents and token_contents["exp"] < time.time(): raise LtiException("Token expired") - if audience and token_contents.get("aud") != audience: - raise LtiException("Invalid audience") - token_scopes = token_contents.get("scopes", "").split(" ") if allowed_scopes: diff --git a/lti1p3platform/message_launch.py b/lti1p3platform/message_launch.py index 8d01716..a0ac87f 100644 --- a/lti1p3platform/message_launch.py +++ b/lti1p3platform/message_launch.py @@ -1,6 +1,7 @@ from __future__ import annotations import typing as t +from urllib.parse import urlparse from abc import ABC, abstractmethod from typing_extensions import TypedDict @@ -304,15 +305,27 @@ def validate_preflight_response( assert self._registration try: + assert preflight_response.get("response_type") == "id_token", "Invalid response type in preflight response" + assert preflight_response.get("scope") == "openid", "Invalid scope in preflight response" assert preflight_response.get("nonce") assert preflight_response.get("state") - assert preflight_response.get("redirect_uri") + redirect_uri = preflight_response.get("redirect_uri") + assert redirect_uri and redirect_uri in self._registration.get_tool_redirect_uris() # pylint: disable=line-too-long + + parsed_redirect_uri = urlparse(redirect_uri) + if parsed_redirect_uri.scheme != "https": + is_allowed_loopback = ( + parsed_redirect_uri.scheme == "http" + and parsed_redirect_uri.hostname in {"localhost", "127.0.0.1", "::1"} + ) + assert is_allowed_loopback + assert ( preflight_response.get("client_id") == self._registration.get_client_id() ) - self._redirect_url = preflight_response.get("redirect_uri") + self._redirect_url = redirect_uri except AssertionError as err: raise exceptions.PreflightRequestValidationException from err diff --git a/lti1p3platform/registration.py b/lti1p3platform/registration.py index 467ba9a..9822b03 100644 --- a/lti1p3platform/registration.py +++ b/lti1p3platform/registration.py @@ -25,8 +25,10 @@ class Registration: _client_id = None _deployment_id = None _oidc_login_url = None + _access_token_url = None _tool_keyset_url = None _tool_keyset = None + _tool_redirect_uris = None _platform_public_key = None _platform_private_key = None _deeplink_launch_url = None @@ -70,6 +72,12 @@ def get_platform_public_key(self) -> t.Optional[str]: """ return self._platform_public_key + def get_access_token_url(self) -> t.Optional[str]: + """ + Get OAuth 2 access token URL (authorization server audience) + """ + return self._access_token_url + def get_platform_private_key(self) -> t.Optional[str]: """ Get Platform private key in PEM format @@ -124,6 +132,14 @@ def set_oidc_login_url(self, oidc_login_url: str) -> Registration: return self + def set_access_token_url(self, access_token_url: str) -> Registration: + """ + Set OAuth 2 access token URL (authorization server audience) + """ + self._access_token_url = access_token_url + + return self + def set_platform_public_key(self, platform_public_key: str) -> Registration: """ Set Platform public key in PEM format @@ -192,6 +208,13 @@ def set_tool_key_set(self, key_set: JWKS) -> Registration: self._tool_keyset = key_set return self + def get_tool_redirect_uris(self) -> t.Optional[t.List[str]]: + return self._tool_redirect_uris + + def set_tool_redirect_uris(self, redirect_uris: t.List[str]) -> Registration: + self._tool_redirect_uris = redirect_uris + return self + @staticmethod def encode_and_sign( payload: t.Dict[str, t.Any], @@ -211,8 +234,20 @@ def encode_and_sign( return encoded_jwt @staticmethod - def decode_and_verify(encoded_jwt: str, public_key: str) -> t.Dict[str, t.Any]: - return jwt.decode(encoded_jwt, public_key, algorithms=["RS256"]) + def decode_and_verify( + encoded_jwt: str, + public_key: str, + audience: t.Optional[str] = None, + ) -> t.Dict[str, t.Any]: + decode_kwargs: t.Dict[str, t.Any] = { + "algorithms": ["RS256"], + "options": {"verify_aud": bool(audience)}, + } + + if audience: + decode_kwargs["audience"] = audience + + return jwt.decode(encoded_jwt, public_key, **decode_kwargs) def platform_encode_and_sign( self, payload: t.Dict[str, t.Any], expiration: t.Optional[int] = None diff --git a/lti1p3platform/utils.py b/lti1p3platform/utils.py index 94f18d3..571c1fc 100644 --- a/lti1p3platform/utils.py +++ b/lti1p3platform/utils.py @@ -1,10 +1,11 @@ +from __future__ import annotations import typing as t from dataclasses import asdict, is_dataclass def dataclass_to_dict(obj: t.Any) -> t.Any: if is_dataclass(obj): - return {k: dataclass_to_dict(v) for k, v in asdict(obj).items()} + return {k: dataclass_to_dict(v) for k, v in asdict(obj).items()} # type: ignore if isinstance(obj, dict): return {k: dataclass_to_dict(v) for k, v in obj.items()} diff --git a/poetry.lock b/poetry.lock index 896268a..74bdbe8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,75 +1,93 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + [[package]] name = "anyio" -version = "3.6.2" +version = "4.5.2" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "dev" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.8" +files = [ + {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, + {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, +] [package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] -doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16,<0.22)"] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +trio = ["trio (>=0.26.1)"] [[package]] name = "asgiref" -version = "3.6.0" +version = "3.8.1" description = "ASGI specs, helper code, and adapters" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] [package.dependencies] -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} [package.extras] -tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "astroid" -version = "2.15.0" +version = "2.15.8" description = "An abstract syntax tree for Python with inference support." -category = "dev" optional = false python-versions = ">=3.7.2" +files = [ + {file = "astroid-2.15.8-py3-none-any.whl", hash = "sha256:1aa149fc5c6589e3d0ece885b4491acd80af4f087baafa3fb5203b113e68cd3c"}, + {file = "astroid-2.15.8.tar.gz", hash = "sha256:6c107453dffee9055899705de3c9ead36e74119cee151e5a9aaf7f0b0e020a6a"}, +] [package.dependencies] lazy-object-proxy = ">=1.4.0" -typed-ast = {version = ">=1.4.0,<2.0", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} wrapt = [ {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}, ] -[[package]] -name = "attrs" -version = "22.2.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -cov = ["attrs", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["attrs"] -docs = ["furo", "sphinx", "myst-parser", "zope.interface", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["attrs", "zope.interface"] -tests-no-zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] -tests_no_zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] - [[package]] name = "black" -version = "23.1.0" +version = "23.12.1" description = "The uncompromising code formatter." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, + {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, + {file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"}, + {file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"}, + {file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"}, + {file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"}, + {file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"}, + {file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"}, + {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"}, + {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"}, + {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"}, + {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"}, + {file = "black-23.12.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f"}, + {file = "black-23.12.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d"}, + {file = "black-23.12.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a"}, + {file = "black-23.12.1-cp38-cp38-win_amd64.whl", hash = "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e"}, + {file = "black-23.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055"}, + {file = "black-23.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54"}, + {file = "black-23.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea"}, + {file = "black-23.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2"}, + {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, + {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, +] [package.dependencies] click = ">=8.0.0" @@ -78,115 +96,291 @@ packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2022.12.7" +version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +files = [ + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, +] [[package]] name = "cffi" -version = "1.15.1" +version = "1.17.1" description = "Foreign Function Interface for Python calling C code." -category = "main" optional = false -python-versions = "*" +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] [package.dependencies] pycparser = "*" [[package]] -name = "chardet" -version = "3.0.4" -description = "Universal encoding detector for Python 2 and 3" -category = "main" +name = "charset-normalizer" +version = "3.4.3" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = "*" +python-versions = ">=3.7" +files = [ + {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, + {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, + {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, +] [[package]] name = "click" -version = "8.1.3" +version = "8.1.8" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] [[package]] name = "cryptography" -version = "39.0.2" +version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] [package.dependencies] -cffi = ">=1.12" +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] -pep8test = ["black", "ruff", "mypy", "types-pytz", "types-requests", "check-manifest"] -sdist = ["setuptools-rust (>=0.11.4)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["pytest (>=6.2.0)", "pytest-shard (>=0.1.2)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] -tox = ["tox"] - -[[package]] -name = "deprecated" -version = "1.2.13" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -wrapt = ">=1.10,<2" - -[package.extras] -dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"] [[package]] name = "dill" -version = "0.3.6" -description = "serialize all of python" -category = "dev" +version = "0.4.0" +description = "serialize all of Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, + {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, +] [package.extras] graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] [[package]] name = "django" -version = "3.2.18" +version = "3.2.25" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." -category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "Django-3.2.25-py3-none-any.whl", hash = "sha256:a52ea7fcf280b16f7b739cec38fa6d3f8953a5456986944c3ca97e79882b4e38"}, + {file = "Django-3.2.25.tar.gz", hash = "sha256:7ca38a78654aee72378594d63e51636c04b8e28574f5505dff630895b5472777"}, +] [package.dependencies] asgiref = ">=3.3.2,<4" @@ -201,9 +395,12 @@ bcrypt = ["bcrypt"] name = "django-stubs" version = "1.16.0" description = "Mypy stubs for Django" -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "django-stubs-1.16.0.tar.gz", hash = "sha256:1bd96207576cd220221a0e615f0259f13d453d515a80f576c1246e0fb547f561"}, + {file = "django_stubs-1.16.0-py3-none-any.whl", hash = "sha256:c95f948e2bfc565f3147e969ff361ef033841a0b8a51cac974a6cc6d0486732c"}, +] [package.dependencies] django = "*" @@ -219,11 +416,14 @@ compatible-mypy = ["mypy (>=1.1.1,<1.2)"] [[package]] name = "django-stubs-ext" -version = "0.8.0" +version = "5.1.3" description = "Monkey-patching and extensions for django-stubs" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "django_stubs_ext-5.1.3-py3-none-any.whl", hash = "sha256:64561fbc53e963cc1eed2c8eb27e18b8e48dcb90771205180fe29fc8a59e55fd"}, + {file = "django_stubs_ext-5.1.3.tar.gz", hash = "sha256:3e60f82337f0d40a362f349bf15539144b96e4ceb4dbd0239be1cd71f6a74ad0"}, +] [package.dependencies] django = "*" @@ -231,87 +431,91 @@ typing-extensions = "*" [[package]] name = "exceptiongroup" -version = "1.1.1" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.95.0" +version = "0.95.2" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "fastapi-0.95.2-py3-none-any.whl", hash = "sha256:d374dbc4ef2ad9b803899bd3360d34c534adc574546e25314ab72c0c4411749f"}, + {file = "fastapi-0.95.2.tar.gz", hash = "sha256:4d9d3e8c71c73f11874bcf5e33626258d143252e329a01002f767306c64fb982"}, +] [package.dependencies] pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" -starlette = ">=0.26.1,<0.27.0" +starlette = ">=0.27.0,<0.28.0" [package.extras] all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] -doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer-cli (>=0.0.13,<0.0.14)", "typer[all] (>=0.6.1,<0.8.0)"] +doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer-cli (>=0.0.13,<0.0.14)", "typer[all] (>=0.6.1,<0.8.0)"] test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.7)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.7.0.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] [[package]] name = "idna" -version = "2.10" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "importlib-metadata" -version = "6.1.0" -description = "Read metadata from Python packages" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] [package.extras] -docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] -perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8", "importlib-resources (>=1.3)"] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.1.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] [[package]] name = "isort" -version = "5.11.5" +version = "5.13.2" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] [package.extras] -pipfile-deprecated-finder = ["pipreqs", "requirementslib", "pip-shims (>=0.5.2)"] -requirements-deprecated-finder = ["pipreqs", "pip-api"] -colors = ["colorama (>=0.4.3,<0.5.0)"] -plugins = ["setuptools"] +colors = ["colorama (>=0.4.6)"] [[package]] name = "jinja2" -version = "3.1.2" +version = "3.1.6" description = "A very fast and expressive template engine." -category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] [package.dependencies] MarkupSafe = ">=2.0" @@ -321,109 +525,263 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "jwcrypto" -version = "1.4.2" +version = "1.5.6" description = "Implementation of JOSE Web standards" -category = "main" optional = false -python-versions = ">= 3.6" +python-versions = ">= 3.8" +files = [ + {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"}, + {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"}, +] [package.dependencies] -cryptography = ">=2.3" -deprecated = "*" +cryptography = ">=3.4" +typing-extensions = ">=4.5.0" [[package]] name = "lazy-object-proxy" -version = "1.9.0" +version = "1.10.0" description = "A fast and thorough lazy object proxy." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "lazy-object-proxy-1.10.0.tar.gz", hash = "sha256:78247b6d45f43a52ef35c25b5581459e85117225408a4128a3daf8bf9648ac69"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:855e068b0358ab916454464a884779c7ffa312b8925c6f7401e952dcf3b89977"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab7004cf2e59f7c2e4345604a3e6ea0d92ac44e1c2375527d56492014e690c3"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc0d2fc424e54c70c4bc06787e4072c4f3b1aa2f897dfdc34ce1013cf3ceef05"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e2adb09778797da09d2b5ebdbceebf7dd32e2c96f79da9052b2e87b6ea495895"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b1f711e2c6dcd4edd372cf5dec5c5a30d23bba06ee012093267b3376c079ec83"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-win32.whl", hash = "sha256:76a095cfe6045c7d0ca77db9934e8f7b71b14645f0094ffcd842349ada5c5fb9"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:b4f87d4ed9064b2628da63830986c3d2dca7501e6018347798313fcf028e2fd4"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fec03caabbc6b59ea4a638bee5fce7117be8e99a4103d9d5ad77f15d6f81020c"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c83f957782cbbe8136bee26416686a6ae998c7b6191711a04da776dc9e47d4"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009e6bb1f1935a62889ddc8541514b6a9e1fcf302667dcb049a0be5c8f613e56"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75fc59fc450050b1b3c203c35020bc41bd2695ed692a392924c6ce180c6f1dc9"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:782e2c9b2aab1708ffb07d4bf377d12901d7a1d99e5e410d648d892f8967ab1f"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-win32.whl", hash = "sha256:edb45bb8278574710e68a6b021599a10ce730d156e5b254941754a9cc0b17d03"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:e271058822765ad5e3bca7f05f2ace0de58a3f4e62045a8c90a0dfd2f8ad8cc6"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e98c8af98d5707dcdecc9ab0863c0ea6e88545d42ca7c3feffb6b4d1e370c7ba"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:952c81d415b9b80ea261d2372d2a4a2332a3890c2b83e0535f263ddfe43f0d43"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80b39d3a151309efc8cc48675918891b865bdf742a8616a337cb0090791a0de9"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e221060b701e2aa2ea991542900dd13907a5c90fa80e199dbf5a03359019e7a3"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:92f09ff65ecff3108e56526f9e2481b8116c0b9e1425325e13245abfd79bdb1b"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-win32.whl", hash = "sha256:3ad54b9ddbe20ae9f7c1b29e52f123120772b06dbb18ec6be9101369d63a4074"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:127a789c75151db6af398b8972178afe6bda7d6f68730c057fbbc2e96b08d282"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9e4ed0518a14dd26092614412936920ad081a424bdcb54cc13349a8e2c6d106a"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad9e6ed739285919aa9661a5bbed0aaf410aa60231373c5579c6b4801bd883c"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fc0a92c02fa1ca1e84fc60fa258458e5bf89d90a1ddaeb8ed9cc3147f417255"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0aefc7591920bbd360d57ea03c995cebc204b424524a5bd78406f6e1b8b2a5d8"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5faf03a7d8942bb4476e3b62fd0f4cf94eaf4618e304a19865abf89a35c0bbee"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-win32.whl", hash = "sha256:e333e2324307a7b5d86adfa835bb500ee70bfcd1447384a822e96495796b0ca4"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:cb73507defd385b7705c599a94474b1d5222a508e502553ef94114a143ec6696"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:366c32fe5355ef5fc8a232c5436f4cc66e9d3e8967c01fb2e6302fd6627e3d94"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2297f08f08a2bb0d32a4265e98a006643cd7233fb7983032bd61ac7a02956b3b"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18dd842b49456aaa9a7cf535b04ca4571a302ff72ed8740d06b5adcd41fe0757"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:217138197c170a2a74ca0e05bddcd5f1796c735c37d0eee33e43259b192aa424"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a3a87cf1e133e5b1994144c12ca4aa3d9698517fe1e2ca82977781b16955658"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-win32.whl", hash = "sha256:30b339b2a743c5288405aa79a69e706a06e02958eab31859f7f3c04980853b70"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:a899b10e17743683b293a729d3a11f2f399e8a90c73b089e29f5d0fe3509f0dd"}, + {file = "lazy_object_proxy-1.10.0-pp310.pp311.pp312.pp38.pp39-none-any.whl", hash = "sha256:80fa48bd89c8f2f456fc0765c11c23bf5af827febacd2f523ca5bc1893fcc09d"}, +] [[package]] name = "markupsafe" -version = "2.1.2" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." -category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] [[package]] name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] [[package]] name = "mypy" -version = "1.1.1" +version = "1.14.1" description = "Optional static typing for Python" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, + {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, + {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, + {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, + {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, + {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, + {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, + {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, + {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, + {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, + {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, + {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, + {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, + {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, + {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, +] [package.dependencies] -mypy-extensions = ">=1.0.0" +mypy_extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} -typing-extensions = ">=3.10" +typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] -python2 = ["typed-ast (>=1.4.0,<2)"] +mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] [[package]] name = "packaging" -version = "23.0" +version = "25.0" description = "Core utilities for Python packages" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] [[package]] name = "pathspec" -version = "0.11.1" +version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] [[package]] name = "platformdirs" -version = "3.1.1" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false -python-versions = ">=3.7" - -[package.dependencies] -typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] [package.extras] -docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx (>=6.1.3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2.1)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] [[package]] name = "pluggy" -version = "1.0.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false -python-versions = ">=3.6" - -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] [package.extras] dev = ["pre-commit", "tox"] @@ -431,19 +789,73 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pycparser" -version = "2.21" +version = "2.22" description = "C parser in Python" -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] [[package]] name = "pydantic" -version = "1.10.7" +version = "1.10.22" description = "Data validation and settings management using python type hints" -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.22-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:57889565ccc1e5b7b73343329bbe6198ebc472e3ee874af2fa1865cfe7048228"}, + {file = "pydantic-1.10.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:90729e22426de79bc6a3526b4c45ec4400caf0d4f10d7181ba7f12c01bb3897d"}, + {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8684d347f351554ec94fdcb507983d3116dc4577fb8799fed63c65869a2d10"}, + {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8dad498ceff2d9ef1d2e2bc6608f5b59b8e1ba2031759b22dfb8c16608e1802"}, + {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fac529cc654d4575cf8de191cce354b12ba705f528a0a5c654de6d01f76cd818"}, + {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4148232aded8dd1dd13cf910a01b32a763c34bd79a0ab4d1ee66164fcb0b7b9d"}, + {file = "pydantic-1.10.22-cp310-cp310-win_amd64.whl", hash = "sha256:ece68105d9e436db45d8650dc375c760cc85a6793ae019c08769052902dca7db"}, + {file = "pydantic-1.10.22-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e530a8da353f791ad89e701c35787418605d35085f4bdda51b416946070e938"}, + {file = "pydantic-1.10.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:654322b85642e9439d7de4c83cb4084ddd513df7ff8706005dada43b34544946"}, + {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8bece75bd1b9fc1c32b57a32831517943b1159ba18b4ba32c0d431d76a120ae"}, + {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eccb58767f13c6963dcf96d02cb8723ebb98b16692030803ac075d2439c07b0f"}, + {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7778e6200ff8ed5f7052c1516617423d22517ad36cc7a3aedd51428168e3e5e8"}, + {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffe02767d27c39af9ca7dc7cd479c00dda6346bb62ffc89e306f665108317a2"}, + {file = "pydantic-1.10.22-cp311-cp311-win_amd64.whl", hash = "sha256:23bc19c55427091b8e589bc08f635ab90005f2dc99518f1233386f46462c550a"}, + {file = "pydantic-1.10.22-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:92d0f97828a075a71d9efc65cf75db5f149b4d79a38c89648a63d2932894d8c9"}, + {file = "pydantic-1.10.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af5a2811b6b95b58b829aeac5996d465a5f0c7ed84bd871d603cf8646edf6ff"}, + {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf06d8d40993e79af0ab2102ef5da77b9ddba51248e4cb27f9f3f591fbb096e"}, + {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:184b7865b171a6057ad97f4a17fbac81cec29bd103e996e7add3d16b0d95f609"}, + {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:923ad861677ab09d89be35d36111156063a7ebb44322cdb7b49266e1adaba4bb"}, + {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:82d9a3da1686443fb854c8d2ab9a473251f8f4cdd11b125522efb4d7c646e7bc"}, + {file = "pydantic-1.10.22-cp312-cp312-win_amd64.whl", hash = "sha256:1612604929af4c602694a7f3338b18039d402eb5ddfbf0db44f1ebfaf07f93e7"}, + {file = "pydantic-1.10.22-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b259dc89c9abcd24bf42f31951fb46c62e904ccf4316393f317abeeecda39978"}, + {file = "pydantic-1.10.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9238aa0964d80c0908d2f385e981add58faead4412ca80ef0fa352094c24e46d"}, + {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8029f05b04080e3f1a550575a1bca747c0ea4be48e2d551473d47fd768fc1b"}, + {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c06918894f119e0431a36c9393bc7cceeb34d1feeb66670ef9b9ca48c073937"}, + {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e205311649622ee8fc1ec9089bd2076823797f5cd2c1e3182dc0e12aab835b35"}, + {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:815f0a73d5688d6dd0796a7edb9eca7071bfef961a7b33f91e618822ae7345b7"}, + {file = "pydantic-1.10.22-cp313-cp313-win_amd64.whl", hash = "sha256:9dfce71d42a5cde10e78a469e3d986f656afc245ab1b97c7106036f088dd91f8"}, + {file = "pydantic-1.10.22-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3ecaf8177b06aac5d1f442db1288e3b46d9f05f34fd17fdca3ad34105328b61a"}, + {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb36c2de9ea74bd7f66b5481dea8032d399affd1cbfbb9bb7ce539437f1fce62"}, + {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6b8d14a256be3b8fff9286d76c532f1a7573fbba5f189305b22471c6679854d"}, + {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:1c33269e815db4324e71577174c29c7aa30d1bba51340ce6be976f6f3053a4c6"}, + {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:8661b3ab2735b2a9ccca2634738534a795f4a10bae3ab28ec0a10c96baa20182"}, + {file = "pydantic-1.10.22-cp37-cp37m-win_amd64.whl", hash = "sha256:22bdd5fe70d4549995981c55b970f59de5c502d5656b2abdfcd0a25be6f3763e"}, + {file = "pydantic-1.10.22-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e3f33d1358aa4bc2795208cc29ff3118aeaad0ea36f0946788cf7cadeccc166b"}, + {file = "pydantic-1.10.22-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:813f079f9cd136cac621f3f9128a4406eb8abd2ad9fdf916a0731d91c6590017"}, + {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab618ab8dca6eac7f0755db25f6aba3c22c40e3463f85a1c08dc93092d917704"}, + {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d128e1aaa38db88caca920d5822c98fc06516a09a58b6d3d60fa5ea9099b32cc"}, + {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:cc97bbc25def7025e55fc9016080773167cda2aad7294e06a37dda04c7d69ece"}, + {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dda5d7157d543b1fa565038cae6e952549d0f90071c839b3740fb77c820fab8"}, + {file = "pydantic-1.10.22-cp38-cp38-win_amd64.whl", hash = "sha256:a093fe44fe518cb445d23119511a71f756f8503139d02fcdd1173f7b76c95ffe"}, + {file = "pydantic-1.10.22-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec54c89b2568b258bb30d7348ac4d82bec1b58b377fb56a00441e2ac66b24587"}, + {file = "pydantic-1.10.22-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8f1d1a1532e4f3bcab4e34e8d2197a7def4b67072acd26cfa60e92d75803a48"}, + {file = "pydantic-1.10.22-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ad83ca35508c27eae1005b6b61f369f78aae6d27ead2135ec156a2599910121"}, + {file = "pydantic-1.10.22-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53cdb44b78c420f570ff16b071ea8cd5a477635c6b0efc343c8a91e3029bbf1a"}, + {file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:16d0a5ae9d98264186ce31acdd7686ec05fd331fab9d68ed777d5cb2d1514e5e"}, + {file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8aee040e25843f036192b1a1af62117504a209a043aa8db12e190bb86ad7e611"}, + {file = "pydantic-1.10.22-cp39-cp39-win_amd64.whl", hash = "sha256:7f691eec68dbbfca497d3c11b92a3e5987393174cbedf03ec7a4184c35c2def6"}, + {file = "pydantic-1.10.22-py3-none-any.whl", hash = "sha256:343037d608bcbd34df937ac259708bfc83664dadf88afe8516c4f282d7d471a9"}, + {file = "pydantic-1.10.22.tar.gz", hash = "sha256:ee1006cebd43a8e7158fb7190bb8f4e2da9649719bff65d0c287282ec38dec6d"}, +] [package.dependencies] typing-extensions = ">=4.2.0" @@ -456,9 +868,12 @@ email = ["email-validator (>=1.0.3)"] name = "pyjwt" version = "1.7.1" description = "JSON Web Token implementation in Python" -category = "main" optional = false python-versions = "*" +files = [ + {file = "PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e"}, + {file = "PyJWT-1.7.1.tar.gz", hash = "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"}, +] [package.extras] crypto = ["cryptography (>=1.4)"] @@ -467,14 +882,17 @@ test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner [[package]] name = "pylint" -version = "2.17.1" +version = "2.17.7" description = "python code static checker" -category = "dev" optional = false python-versions = ">=3.7.2" +files = [ + {file = "pylint-2.17.7-py3-none-any.whl", hash = "sha256:27a8d4c7ddc8c2f8c18aa0050148f89ffc09838142193fdbe98f172781a3ff87"}, + {file = "pylint-2.17.7.tar.gz", hash = "sha256:f4fcac7ae74cfe36bc8451e931d8438e4a476c20314b1101c458ad0f05191fad"}, +] [package.dependencies] -astroid = ">=2.15.0,<=2.17.0-dev0" +astroid = ">=2.15.8,<=2.17.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, @@ -491,112 +909,121 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] -[[package]] -name = "pyopenssl" -version = "23.0.0" -description = "Python wrapper module around the OpenSSL library" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -cryptography = ">=38.0.0,<40" - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] -test = ["flaky", "pretend", "pytest (>=3.0.1)"] - [[package]] name = "pytest" -version = "7.2.2" +version = "7.4.4" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] [package.dependencies] six = ">=1.5" [[package]] name = "pytz" -version = "2023.2" +version = "2025.2" description = "World timezone definitions, modern and historical" -category = "dev" optional = false python-versions = "*" +files = [ + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] [[package]] name = "requests" -version = "2.24.0" +version = "2.32.4" description = "Python HTTP for Humans." -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" -cryptography = {version = ">=1.3.4", optional = true, markers = "extra == \"security\""} -idna = ">=2.5,<3" -pyOpenSSL = {version = ">=0.14", optional = true, markers = "extra == \"security\""} -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] [[package]] name = "sniffio" -version = "1.3.0" +version = "1.3.1" description = "Sniff out which async library your code is running under" -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] [[package]] name = "sqlparse" -version = "0.4.3" +version = "0.5.3" description = "A non-validating SQL parser." -category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" +files = [ + {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, + {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] [[package]] name = "starlette" -version = "0.26.1" +version = "0.27.0" description = "The little ASGI library that shines." -category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, + {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, +] [package.dependencies] anyio = ">=3.4.0,<5" @@ -607,714 +1034,247 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam [[package]] name = "tomli" -version = "2.0.1" +version = "2.2.1" description = "A lil' TOML parser" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] [[package]] name = "tomlkit" -version = "0.11.6" +version = "0.13.3" description = "Style preserving TOML library" -category = "dev" optional = false -python-versions = ">=3.6" - -[[package]] -name = "typed-ast" -version = "1.5.4" -description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" -optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] [[package]] name = "types-cryptography" version = "3.3.23.2" description = "Typing stubs for cryptography" -category = "dev" optional = false python-versions = "*" +files = [ + {file = "types-cryptography-3.3.23.2.tar.gz", hash = "sha256:09cc53f273dd4d8c29fa7ad11fefd9b734126d467960162397bc5e3e604dea75"}, + {file = "types_cryptography-3.3.23.2-py3-none-any.whl", hash = "sha256:b965d548f148f8e87f353ccf2b7bd92719fdf6c845ff7cedf2abb393a0643e4f"}, +] [[package]] name = "types-pyjwt" version = "1.7.1" description = "Typing stubs for PyJWT" -category = "dev" optional = false python-versions = "*" +files = [ + {file = "types-PyJWT-1.7.1.tar.gz", hash = "sha256:99c1a0d94d370951f9c6e57b1c369be280b2cbfab72c0f9c0998707490f015c9"}, + {file = "types_PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:810112a84b6c060bb5bc1959a1d229830465eccffa91d8a68eeaac28fb7713ac"}, +] [package.dependencies] types-cryptography = "*" [[package]] name = "types-pytz" -version = "2023.2.0.0" +version = "2024.2.0.20241221" description = "Typing stubs for pytz" -category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.8" +files = [ + {file = "types_pytz-2024.2.0.20241221-py3-none-any.whl", hash = "sha256:8fc03195329c43637ed4f593663df721fef919b60a969066e22606edf0b53ad5"}, + {file = "types_pytz-2024.2.0.20241221.tar.gz", hash = "sha256:06d7cde9613e9f7504766a0554a270c369434b50e00975b3a4a0f6eed0f2c1a9"}, +] [[package]] name = "types-pyyaml" -version = "6.0.12.8" +version = "6.0.12.20241230" description = "Typing stubs for PyYAML" -category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.8" +files = [ + {file = "types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6"}, + {file = "types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c"}, +] [[package]] name = "types-requests" -version = "2.28.11.16" +version = "2.32.0.20241016" description = "Typing stubs for requests" -category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.8" +files = [ + {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, + {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, +] [package.dependencies] -types-urllib3 = "<1.27" +urllib3 = ">=2" [[package]] name = "types-setuptools" -version = "67.6.0.7" +version = "67.8.0.0" description = "Typing stubs for setuptools" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "types-urllib3" -version = "1.26.25.8" -description = "Typing stubs for urllib3" -category = "dev" optional = false python-versions = "*" +files = [ + {file = "types-setuptools-67.8.0.0.tar.gz", hash = "sha256:95c9ed61871d6c0e258433373a4e1753c0a7c3627a46f4d4058c7b5a08ab844f"}, + {file = "types_setuptools-67.8.0.0-py3-none-any.whl", hash = "sha256:6df73340d96b238a4188b7b7668814b37e8018168aef1eef94a3b1872e3f60ff"}, +] [[package]] name = "typing-extensions" -version = "4.5.0" -description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] [[package]] name = "urllib3" -version = "1.25.11" +version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] [package.extras] -brotli = ["brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "wrapt" -version = "1.15.0" +version = "1.17.3" description = "Module for decorators, wrappers and monkey patching." -category = "main" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.extras] -docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "jaraco.functools", "more-itertools", "big-o", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"] +python-versions = ">=3.8" +files = [ + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"}, + {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"}, + {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"}, + {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"}, + {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"}, + {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"}, + {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"}, + {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"}, + {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"}, + {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"}, + {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"}, + {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"}, + {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"}, + {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"}, + {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"}, + {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"}, + {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f"}, + {file = "wrapt-1.17.3-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00"}, + {file = "wrapt-1.17.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56"}, + {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5"}, + {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22"}, + {file = "wrapt-1.17.3-cp38-cp38-win32.whl", hash = "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c"}, + {file = "wrapt-1.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d"}, + {file = "wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a"}, + {file = "wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139"}, + {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df"}, + {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b"}, + {file = "wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81"}, + {file = "wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f"}, + {file = "wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f"}, + {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, + {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, +] [metadata] -lock-version = "1.1" -python-versions = "^3.7.2" -content-hash = "cbb9c69ffd582d4168047e990bb7083133d82aa3402be48c7ad545bd04b4994c" - -[metadata.files] -anyio = [ - {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, - {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, -] -asgiref = [ - {file = "asgiref-3.6.0-py3-none-any.whl", hash = "sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac"}, - {file = "asgiref-3.6.0.tar.gz", hash = "sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506"}, -] -astroid = [ - {file = "astroid-2.15.0-py3-none-any.whl", hash = "sha256:e3e4d0ffc2d15d954065579689c36aac57a339a4679a679579af6401db4d3fdb"}, - {file = "astroid-2.15.0.tar.gz", hash = "sha256:525f126d5dc1b8b0b6ee398b33159105615d92dc4a17f2cd064125d57f6186fa"}, -] -attrs = [ - {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, - {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, -] -black = [ - {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"}, - {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"}, - {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"}, - {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"}, - {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"}, - {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"}, - {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"}, - {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"}, - {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"}, - {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"}, - {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"}, - {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"}, - {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"}, - {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"}, - {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"}, - {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"}, - {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"}, - {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"}, - {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"}, - {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"}, - {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"}, - {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"}, - {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"}, - {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"}, - {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"}, -] -certifi = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, -] -cffi = [ - {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, - {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, - {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, - {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, - {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, - {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, - {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, - {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, - {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, - {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, - {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, -] -chardet = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, -] -click = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, -] -colorama = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -cryptography = [ - {file = "cryptography-39.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:2725672bb53bb92dc7b4150d233cd4b8c59615cd8288d495eaa86db00d4e5c06"}, - {file = "cryptography-39.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:23df8ca3f24699167daf3e23e51f7ba7334d504af63a94af468f468b975b7dd7"}, - {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:eb40fe69cfc6f5cdab9a5ebd022131ba21453cf7b8a7fd3631f45bbf52bed612"}, - {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc0521cce2c1d541634b19f3ac661d7a64f9555135e9d8af3980965be717fd4a"}, - {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffd394c7896ed7821a6d13b24657c6a34b6e2650bd84ae063cf11ccffa4f1a97"}, - {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:e8a0772016feeb106efd28d4a328e77dc2edae84dfbac06061319fdb669ff828"}, - {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8f35c17bd4faed2bc7797d2a66cbb4f986242ce2e30340ab832e5d99ae60e011"}, - {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b49a88ff802e1993b7f749b1eeb31134f03c8d5c956e3c125c75558955cda536"}, - {file = "cryptography-39.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c682e736513db7d04349b4f6693690170f95aac449c56f97415c6980edef5"}, - {file = "cryptography-39.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:d7d84a512a59f4412ca8549b01f94be4161c94efc598bf09d027d67826beddc0"}, - {file = "cryptography-39.0.2-cp36-abi3-win32.whl", hash = "sha256:c43ac224aabcbf83a947eeb8b17eaf1547bce3767ee2d70093b461f31729a480"}, - {file = "cryptography-39.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:788b3921d763ee35dfdb04248d0e3de11e3ca8eb22e2e48fef880c42e1f3c8f9"}, - {file = "cryptography-39.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d15809e0dbdad486f4ad0979753518f47980020b7a34e9fc56e8be4f60702fac"}, - {file = "cryptography-39.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:50cadb9b2f961757e712a9737ef33d89b8190c3ea34d0fb6675e00edbe35d074"}, - {file = "cryptography-39.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:103e8f7155f3ce2ffa0049fe60169878d47a4364b277906386f8de21c9234aa1"}, - {file = "cryptography-39.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6236a9610c912b129610eb1a274bdc1350b5df834d124fa84729ebeaf7da42c3"}, - {file = "cryptography-39.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e944fe07b6f229f4c1a06a7ef906a19652bdd9fd54c761b0ff87e83ae7a30354"}, - {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:35d658536b0a4117c885728d1a7032bdc9a5974722ae298d6c533755a6ee3915"}, - {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:30b1d1bfd00f6fc80d11300a29f1d8ab2b8d9febb6ed4a38a76880ec564fae84"}, - {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e029b844c21116564b8b61216befabca4b500e6816fa9f0ba49527653cae2108"}, - {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fa507318e427169ade4e9eccef39e9011cdc19534f55ca2f36ec3f388c1f70f3"}, - {file = "cryptography-39.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8bc0008ef798231fac03fe7d26e82d601d15bd16f3afaad1c6113771566570f3"}, - {file = "cryptography-39.0.2.tar.gz", hash = "sha256:bc5b871e977c8ee5a1bbc42fa8d19bcc08baf0c51cbf1586b0e87a2694dde42f"}, -] -deprecated = [ - {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, - {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, -] -dill = [ - {file = "dill-0.3.6-py3-none-any.whl", hash = "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0"}, - {file = "dill-0.3.6.tar.gz", hash = "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"}, -] -django = [ - {file = "Django-3.2.18-py3-none-any.whl", hash = "sha256:4d492d9024c7b3dfababf49f94511ab6a58e2c9c3c7207786f1ba4eb77750706"}, - {file = "Django-3.2.18.tar.gz", hash = "sha256:08208dfe892eb64fff073ca743b3b952311104f939e7f6dae954fe72dcc533ba"}, -] -django-stubs = [ - {file = "django-stubs-1.16.0.tar.gz", hash = "sha256:1bd96207576cd220221a0e615f0259f13d453d515a80f576c1246e0fb547f561"}, - {file = "django_stubs-1.16.0-py3-none-any.whl", hash = "sha256:c95f948e2bfc565f3147e969ff361ef033841a0b8a51cac974a6cc6d0486732c"}, -] -django-stubs-ext = [ - {file = "django-stubs-ext-0.8.0.tar.gz", hash = "sha256:9a9ba9e2808737949de96a0fce8b054f12d38e461011d77ebc074ffe8c43dfcb"}, - {file = "django_stubs_ext-0.8.0-py3-none-any.whl", hash = "sha256:a454d349d19c26d6c50c4c6dbc1e8af4a9cda4ce1dc4104e3dd4c0330510cc56"}, -] -exceptiongroup = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, -] -fastapi = [ - {file = "fastapi-0.95.0-py3-none-any.whl", hash = "sha256:daf73bbe844180200be7966f68e8ec9fd8be57079dff1bacb366db32729e6eb5"}, - {file = "fastapi-0.95.0.tar.gz", hash = "sha256:99d4fdb10e9dd9a24027ac1d0bd4b56702652056ca17a6c8721eec4ad2f14e18"}, -] -idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, -] -importlib-metadata = [ - {file = "importlib_metadata-6.1.0-py3-none-any.whl", hash = "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09"}, - {file = "importlib_metadata-6.1.0.tar.gz", hash = "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20"}, -] -iniconfig = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] -isort = [ - {file = "isort-5.11.5-py3-none-any.whl", hash = "sha256:ba1d72fb2595a01c7895a5128f9585a5cc4b6d395f1c8d514989b9a7eb2a8746"}, - {file = "isort-5.11.5.tar.gz", hash = "sha256:6be1f76a507cb2ecf16c7cf14a37e41609ca082330be4e3436a18ef74add55db"}, -] -jinja2 = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, -] -jwcrypto = [ - {file = "jwcrypto-1.4.2.tar.gz", hash = "sha256:80a35e9ed1b3b2c43ce03d92c5d48e6d0b6647e2aa2618e4963448923d78a37b"}, -] -lazy-object-proxy = [ - {file = "lazy-object-proxy-1.9.0.tar.gz", hash = "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae"}, - {file = "lazy_object_proxy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7"}, - {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4"}, - {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd"}, - {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701"}, - {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46"}, - {file = "lazy_object_proxy-1.9.0-cp310-cp310-win32.whl", hash = "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455"}, - {file = "lazy_object_proxy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e"}, - {file = "lazy_object_proxy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07"}, - {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a"}, - {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59"}, - {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4"}, - {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9"}, - {file = "lazy_object_proxy-1.9.0-cp311-cp311-win32.whl", hash = "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586"}, - {file = "lazy_object_proxy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb"}, - {file = "lazy_object_proxy-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e"}, - {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8"}, - {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2"}, - {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8"}, - {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda"}, - {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win32.whl", hash = "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734"}, - {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671"}, - {file = "lazy_object_proxy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63"}, - {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171"}, - {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be"}, - {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30"}, - {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11"}, - {file = "lazy_object_proxy-1.9.0-cp38-cp38-win32.whl", hash = "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82"}, - {file = "lazy_object_proxy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b"}, - {file = "lazy_object_proxy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b"}, - {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4"}, - {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006"}, - {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494"}, - {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382"}, - {file = "lazy_object_proxy-1.9.0-cp39-cp39-win32.whl", hash = "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821"}, - {file = "lazy_object_proxy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f"}, -] -markupsafe = [ - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, - {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, -] -mccabe = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] -mypy = [ - {file = "mypy-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39c7119335be05630611ee798cc982623b9e8f0cff04a0b48dfc26100e0b97af"}, - {file = "mypy-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61bf08362e93b6b12fad3eab68c4ea903a077b87c90ac06c11e3d7a09b56b9c1"}, - {file = "mypy-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbb19c9f662e41e474e0cff502b7064a7edc6764f5262b6cd91d698163196799"}, - {file = "mypy-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:315ac73cc1cce4771c27d426b7ea558fb4e2836f89cb0296cbe056894e3a1f78"}, - {file = "mypy-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5cb14ff9919b7df3538590fc4d4c49a0f84392237cbf5f7a816b4161c061829e"}, - {file = "mypy-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26cdd6a22b9b40b2fd71881a8a4f34b4d7914c679f154f43385ca878a8297389"}, - {file = "mypy-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b5f81b40d94c785f288948c16e1f2da37203c6006546c5d947aab6f90aefef2"}, - {file = "mypy-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b437be1c02712a605591e1ed1d858aba681757a1e55fe678a15c2244cd68a5"}, - {file = "mypy-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d809f88734f44a0d44959d795b1e6f64b2bbe0ea4d9cc4776aa588bb4229fc1c"}, - {file = "mypy-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:a380c041db500e1410bb5b16b3c1c35e61e773a5c3517926b81dfdab7582be54"}, - {file = "mypy-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b7c7b708fe9a871a96626d61912e3f4ddd365bf7f39128362bc50cbd74a634d5"}, - {file = "mypy-1.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c10fa12df1232c936830839e2e935d090fc9ee315744ac33b8a32216b93707"}, - {file = "mypy-1.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0a28a76785bf57655a8ea5eb0540a15b0e781c807b5aa798bd463779988fa1d5"}, - {file = "mypy-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ef6a01e563ec6a4940784c574d33f6ac1943864634517984471642908b30b6f7"}, - {file = "mypy-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d64c28e03ce40d5303450f547e07418c64c241669ab20610f273c9e6290b4b0b"}, - {file = "mypy-1.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64cc3afb3e9e71a79d06e3ed24bb508a6d66f782aff7e56f628bf35ba2e0ba51"}, - {file = "mypy-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce61663faf7a8e5ec6f456857bfbcec2901fbdb3ad958b778403f63b9e606a1b"}, - {file = "mypy-1.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b0c373d071593deefbcdd87ec8db91ea13bd8f1328d44947e88beae21e8d5e9"}, - {file = "mypy-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:2888ce4fe5aae5a673386fa232473014056967f3904f5abfcf6367b5af1f612a"}, - {file = "mypy-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:19ba15f9627a5723e522d007fe708007bae52b93faab00f95d72f03e1afa9598"}, - {file = "mypy-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:59bbd71e5c58eed2e992ce6523180e03c221dcd92b52f0e792f291d67b15a71c"}, - {file = "mypy-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9401e33814cec6aec8c03a9548e9385e0e228fc1b8b0a37b9ea21038e64cdd8a"}, - {file = "mypy-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b398d8b1f4fba0e3c6463e02f8ad3346f71956b92287af22c9b12c3ec965a9f"}, - {file = "mypy-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:69b35d1dcb5707382810765ed34da9db47e7f95b3528334a3c999b0c90fe523f"}, - {file = "mypy-1.1.1-py3-none-any.whl", hash = "sha256:4e4e8b362cdf99ba00c2b218036002bdcdf1e0de085cdb296a49df03fb31dfc4"}, - {file = "mypy-1.1.1.tar.gz", hash = "sha256:ae9ceae0f5b9059f33dbc62dea087e942c0ccab4b7a003719cb70f9b8abfa32f"}, -] -mypy-extensions = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] -packaging = [ - {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, - {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, -] -pathspec = [ - {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, - {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, -] -platformdirs = [ - {file = "platformdirs-3.1.1-py3-none-any.whl", hash = "sha256:e5986afb596e4bb5bde29a79ac9061aa955b94fca2399b7aaac4090860920dd8"}, - {file = "platformdirs-3.1.1.tar.gz", hash = "sha256:024996549ee88ec1a9aa99ff7f8fc819bb59e2c3477b410d90a16d32d6e707aa"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -pycparser = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, -] -pydantic = [ - {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, - {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"}, - {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"}, - {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"}, - {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"}, - {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"}, - {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"}, - {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"}, - {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"}, - {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"}, - {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"}, - {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"}, - {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"}, - {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"}, - {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"}, - {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"}, - {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"}, - {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"}, - {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"}, - {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"}, - {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"}, - {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"}, - {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"}, - {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"}, - {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"}, - {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"}, - {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"}, - {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"}, - {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"}, - {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"}, - {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"}, - {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"}, - {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"}, - {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"}, - {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"}, - {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"}, -] -pyjwt = [ - {file = "PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e"}, - {file = "PyJWT-1.7.1.tar.gz", hash = "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"}, -] -pylint = [ - {file = "pylint-2.17.1-py3-none-any.whl", hash = "sha256:8660a54e3f696243d644fca98f79013a959c03f979992c1ab59c24d3f4ec2700"}, - {file = "pylint-2.17.1.tar.gz", hash = "sha256:d4d009b0116e16845533bc2163493d6681846ac725eab8ca8014afb520178ddd"}, -] -pyopenssl = [ - {file = "pyOpenSSL-23.0.0-py3-none-any.whl", hash = "sha256:df5fc28af899e74e19fccb5510df423581047e10ab6f1f4ba1763ff5fde844c0"}, - {file = "pyOpenSSL-23.0.0.tar.gz", hash = "sha256:c1cc5f86bcacefc84dada7d31175cae1b1518d5f60d3d0bb595a67822a868a6f"}, -] -pytest = [ - {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"}, - {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"}, -] -python-dateutil = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] -pytz = [ - {file = "pytz-2023.2-py2.py3-none-any.whl", hash = "sha256:8a8baaf1e237175b02f5c751eea67168043a749c843989e2b3015aa1ad9db68b"}, - {file = "pytz-2023.2.tar.gz", hash = "sha256:a27dcf612c05d2ebde626f7d506555f10dfc815b3eddccfaadfc7d99b11c9a07"}, -] -requests = [ - {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, - {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, -] -six = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] -sniffio = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, -] -sqlparse = [ - {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, - {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, -] -starlette = [ - {file = "starlette-0.26.1-py3-none-any.whl", hash = "sha256:e87fce5d7cbdde34b76f0ac69013fd9d190d581d80681493016666e6f96c6d5e"}, - {file = "starlette-0.26.1.tar.gz", hash = "sha256:41da799057ea8620e4667a3e69a5b1923ebd32b1819c8fa75634bbe8d8bea9bd"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] -tomlkit = [ - {file = "tomlkit-0.11.6-py3-none-any.whl", hash = "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b"}, - {file = "tomlkit-0.11.6.tar.gz", hash = "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73"}, -] -typed-ast = [ - {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, - {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, - {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, - {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, - {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, - {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, - {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, - {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, - {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, - {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, - {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, - {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, - {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, - {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, - {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, - {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, - {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, - {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, - {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, - {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, - {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, - {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, - {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, - {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, -] -types-cryptography = [ - {file = "types-cryptography-3.3.23.2.tar.gz", hash = "sha256:09cc53f273dd4d8c29fa7ad11fefd9b734126d467960162397bc5e3e604dea75"}, - {file = "types_cryptography-3.3.23.2-py3-none-any.whl", hash = "sha256:b965d548f148f8e87f353ccf2b7bd92719fdf6c845ff7cedf2abb393a0643e4f"}, -] -types-pyjwt = [ - {file = "types-PyJWT-1.7.1.tar.gz", hash = "sha256:99c1a0d94d370951f9c6e57b1c369be280b2cbfab72c0f9c0998707490f015c9"}, - {file = "types_PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:810112a84b6c060bb5bc1959a1d229830465eccffa91d8a68eeaac28fb7713ac"}, -] -types-pytz = [ - {file = "types-pytz-2023.2.0.0.tar.gz", hash = "sha256:eec7c806220776cde7b2fb20371853ddd0d14fa7544ca3c6862433ade37469df"}, - {file = "types_pytz-2023.2.0.0-py3-none-any.whl", hash = "sha256:a6cca439e1f87be0ce745181543b58587f4e685d6b14627255dced68004ba05e"}, -] -types-pyyaml = [ - {file = "types-PyYAML-6.0.12.8.tar.gz", hash = "sha256:19304869a89d49af00be681e7b267414df213f4eb89634c4495fa62e8f942b9f"}, - {file = "types_PyYAML-6.0.12.8-py3-none-any.whl", hash = "sha256:5314a4b2580999b2ea06b2e5f9a7763d860d6e09cdf21c0e9561daa9cbd60178"}, -] -types-requests = [ - {file = "types-requests-2.28.11.16.tar.gz", hash = "sha256:9d4002056df7ebc4ec1f28fd701fba82c5c22549c4477116cb2656aa30ace6db"}, - {file = "types_requests-2.28.11.16-py3-none-any.whl", hash = "sha256:a86921028335fdcc3aaf676c9d3463f867db6af2303fc65aa309b13ae1e6dd53"}, -] -types-setuptools = [ - {file = "types-setuptools-67.6.0.7.tar.gz", hash = "sha256:f46b11773b1aeddbd2ef32fd6a6091ef33aa9b32daa124f6ce63f616de59ae51"}, - {file = "types_setuptools-67.6.0.7-py3-none-any.whl", hash = "sha256:ea2873dc8dd9e8421929dc50617ac7c2054c9a873942c5b5b606e2effef5db12"}, -] -types-urllib3 = [ - {file = "types-urllib3-1.26.25.8.tar.gz", hash = "sha256:ecf43c42d8ee439d732a1110b4901e9017a79a38daca26f08e42c8460069392c"}, - {file = "types_urllib3-1.26.25.8-py3-none-any.whl", hash = "sha256:95ea847fbf0bf675f50c8ae19a665baedcf07e6b4641662c4c3c72e7b2edf1a9"}, -] -typing-extensions = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, -] -urllib3 = [ - {file = "urllib3-1.25.11-py2.py3-none-any.whl", hash = "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"}, - {file = "urllib3-1.25.11.tar.gz", hash = "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2"}, -] -wrapt = [ - {file = "wrapt-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1"}, - {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29"}, - {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2"}, - {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46"}, - {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c"}, - {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09"}, - {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079"}, - {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e"}, - {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a"}, - {file = "wrapt-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923"}, - {file = "wrapt-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee"}, - {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727"}, - {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7"}, - {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0"}, - {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec"}, - {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90"}, - {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975"}, - {file = "wrapt-1.15.0-cp310-cp310-win32.whl", hash = "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1"}, - {file = "wrapt-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e"}, - {file = "wrapt-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7"}, - {file = "wrapt-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72"}, - {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb"}, - {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e"}, - {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c"}, - {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3"}, - {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92"}, - {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98"}, - {file = "wrapt-1.15.0-cp311-cp311-win32.whl", hash = "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416"}, - {file = "wrapt-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705"}, - {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29"}, - {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd"}, - {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb"}, - {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248"}, - {file = "wrapt-1.15.0-cp35-cp35m-win32.whl", hash = "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559"}, - {file = "wrapt-1.15.0-cp35-cp35m-win_amd64.whl", hash = "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639"}, - {file = "wrapt-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba"}, - {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752"}, - {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364"}, - {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475"}, - {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8"}, - {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418"}, - {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2"}, - {file = "wrapt-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1"}, - {file = "wrapt-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420"}, - {file = "wrapt-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317"}, - {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e"}, - {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e"}, - {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0"}, - {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019"}, - {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034"}, - {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653"}, - {file = "wrapt-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0"}, - {file = "wrapt-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e"}, - {file = "wrapt-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145"}, - {file = "wrapt-1.15.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f"}, - {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd"}, - {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b"}, - {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f"}, - {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6"}, - {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094"}, - {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7"}, - {file = "wrapt-1.15.0-cp38-cp38-win32.whl", hash = "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b"}, - {file = "wrapt-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1"}, - {file = "wrapt-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86"}, - {file = "wrapt-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c"}, - {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d"}, - {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc"}, - {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29"}, - {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a"}, - {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8"}, - {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9"}, - {file = "wrapt-1.15.0-cp39-cp39-win32.whl", hash = "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff"}, - {file = "wrapt-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6"}, - {file = "wrapt-1.15.0-py3-none-any.whl", hash = "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640"}, - {file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"}, -] -zipp = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "464b56f557915204124705597604a3039959fd7bcc5343fd44573b94acfe3151" diff --git a/pyproject.toml b/pyproject.toml index c1f7f35..599e070 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "lti1p3platform" -version = "0.0.7" +version = "0.1.8" authors = [ { name="Jun Tu", email="jun@openlearning.com" }, ] description = "LTI 1.3 Platform implementation" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -15,22 +15,24 @@ classifiers = [ dependencies = [ "requests", "PyJWT", - "jwcrypto", - "Jinja2" + "jwcrypto" ] +[project.optional-dependencies] +Django=["Django", "Jinja2", "django-stubs"] +fastapi=["fastapi", "Jinja2"] + [tool.poetry] name = "lti1p3platform" -version = "0.0.7" +version = "0.1.8" description = "LTI 1.3 Platform implementation" authors = ["Jun Tu "] [tool.poetry.dependencies] -python = "^3.7.2" -PyJWT = "1.7.1" -requests = {version = "2.24.0", extras = ["security"]} +python = "^3.8" +PyJWT = "^1.7.1" +requests = {version = "^2.24.0", extras = ["security"]} jwcrypto = "^1.4.2" -Jinja2 = "^3.1.2" python-dateutil = "^2.8.2" [tool.poetry.dev-dependencies] diff --git a/tests/platform_config.py b/tests/platform_config.py index 66fe9e7..37f0672 100644 --- a/tests/platform_config.py +++ b/tests/platform_config.py @@ -86,6 +86,7 @@ "iss": "http://test-platform.example/", "client_id": "test-platform", "deployment_id": 1, + "access_token_url": "https://test-platform.example/token", "launch_url": "https://lti-ri.imsglobal.org/lti/tools/3674/launches", "oidc_login_url": "https://lti-ri.imsglobal.org/lti/tools/3674/login_initiations", "key_set_url": "https://lti-ri.imsglobal.org/lti/tools/3674/.well-known/jwks.json", @@ -104,6 +105,7 @@ def init_platform_config(self, **kwargs) -> None: .set_iss(PLATFORM_CONFIG["iss"]) .set_client_id(PLATFORM_CONFIG["client_id"]) .set_deployment_id(PLATFORM_CONFIG["deployment_id"]) + .set_access_token_url(PLATFORM_CONFIG["access_token_url"]) .set_oidc_login_url(PLATFORM_CONFIG["oidc_login_url"]) .set_launch_url(PLATFORM_CONFIG["launch_url"]) .set_platform_public_key(RSA_PUBLIC_KEY_PEM) diff --git a/tests/test_platform_conf.py b/tests/test_platform_conf.py index bb1aa50..4ead1cd 100644 --- a/tests/test_platform_conf.py +++ b/tests/test_platform_conf.py @@ -39,6 +39,7 @@ def test_get_access_tokens(): jwt_claims = { "iss": PLATFORM_CONFIG["client_id"], "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], "iat": int(time.time()) - 5, "exp": int(time.time()) + 60, "jti": "lti-service-token-" + str(uuid.uuid4()), From 4a551d643e8e5d6cb455e8951102d50ef1f7a999 Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Fri, 3 Apr 2026 19:46:28 +1100 Subject: [PATCH 2/9] add comments --- lti1p3platform/ags.py | 159 +++++++++++++++-- lti1p3platform/deep_linking.py | 124 ++++++++++++- lti1p3platform/jwt_helper.py | 106 ++++++++++- lti1p3platform/ltiplatform.py | 293 +++++++++++++++++++++++++++++-- lti1p3platform/message_launch.py | 175 +++++++++++++++++- lti1p3platform/nrps.py | 199 +++++++++++++++++++-- lti1p3platform/oidc_login.py | 168 ++++++++++++++++-- lti1p3platform/registration.py | 159 +++++++++++++++-- 8 files changed, 1293 insertions(+), 90 deletions(-) diff --git a/lti1p3platform/ags.py b/lti1p3platform/ags.py index 982bc6c..3e70324 100644 --- a/lti1p3platform/ags.py +++ b/lti1p3platform/ags.py @@ -1,5 +1,32 @@ """ -LTI Advantage Assignments and Grades service implementation +LTI 1.3 Advantage Services - Assignments and Grades Service (AGS) Implementation + +Assignments and Grades Service (AGS): +==================================== +AGS allows tools (like homework/quiz platforms) to: +1. Create grading items (assignments/assessments) in the LMS +2. Submit student grades/results back to the LMS +3. Query existing grades and assignments + +Real-World Example: +- Student uses external quiz tool to take quiz +- Quiz tool submits grade back to platform +- Platform records grade in gradebook +- Instructor can see student's quiz grade in LMS gradebook +- Tool can integrate with platform's grading system + +Security: +- Tool must request specific OAuth scopes for AGS +- Platform validates scopes before allowing API calls +- All API calls use access_token (JWT Bearer token) +- Scopes control what tool can do: + * lineitem.readonly: See assignments only + * score: Submit new grades (recommended for quiz tools) + * result.readonly: See grades only + * lineitem: Create/delete assignments (for creation tools) + * result: Modify grades (broader than score) + +Reference: https://www.imsglobal.org/spec/lti-ags/v2p0/ """ from __future__ import annotations @@ -8,16 +35,47 @@ class LtiAgs: """ - LTI Advantage Consumer - - Implements LTI Advantage Services and ties them in - with the LTI Consumer. This only handles the LTI - message claim inclusion and token handling. - - Available services: - * Assignments and Grades services claim - - Reference: https://www.imsglobal.org/spec/lti-ags/v2p0/#assignment-and-grade-service-claim + LTI 1.3 Advantage Services - Assignments and Grades Service Configuration + + AGS provides three main APIs: + + 1. LineItem API (Assignment Management API): + - GET /lineitems: List all grading items (assignments) + - POST /lineitems: Create new grading item (if allowed) + - GET /lineitems/{id}: Get specific grading item details + - PUT /lineitems/{id}: Update grading item + - DELETE /lineitems/{id}: Delete grading item + + Scopes required: + - lineitem.readonly: View only + - lineitem: Create/modify/delete + + 2. Score API (Grade Submission API): + - POST /lineitems/{id}/scores: Submit student grade + - Scopes: score (most restrictive, recommended) + - Allows tool to submit grades without modifying items + + 3. Result API (Detailed Grade Query API): + - GET /lineitems/{id}/results: Retrieve all results for an item + - GET /lineitems/{id}/results/{user_id}: Get specific student's result + - Scopes: result.readonly (view) or result (modify) + + This class configures which AGS capabilities are available to tools. + + Parameters: + - lineitems_url: Platform's API endpoint for listing/creating assignments + - lineitem_url: Template URL for accessing specific assignment (contains {id}) + - allow_creating_lineitems: If False, tool can only see existing items (not create) + - results_service_enabled: If True, tool can query student results + - scores_service_enabled: If True, tool can submit grades + + Platform Security Considerations: + - Only enable services actually used by this tool + - Restrict scopes to minimum needed + - Monitor tool's API usage for suspicious patterns + - Default: conservative (results=true, scores=true, creation=false) + + Reference: https://www.imsglobal.org/spec/lti-ags/v2p0/ """ # pylint: disable=too-many-arguments @@ -30,23 +88,96 @@ def __init__( scores_service_enabled: bool = True, ) -> None: """ - Instance class with LTI AGS Global settings. + Initialize AGS configuration for a tool integration + + Parameters: + lineitems_url: Platform's API endpoint for line item list/creation + - Format: "https://platform.edu/lti/ags/lineitems" + - Tool makes GET/POST requests to this endpoint + - Required if scores/results services enabled + + lineitem_url: Template URL for accessing individual line item + - Format: "https://platform.edu/lti/ags/lineitems/123" + - Contains {id} placeholder replaced with item ID + - Required if scores/results services enabled + + allow_creating_lineitems: Allow tool to create new assignments + - Default: False (tool can only use existing items) + - Set True for content creation tools + - False prevents tool from cluttering gradebook + + results_service_enabled: Allow tool to query student results + - Default: True (most tools need this) + - Disabled if tool only submits grades (no results lookup) + - Results API requires 'result.readonly' or 'result' scope + + scores_service_enabled: Allow tool to submit grades/scores + - Default: True (most tools need this) + - Disabled if tool is view-only + - Scores API requires 'score' scope """ # If the platform allows creating lineitems, set this - # to True. + # to True. This allows tools like content creators to add + # new assignments to the platform's gradebook. self.allow_creating_lineitems = allow_creating_lineitems # Result and scores services + # These indicate which AGS APIs the platform supports self.results_service_enabled = results_service_enabled self.scores_service_enabled = scores_service_enabled # Lineitems urls + # These are the API endpoints where tool makes requests self.lineitems_url = lineitems_url self.lineitem_url = lineitem_url def get_available_scopes(self) -> t.List[str]: """ - Retrieves list of available token scopes in this instance. + Retrieves list of available OAuth 2.0 scopes for this AGS configuration + + OAuth 2.0 Scopes determine what the tool is allowed to do on the platform. + Scopes are included in the access_token JWT and validated by the platform. + + Available AGS Scopes: + - https://purl.imsglobal.org/spec/lti-ags/scope/lineitem + * Create/modify/delete line items (assignments) + * Requires: allow_creating_lineitems=True + * Only included if tool needs to create items + + - https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly + * View line items only, cannot modify + * Less restrictive than lineitem + * Default for tools that don't create items + + - https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly + * View student results/grades only + * Cannot modify or change grades + * Safest scope for read-only tools + + - https://purl.imsglobal.org/spec/lti-ags/scope/result + * View and modify student results/grades + * More permissive than result.readonly + * Used by tools that need full result access + + - https://purl.imsglobal.org/spec/lti-ags/scope/score + * Submit grades for students + * Most restrictive scope (RECOMMENDED!) + * Used by quiz/homework tools + * Cannot view other students' scores + + Scope Selection Best Practice: + - Use 'score' if only submitting grades (quiz tools, most secure) + - Use 'lineitem.readonly' if only viewing assignments + - Use 'result.readonly' if only viewing grades + - Use 'result' only if truly needing result modification + - Use 'lineitem' only for content creation tools + + Returns: + List of scope URIs the platform will provide tokens for + + Reference: + - Scope descriptions: https://www.imsglobal.org/spec/lti-ags/v2p0/#scopes + - OAuth 2.0 Scopes: https://tools.ietf.org/html/rfc6749#section-3.3 """ scopes = [] diff --git a/lti1p3platform/deep_linking.py b/lti1p3platform/deep_linking.py index b18c807..52d9a55 100644 --- a/lti1p3platform/deep_linking.py +++ b/lti1p3platform/deep_linking.py @@ -1,5 +1,20 @@ """ -LTI Deep Linking service implementation +LTI 1.3 Advantage - Deep Linking Service + +Deep Linking is an LTI Advantage service that allows platforms to let users +(usually instructors) browse and select content from external tools. + +Use Case: +- Instructor in LMS wants to add content from external tool to course +- Rather than navigating to external tool, then copying URLs back +- Instructor uses Deep Linking UI within LMS to find and select content +- Tool returns structured content reference to LMS +- LMS records the content selection and embeds it in course + +Reference: +- LTI Deep Linking Spec: https://www.imsglobal.org/spec/lti-dl/v2p0/ +- Deep Linking Launch: https://www.imsglobal.org/spec/lti-dl/v2p0/#launch +- Deep Linking Response: https://www.imsglobal.org/spec/lti-dl/v2p0/#deep_linking_response """ import typing as t from .constants import LTI_DEEP_LINKING_ACCEPTED_TYPES @@ -9,10 +24,55 @@ # pylint: disable=too-few-public-methods class LtiDeepLinking: """ - LTI Advantage - Deep Linking Service - + LTI 1.3 Advantage - Deep Linking Service Handler + + Deep Linking Launch Flow: + 1. Instructor clicks "Select Content from External Tool" in LMS + 2. Platform sends special LTI message to tool (includes deep_linking data) + 3. Tool displays content browser/selection UI + 4. User selects one or more items + 5. Tool creates Deep Link Response (signed JWT) with selected content + 6. Tool redirects back to platform with response + 7. Platform validates response and records selection + 8. Content appears in course + + This class handles: + - Creating Deep Linking launch claims (Step 2) + - Validating Deep Linking responses (Step 6) + - Creating Deep Linking response returns (Step 5) + + Deep Linking Claims in Launch Message: + - accept_types: What types of content the platform can accept + - accept_media_types: What media types are acceptable + - accept_presentation_document_targets: Where to embed (window, frame, etc.) + - title: Prefill title for content browser + - text: Prefill description for content browser + - data: Additional custom data from platform + - auto_create: Allow tool to auto-select instead of showing UI (optional) + - accept_unsigned: Allow unsigned deep links (default: false, require signature) + - accept_multiple: Allow selecting multiple items (default: false, single item) + - accept_lineitem: Platform accepts a grading item (AGS) + + Deep Linking Response Format: + - Signed JWT with claims: + * https://purl.imsglobal.org/spec/lti-dl/claim/content_items: + Array of selected content items + * https://purl.imsglobal.org/spec/lti-dl/claim/data: Echo back platform's data + * nonce: Echoed from request (replay protection) + * aud: Platform's deep link return URL + * Plus standard claims (iss, sub, iat, exp, jti) + + Security Mechanisms: + - Only platform's registered deep_linking_launch_url can receive launch + - Tool must validate authorization before showing content browser + - Deep Link Response is signed JWT (can't be forged) + - Nonce prevents replay of old responses + - JTI prevents token reuse + - Return URL (aud claim) prevents sending response to wrong platform + Reference: - http://www.imsglobal.org/spec/lti-dl/v2p0#file + - LTI Advantage Services: https://www.imsglobal.org/spec/lti/v1p3/#lti-advantage-services + - Deep Linking Spec: https://www.imsglobal.org/spec/lti-dl/v2p0/ """ def __init__( @@ -20,7 +80,13 @@ def __init__( deep_linking_return_url: str, ) -> None: """ - Class initialization. + Initialize Deep Linking response handler + + Parameters: + deep_linking_return_url: URL where to POST Deep Link Response + - Is provided by platform in the launch message + - Is also the audience (aud claim) for the response JWT + - MUST use HTTPS in production """ self.deep_linking_return_url = deep_linking_return_url @@ -32,7 +98,53 @@ def get_lti_deep_linking_launch_claim( extra_data: t.Optional[t.Dict[str, t.Any]] = None, ) -> t.Dict[str, t.Any]: """ - Returns LTI Deep Linking Claim to be injected in the LTI launch message. + Generate Deep Linking Launch Claim for LTI message + + This claim is included in the LTI launch message when the platform wants + the tool to display a content selection interface (Deep Linking). + + Platform -> Tool Communication: + The platform includes this claim in the id_token JWT to tell the tool: + "Display content selection UI and return what the user selects" + + Claim Structure: + ================ + { + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings": { + "accept_types": [content type URIs], + "accept_media_types": [media type filter], + "accept_presentation_document_targets": [embed options], + "accept_multiple": false/true, # Single or multiple selection + "auto_create": false/true, # Show UI or auto-create + "accept_unsigned": false/true, # Require signed response JWT + "accept_lineitem": false/true, # Include grading item + "title": "Prefill title for content browser", + "text": "Prefill description", + "data": "Custom data to echo back" # Platform can pass arbitrary data + } + } + + Parameters: + title: Prefill title in tool's content browser UI + description: Prefill description/text in tool's content browser UI + accept_types: What content types the platform can store + - "ltiResourceLink": Standard LTI launch content + - "file": File upload (document, video, etc.) + - "html": HTML/inline content + - "image": Image content + - "link": External URL/link + - If None: all types accepted + + extra_data: Custom data returned in Deep Link Response + - Platform can include any opaque data + - Tool must echo this back in response + - Allows platform to remember context (course ID, module ID, etc.) + + Returns: + dict: The deep_linking_settings claim to inject into LTI launch message + + Raises: + LtiDeepLinkingContentTypeNotSupported: If invalid content types requested """ if not accept_types: accept_types = LTI_DEEP_LINKING_ACCEPTED_TYPES diff --git a/lti1p3platform/jwt_helper.py b/lti1p3platform/jwt_helper.py index 986aaa8..2f4aca2 100644 --- a/lti1p3platform/jwt_helper.py +++ b/lti1p3platform/jwt_helper.py @@ -1,3 +1,51 @@ +""" +JWT (JSON Web Token) Encoding Utilities for LTI 1.3 + +What is JWT? +============ +JWT (JSON Web Token) is a standard format for securely transmitting claims between parties. +All LTI 1.3 messages are signed JWTs. + +JWT Structure: +- Header: Specifies algorithm and key ID +- Payload: The actual data/claims +- Signature: Generated using private key, verified with public key + +Example JWT: +eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJpc3MiOiJodHRwczovL3BsYXRmb3JtLnRydWN1dC5jb20ifQ.signature... + +Three parts separated by dots (base64url-encoded): +1. Header: eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ + Decoded: {"alg":"RS256","kid":"1"} + +2. Payload: eyJpc3MiOiJodHRwczovL3BsYXRmb3JtLnRydWN1dC5jb20ifQ + Decoded: {"iss":"https://platform.trucut.com"} + +3. Signature: signature... (cryptographic signature) + +LTI 1.3 JWT Requirements: +======================== +- Algorithm: RS256 (RSA with SHA-256) +- Key: Platform's private key for signing +- Payload: Contains claims about user/context + +Common JWT Claims: +- iss (issuer): Who created the token +- sub (subject): Who the token is about +- aud (audience): Who should validate this token +- iat (issued at): When the token was created +- exp (expiration): When the token expires +- nonce: Random value for replay protection +- jti: Unique token ID for replay protection + +Security: +- Signature proves token wasn't tampered with +- Signature proves it came from who claims to have created it +- Can be validated offline without contacting issuer +- No secrets transmitted (only public key needed to verify) + +Reference: https://tools.ietf.org/html/rfc7519 +""" import typing as t import jwt @@ -10,9 +58,61 @@ def jwt_encode( json_encoder: t.Optional[t.Callable[(...), t.Any]] = None, ) -> str: """ - PyJWT encode wrapper to handle bytes/str - In PyJWT 2.0.0, tokens are returned as string instead of a byte string - But in old version, it still returns a byte string + Encode a JWT token with the given payload and key + + PyJWT Wrapper: Handles compatibility between PyJWT versions + - PyJWT 2.0.0+: Returns string (UTF-8 encoded) + - PyJWT < 2.0.0: Returns bytes + - This function always returns a string for consistency + + LTI 1.3 JWT Encoding Process: + 1. Create payload dict with all necessary claims + 2. Call this function with payload and platform's private key + 3. PyJWT creates signature using RS256 algorithm + 4. Returns signed JWT string (three dot-separated base64url-encoded parts) + 5. Platform sends this JWT to tool (usually as parameter in redirect) + + JWT Payload Example (LTI Launch Message): + { + "iss": "https://platform.trucut.com", + "sub": "user-123", + "aud": "https://tool.example.com", + "iat": 1634567890, + "exp": 1634567950, + "nonce": "random-nonce-xyz", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "deployment-456", + "https://purl.imsglobal.org/spec/lti/claim/user_id": "user-123", + ... + } + + Parameters: + payload: Dictionary of claims to include in JWT + key: Private key in PEM format (string) + algorithm: Signing algorithm (default HS256, LTI uses RS256) + - HS256: HMAC with SHA-256 (symmetric, shared secret) + - RS256: RSA with SHA-256 (asymmetric, public/private key) + - LTI 1.3 uses RS256 for security + headers: Additional JWT headers (e.g., key ID 'kid' for key rotation) + json_encoder: Custom JSON encoder if needed + + Returns: + str: The encoded JWT token (three dot-separated parts) + + Example Use: + >>> payload = { + ... "iss": "https://platform.com", + ... "sub": "user-123", + ... "aud": "https://tool.com", + ... "iat": 1634567890, + ... "exp": 1634567950 + ... } + >>> token = jwt_encode(payload, private_key_pem, "RS256") + >>> # token = "eyJ0eXAiOiJKV1QiLCJhbGc..." + + Reference: + - JWT Format: https://tools.ietf.org/html/rfc7519#section-3 + - RS256 Algorithm: https://tools.ietf.org/html/rfc7518#section-3.3 + - LTI JWT Encoding: https://www.imsglobal.org/spec/lti/v1p3/#jwt-message-claims """ encoded_jwt = jwt.encode(payload, key, algorithm, headers, json_encoder) diff --git a/lti1p3platform/ltiplatform.py b/lti1p3platform/ltiplatform.py index 994db3e..a80fa94 100644 --- a/lti1p3platform/ltiplatform.py +++ b/lti1p3platform/ltiplatform.py @@ -44,16 +44,49 @@ class AccessTokenResponse(TypedDict): class LTI1P3PlatformConfAbstract(ABC): - _registration = None - _accepted_deeplinking_types = LTI_DEEP_LINKING_ACCEPTED_TYPES - """ LTI 1.3 Platform Data storage abstract class + + This class implements the Learning Tools Interoperability (LTI) 1.3 specification, + which defines a standards-based approach for educational tools to integrate with + learning platforms (e.g., LMS systems). + + LTI 1.3 Key Concepts: + - Uses OAuth 2.0 authorization framework combined with OpenID Connect (OIDC) + - Platform acts as the OAuth Authorization Server and OpenID Provider + - Tool acts as the OAuth Client/Relying Party + - Communication is secured via HTTPS and JWT (JSON Web Tokens) + + Security Architecture: + - Platform and Tool exchange cryptographic keys via JWK Sets (JSON Web Key Sets) + - All messages are signed JWTs using RS256 (RSA with SHA-256) + - Audience ('aud') claim validates the token is intended for specific recipient + - Nonce and JTI (JWT ID) claims prevent token replay attacks + - All URLs must use HTTPS for production (except localhost for development) + + Key Flows: + 1. OIDC Login Initiation: Platform redirects user to tool's OIDC login endpoint + 2. Authorization: Tool routes back through platform's OIDC authorization endpoint + 3. Token Validation: Platform validates signed ID token from tool + 4. Message Launch: Platform sends signed LTI message with user/context claims + 5. Service Calls: Tool calls platform APIs (AGS, NRPS) with signed access tokens + + References: + - IMS LTI 1.3 Core: https://www.imsglobal.org/spec/lti/v1p3/ + - IMS Security Framework: https://www.imsglobal.org/spec/security/v1p0/ + - OpenID Connect Core: https://openid.net/specs/openid-connect-core-1_0.html """ + + _registration = None + _accepted_deeplinking_types = LTI_DEEP_LINKING_ACCEPTED_TYPES def __init__(self, **kwargs: t.Any) -> None: self._jwt: t.Dict[str, t.Any] = {} + # Cache for tracking used JTI (JWT ID) values to prevent token replay attacks + # Maps JTI -> expiration timestamp (used for cleanup of expired entries) self._used_tool_jtis: t.Dict[str, int] = {} + # Cache for tracking used nonce values to prevent token replay attacks + # Maps nonce -> expiration timestamp (used for cleanup of expired entries) self._used_tool_nonces: t.Dict[str, int] = {} self.init_platform_config(**kwargs) @@ -89,7 +122,15 @@ def get_registration(self, **kwargs: t.Any) -> Registration: def get_jwks(self) -> JWKS: """ - Get JWKS + Get JWKS (JSON Web Key Set) from the platform + + The platform exposes its public keys via a JWK Set endpoint. + Tools fetch these keys to validate signatures on messages from the platform. + + LTI 1.3 Spec Reference: https://www.imsglobal.org/spec/lti/v1p3/#platform-jwks + + Returns: + JWKS: Dictionary with 'keys' list containing JWK objects """ assert self._registration is not None, "Registration not yet set" @@ -97,7 +138,38 @@ def get_jwks(self) -> JWKS: def fetch_public_key(self, key_set_url: str) -> JWKS: """ - Fetch public key from url + Fetch tool's public key set from the provided URL + + Security Requirements (LTI 1.3 Spec & 1EdTech Security Framework): + 1. URL MUST use HTTPS (https:// scheme required) + - Prevents man-in-the-middle attacks + - TLS 1.2+ provides encryption and authentication + + 2. No Private IP Addresses: + - Reject private/RFC 1918 IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) + - Prevents Server-Side Request Forgery (SSRF) attacks on internal infrastructure + - Reject loopback/localhost addresses (127.0.0.1, ::1) + - Reject link-local addresses (169.254.x.x) + - Reject reserved/multicast addresses + + 3. Hostname Validation: + - Reject literal hostname 'localhost' (bypass check) + - Accept only fully-qualified domain names in production + + These restrictions prevent: + - Attacks on internal services (databases, admin panels, cloud metadata endpoints) + - Bypassing network security controls + + LTI 1.3 References: + - Transport security: https://www.imsglobal.org/spec/lti/v1p3/#securing_web_services + - 1EdTech Security Framework: https://www.imsglobal.org/spec/security/v1p0/ + + Raises: + InvalidKeySetUrl: If URL doesn't meet security requirements + LtiException: If network request fails or response is invalid JSON + + Returns: + JWKS: The fetched JSON Web Key Set """ parsed_url = urlparse(key_set_url) if parsed_url.scheme != "https": @@ -214,6 +286,39 @@ def get_tool_public_key(self) -> bytes: def tool_validate_and_decode( self, jwt_token_string: str, audience: str ) -> t.Dict[str, t.Any]: + """ + Validate and decode a JWT token from the tool + + LTI 1.3 JWT Validation Process: + 1. Format Validation: JWT must have 3 parts separated by dots (header.payload.signature) + 2. Signature Verification: Use tool's public key to validate the signature (RS256) + 3. Audience Validation: Verify the 'aud' claim matches the expected platform + - Ensures tool created this token specifically for us + - Prevents token confusion attacks + 4. Additional claims verified by PyJWT: + - 'exp' (expiration): Token must not be expired + - 'iat' (issued at): Token must not be issued in the future (clock skew tolerance) + + Why Audience Verification is Critical: + - Without audience verification, tokens created for one recipient could be misused + - Example attack: Token meant for Platform A used to access Platform B + - Audience claim ties the token to a specific platform instance + + OpenID Connect Spec Reference: + - ID Token validation: https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + + Parameters: + jwt_token_string: The JWT token to validate (format: header.payload.signature) + audience: The expected audience value from 'aud' claim + + Returns: + dict: Decoded JWT payload (claims) + + Raises: + LtiException: If JWT format is invalid + jwt.InvalidAudienceError: If audience doesn't match + jwt.ExpiredSignatureError: If token is expired + """ self.validate_jwt_format(jwt_token_string) public_key = self.get_tool_public_key() @@ -227,6 +332,35 @@ def tool_validate_and_decode( ) def _is_token_replay(self, jti: str, exp: int) -> bool: + """ + Detect and prevent JWT token replay attacks using JTI (JWT ID) + + Replay Attack Prevention Strategy: + - Each JWT must include a 'jti' (JWT ID) claim - a unique identifier per token + - Platform maintains an in-memory cache of all seen JTIs + - If same JTI appears twice, it's a replay attack + - Cache is cleaned of expired JTIs to prevent unbounded memory growth + + Why JTI is Required (LTI 1.3 Spec): + - Prevents attacker from replaying intercepted tokens + - Example: Attacker intercepts token with jti='abc123', tries to replay it + - Platform recognizes jti='abc123' already used, rejects the replay + + Implementation Notes: + - Expiration timestamp used for cache cleanup (best-effort) + - Expired JTIs can be safely removed since they can't be valid anyway + - Token signature validation + JTI check provides defense-in-depth + + 1EdTech Security Framework Reference: + - JWT Bearer Assertions: https://www.imsglobal.org/spec/security/v1p0/#making_authenticated_requests + + Parameters: + jti: The JWT ID claim value from the token + exp: The expiration timestamp of the token (UNIX timestamp) + + Returns: + bool: True if this is a replay (JTI previously seen), False if unique (first time) + """ now = int(time.time()) # Best-effort cleanup of expired JTIs. @@ -241,6 +375,41 @@ def _is_token_replay(self, jti: str, exp: int) -> bool: return False def _is_nonce_replay(self, nonce: str, exp: int) -> bool: + """ + Detect and prevent nonce replay attacks in deep linking responses + + Nonce (Number Used Once) Security: + - Platform generates a random nonce for each deep linking request + - Tool includes this nonce in the deep linking response + - Platform validates that the received nonce matches what it sent + - This is a Cross-Site Request Forgery (CSRF) protection mechanism + + Replay Attack Prevention: + - Platform caches all nonces received from tools + - If same nonce appears twice, it indicates a replay/reuse attempt + - Cache is cleaned of expired nonces to prevent unbounded memory growth + - Prevents attacker from reusing old deep linking messages + + Flow Example: + 1. Platform generates nonce='xyz789' and sends to tool + 2. Tool includes nonce='xyz789' in deep linking response + 3. Platform caches nonce='xyz789' with expiration time + 4. If nonce='xyz789' appears again, it's rejected as replay + + OpenID Connect Specification: + - Nonce validation prevents authorization code/token replay attacks + - See: https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes + + LTI Deep Linking Spec Reference: + - Deep linking request/response: https://www.imsglobal.org/spec/lti-dl/v2p0/ + + Parameters: + nonce: The nonce value from the deep linking response + exp: The expiration timestamp of the response (UNIX timestamp) + + Returns: + bool: True if this is a replay (nonce previously seen), False if unique + """ now = int(time.time()) expired_nonces = [ @@ -258,6 +427,54 @@ def _is_nonce_replay(self, nonce: str, exp: int) -> bool: def _validate_tool_access_token_assertion( self, decoded_assertion: t.Dict[str, t.Any], expected_audience: str ) -> None: + """ + Validate semantic correctness of tool's access token assertion (client credentials JWT) + + LTI 1.3 Tool Access Token Request Flow (OAuth 2.0 Client Credentials Grant): + 1. Tool wants to call platform APIs (e.g., Grade Passback via AGS) + 2. Tool creates a JWT assertion signed with its private key + 3. Tool sends this assertion to platform's token endpoint + 4. Platform validates the assertion and returns an access_token + 5. Tool uses access_token to call platform APIs + + JWT Assertion Requirements (1EdTech Security Framework): + - client_assertion_type: Must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + - Claims that must be present: + * iss (issuer): Client ID - identifies the tool making the request + * sub (subject): Client ID - same as iss (tool is resource owner) + * aud (audience): Token endpoint URL - tool asserts this token is for platform's token endpoint + * iat (issued at): Timestamp when token was created + * exp (expiration): Timestamp when token expires + * jti (JWT ID): Random unique identifier, prevents token replay attacks + + Semantic Validation Rules: + 1. Required Claims Check: All 6 required claims must be present + 2. iss Must Equal Client ID: Ensures tool is who they claim to be + 3. sub Must Equal Client ID: OAuth 2.0 convention (resource owner is the tool) + 4. aud Must Include Token Endpoint: Prevents token confusion attacks + - Token created for token endpoint can't be misused for other endpoints + 5. Timing Validation: + - iat must not be in future (within 60 second clock skew tolerance) + - exp must not be in past (token must not be expired) + 6. JTI Uniqueness: No replay of same jti value (prevents token reuse) + + Security Implications: + - These checks ensure tool authentication, token intent, and prevent replay attacks + - Without these, unauthorized tools could impersonate real tools + - Without audience validation, tokens could be misused at wrong endpoints + + 1EdTech Security Framework Reference: + - https://www.imsglobal.org/spec/security/v1p0/#securing_web_services + - https://www.imsglobal.org/spec/security/v1p0/#token_request + + Parameters: + decoded_assertion: The decoded JWT payload with all claims + expected_audience: The platform's token endpoint URL (should match aud claim) + + Raises: + MissingRequiredClaim: If required header/claim is missing + LtiException: If any semantic validation fails (iss, sub, aud, timing, jti) + """ assert self._registration is not None, "Registration not yet set" required_claims = ["iss", "sub", "aud", "iat", "exp", "jti"] @@ -305,23 +522,61 @@ def get_access_token( self, token_request_data: t.Dict[str, t.Any] ) -> AccessTokenResponse: """ - Validate request and return JWT access token. - - This complies to IMS Security Framework and accepts a JWT - as a secret for the client credentials grant. - See this section: - https://www.imsglobal.org/spec/security/v1p0/#securing_web_services - - Full spec reference: - https://www.imsglobal.org/spec/security/v1p0/ - + Validate tool's token request and return JWT access token + + OAuth 2.0 Client Credentials Grant with JWT Bearer Assertion: + This endpoint implements the OAuth 2.0 Client Credentials Grant Type using + JWT Bearer Assertions as per the 1EdTech Security Framework. + + Request Flow: + 1. Tool sends POST to /token with: + - grant_type = "client_credentials" + - client_assertion = signed JWT (tool's credentials) + - client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + - scope = requested platform capabilities (e.g., "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly") + + 2. Platform validates: + - client_assertion_type is exactly the specified Bearer assertion type + - client_assertion JWT has valid signature (signed by tool's private key) + - JWT contains required claims (iss, sub, aud, iat, exp, jti) + - JWT claims are semantically correct (iss==client_id, aud includes token endpoint) + - JWT hasn't been used before (jti not in cache) + - JWT is not expired and not issued in future + + 3. Platform returns: + - access_token: Signed JWT containing scope and other claims + - token_type: "Bearer" + - expires_in: seconds until token expires + - scope: list of granted capabilities + + Token Usage: + - Tool includes access_token in Authorization header when calling platform APIs + - Example: "Authorization: Bearer " + - Platform validates token signature and verifies requested scopes + + Security Benefits: + - JWT Bearer Assertions: No shared secrets, only public/private key pairs + - Prevents unauthorized tools from requesting tokens + - Nonce-like mechanism (jti) prevents token reuse/replay attacks + - Signed tokens can be verified offline without hitting a database + + Reference: + - 1EdTech Security Framework: https://www.imsglobal.org/spec/security/v1p0/#securing_web_services + - LTI Advantage Services (AGS spec): https://www.imsglobal.org/spec/lti-ags/v2p0/ + Parameters: - token_request_data: Dict of parameters sent by LTI tool as form_data. + token_request_data: Dict of form parameters: + - grant_type: Must be "client_credentials" + - client_assertion: Signed JWT from tool + - client_assertion_type: Must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + - scope: Space-separated list of requested scopes Returns: - A dict containing the JSON response containing a JWT and some extra - parameters required by LTI tools. This token gives access to all - supported LTI Scopes from this tool. + AccessTokenResponse: Dictionary with 'access_token', 'expires_in', 'token_type', 'scope' + + Raises: + UnsupportedGrantType: If grant_type is not client_credentials + LtiException: Various validation failures (see _validate_tool_access_token_assertion) """ assert self._registration is not None, "Registration not yet set" diff --git a/lti1p3platform/message_launch.py b/lti1p3platform/message_launch.py index a0ac87f..d64fb82 100644 --- a/lti1p3platform/message_launch.py +++ b/lti1p3platform/message_launch.py @@ -25,6 +25,40 @@ class LaunchData(TypedDict): # pylint: disable=too-many-instance-attributes class MessageLaunchAbstract(ABC): + """ + Abstract base class for LTI 1.3 Message Launch handling + + LTI 1.3 Launch Process (simplified): + 1. User clicks "launch tool" in platform + 2. Platform creates LTI message with user/content context (JWT) + 3. Platform sends POST with id_token + state to tool's launch_url + 4. Tool validates JWT signature and claims + 5. Tool displays interface with user's context + + This class handles: + - Receiving and parsing launch requests + - Validating JWT signatures and claims + - Extracting LTI claims (user roles, resource context, etc.) + - Building response data + - Interfacing with LTI Advantage services (AGS, NRPS) + + Message Contents: + The id_token JWT contains many claims about the launch context: + - User identity: sub (subject/user ID), email, name, roles + - Resource: resource_link (content being launched) + - Context: context (course/organization info) + - Custom parameters: Custom data platform passes to tool + - Roles: User roles (e.g., 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor') + + HTTPS Requirement: + - All Platform/Tool URLs must use HTTPS for production + - Except localhost (127.0.0.1, ::1) allowed for development + - Prevents man-in-the-middle attacks on tokens + + Reference: + - LTI 1.3 Resource Link Request: https://www.imsglobal.org/spec/lti/v1p3/#resource-link-request + - LTI Deep Link Launch: https://www.imsglobal.org/spec/lti-dl/v2p0/#lifecycle + """ _request = None _registration: t.Optional[Registration] = None @@ -66,10 +100,58 @@ def set_user_data( preferred_username: t.Optional[str] = None, ) -> None: """ - Set user data/roles and convert to IMS Specification - - User Claim doc: http://www.imsglobal.org/spec/lti/v1p3/#user-identity-claims - Roles Claim doc: http://www.imsglobal.org/spec/lti/v1p3/#roles-claim + Set user data/roles and convert to IMS LTI 1.3 Standard Claims + + LTI 1.3 User Claims: + The platform includes these claims in the LTI launch JWT to tell the tool + about the user launching the tool. + + User Identity Claims: + - sub: Locally stable opaque user identifier + * Does NOT need to be user's actual username + * Must be stable (same user always gets same sub value) + * Example: "user-123" (platform-specific ID) + * Tool uses this to correlate requests from same user + + Roles Claim: + - https://purl.imsglobal.org/spec/lti/claim/roles + - Array of URIs representing user's roles in the context + - Role examples: + * http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor + * http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student + * http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator + - Tool uses roles to determine what user can do (e.g., grading UI for instructors only) + + Optional Identity Claims: + - name: Full name of the user + - email: Email address (if available and privacy rules allow) + - preferred_username: Username if appropriate + + Privacy Considerations: + - Platform should only include claims that: + 1. User has agreed to share + 2. Tool legitimately needs to function + 3. Comply with institutional privacy policies + - PII (Personally Identifiable Information) should be minimal and necessary + + Usage by Tool: + 1. Tool receives JWT with these claims + 2. Tool validates JWT signature (verify platform signed it) + 3. Tool extracts user claims to identify user + 4. Tool can check roles to enable/disable features + 5. Tool registers/creates user account if first time + + Reference (User Identity Claims): + - https://www.imsglobal.org/spec/lti/v1p3/#user-identity-claims + - https://www.imsglobal.org/spec/lti/v1p3/#roles-claim + - https://www.imsglobal.org/spec/lti/v1p3/#core-recommended-claims + + Parameters: + user_id: Unique, stable user identifier (sub claim) + lis_roles: List of role URIs from IMS LIS vocabulary + full_name: User's full name (optional) + email_address: User's email address (optional, privacy-sensitive) + preferred_username: User's preferred username (optional) """ self.lti_claim_user_data = { # User identity claims @@ -296,11 +378,86 @@ def validate_preflight_response( self, preflight_response: t.Dict[str, t.Any] ) -> None: """ - Validates a preflight response to be used in a launch request - - Raises ValueError in case of validation failure - - :param response: the preflight response to be validated + Validate LTI launch preflight response from platform's authorization endpoint + + OpenID Connect Authorization Endpoint Response: + When the platform's OIDC authorization endpoint processes the login request, + it returns parameters that the tool must validate before granting access. + + Validation Checks: + ================== + + 1. response_type = "id_token" + - OIDC Implicit Flow: Request JWT token directly, no authorization code exchange + - Alternative flows use "code" (Authorization Code Flow) + - LTI 1.3 uses "id_token" response type + + 2. scope = "openid" + - OIDC scope indicating OpenID Connect authentication + - (Not the same as OAuth 2.0 scope for API permissions) + - Tell authorization server we want OIDC identity token + + 3. nonce present and valid + - Random value generated by tool before redirect to platform + - Platform must include exact same nonce in response + - Prevents authorization code/token interception attacks + - Replay attack protection (See OIDC spec) + - Example: Tool generates nonce='abc123', validates response has nonce='abc123' + + 4. state present and valid + - Random value generated by tool before redirect to platform + - Platform must include exact same state in response + - Prevents Cross-Site Request Forgery (CSRF) attacks + - Example attack prevented: Attacker tricks user into visiting evil.com which + sends them to wrong authorization endpoint, tries to get them logged in there + + 5. redirect_uri validation + - Must match one of tool's pre-registered redirect URIs + - HTTPS REQUIRED in production + - Prevents open redirect attacks + - Ensures response goes only to legitimate tool endpoint + - Allows http://localhost for local development + - Allows http://127.0.0.1 for local development + - Allows http://::1 for local IPv6 localhost development + + 6. client_id validation + - Must match tool's registered client_id at platform + - Ensures response is for correct tool instance + + HTTPS Requirement (Production Security): + ========================================= + - All redirect_uri values must use HTTPS + - Prevents man-in-the-middle attacks + - Attackers cannot intercept tokens in transit + - Exceptions: localhost addresses for development/testing + + Token Flow After Validation: + ============================= + After these validations pass, the tool will: + 1. Extract id_token from response parameters + 2. Validate JWT signature (verify platform signed it) + 3. Verify all claims in id_token + 4. Extract user/context information from claims + 5. Grant access to user + + Security Considerations: + ======================== + - Validate AGAINST a whitelist of known-good values + - Never trust input parameters directly + - HTTPS encryption prevents token interception + - JWT signature proves platform created this response + + Reference: + - OIDC Implicit Flow: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlow + - OIDC Nonce: https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes + - OAuth 2.0 CSRF: https://tools.ietf.org/html/rfc6749#section-10.12 + - LTI 1.3 Preflight Response Validation: https://www.imsglobal.org/spec/lti/v1p3/#authorization + + Parameters: + preflight_response: Dict with response parameters from authorization endpoint + + Raises: + PreflightRequestValidationException: If any validation fails """ assert self._registration diff --git a/lti1p3platform/nrps.py b/lti1p3platform/nrps.py index 17c0f5f..3699515 100644 --- a/lti1p3platform/nrps.py +++ b/lti1p3platform/nrps.py @@ -1,5 +1,49 @@ """ -LTI Names and Role Provisioning Service implementation +LTI 1.3 Advantage Services - Names and Role Provisioning Service (NRPS) + +Names and Role Provisioning Service (NRPS): +=========================================== +NRPS allows tools to fetch a list of users (students, instructors) in a course +and their roles, without needing separate user management integrations like LDAP/Active Directory. + +Real-World Example: +- Grade book tool wants to display all students in a course +- Rather than syncing via separate directory service +- Tool calls platform's NRPS API: /memberships?context_id=course-123 +- Platform returns list of users with roles in that course +- Tool can display students, instructors, TAs, etc. + +Use Cases: +- Roster synchronization: Get current class list +- Permission management: Know which users are instructors vs students +- Context-aware features: Show appropriate features based on role +- Gradebook setup: Know which students to create grade columns for +- Bulk operations: Process grades for all students at once + +API Details: +- Context Membership Service: Get members (users) in a course/context + * Endpoint: /memberships?context_id=&role=&limit=&offset= + * Returns: List of users with their roles in the context + * Pagination supported via limit/offset + +Scopes: +- contextmembership.readonly: Can only read/fetch the member list +- (No write scope - NRPS is read-only by design) + +Security: +- Tool authenticates with access_token (OAuth 2.0 Bearer) +- Platform controls who can access roster data +- Scope-based access control +- Can filter by role (students only, instructors only, etc.) +- Data includes: user_id, roles, names (based on privacy settings) + +Privacy Considerations: +- Tools should only request scope when needed +- Platform may limit PII (Personally Identifiable Information) shared +- Instructor may control if students can see class roster +- Names/emails may be redacted based on privacy policies + +Reference: https://www.imsglobal.org/spec/lti-nrps/v2p0/ """ from __future__ import annotations @@ -8,26 +52,90 @@ class LtiNrps: """ - LTI NRPS Consumer - - Implements Names and Role Provisioning Services and ties - them in with the LTI Consumer. - - Available services: - * Context Membership Service - - Reference: https://www.imsglobal.org/spec/lti-nrps/v2p0#overview + LTI 1.3 Advantage Services - Names and Role Provisioning Service Configuration + + NRPS (Names and Role Provisioning Service) Overview: + ==================================================== + + Purpose: + - Enables tools to query course membership information from the platform + - Alternative to separate directory integrations (LDAP, AD, etc.) + - Dynamically fetches roster without pre-synced data + + Context Membership Service: + - Provides list of users enrolled in a course/context + - Includes user identifiers and role information + - Supports pagination for large courses (thousands of students) + + Platform Role Examples: + - http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor + - http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student + - http://purl.imsglobal.org/vocab/lis/v2/institution/person#Learner + - http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator + + Tool Security Considerations: + - Cache data locally if possible (reduce API calls) + - Respect privacy settings and role filtering + - Use appropriate scopes + - Handle pagination for large courses + - Handle errors gracefully if roster API unavailable + + This class configures NRPS access for a tool integration. + + Reference: https://www.imsglobal.org/spec/lti-nrps/v2p0/ """ def __init__( self, context_memberships_url: str, ): + """ + Initialize NRPS configuration for a tool integration + + Parameters: + context_memberships_url: Platform's API endpoint for roster/memberships + - Format: "https://platform.edu/lti/nrps/memberships" + - Tool makes GET requests to query course members + - URL provided by platform in the launch message + - Actual member retrieval happens when tool calls this API + """ self.context_memberships_url = context_memberships_url def get_available_scopes(self) -> t.List[str]: """ - Retrieves list of available token scopes in this instance. + Retrieves list of available OAuth 2.0 scopes for NRPS + + OAuth 2.0 Scopes for NRPS: + + - https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly + * Access permission for Context Membership Service + * Read-only: can only fetch roster data + * Cannot modify/delete members (NRPS is read-only) + * Included in access_token if enabled + + Scope Usage: + - Platform includes this scope in access_token if NRPS enabled for tool + - Tool includes this scope in token request when calling APIs + - Platform validates token has required scope before returning roster + + No 'write' Scope: + - NRPS is intentionally read-only + - Tools cannot add/remove/modify course members via NRPS + - Member management handled through platform UI + - Prevents accidental/malicious roster changes from tools + + Typical Scope in Access Token (JWT): + { + "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem + https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly", + ... + } + + Returns: + List containing the NRPS contextmembership.readonly scope URI + + Reference: + - Scope specification: https://www.imsglobal.org/spec/lti-nrps/v2p0/#scopes """ return [ @@ -36,7 +144,74 @@ def get_available_scopes(self) -> t.List[str]: def get_lti_nrps_launch_claim(self) -> t.Dict[str, t.Any]: """ - Returns LTI NRPS Claim to be injected in the LTI launch message. + Generate NRPS Launch Claim for LTI message + + This claim is included in the LTI launch message to tell the tool + where to call to fetch the course roster (member list). + + Claim Structure: + { + "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { + "context_memberships_url": "", + "service_versions": ["2.0"] + } + } + + Usage: + 1. Tool receives launch message with NRPS claim + 2. Tool extracts context_memberships_url from claim + 3. Tool calls API: GET context_memberships_url?context_id= + 4. Platform returns JSON list of members with roles + 5. Tool processes roster data for its features + + API Call Example: + GET /lti/nrps/memberships?context_id=course-123&limit=50&offset=0 + With Authorization: Bearer + + API Response Example: + { + "context": { + "id": "course-123", + "label": "Biology 101" + }, + "members": [ + { + "status": "Active", + "name": "Jane Instructor", + "picture": "https://...", + "given_name": "Jane", + "family_name": "Instructor", + "email": "jane@university.edu", + "user_id": "user-456", + "roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor" + ] + }, + { + "status": "Active", + "name": "John Student", + "user_id": "user-789", + "roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student" + ] + } + ], + "pageNumber": 1, + "pageSize": 50, + "pageCount": 1 + } + + Pagination: + - pageNumber: Current page (1-indexed) + - pageSize: Number of members per page + - pageCount: Total number of pages + - Control pagination via ?limit=50&offset=0 parameters + + Returns: + dict: The namesroleservice claim to inject into LTI launch message + + Reference: + - Context Membership Service: https://www.imsglobal.org/spec/lti-nrps/v2p0/#context-memberships-service """ return { diff --git a/lti1p3platform/oidc_login.py b/lti1p3platform/oidc_login.py index 8bb2100..d0794d2 100644 --- a/lti1p3platform/oidc_login.py +++ b/lti1p3platform/oidc_login.py @@ -13,6 +13,51 @@ class OIDCLoginAbstract(ABC): + """ + Abstract base class for OIDC (OpenID Connect) Login Initiation + + LTI 1.3 Launch Flow Overview (Step 1: OIDC Login Initiation): + ============================================================ + + The LTI 1.3 specification uses OpenID Connect 3rd-Party-Initiated Login. + This is how the launch process begins. + + Flow: + 1. User visits platform, clicks "Launch Tool" button + 2. Platform initiates OIDC login by redirecting to tool's OIDC login endpoint + GET /tool/login?iss=&target_link_uri=&login_hint=&... + 3. Tool receives login request, validates parameters + 4. Tool redirects to platform's OIDC authorization endpoint + GET /platform/auth?client_id=&redirect_uri=&response_type=id_token&... + 5. Platform authenticates user (usually already authenticated, quick consent screen) + 6. Platform redirects to tool's callback with id_token (signed JWT with user/context claims) + POST /tool/callback with id_token=&state= + 7. Tool validates id_token JWT signature and claims + 8. Tool grants access to user with appropriate context + + This class handles Step 2 (prepare the login request) and Step 3 (initiate redirect). + + OpenID Connect 3rd-Party-Initiated Login Details: + - 'iss' (Issuer): Identifies the platform doing the login + - 'client_id' (optional): If multiple registrations, tells tool which one to use + - 'login_hint': Helps tool identify which user to prompt for (can be opaque platform identifier) + - 'lti_message_hint': Identifies the message/resource being launched + - 'target_link_uri': Where tool should take user after launch (platform's tool URL) + - 'lti_deployment_id' (optional): If issuer has multiple deployments + + Security: + - HTTPS only (production) + - State/nonce parameters prevent CSRF attacks + - ID Token JWT must be validated before granting access + + References: + - OpenID Connect Core (3rd-Party-Initiated): + https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + - LTI 1.3 Deep Linking: + https://www.imsglobal.org/spec/lti/v1p3/#third-party-initiated-login + - LTI Deep Linking Launch: + https://www.imsglobal.org/spec/lti-dl/v2p0/#lifecycle + """ _request = None _platform_config = None _registration = None # type: Registration @@ -57,22 +102,89 @@ def get_launch_url(self) -> t.Optional[str]: def prepare_preflight_url(self, user_id: str) -> str: """ - Prepare OIDC preflight url - - - iss: required, the issuer identifier identifying the learning platform - - target_link_uri: required, the actual end point that should be - executed at the end of the OIDC authentication flow - - lti_message_hint: required, this is an LTI specific parameter identifying - the actual message to be executed. For example it may be the resource link id - when the message is a resource link request. - - login_hint: required, a platform opaque identifier identifying the user to login - - client_id: optional, specifies the client id for the authorization server that - should be used to authorize the subsequent LTI message request. This allows for - a platform to support multiple registrations from a single issuer, - without relying on the initiate_login_uri as a key - - lti_deployment_id: optional, if included, MUST contain the same deployment id - that would be passed in the https://purl.imsglobal.org/spec/lti/claim/deployment_id - claim for the subsequent LTI message launch + Prepare OIDC preflight url for 3rd-party-initiated login + + This creates the URL that redirects the user to the platform's OIDC login endpoint. + This is Step 2 in the LTI launch flow. + + URL Parameters: + =============== + + REQUIRED: + - iss: Issuer (platform) identifier + * Uniquely identifies learning platform + * Example: "https://canvas.instructure.com" or "https://moodle.example.edu" + * Tool uses this to look up platform configuration + + - target_link_uri: The tool URL to launch + * Where user should be taken after successful launch + * Should match the tool's registered URLs + * Must use HTTPS in production + * Example: "https://tool.example.com/launch/resource/123" + + - lti_message_hint: Identifier for the message/resource + * Helps platform remember which content triggered the launch + * Opaque to platform, meaningful to tool + * Platform generated value (e.g., "resource-link-id-456") + * Prevents mix-ups if user trying to launch different content + + - login_hint: User identifier + * Helps platform identify user without additional authentication + * Often opaque ID assigned by platform (not email/username) + * Used to prevent "username confusion" attacks + * Format depends on platform (could be "user:12345" or UUID) + + OPTIONAL: + - client_id: Platform's OAuth client ID at tool + * Used when tool has multiple registrations from same platform + * Allows tool to select correct configuration + * If not provided, tool uses first registration for issuer + + - lti_deployment_id: Deployment identifier + * Used when single platform instance supports multiple orgs + * Helps tool route to correct customer/tenant config + * Example: university with multiple campuses + + CSRF Protection: + - Platform will add 'state' parameter before redirect to authorization endpoint + - Tool receives state back in callback, validates it matches + - Prevents Cross-Site Request Forgery attacks + - Example attack prevented: attacker tricks user into launching tool from wrong platform + + Example Flow: + 1. User at https://myuniversity.edu clicks "Launch Tool" + 2. Platform creates login URL: + https://tool.example.com/login?iss=https://myuniversity.edu&client_id=uni-client-123& + target_link_uri=https://myuniversity.edu/courses/101/tool&login_hint=user-987& + lti_message_hint=resource-link-456<i_deployment_id=university-main + 3. Platform redirects browser to this URL + 4. Tool receives these parameters, validates platform is registered + 5. Tool generates state + nonce for security + 6. Tool redirects to: https://myuniversity.edu/auth?client_id=uni-client-123&...&state=... + 7. University authenticates the user (already logged in? quick redirect) + 8. University redirects back to tool with id_token containing user/context claims + + Security Considerations: + - Validate iss is registered before processing + - Validate target_link_uri matches platform's list of allowed URLs + - Check that user (from login_hint) is allowed to access resource (lti_message_hint) + - Use HTTPS to prevent credential theft + - Validate state parameter when receiving callback + + Reference: + - OpenID Connect 3rd-Party-Initiated Login: + https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + - LTI 1.3 Step 1 (3rd-Party-Initiated Login): + https://www.imsglobal.org/spec/lti/v1p3/#step-1-third-party-initiated-login + + Parameters: + user_id: Opaque user identifier from platform + + Returns: + str: The complete preflight URL (includes iss, client_id, target_link_uri, etc.) + + Raises: + PreflightRequestValidationException: If required fields not configured """ launch_url = self.get_launch_url() try: @@ -121,7 +233,29 @@ def get_redirect(self, url: str) -> t.Any: def initiate_login(self, user_id: str) -> t.Any: """ - Initiate OIDC login + Initiate OIDC login by redirecting to platform's OIDC login endpoint + + This is the main entry point for starting an LTI launch. + + Process: + 1. Prepare login URL with all required parameters (iss, client_id, target_link_uri, etc.) + 2. Redirect user's browser to platform's login endpoint + + The platform's login endpoint will: + 1. Validate all parameters + 2. Authenticate user if needed + 3. Generate state + nonce for CSRF/replay protection + 4. Redirect to authorization endpoint + 5. Eventually redirect back to tool's callback with id_token + + This is an abstract method; implementing frameworks (Django, FastAPI, Flask) + override this to return appropriate HTTP redirects. + + Returns: + HTTP redirect response (specific to framework) + + Raises: + PreflightRequestValidationException: If configuration validation fails """ # prepare preflight url preflight_url = self.prepare_preflight_url(user_id) diff --git a/lti1p3platform/registration.py b/lti1p3platform/registration.py index 9822b03..0987ef8 100644 --- a/lti1p3platform/registration.py +++ b/lti1p3platform/registration.py @@ -18,6 +18,33 @@ class Registration: """ Platform registration data storage class + + LTI 1.3 Registration Data Model: + This class stores the configuration needed for secure OAuth 2.0 / OIDC communication + between a learning platform and an external tool/service. + + Key Concepts: + - Issuer (iss): Unique identifier for the learning platform + - Client ID: Platform's identifier issued by the tool (used in OAuth 2.0 flows) + - Deployment ID: Organization or account identifier; allows multiple deployments per issuer + + Public Key Exchange: + - Platform Public Key: Used by tool to verify platform-signed messages + - Tool Public Key Set URL: Tool's JWKS endpoint; platform fetches these to verify tool's signatures + + OAuth 2.0 / OIDC Endpoints: + - OIDC Login URL: Tool's endpoint for initiating OIDC Login (3rd-party-initiated) + - Access Token URL: Platform's token endpoint where tool requests access tokens + * Used in OAuth 2.0 Client Credentials grant + * Tool sends JWT assertion signed with its private key + * Platform returns access_token used for AGS/NRPS API calls + + Launch URLs: + - Launch URL: Endpoint where platform sends LTI resource link messages + - Deep Link Launch URL: Endpoint where platform sends deep linking requests + + Security: All data should be stored securely; private keys must be protected. + Reference: https://www.imsglobal.org/spec/lti/v1p3/ """ _iss = None @@ -35,46 +62,158 @@ class Registration: def get_iss(self) -> t.Optional[str]: """ - Get issuer + Get issuer (Identifier for the platform) + + In LTI 1.3 / OIDC terminology: + - 'iss' is the issuer claim in JWT tokens + - Uniquely identifies the learning platform instance + - Example: "https://platform.example.com" + - Used in token audience validation and claims """ return self._iss def get_launch_url(self) -> t.Optional[str]: """ - Get tool provider launch url + Get tool provider launch url (Resource Link Launch Request endpoint) + + LTI 1.3 Launch Flow: + 1. Platform user clicks "launch tool" link on platform + 2. Platform sends signed LTI Message JWT to this URL + 3. Tool receives message, validates signature/claims, renders tool interface + + Message Contents (JWT claims): + - https://purl.imsglobal.org/spec/lti/claim/resource_link (resource context) + - https://purl.imsglobal.org/spec/lti/claim/roles (user roles) + - https://purl.imsglobal.org/spec/lti/claim/user_id (user identifier) + - And many other context claims + + Reference: https://www.imsglobal.org/spec/lti/v1p3/#resource-link-request """ return self._launch_url def get_client_id(self) -> t.Optional[str]: """ - Get platform client id - The client_id is created by the platform and used to identify itself to the tool provider + Get platform client id (OAuth 2.0 Client Identifier) + + The client_id is assigned by the tool to the platform during registration. + It identifies the platform in OAuth 2.0 / OIDC flows: + + Usage sites: + 1. Tool's OIDC Login endpoint: + - User redirects to tool /login?iss=&client_id=&... + - Client ID tells tool "this is from the platform I spoke to" + + 2. Platform's Token endpoint: + - Tool sends JWT assertion with claim: iss= + - Platform verifies this matches registered client_id + + 3. OAuth 2.0 JWT Bearer Assertion: + - Both 'iss' and 'sub' claim must equal client_id + - This ties the assertion to the specific platform + + Reference (OAuth 2.0 Client Credentials): https://tools.ietf.org/html/rfc6749#section-4.4 """ return self._client_id def get_deployment_id(self) -> t.Optional[str]: """ - Get deployment id - The deployment id is created by the platform and used as an account identifier + Get deployment id (Organization / Tenant Identifier) + + The deployment_id allows a single issuer (learning platform instance) + to support multiple organizations/institutions/accounts. + + Examples: + - SaaS platform with multiple school districts: each has its own deployment_id + - University system with multiple campuses: each campus has deployment_id + + Usage: + - Included in LTI launch message as claim: + 'https://purl.imsglobal.org/spec/lti/claim/deployment_id' + - Tool can use this to determine which customer sent the message + - Enables multi-tenant tool deployments + + Reference: https://www.imsglobal.org/spec/lti/v1p3/#tool-and-platform-identifiers """ return self._deployment_id def get_oidc_login_url(self) -> t.Optional[str]: """ - Get OIDC login url - A url used by the platform to initiate LTI launch + Get OIDC login url (Tool's OIDC Login Initiation Endpoint) + + OIDC 3rd-Party-Initiated Login Flow (OpenID Connect): + This is how the LTI launch begins - tool initiates OIDC login. + + Flow: + 1. Platform URL called: /oidc_login?iss=&... + 2. Platform responds with redirect to this URL: + /tool_login_endpoint?iss=&client_id=&... + 3. Tool's OIDC Login endpoint: + - Validates parameters + - Checks if platform (iss) is registered + - Generates state/nonce for CSRF protection + - Redirects user to platform authorization endpoint + + This URL is called by the platform during OIDC login initiation. + Platform calls this endpoint as part of OAuth 2.0 + OIDC integration. + + Reference (3rd-Party-Initiated Login): + - https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + - https://www.imsglobal.org/spec/lti/v1p3/#step-1-third-party-initiated-login """ return self._oidc_login_url def get_platform_public_key(self) -> t.Optional[str]: """ - Get Platform public key in PEM format + Get Platform public key in PEM format (RS256 public key) + + Key Exchange in LTI 1.3: + - Platform has an RSA key pair (public + private) + - Platform shares its PUBLIC key with tool during registration + - Tool stores platform's public key + - When platform sends messages/tokens, it signs with PRIVATE key + - Tool verifies signature with platform's PUBLIC key + + This public key is used by the tool to verify: + 1. ID Tokens (OIDC messages) from platform + 2. JWT assertions in authorization server requests + 3. Any messages signed by the platform + + Security: Only the public key is shared. Private key never leaves platform. + + Format: PEM-encoded RSA public key (typically 2048 or 4096 bit) + Algorithm: RS256 (RSA with SHA-256) + + Reference: https://www.imsglobal.org/spec/lti/v1p3/#platform-keyset """ return self._platform_public_key def get_access_token_url(self) -> t.Optional[str]: """ - Get OAuth 2 access token URL (authorization server audience) + Get OAuth 2 access token URL (Token Endpoint / Authorization Server Endpoint) + + This is the platform's token endpoint where tool requests access tokens. + + OAuth 2.0 Client Credentials Grant with JWT Bearer Assertion: + 1. Tool wants to call platform APIs (e.g., submit grades via AGS) + 2. Tool creates JWT assertion signed with its private key + 3. Tool sends: POST /token + - grant_type=client_credentials + - client_assertion= + - client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer + 4. Platform validates JWT, returns access_token + 5. Tool uses access_token to call platform APIs + + Audience Validation: + - The JWT assertion must include 'aud' claim = this access_token_url + - Prevents assertionintended for endpoint A from being misused at endpoint B + - Security mechanism: "this token was created specifically for this endpoint" + + Format: Full HTTPS URL to platform's token endpoint + Example: "https://platform.example.com/lti/token" + + Reference: + - Token endpoint: https://www.imsglobal.org/spec/lti/v1p3/#access-token-endpoint + - 1EdTech Security Framework: https://www.imsglobal.org/spec/security/v1p0/#token_request """ return self._access_token_url From 77660ba6d78ea0f38e84bbe17e04c02e3e083156 Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Fri, 3 Apr 2026 20:59:20 +1100 Subject: [PATCH 3/9] cached used jti, nonce --- examples/django_platform/README.md | 10 +- .../django_platform/django_platform/views.py | 52 +++++++++- lti1p3platform/ltiplatform.py | 99 ++++++++++++++----- tests/platform_config.py | 26 ++++- tests/test_platform_conf.py | 6 +- 5 files changed, 156 insertions(+), 37 deletions(-) diff --git a/examples/django_platform/README.md b/examples/django_platform/README.md index a2698c4..aacda6a 100644 --- a/examples/django_platform/README.md +++ b/examples/django_platform/README.md @@ -22,8 +22,8 @@ You also need to add platform config to the tool config file [game.json](https:/ "client_id": "12345", "auth_login_url": "http://127.0.0.1:9002/authorization", "auth_token_url": "http://127.0.0.1:9002/access_token", - "auth_audience": null, - "key_set_url": "http://127.0.0.1:9002/jwks", + "auth_audience": "http://127.0.0.1:9002/access_token", + "key_set_url": "https://127.0.0.1:9002/jwks", "key_set": null, "private_key_file": "private.key", "public_key_file": "public.key", @@ -31,6 +31,12 @@ You also need to add platform config to the tool config file [game.json](https:/ }] } +Security note for recent validation updates: + +- `auth_audience` must match the platform access token endpoint. +- Tool key set URLs must be HTTPS (`https://`) to pass JWKS URL validation. +- If you run locally without TLS, use a local HTTPS proxy/tunnel for the JWKS endpoint. + Now there is game example tool you can launch into on the port 9001 which is already set up in `platform.json`: Initial Login URL: http://127.0.0.1:9001/login diff --git a/examples/django_platform/django_platform/views.py b/examples/django_platform/django_platform/views.py index fcb1b61..85917f7 100644 --- a/examples/django_platform/django_platform/views.py +++ b/examples/django_platform/django_platform/views.py @@ -25,6 +25,51 @@ class LTIPlatformConf(LTI1P3PlatformConfAbstract): + """ + Concrete platform configuration for the example Django app. + + Cache backend + ------------- + Replay-detection (JTI and nonce) is backed by Django's cache framework + (``django.core.cache.cache``) so that entries are shared across + processes and survive restarts. Configure the backend in settings.py; + the default LocMemCache works for local development. + + For production, point ``CACHES`` at Redis or Memcached:: + + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": "redis://127.0.0.1:6379", + } + } + """ + + # ------------------------------------------------------------------ # + # Replay-detection cache (implements LTI1P3PlatformConfAbstract API) # + # ------------------------------------------------------------------ # + + def cache_get(self, key: str) -> t.Optional[int]: + """ + Look up a replay-detection entry via Django's cache framework. + Returns the stored expiration timestamp, or None if absent/expired. + """ + from django.core.cache import cache # pylint: disable=import-outside-toplevel + + return cache.get(key) # type: ignore[return-value] + + def cache_set(self, key: str, exp: int) -> None: + """ + Persist a replay-detection entry via Django's cache framework. + TTL is derived from the token expiration so entries are evicted + automatically when the token would already be invalid. + """ + import time as _time # pylint: disable=import-outside-toplevel + from django.core.cache import cache # pylint: disable=import-outside-toplevel + + ttl = max(1, exp - int(_time.time())) + cache.set(key, exp, timeout=ttl) + def init_platform_config(self, platform_settings: t.Dict[str, t.Any]) -> None: """ register platform configuration @@ -42,9 +87,10 @@ def init_platform_config(self, platform_settings: t.Dict[str, t.Any]) -> None: .set_platform_private_key(platform_settings["private_key"]) ) - access_token_url = platform_settings.get("access_token_url") - if access_token_url: - registration.set_access_token_url(access_token_url) + access_token_url = platform_settings.get("access_token_url") or get_url( + reverse("access-token") + ) + registration.set_access_token_url(access_token_url) self._registration = registration diff --git a/lti1p3platform/ltiplatform.py b/lti1p3platform/ltiplatform.py index a80fa94..2bd3c86 100644 --- a/lti1p3platform/ltiplatform.py +++ b/lti1p3platform/ltiplatform.py @@ -82,12 +82,6 @@ class LTI1P3PlatformConfAbstract(ABC): def __init__(self, **kwargs: t.Any) -> None: self._jwt: t.Dict[str, t.Any] = {} - # Cache for tracking used JTI (JWT ID) values to prevent token replay attacks - # Maps JTI -> expiration timestamp (used for cleanup of expired entries) - self._used_tool_jtis: t.Dict[str, int] = {} - # Cache for tracking used nonce values to prevent token replay attacks - # Maps nonce -> expiration timestamp (used for cleanup of expired entries) - self._used_tool_nonces: t.Dict[str, int] = {} self.init_platform_config(**kwargs) @@ -95,6 +89,72 @@ def __init__(self, **kwargs: t.Any) -> None: def init_platform_config(self, **kwargs: t.Any) -> t.Any: pass + @abstractmethod + def cache_get(self, key: str) -> t.Optional[int]: + """ + Retrieve a replay-detection entry from the cache. + + Keying convention: + - JTI replay checks use key ``"jti:"`` + - Nonce replay checks use key ``"nonce:"`` + + Returns: + The stored expiration timestamp (UNIX seconds) if the entry + exists and has not yet expired, or ``None`` otherwise. + + Production implementations: + + **Redis**:: + + def cache_get(self, key): + val = redis_client.get(key) + return int(val) if val is not None else None + + **Django cache**:: + + def cache_get(self, key): + return cache.get(key) + + **Memcached**:: + + def cache_get(self, key): + return mc.get(key) + """ + raise NotImplementedError() + + @abstractmethod + def cache_set(self, key: str, exp: int) -> None: + """ + Store a replay-detection entry in the cache. + + Parameters: + key: Namespaced cache key (e.g. ``"jti:abc123"`` or ``"nonce:xyz789"``). + exp: UNIX timestamp at which the associated token/nonce expires. + Implementations should derive the TTL from this value so + entries are evicted automatically. + + Production implementations: + + **Redis**:: + + def cache_set(self, key, exp): + ttl = max(1, exp - int(time.time())) + redis_client.set(key, exp, ex=ttl) + + **Django cache**:: + + def cache_set(self, key, exp): + ttl = max(1, exp - int(time.time())) + cache.set(key, exp, timeout=ttl) + + **Memcached**:: + + def cache_set(self, key, exp): + ttl = max(1, exp - int(time.time())) + mc.set(key, exp, time=ttl) + """ + raise NotImplementedError() + @abstractmethod def get_registration_by_params( self, @@ -361,17 +421,10 @@ def _is_token_replay(self, jti: str, exp: int) -> bool: Returns: bool: True if this is a replay (JTI previously seen), False if unique (first time) """ - now = int(time.time()) - - # Best-effort cleanup of expired JTIs. - expired_jtis = [key for key, expires in self._used_tool_jtis.items() if expires < now] - for key in expired_jtis: - del self._used_tool_jtis[key] - - if jti in self._used_tool_jtis: + cache_key = f"jti:{jti}" + if self.cache_get(cache_key) is not None: return True - - self._used_tool_jtis[jti] = exp + self.cache_set(cache_key, exp) return False def _is_nonce_replay(self, nonce: str, exp: int) -> bool: @@ -410,18 +463,10 @@ def _is_nonce_replay(self, nonce: str, exp: int) -> bool: Returns: bool: True if this is a replay (nonce previously seen), False if unique """ - now = int(time.time()) - - expired_nonces = [ - key for key, expires in self._used_tool_nonces.items() if expires < now - ] - for key in expired_nonces: - del self._used_tool_nonces[key] - - if nonce in self._used_tool_nonces: + cache_key = f"nonce:{nonce}" + if self.cache_get(cache_key) is not None: return True - - self._used_tool_nonces[nonce] = exp + self.cache_set(cache_key, exp) return False def _validate_tool_access_token_assertion( diff --git a/tests/platform_config.py b/tests/platform_config.py index 37f0672..fa66e2c 100644 --- a/tests/platform_config.py +++ b/tests/platform_config.py @@ -1,5 +1,7 @@ from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract from lti1p3platform.registration import Registration +import time +import typing as t RSA_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1du+3Vg1huBld4X7y8FS @@ -94,11 +96,31 @@ } -class TestPlatform(LTI1P3PlatformConfAbstract): +class PlatformConf(LTI1P3PlatformConfAbstract): """ - Test platform configuration + Test platform configuration. + + Cache backend: simple in-memory dict for unit test use only. + Not suitable for production (not shared across processes). """ + def __init__(self, **kwargs: t.Any) -> None: + # In-memory cache: key -> expiration timestamp + self._cache: t.Dict[str, int] = {} + super().__init__(**kwargs) + + def cache_get(self, key: str) -> t.Optional[int]: + """Return stored expiry, or None if absent/expired.""" + exp = self._cache.get(key) + if exp is None or exp < int(time.time()): + self._cache.pop(key, None) + return None + return exp + + def cache_set(self, key: str, exp: int) -> None: + """Store entry; will be evicted lazily on next cache_get.""" + self._cache[key] = exp + def init_platform_config(self, **kwargs) -> None: self._registration = ( Registration() diff --git a/tests/test_platform_conf.py b/tests/test_platform_conf.py index 4ead1cd..d0005e2 100644 --- a/tests/test_platform_conf.py +++ b/tests/test_platform_conf.py @@ -4,14 +4,14 @@ from lti1p3platform.registration import Registration from lti1p3platform.jwt_helper import jwt_encode -from .platform_config import TestPlatform, TOOL_PRIVATE_KEY_PEM, PLATFORM_CONFIG +from .platform_config import PlatformConf, TOOL_PRIVATE_KEY_PEM, PLATFORM_CONFIG def test_get_jwks(): """ Test get jwks """ - test_platform = TestPlatform() + test_platform = PlatformConf() jwks = test_platform.get_jwks() expected_jwks = { @@ -34,7 +34,7 @@ def test_get_access_tokens(): """ test get access token """ - test_platform = TestPlatform() + test_platform = PlatformConf() jwt_claims = { "iss": PLATFORM_CONFIG["client_id"], From d4247402d4ec47afd9cd1bffa4f1b43cc545f32b Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Fri, 3 Apr 2026 22:01:56 +1100 Subject: [PATCH 4/9] support Python 3.8 poetry version --- .github/workflows/commit-checks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/commit-checks.yml b/.github/workflows/commit-checks.yml index 775f6d9..63532ea 100644 --- a/.github/workflows/commit-checks.yml +++ b/.github/workflows/commit-checks.yml @@ -26,6 +26,7 @@ jobs: - name: Install Poetry uses: snok/install-poetry@v1 with: + version: 1.7.1 virtualenvs-create: true virtualenvs-in-project: true installer-parallel: true From a70edc5a9a6470c94201ed5f924dba9c2d49a4ba Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Fri, 3 Apr 2026 23:26:03 +1100 Subject: [PATCH 5/9] add more tests --- lti1p3platform/ltiplatform.py | 120 ++-- lti1p3platform/message_launch.py | 14 +- lti1p3platform/nrps.py | 3 +- lti1p3platform/registration.py | 71 +-- tests/platform_config.py | 5 +- tests/test_deep_linking.py | 77 +++ tests/test_ltiplatform_coverage.py | 843 +++++++++++++++++++++++++++++ tests/test_registration.py | 170 ++++++ 8 files changed, 1204 insertions(+), 99 deletions(-) create mode 100644 tests/test_deep_linking.py create mode 100644 tests/test_ltiplatform_coverage.py create mode 100644 tests/test_registration.py diff --git a/lti1p3platform/ltiplatform.py b/lti1p3platform/ltiplatform.py index 2bd3c86..cb89390 100644 --- a/lti1p3platform/ltiplatform.py +++ b/lti1p3platform/ltiplatform.py @@ -46,37 +46,37 @@ class AccessTokenResponse(TypedDict): class LTI1P3PlatformConfAbstract(ABC): """ LTI 1.3 Platform Data storage abstract class - + This class implements the Learning Tools Interoperability (LTI) 1.3 specification, which defines a standards-based approach for educational tools to integrate with learning platforms (e.g., LMS systems). - + LTI 1.3 Key Concepts: - Uses OAuth 2.0 authorization framework combined with OpenID Connect (OIDC) - Platform acts as the OAuth Authorization Server and OpenID Provider - Tool acts as the OAuth Client/Relying Party - Communication is secured via HTTPS and JWT (JSON Web Tokens) - + Security Architecture: - Platform and Tool exchange cryptographic keys via JWK Sets (JSON Web Key Sets) - All messages are signed JWTs using RS256 (RSA with SHA-256) - Audience ('aud') claim validates the token is intended for specific recipient - Nonce and JTI (JWT ID) claims prevent token replay attacks - All URLs must use HTTPS for production (except localhost for development) - + Key Flows: 1. OIDC Login Initiation: Platform redirects user to tool's OIDC login endpoint 2. Authorization: Tool routes back through platform's OIDC authorization endpoint 3. Token Validation: Platform validates signed ID token from tool 4. Message Launch: Platform sends signed LTI message with user/context claims 5. Service Calls: Tool calls platform APIs (AGS, NRPS) with signed access tokens - + References: - IMS LTI 1.3 Core: https://www.imsglobal.org/spec/lti/v1p3/ - IMS Security Framework: https://www.imsglobal.org/spec/security/v1p0/ - OpenID Connect Core: https://openid.net/specs/openid-connect-core-1_0.html """ - + _registration = None _accepted_deeplinking_types = LTI_DEEP_LINKING_ACCEPTED_TYPES @@ -183,12 +183,12 @@ def get_registration(self, **kwargs: t.Any) -> Registration: def get_jwks(self) -> JWKS: """ Get JWKS (JSON Web Key Set) from the platform - + The platform exposes its public keys via a JWK Set endpoint. Tools fetch these keys to validate signatures on messages from the platform. - + LTI 1.3 Spec Reference: https://www.imsglobal.org/spec/lti/v1p3/#platform-jwks - + Returns: JWKS: Dictionary with 'keys' list containing JWK objects """ @@ -199,35 +199,35 @@ def get_jwks(self) -> JWKS: def fetch_public_key(self, key_set_url: str) -> JWKS: """ Fetch tool's public key set from the provided URL - + Security Requirements (LTI 1.3 Spec & 1EdTech Security Framework): 1. URL MUST use HTTPS (https:// scheme required) - Prevents man-in-the-middle attacks - TLS 1.2+ provides encryption and authentication - + 2. No Private IP Addresses: - Reject private/RFC 1918 IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) - Prevents Server-Side Request Forgery (SSRF) attacks on internal infrastructure - Reject loopback/localhost addresses (127.0.0.1, ::1) - Reject link-local addresses (169.254.x.x) - Reject reserved/multicast addresses - + 3. Hostname Validation: - Reject literal hostname 'localhost' (bypass check) - Accept only fully-qualified domain names in production - + These restrictions prevent: - Attacks on internal services (databases, admin panels, cloud metadata endpoints) - Bypassing network security controls - + LTI 1.3 References: - Transport security: https://www.imsglobal.org/spec/lti/v1p3/#securing_web_services - 1EdTech Security Framework: https://www.imsglobal.org/spec/security/v1p0/ - + Raises: InvalidKeySetUrl: If URL doesn't meet security requirements LtiException: If network request fails or response is invalid JSON - + Returns: JWKS: The fetched JSON Web Key Set """ @@ -348,7 +348,7 @@ def tool_validate_and_decode( ) -> t.Dict[str, t.Any]: """ Validate and decode a JWT token from the tool - + LTI 1.3 JWT Validation Process: 1. Format Validation: JWT must have 3 parts separated by dots (header.payload.signature) 2. Signature Verification: Use tool's public key to validate the signature (RS256) @@ -358,22 +358,23 @@ def tool_validate_and_decode( 4. Additional claims verified by PyJWT: - 'exp' (expiration): Token must not be expired - 'iat' (issued at): Token must not be issued in the future (clock skew tolerance) - + Why Audience Verification is Critical: - Without audience verification, tokens created for one recipient could be misused - Example attack: Token meant for Platform A used to access Platform B - Audience claim ties the token to a specific platform instance - + OpenID Connect Spec Reference: - - ID Token validation: https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation - + - ID Token validation: + https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + Parameters: jwt_token_string: The JWT token to validate (format: header.payload.signature) audience: The expected audience value from 'aud' claim - + Returns: dict: Decoded JWT payload (claims) - + Raises: LtiException: If JWT format is invalid jwt.InvalidAudienceError: If audience doesn't match @@ -394,30 +395,31 @@ def tool_validate_and_decode( def _is_token_replay(self, jti: str, exp: int) -> bool: """ Detect and prevent JWT token replay attacks using JTI (JWT ID) - + Replay Attack Prevention Strategy: - Each JWT must include a 'jti' (JWT ID) claim - a unique identifier per token - Platform maintains an in-memory cache of all seen JTIs - If same JTI appears twice, it's a replay attack - Cache is cleaned of expired JTIs to prevent unbounded memory growth - + Why JTI is Required (LTI 1.3 Spec): - Prevents attacker from replaying intercepted tokens - Example: Attacker intercepts token with jti='abc123', tries to replay it - Platform recognizes jti='abc123' already used, rejects the replay - + Implementation Notes: - Expiration timestamp used for cache cleanup (best-effort) - Expired JTIs can be safely removed since they can't be valid anyway - Token signature validation + JTI check provides defense-in-depth - + 1EdTech Security Framework Reference: - - JWT Bearer Assertions: https://www.imsglobal.org/spec/security/v1p0/#making_authenticated_requests - + - JWT Bearer Assertions: + https://www.imsglobal.org/spec/security/v1p0/#making_authenticated_requests + Parameters: jti: The JWT ID claim value from the token exp: The expiration timestamp of the token (UNIX timestamp) - + Returns: bool: True if this is a replay (JTI previously seen), False if unique (first time) """ @@ -430,36 +432,36 @@ def _is_token_replay(self, jti: str, exp: int) -> bool: def _is_nonce_replay(self, nonce: str, exp: int) -> bool: """ Detect and prevent nonce replay attacks in deep linking responses - + Nonce (Number Used Once) Security: - Platform generates a random nonce for each deep linking request - Tool includes this nonce in the deep linking response - Platform validates that the received nonce matches what it sent - This is a Cross-Site Request Forgery (CSRF) protection mechanism - + Replay Attack Prevention: - Platform caches all nonces received from tools - If same nonce appears twice, it indicates a replay/reuse attempt - Cache is cleaned of expired nonces to prevent unbounded memory growth - Prevents attacker from reusing old deep linking messages - + Flow Example: 1. Platform generates nonce='xyz789' and sends to tool 2. Tool includes nonce='xyz789' in deep linking response 3. Platform caches nonce='xyz789' with expiration time 4. If nonce='xyz789' appears again, it's rejected as replay - + OpenID Connect Specification: - Nonce validation prevents authorization code/token replay attacks - See: https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes - + LTI Deep Linking Spec Reference: - Deep linking request/response: https://www.imsglobal.org/spec/lti-dl/v2p0/ - + Parameters: nonce: The nonce value from the deep linking response exp: The expiration timestamp of the response (UNIX timestamp) - + Returns: bool: True if this is a replay (nonce previously seen), False if unique """ @@ -474,24 +476,25 @@ def _validate_tool_access_token_assertion( ) -> None: """ Validate semantic correctness of tool's access token assertion (client credentials JWT) - + LTI 1.3 Tool Access Token Request Flow (OAuth 2.0 Client Credentials Grant): 1. Tool wants to call platform APIs (e.g., Grade Passback via AGS) 2. Tool creates a JWT assertion signed with its private key 3. Tool sends this assertion to platform's token endpoint 4. Platform validates the assertion and returns an access_token 5. Tool uses access_token to call platform APIs - + JWT Assertion Requirements (1EdTech Security Framework): - client_assertion_type: Must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - Claims that must be present: * iss (issuer): Client ID - identifies the tool making the request * sub (subject): Client ID - same as iss (tool is resource owner) - * aud (audience): Token endpoint URL - tool asserts this token is for platform's token endpoint + * aud (audience): Token endpoint URL - tool asserts this token is for + platform's token endpoint * iat (issued at): Timestamp when token was created * exp (expiration): Timestamp when token expires * jti (JWT ID): Random unique identifier, prevents token replay attacks - + Semantic Validation Rules: 1. Required Claims Check: All 6 required claims must be present 2. iss Must Equal Client ID: Ensures tool is who they claim to be @@ -502,20 +505,20 @@ def _validate_tool_access_token_assertion( - iat must not be in future (within 60 second clock skew tolerance) - exp must not be in past (token must not be expired) 6. JTI Uniqueness: No replay of same jti value (prevents token reuse) - + Security Implications: - These checks ensure tool authentication, token intent, and prevent replay attacks - Without these, unauthorized tools could impersonate real tools - Without audience validation, tokens could be misused at wrong endpoints - + 1EdTech Security Framework Reference: - https://www.imsglobal.org/spec/security/v1p0/#securing_web_services - https://www.imsglobal.org/spec/security/v1p0/#token_request - + Parameters: decoded_assertion: The decoded JWT payload with all claims expected_audience: The platform's token endpoint URL (should match aud claim) - + Raises: MissingRequiredClaim: If required header/claim is missing LtiException: If any semantic validation fails (iss, sub, aud, timing, jti) @@ -568,18 +571,19 @@ def get_access_token( ) -> AccessTokenResponse: """ Validate tool's token request and return JWT access token - + OAuth 2.0 Client Credentials Grant with JWT Bearer Assertion: This endpoint implements the OAuth 2.0 Client Credentials Grant Type using JWT Bearer Assertions as per the 1EdTech Security Framework. - + Request Flow: 1. Tool sends POST to /token with: - grant_type = "client_credentials" - client_assertion = signed JWT (tool's credentials) - client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - - scope = requested platform capabilities (e.g., "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly") - + - scope = requested platform capabilities + (e.g., "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly") + 2. Platform validates: - client_assertion_type is exactly the specified Bearer assertion type - client_assertion JWT has valid signature (signed by tool's private key) @@ -587,38 +591,40 @@ def get_access_token( - JWT claims are semantically correct (iss==client_id, aud includes token endpoint) - JWT hasn't been used before (jti not in cache) - JWT is not expired and not issued in future - + 3. Platform returns: - access_token: Signed JWT containing scope and other claims - token_type: "Bearer" - expires_in: seconds until token expires - scope: list of granted capabilities - + Token Usage: - Tool includes access_token in Authorization header when calling platform APIs - Example: "Authorization: Bearer " - Platform validates token signature and verifies requested scopes - + Security Benefits: - JWT Bearer Assertions: No shared secrets, only public/private key pairs - Prevents unauthorized tools from requesting tokens - Nonce-like mechanism (jti) prevents token reuse/replay attacks - Signed tokens can be verified offline without hitting a database - + Reference: - - 1EdTech Security Framework: https://www.imsglobal.org/spec/security/v1p0/#securing_web_services + - 1EdTech Security Framework: + https://www.imsglobal.org/spec/security/v1p0/#securing_web_services - LTI Advantage Services (AGS spec): https://www.imsglobal.org/spec/lti-ags/v2p0/ - + Parameters: token_request_data: Dict of form parameters: - grant_type: Must be "client_credentials" - client_assertion: Signed JWT from tool - - client_assertion_type: Must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + - client_assertion_type: Must be + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - scope: Space-separated list of requested scopes Returns: AccessTokenResponse: Dictionary with 'access_token', 'expires_in', 'token_type', 'scope' - + Raises: UnsupportedGrantType: If grant_type is not client_credentials LtiException: Various validation failures (see _validate_tool_access_token_assertion) diff --git a/lti1p3platform/message_launch.py b/lti1p3platform/message_launch.py index d64fb82..12da676 100644 --- a/lti1p3platform/message_launch.py +++ b/lti1p3platform/message_launch.py @@ -48,7 +48,8 @@ class MessageLaunchAbstract(ABC): - Resource: resource_link (content being launched) - Context: context (course/organization info) - Custom parameters: Custom data platform passes to tool - - Roles: User roles (e.g., 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor') + - Roles: User roles (e.g., + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor') HTTPS Requirement: - All Platform/Tool URLs must use HTTPS for production @@ -451,7 +452,8 @@ def validate_preflight_response( - OIDC Implicit Flow: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlow - OIDC Nonce: https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes - OAuth 2.0 CSRF: https://tools.ietf.org/html/rfc6749#section-10.12 - - LTI 1.3 Preflight Response Validation: https://www.imsglobal.org/spec/lti/v1p3/#authorization + - LTI 1.3 Preflight Response Validation: # pylint: disable=line-too-long + https://www.imsglobal.org/spec/lti/v1p3/#authorization Parameters: preflight_response: Dict with response parameters from authorization endpoint @@ -462,8 +464,12 @@ def validate_preflight_response( assert self._registration try: - assert preflight_response.get("response_type") == "id_token", "Invalid response type in preflight response" - assert preflight_response.get("scope") == "openid", "Invalid scope in preflight response" + assert ( + preflight_response.get("response_type") == "id_token" + ), "Invalid response type in preflight response" + assert ( + preflight_response.get("scope") == "openid" + ), "Invalid scope in preflight response" assert preflight_response.get("nonce") assert preflight_response.get("state") redirect_uri = preflight_response.get("redirect_uri") diff --git a/lti1p3platform/nrps.py b/lti1p3platform/nrps.py index 3699515..8b96a84 100644 --- a/lti1p3platform/nrps.py +++ b/lti1p3platform/nrps.py @@ -211,7 +211,8 @@ def get_lti_nrps_launch_claim(self) -> t.Dict[str, t.Any]: dict: The namesroleservice claim to inject into LTI launch message Reference: - - Context Membership Service: https://www.imsglobal.org/spec/lti-nrps/v2p0/#context-memberships-service + - Context Membership Service: # pylint: disable=line-too-long + https://www.imsglobal.org/spec/lti-nrps/v2p0/#context-memberships-service """ return { diff --git a/lti1p3platform/registration.py b/lti1p3platform/registration.py index 0987ef8..b29224d 100644 --- a/lti1p3platform/registration.py +++ b/lti1p3platform/registration.py @@ -18,31 +18,32 @@ class Registration: """ Platform registration data storage class - + LTI 1.3 Registration Data Model: This class stores the configuration needed for secure OAuth 2.0 / OIDC communication between a learning platform and an external tool/service. - + Key Concepts: - Issuer (iss): Unique identifier for the learning platform - Client ID: Platform's identifier issued by the tool (used in OAuth 2.0 flows) - Deployment ID: Organization or account identifier; allows multiple deployments per issuer - + Public Key Exchange: - Platform Public Key: Used by tool to verify platform-signed messages - - Tool Public Key Set URL: Tool's JWKS endpoint; platform fetches these to verify tool's signatures - + - Tool Public Key Set URL: Tool's JWKS endpoint; platform fetches these to + verify tool's signatures + OAuth 2.0 / OIDC Endpoints: - OIDC Login URL: Tool's endpoint for initiating OIDC Login (3rd-party-initiated) - Access Token URL: Platform's token endpoint where tool requests access tokens * Used in OAuth 2.0 Client Credentials grant * Tool sends JWT assertion signed with its private key * Platform returns access_token used for AGS/NRPS API calls - + Launch URLs: - Launch URL: Endpoint where platform sends LTI resource link messages - Deep Link Launch URL: Endpoint where platform sends deep linking requests - + Security: All data should be stored securely; private keys must be protected. Reference: https://www.imsglobal.org/spec/lti/v1p3/ """ @@ -63,7 +64,7 @@ class Registration: def get_iss(self) -> t.Optional[str]: """ Get issuer (Identifier for the platform) - + In LTI 1.3 / OIDC terminology: - 'iss' is the issuer claim in JWT tokens - Uniquely identifies the learning platform instance @@ -75,18 +76,18 @@ def get_iss(self) -> t.Optional[str]: def get_launch_url(self) -> t.Optional[str]: """ Get tool provider launch url (Resource Link Launch Request endpoint) - + LTI 1.3 Launch Flow: 1. Platform user clicks "launch tool" link on platform 2. Platform sends signed LTI Message JWT to this URL 3. Tool receives message, validates signature/claims, renders tool interface - + Message Contents (JWT claims): - https://purl.imsglobal.org/spec/lti/claim/resource_link (resource context) - https://purl.imsglobal.org/spec/lti/claim/roles (user roles) - https://purl.imsglobal.org/spec/lti/claim/user_id (user identifier) - And many other context claims - + Reference: https://www.imsglobal.org/spec/lti/v1p3/#resource-link-request """ return self._launch_url @@ -94,23 +95,23 @@ def get_launch_url(self) -> t.Optional[str]: def get_client_id(self) -> t.Optional[str]: """ Get platform client id (OAuth 2.0 Client Identifier) - + The client_id is assigned by the tool to the platform during registration. It identifies the platform in OAuth 2.0 / OIDC flows: - + Usage sites: 1. Tool's OIDC Login endpoint: - User redirects to tool /login?iss=&client_id=&... - Client ID tells tool "this is from the platform I spoke to" - + 2. Platform's Token endpoint: - Tool sends JWT assertion with claim: iss= - Platform verifies this matches registered client_id - + 3. OAuth 2.0 JWT Bearer Assertion: - Both 'iss' and 'sub' claim must equal client_id - This ties the assertion to the specific platform - + Reference (OAuth 2.0 Client Credentials): https://tools.ietf.org/html/rfc6749#section-4.4 """ return self._client_id @@ -118,20 +119,20 @@ def get_client_id(self) -> t.Optional[str]: def get_deployment_id(self) -> t.Optional[str]: """ Get deployment id (Organization / Tenant Identifier) - + The deployment_id allows a single issuer (learning platform instance) to support multiple organizations/institutions/accounts. - + Examples: - SaaS platform with multiple school districts: each has its own deployment_id - University system with multiple campuses: each campus has deployment_id - + Usage: - Included in LTI launch message as claim: 'https://purl.imsglobal.org/spec/lti/claim/deployment_id' - Tool can use this to determine which customer sent the message - Enables multi-tenant tool deployments - + Reference: https://www.imsglobal.org/spec/lti/v1p3/#tool-and-platform-identifiers """ return self._deployment_id @@ -139,10 +140,10 @@ def get_deployment_id(self) -> t.Optional[str]: def get_oidc_login_url(self) -> t.Optional[str]: """ Get OIDC login url (Tool's OIDC Login Initiation Endpoint) - + OIDC 3rd-Party-Initiated Login Flow (OpenID Connect): This is how the LTI launch begins - tool initiates OIDC login. - + Flow: 1. Platform URL called: /oidc_login?iss=&... 2. Platform responds with redirect to this URL: @@ -152,10 +153,10 @@ def get_oidc_login_url(self) -> t.Optional[str]: - Checks if platform (iss) is registered - Generates state/nonce for CSRF protection - Redirects user to platform authorization endpoint - + This URL is called by the platform during OIDC login initiation. Platform calls this endpoint as part of OAuth 2.0 + OIDC integration. - + Reference (3rd-Party-Initiated Login): - https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin - https://www.imsglobal.org/spec/lti/v1p3/#step-1-third-party-initiated-login @@ -165,24 +166,24 @@ def get_oidc_login_url(self) -> t.Optional[str]: def get_platform_public_key(self) -> t.Optional[str]: """ Get Platform public key in PEM format (RS256 public key) - + Key Exchange in LTI 1.3: - Platform has an RSA key pair (public + private) - Platform shares its PUBLIC key with tool during registration - Tool stores platform's public key - When platform sends messages/tokens, it signs with PRIVATE key - Tool verifies signature with platform's PUBLIC key - + This public key is used by the tool to verify: 1. ID Tokens (OIDC messages) from platform 2. JWT assertions in authorization server requests 3. Any messages signed by the platform - + Security: Only the public key is shared. Private key never leaves platform. - + Format: PEM-encoded RSA public key (typically 2048 or 4096 bit) Algorithm: RS256 (RSA with SHA-256) - + Reference: https://www.imsglobal.org/spec/lti/v1p3/#platform-keyset """ return self._platform_public_key @@ -190,9 +191,9 @@ def get_platform_public_key(self) -> t.Optional[str]: def get_access_token_url(self) -> t.Optional[str]: """ Get OAuth 2 access token URL (Token Endpoint / Authorization Server Endpoint) - + This is the platform's token endpoint where tool requests access tokens. - + OAuth 2.0 Client Credentials Grant with JWT Bearer Assertion: 1. Tool wants to call platform APIs (e.g., submit grades via AGS) 2. Tool creates JWT assertion signed with its private key @@ -202,15 +203,15 @@ def get_access_token_url(self) -> t.Optional[str]: - client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer 4. Platform validates JWT, returns access_token 5. Tool uses access_token to call platform APIs - + Audience Validation: - The JWT assertion must include 'aud' claim = this access_token_url - Prevents assertionintended for endpoint A from being misused at endpoint B - Security mechanism: "this token was created specifically for this endpoint" - + Format: Full HTTPS URL to platform's token endpoint Example: "https://platform.example.com/lti/token" - + Reference: - Token endpoint: https://www.imsglobal.org/spec/lti/v1p3/#access-token-endpoint - 1EdTech Security Framework: https://www.imsglobal.org/spec/security/v1p0/#token_request @@ -349,7 +350,7 @@ def set_tool_key_set(self, key_set: JWKS) -> Registration: def get_tool_redirect_uris(self) -> t.Optional[t.List[str]]: return self._tool_redirect_uris - + def set_tool_redirect_uris(self, redirect_uris: t.List[str]) -> Registration: self._tool_redirect_uris = redirect_uris return self diff --git a/tests/platform_config.py b/tests/platform_config.py index fa66e2c..649bced 100644 --- a/tests/platform_config.py +++ b/tests/platform_config.py @@ -1,8 +1,9 @@ -from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract -from lti1p3platform.registration import Registration import time import typing as t +from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract +from lti1p3platform.registration import Registration + RSA_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1du+3Vg1huBld4X7y8FS y7bOFbEje00BJlpzCGYLAhKQL+kV4eeu6fQRJJ8rvknlElXUHs99/jTHAe0em0kr diff --git a/tests/test_deep_linking.py b/tests/test_deep_linking.py new file mode 100644 index 0000000..a76267c --- /dev/null +++ b/tests/test_deep_linking.py @@ -0,0 +1,77 @@ +""" +Tests for lti1p3platform.deep_linking.LtiDeepLinking. +""" +import pytest + +from lti1p3platform.deep_linking import LtiDeepLinking +from lti1p3platform.exceptions import LtiDeepLinkingContentTypeNotSupported +from lti1p3platform.constants import LTI_DEEP_LINKING_ACCEPTED_TYPES + + +def test_get_lti_deep_linking_launch_claim_defaults(): + """No accept_types → all types included.""" + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = dl.get_lti_deep_linking_launch_claim() + settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + for t in LTI_DEEP_LINKING_ACCEPTED_TYPES: + assert t in settings["accept_types"] + assert settings["deep_link_return_url"] == "https://platform.example.com/deeplink_return" + + +def test_get_lti_deep_linking_launch_claim_custom_types(): + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = dl.get_lti_deep_linking_launch_claim( + accept_types={"ltiResourceLink", "link"} + ) + settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + assert "ltiResourceLink" in settings["accept_types"] + assert "link" in settings["accept_types"] + # Others should not be present + assert "html" not in settings["accept_types"] + + +def test_get_lti_deep_linking_launch_claim_invalid_type_raises(): + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + with pytest.raises(LtiDeepLinkingContentTypeNotSupported): + dl.get_lti_deep_linking_launch_claim(accept_types={"bogusType"}) + + +def test_get_lti_deep_linking_launch_claim_with_title_and_description(): + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = dl.get_lti_deep_linking_launch_claim( + title="My Title", description="My Description" + ) + settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + assert settings["title"] == "My Title" + assert settings["text"] == "My Description" + + +def test_get_lti_deep_linking_launch_claim_with_extra_data(): + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + extra = {"course_id": "course-123", "module_id": "module-456"} + claim = dl.get_lti_deep_linking_launch_claim(extra_data=extra) + settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + assert settings["data"] == extra + + +def test_get_lti_deep_linking_launch_claim_without_extra_data(): + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = dl.get_lti_deep_linking_launch_claim() + settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + assert "data" not in settings + + +def test_get_lti_deep_linking_launch_claim_accept_multiple_and_auto_create(): + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = dl.get_lti_deep_linking_launch_claim() + settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + assert settings["accept_multiple"] is True + assert settings["auto_create"] is True + + +def test_get_lti_deep_linking_launch_claim_presentation_targets(): + dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = dl.get_lti_deep_linking_launch_claim() + settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + assert "iframe" in settings["accept_presentation_document_targets"] + assert "window" in settings["accept_presentation_document_targets"] diff --git a/tests/test_ltiplatform_coverage.py b/tests/test_ltiplatform_coverage.py new file mode 100644 index 0000000..81c0651 --- /dev/null +++ b/tests/test_ltiplatform_coverage.py @@ -0,0 +1,843 @@ +""" +Tests for lti1p3platform.ltiplatform to increase coverage. + +Covers: +- set_accepted_deeplinking_types +- fetch_public_key (security validations) +- get_tool_key_set (URL path) +- validate_jwt_format (invalid input) +- get_tool_public_key (error paths) +- _is_token_replay / _is_nonce_replay +- _validate_tool_access_token_assertion (all error paths) +- get_access_token (error paths) +- validate_deeplinking_resp (all paths) +- validate_token +""" +import time +import uuid +from unittest.mock import patch, MagicMock + +import pytest + +from lti1p3platform.registration import Registration +from lti1p3platform.jwt_helper import jwt_encode +from lti1p3platform.exceptions import ( + InvalidKeySetUrl, + LtiException, + MissingRequiredClaim, + UnsupportedGrantType, + LtiDeepLinkingResponseException, +) + +from .platform_config import ( + PlatformConf, + TOOL_PRIVATE_KEY_PEM, + TOOL_KEY_SET, + PLATFORM_CONFIG, + RSA_PRIVATE_KEY_PEM, +) + + # --------------------------------------------------------------------------- + # Minimal platform variant helpers for edge-case coverage + # --------------------------------------------------------------------------- + +import typing as t +from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract +from lti1p3platform.registration import Registration as _Registration +from tests.platform_config import ( # noqa: F401 (used below) + RSA_PUBLIC_KEY_PEM as _PUB, + RSA_PRIVATE_KEY_PEM as _PRIV, + TOOL_KEY_SET as _TKS, +) + + +class _NullRegistrationPlatform(LTI1P3PlatformConfAbstract): + """Platform whose _registration is initially None; returned by get_registration_by_params.""" + + def __init__(self) -> None: + self._cache: t.Dict[str, int] = {} + super().__init__() + + def cache_get(self, key: str) -> t.Optional[int]: + return self._cache.get(key) + + def cache_set(self, key: str, exp: int) -> None: + self._cache[key] = exp + + def init_platform_config(self, **kwargs: t.Any) -> None: + pass # _registration stays None + + def get_registration_by_params(self, **kwargs: t.Any) -> _Registration: + reg = ( + _Registration() + .set_iss(PLATFORM_CONFIG["iss"]) + .set_client_id(PLATFORM_CONFIG["client_id"]) + .set_access_token_url(PLATFORM_CONFIG["access_token_url"]) + .set_platform_public_key(_PUB) + .set_platform_private_key(_PRIV) + .set_tool_key_set(_TKS) + ) + return reg + + +class _NoClientIdPlatform(PlatformConf): + """Platform with no client_id in registration (triggers line 537 in ltiplatform.py).""" + + def init_platform_config(self, **kwargs: t.Any) -> None: + super().init_platform_config(**kwargs) + self._registration.set_client_id(None) # type: ignore[arg-type] + + +class _NoAudiencePlatform(PlatformConf): + """Platform with no access_token_url (triggers line 658 in ltiplatform.py).""" + + def init_platform_config(self, **kwargs: t.Any) -> None: + super().init_platform_config(**kwargs) + self._registration.set_access_token_url(None) # type: ignore[arg-type] + +import typing as t + +from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract +from lti1p3platform.registration import Registration as _Registration +from .platform_config import RSA_PUBLIC_KEY_PEM as _PUB # noqa: F401 + + +class _NullRegistrationPlatform(LTI1P3PlatformConfAbstract): + """Platform whose _registration is initially None.""" + + def __init__(self) -> None: + self._cache: t.Dict[str, int] = {} + super().__init__() + + def cache_get(self, key: str) -> t.Optional[int]: + return self._cache.get(key) + + def cache_set(self, key: str, exp: int) -> None: + self._cache[key] = exp + + def init_platform_config(self, **kwargs: t.Any) -> None: + pass # _registration stays None + + def get_registration_by_params(self, **kwargs: t.Any) -> _Registration: + return ( + _Registration() + .set_iss(PLATFORM_CONFIG["iss"]) + .set_client_id(PLATFORM_CONFIG["client_id"]) + .set_access_token_url(PLATFORM_CONFIG["access_token_url"]) + .set_platform_public_key(_PUB) + .set_platform_private_key(RSA_PRIVATE_KEY_PEM) + .set_tool_key_set(TOOL_KEY_SET) + ) + + +class _NoClientIdPlatform(PlatformConf): + """Platform with no client_id (exercises line 537).""" + + def init_platform_config(self, **kwargs: t.Any) -> None: + super().init_platform_config(**kwargs) + self._registration.set_client_id(None) # type: ignore[arg-type] + + +class _NoAudiencePlatform(PlatformConf): + """Platform with no access_token_url (exercises line 658).""" + + def init_platform_config(self, **kwargs: t.Any) -> None: + super().init_platform_config(**kwargs) + self._registration.set_access_token_url(None) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_tool_jwt(claims, private_key=None, headers=None): + """Return a JWT signed with the tool's private key.""" + if private_key is None: + private_key = TOOL_PRIVATE_KEY_PEM + if headers is None: + jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + headers = {"kid": jwk.get("kid")} + return jwt_encode(claims, private_key, algorithm="RS256", headers=headers) + + +def _make_valid_assertion(jti=None, extra=None): + """Return a signed JWT suitable for a client_credentials assertion.""" + claims = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": jti or ("token-" + str(uuid.uuid4())), + } + if extra: + claims.update(extra) + return _make_tool_jwt(claims) + + +def _make_deeplink_jwt(extra_claims=None): + """Return a valid deep-link response JWT signed by the tool.""" + claims = { + "iss": "https://tool.example.com", + "sub": PLATFORM_CONFIG["client_id"], + "aud": PLATFORM_CONFIG["iss"], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "nonce": str(uuid.uuid4()), + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [], + } + if extra_claims: + claims.update(extra_claims) + return _make_tool_jwt(claims) + + +# --------------------------------------------------------------------------- +# set_accepted_deeplinking_types +# --------------------------------------------------------------------------- + +def test_set_accepted_types_filters_valid(): + platform = PlatformConf() + platform.set_accepted_deeplinking_types(["ltiResourceLink", "link"]) + assert "ltiResourceLink" in platform._accepted_deeplinking_types + assert "link" in platform._accepted_deeplinking_types + + +def test_set_accepted_types_excludes_invalid(): + platform = PlatformConf() + platform.set_accepted_deeplinking_types(["ltiResourceLink", "bogusType"]) + assert "ltiResourceLink" in platform._accepted_deeplinking_types + assert "bogusType" not in platform._accepted_deeplinking_types + + +def test_set_accepted_types_empty_list(): + platform = PlatformConf() + platform.set_accepted_deeplinking_types([]) + assert len(platform._accepted_deeplinking_types) == 0 + + +# --------------------------------------------------------------------------- +# fetch_public_key – security validation +# --------------------------------------------------------------------------- + +def test_fetch_public_key_http_raises(): + platform = PlatformConf() + with pytest.raises(InvalidKeySetUrl): + platform.fetch_public_key("http://example.com/jwks") + + +def test_fetch_public_key_localhost_raises(): + platform = PlatformConf() + with pytest.raises(InvalidKeySetUrl): + platform.fetch_public_key("https://localhost/jwks") + + +def test_fetch_public_key_loopback_ip_raises(): + platform = PlatformConf() + with pytest.raises(InvalidKeySetUrl): + platform.fetch_public_key("https://127.0.0.1/jwks") + + +def test_fetch_public_key_private_10_raises(): + platform = PlatformConf() + with pytest.raises(InvalidKeySetUrl): + platform.fetch_public_key("https://10.0.0.1/jwks") + + +def test_fetch_public_key_private_192_raises(): + platform = PlatformConf() + with pytest.raises(InvalidKeySetUrl): + platform.fetch_public_key("https://192.168.1.1/jwks") + + +def test_fetch_public_key_private_172_raises(): + platform = PlatformConf() + with pytest.raises(InvalidKeySetUrl): + platform.fetch_public_key("https://172.16.0.1/jwks") + + +def test_fetch_public_key_valid_url_returns_jwks(): + platform = PlatformConf() + mock_response = MagicMock() + mock_response.json.return_value = TOOL_KEY_SET + with patch("lti1p3platform.ltiplatform.requests.get", return_value=mock_response): + result = platform.fetch_public_key("https://example.com/jwks") + assert result == TOOL_KEY_SET + + +def test_fetch_public_key_request_error_raises(): + import requests as req + platform = PlatformConf() + with patch( + "lti1p3platform.ltiplatform.requests.get", + side_effect=req.exceptions.ConnectionError("network failure"), + ): + with pytest.raises(LtiException): + platform.fetch_public_key("https://example.com/jwks") + + +def test_fetch_public_key_invalid_json_raises(): + platform = PlatformConf() + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "not json" + with patch("lti1p3platform.ltiplatform.requests.get", return_value=mock_response): + with pytest.raises(LtiException): + platform.fetch_public_key("https://example.com/jwks") + + +# --------------------------------------------------------------------------- +# get_tool_key_set – non-HTTPS URL +# --------------------------------------------------------------------------- + +def test_get_tool_key_set_non_https_url_raises(): + platform = PlatformConf() + # Replace key set with None, set an HTTP URL + platform._registration.set_tool_key_set(None) + platform._registration.set_tool_key_set_url("http://example.com/jwks") + with pytest.raises(InvalidKeySetUrl): + platform.get_tool_key_set() + + +def test_get_tool_key_set_https_url_returns_and_caches(): + platform = PlatformConf() + platform._registration.set_tool_key_set(None) + platform._registration.set_tool_key_set_url("https://example.com/jwks") + mock_response = MagicMock() + mock_response.json.return_value = TOOL_KEY_SET + with patch("lti1p3platform.ltiplatform.requests.get", return_value=mock_response): + result = platform.get_tool_key_set() + assert result == TOOL_KEY_SET + # Subsequent call uses cache (no extra HTTP calls) + result2 = platform.get_tool_key_set() + assert result2 == TOOL_KEY_SET + + +# --------------------------------------------------------------------------- +# validate_jwt_format – error paths +# --------------------------------------------------------------------------- + +def test_validate_jwt_format_wrong_parts(): + platform = PlatformConf() + with pytest.raises(LtiException, match="JWT must contain 3 parts"): + platform.validate_jwt_format("only.two") + + +def test_validate_jwt_format_invalid_base64(): + platform = PlatformConf() + with pytest.raises(LtiException, match="can't be decoded"): + platform.validate_jwt_format("!@#$.!@#$.!@#$") + + +# --------------------------------------------------------------------------- +# get_tool_public_key – error paths +# --------------------------------------------------------------------------- + +def test_get_tool_public_key_no_kid_raises(): + platform = PlatformConf() + claims = { + "iss": "tool", + "aud": "platform", + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + } + token = jwt_encode(claims, TOOL_PRIVATE_KEY_PEM, algorithm="RS256") + platform.validate_jwt_format(token) + platform._jwt["header"].pop("kid", None) + with pytest.raises(LtiException, match="KID not found"): + platform.get_tool_public_key() + + +def test_get_tool_public_key_no_alg_raises(): + platform = PlatformConf() + jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + claims = { + "iss": "tool", + "aud": "platform", + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + } + token = jwt_encode( + claims, TOOL_PRIVATE_KEY_PEM, algorithm="RS256", headers={"kid": jwk.get("kid")} + ) + platform.validate_jwt_format(token) + platform._jwt["header"].pop("alg", None) + with pytest.raises(LtiException, match="ALG not found"): + platform.get_tool_public_key() + + +def test_get_tool_public_key_kid_not_found_raises(): + platform = PlatformConf() + jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + claims = { + "iss": "tool", + "aud": "platform", + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + } + token = jwt_encode( + claims, TOOL_PRIVATE_KEY_PEM, algorithm="RS256", headers={"kid": jwk.get("kid")} + ) + platform.validate_jwt_format(token) + platform._jwt["header"]["kid"] = "nonexistent-kid" + with pytest.raises(LtiException, match="Unable to find public key"): + platform.get_tool_public_key() + + +# --------------------------------------------------------------------------- +# _is_token_replay / _is_nonce_replay +# --------------------------------------------------------------------------- + +def test_token_replay_first_use_returns_false(): + platform = PlatformConf() + assert platform._is_token_replay(str(uuid.uuid4()), int(time.time()) + 60) is False + + +def test_token_replay_second_use_returns_true(): + platform = PlatformConf() + jti = str(uuid.uuid4()) + exp = int(time.time()) + 60 + platform._is_token_replay(jti, exp) + assert platform._is_token_replay(jti, exp) is True + + +def test_nonce_replay_first_use_returns_false(): + platform = PlatformConf() + assert platform._is_nonce_replay(str(uuid.uuid4()), int(time.time()) + 60) is False + + +def test_nonce_replay_second_use_returns_true(): + platform = PlatformConf() + nonce = str(uuid.uuid4()) + exp = int(time.time()) + 60 + platform._is_nonce_replay(nonce, exp) + assert platform._is_nonce_replay(nonce, exp) is True + + +# --------------------------------------------------------------------------- +# _validate_tool_access_token_assertion – error paths +# --------------------------------------------------------------------------- + +def test_assertion_missing_jti_raises(): + platform = PlatformConf() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + # Missing "jti" + } + with pytest.raises(MissingRequiredClaim): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_invalid_iss_raises(): + platform = PlatformConf() + decoded = { + "iss": "wrong-client-id", + "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": str(uuid.uuid4()), + } + with pytest.raises(LtiException, match="Invalid client_assertion iss"): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_invalid_sub_raises(): + platform = PlatformConf() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": "wrong-sub", + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": str(uuid.uuid4()), + } + with pytest.raises(LtiException, match="Invalid client_assertion sub"): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_aud_string_wrong_raises(): + platform = PlatformConf() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": "https://wrong-audience.example/token", + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": str(uuid.uuid4()), + } + with pytest.raises(LtiException, match="Invalid client_assertion audience"): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_aud_string_correct_succeeds(): + platform = PlatformConf() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": PLATFORM_CONFIG["access_token_url"], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": str(uuid.uuid4()), + } + # Should not raise + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_aud_invalid_type_raises(): + platform = PlatformConf() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": 99999, # Not a str or list + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": str(uuid.uuid4()), + } + with pytest.raises(LtiException, match="Invalid client_assertion aud"): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_iat_far_future_raises(): + platform = PlatformConf() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) + 300, # 5 min into future (> 60s tolerance) + "exp": int(time.time()) + 600, + "jti": str(uuid.uuid4()), + } + with pytest.raises(LtiException, match="Invalid client_assertion iat"): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_exp_in_past_raises(): + platform = PlatformConf() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) - 120, + "exp": int(time.time()) - 60, # Already expired + "jti": str(uuid.uuid4()), + } + with pytest.raises(LtiException, match="Invalid client_assertion exp"): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +def test_assertion_jti_replay_raises(): + platform = PlatformConf() + jti = str(uuid.uuid4()) + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": jti, + } + # First call succeeds + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + # Second call with same JTI must fail + decoded2 = dict(decoded) + with pytest.raises(LtiException, match="Replay detected"): + platform._validate_tool_access_token_assertion( + decoded2, PLATFORM_CONFIG["access_token_url"] + ) + + +# --------------------------------------------------------------------------- +# get_access_token – error paths +# --------------------------------------------------------------------------- + +def test_get_access_token_missing_claim_raises(): + platform = PlatformConf() + with pytest.raises(MissingRequiredClaim): + platform.get_access_token( + { + "grant_type": "client_credentials", + "client_assertion": "xxx", + "scope": "", + # Missing "client_assertion_type" + } + ) + + +def test_get_access_token_wrong_grant_type_raises(): + platform = PlatformConf() + with pytest.raises(UnsupportedGrantType): + platform.get_access_token( + { + "grant_type": "implicit", + "client_assertion_type": ( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + ), + "client_assertion": "xxx", + "scope": "", + } + ) + + +def test_get_access_token_wrong_assertion_type_raises(): + platform = PlatformConf() + with pytest.raises(LtiException, match="Invalid client_assertion_type"): + platform.get_access_token( + { + "grant_type": "client_credentials", + "client_assertion_type": "wrong-type", + "client_assertion": "xxx", + "scope": "", + } + ) + + +def test_get_access_token_unsupported_scope_returns_empty(): + platform = PlatformConf() + encoded_jwt = _make_valid_assertion() + result = platform.get_access_token( + { + "grant_type": "client_credentials", + "client_assertion_type": ( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + ), + "client_assertion": encoded_jwt, + "scope": "https://not.a.valid/scope", + } + ) + assert result["scope"] == "" + assert result["token_type"] == "bearer" + assert result["expires_in"] == 3600 + + +# --------------------------------------------------------------------------- +# validate_deeplinking_resp +# --------------------------------------------------------------------------- + +def test_validate_deeplinking_resp_empty_content_items(): + platform = PlatformConf() + token = _make_deeplink_jwt() + result = platform.validate_deeplinking_resp({"JWT": token}) + assert result == [] + + +def test_validate_deeplinking_resp_with_link_items(): + platform = PlatformConf() + items = [{"type": "ltiResourceLink", "url": "https://tool.example.com/resource"}] + token = _make_deeplink_jwt( + extra_claims={ + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": items + } + ) + result = platform.validate_deeplinking_resp({"JWT": token}) + assert len(result) == 1 + assert result[0]["type"] == "ltiResourceLink" + + +def test_validate_deeplinking_resp_missing_nonce_raises(): + platform = PlatformConf() + jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + claims = { + "iss": "https://tool.example.com", + "sub": PLATFORM_CONFIG["client_id"], + "aud": PLATFORM_CONFIG["iss"], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + # No nonce + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [], + } + token = jwt_encode( + claims, TOOL_PRIVATE_KEY_PEM, algorithm="RS256", headers={"kid": jwk.get("kid")} + ) + with pytest.raises(LtiDeepLinkingResponseException, match="nonce is missing"): + platform.validate_deeplinking_resp({"JWT": token}) + + +def test_validate_deeplinking_resp_missing_exp_raises(): + """Token without exp claim should raise LtiDeepLinkingResponseException.""" + platform = PlatformConf() + jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + claims = { + "iss": "https://tool.example.com", + "sub": PLATFORM_CONFIG["client_id"], + "aud": PLATFORM_CONFIG["iss"], + "iat": int(time.time()) - 5, + # No "exp" claim + "nonce": str(uuid.uuid4()), + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [], + } + token = jwt_encode( + claims, TOOL_PRIVATE_KEY_PEM, algorithm="RS256", headers={"kid": jwk.get("kid")} + ) + with pytest.raises(LtiDeepLinkingResponseException, match="exp is missing"): + platform.validate_deeplinking_resp({"JWT": token}) + + +def test_validate_deeplinking_resp_nonce_replay_raises(): + platform = PlatformConf() + nonce = str(uuid.uuid4()) + # First request succeeds + token1 = _make_deeplink_jwt(extra_claims={"nonce": nonce}) + platform.validate_deeplinking_resp({"JWT": token1}) + # Second request with same nonce must be rejected + token2 = _make_deeplink_jwt(extra_claims={"nonce": nonce}) + with pytest.raises(LtiDeepLinkingResponseException, match="Replay detected"): + platform.validate_deeplinking_resp({"JWT": token2}) + + +def test_validate_deeplinking_resp_wrong_message_type_raises(): + platform = PlatformConf() + token = _make_deeplink_jwt( + extra_claims={ + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest" + } + ) + with pytest.raises(LtiDeepLinkingResponseException, match="Deep Linking Response"): + platform.validate_deeplinking_resp({"JWT": token}) + + +def test_validate_deeplinking_resp_unsupported_content_type_raises(): + platform = PlatformConf() + token = _make_deeplink_jwt( + extra_claims={ + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [ + {"type": "unsupported_content_type"} + ] + } + ) + with pytest.raises(LtiDeepLinkingResponseException, match="not supported"): + platform.validate_deeplinking_resp({"JWT": token}) + + +# --------------------------------------------------------------------------- +# validate_token +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# get_registration – _registration initially None path (lines 178-181) +# --------------------------------------------------------------------------- + +def test_get_registration_lazy_loads_when_none(): + platform = _NullRegistrationPlatform() + # _registration is None initially + assert platform._registration is None + reg = platform.get_registration() + assert reg is not None + # Second call returns cached instance + assert platform.get_registration() is reg + + +# --------------------------------------------------------------------------- +# _validate_tool_access_token_assertion – no client_id (line 537) +# --------------------------------------------------------------------------- + +def test_assertion_no_client_id_raises(): + platform = _NoClientIdPlatform() + decoded = { + "iss": PLATFORM_CONFIG["client_id"], + "sub": PLATFORM_CONFIG["client_id"], + "aud": [PLATFORM_CONFIG["access_token_url"]], + "iat": int(time.time()) - 5, + "exp": int(time.time()) + 60, + "jti": str(uuid.uuid4()), + } + with pytest.raises(LtiException, match="Client ID is not set"): + platform._validate_tool_access_token_assertion( + decoded, PLATFORM_CONFIG["access_token_url"] + ) + + +# --------------------------------------------------------------------------- +# get_access_token – no expected audience (line 658) +# --------------------------------------------------------------------------- + +def test_get_access_token_no_audience_raises(): + platform = _NoAudiencePlatform() + encoded_jwt = _make_valid_assertion() + with pytest.raises(LtiException, match="No expected audience configured"): + platform.get_access_token( + { + "grant_type": "client_credentials", + "client_assertion_type": ( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + ), + "client_assertion": encoded_jwt, + "scope": "", + } + ) + +def _make_platform_token(extra_claims=None): + """Return a JWT signed by the platform's private key.""" + platform = PlatformConf() + claims = { + "sub": PLATFORM_CONFIG["client_id"], + "iss": PLATFORM_CONFIG["iss"], + "scopes": "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + } + if extra_claims: + claims.update(extra_claims) + return platform._registration.platform_encode_and_sign(claims, expiration=3600) + + +def test_validate_token_valid_returns_true(): + platform = PlatformConf() + token = _make_platform_token() + assert platform.validate_token(token) is True + + +def test_validate_token_matching_scope_returns_true(): + platform = PlatformConf() + token = _make_platform_token() + assert platform.validate_token( + token, + allowed_scopes=["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"], + ) is True + + +def test_validate_token_non_matching_scope_returns_false(): + platform = PlatformConf() + token = _make_platform_token() + assert platform.validate_token( + token, + allowed_scopes=["https://purl.imsglobal.org/spec/lti-ags/scope/score"], + ) is False + + +def test_validate_token_invalid_iss_raises(): + """Token signed by platform key but with wrong iss should raise.""" + platform = PlatformConf() + token = Registration.encode_and_sign( + { + "sub": PLATFORM_CONFIG["client_id"], + "iss": "https://wrong-issuer.example/", + "scopes": "", + }, + RSA_PRIVATE_KEY_PEM, + expiration=3600, + ) + with pytest.raises(LtiException, match="Invalid issuer"): + platform.validate_token(token) diff --git a/tests/test_registration.py b/tests/test_registration.py new file mode 100644 index 0000000..ca0b366 --- /dev/null +++ b/tests/test_registration.py @@ -0,0 +1,170 @@ +""" +Tests for lti1p3platform.registration to increase coverage. + +Covers getters/setters not exercised by other test files, plus +encode_and_sign, decode_and_verify, and platform_encode_and_sign. +""" +import time + +import pytest + +from lti1p3platform.registration import Registration + +from .platform_config import ( + RSA_PUBLIC_KEY_PEM, + RSA_PRIVATE_KEY_PEM, + TOOL_KEY_SET, +) + + +def _make_registration(): + return ( + Registration() + .set_iss("https://platform.example.com") + .set_client_id("client-123") + .set_deployment_id("deploy-1") + .set_oidc_login_url("https://tool.example.com/oidc_login") + .set_access_token_url("https://platform.example.com/token") + .set_launch_url("https://tool.example.com/launch") + .set_platform_public_key(RSA_PUBLIC_KEY_PEM) + .set_platform_private_key(RSA_PRIVATE_KEY_PEM) + .set_tool_key_set(TOOL_KEY_SET) + ) + + +def test_get_iss(): + reg = _make_registration() + assert reg.get_iss() == "https://platform.example.com" + + +def test_get_client_id(): + reg = _make_registration() + assert reg.get_client_id() == "client-123" + + +def test_get_deployment_id(): + reg = _make_registration() + assert reg.get_deployment_id() == "deploy-1" + + +def test_get_oidc_login_url(): + reg = _make_registration() + assert reg.get_oidc_login_url() == "https://tool.example.com/oidc_login" + + +def test_get_access_token_url(): + reg = _make_registration() + assert reg.get_access_token_url() == "https://platform.example.com/token" + + +def test_get_launch_url(): + reg = _make_registration() + assert reg.get_launch_url() == "https://tool.example.com/launch" + + +def test_get_platform_public_key(): + reg = _make_registration() + assert "PUBLIC KEY" in reg.get_platform_public_key() + + +def test_get_platform_private_key(): + reg = _make_registration() + assert "PRIVATE KEY" in reg.get_platform_private_key() + + +def test_set_and_get_deeplink_launch_url(): + reg = _make_registration() + reg.set_deeplink_launch_url("https://tool.example.com/deeplink") + assert reg.get_deeplink_launch_url() == "https://tool.example.com/deeplink" + + +def test_get_deeplink_launch_url_default_none(): + reg = Registration() + assert reg.get_deeplink_launch_url() is None + + +def test_get_jwk_returns_dict_with_kid(): + jwk = Registration.get_jwk(RSA_PUBLIC_KEY_PEM) + assert "kid" in jwk + assert jwk.get("alg") == "RS256" + assert jwk.get("use") == "sig" + assert jwk.get("kty") == "RSA" + + +def test_get_kid_returns_string(): + reg = _make_registration() + kid = reg.get_kid() + assert kid is not None + assert isinstance(kid, str) + + +def test_get_tool_key_set(): + reg = _make_registration() + assert reg.get_tool_key_set() == TOOL_KEY_SET + + +def test_set_and_get_tool_key_set_url(): + reg = _make_registration() + reg.set_tool_key_set_url("https://tool.example.com/jwks") + assert reg.get_tool_key_set_url() == "https://tool.example.com/jwks" + + +def test_get_tool_key_set_url_default_none(): + reg = Registration() + assert reg.get_tool_key_set_url() is None + + +def test_set_and_get_tool_redirect_uris(): + reg = _make_registration() + uris = ["https://tool.example.com/launch", "https://tool.example.com/deeplink"] + reg.set_tool_redirect_uris(uris) + assert reg.get_tool_redirect_uris() == uris + + +def test_get_tool_redirect_uris_default_none(): + reg = Registration() + assert reg.get_tool_redirect_uris() is None + + +def test_encode_and_sign_without_expiration(): + """encode_and_sign without expiration should not add iat/exp.""" + payload = {"sub": "user-1", "iss": "https://platform.example.com"} + token = Registration.encode_and_sign(payload, RSA_PRIVATE_KEY_PEM) + decoded = Registration.decode_and_verify( + token, RSA_PUBLIC_KEY_PEM, audience=None + ) + assert decoded["sub"] == "user-1" + assert "exp" not in decoded + + +def test_encode_and_sign_with_expiration(): + payload = {"sub": "user-1", "iss": "https://platform.example.com"} + token = Registration.encode_and_sign(payload, RSA_PRIVATE_KEY_PEM, expiration=3600) + decoded = Registration.decode_and_verify( + token, RSA_PUBLIC_KEY_PEM, audience=None + ) + assert decoded["sub"] == "user-1" + assert "exp" in decoded + assert decoded["exp"] > int(time.time()) + + +def test_decode_and_verify_without_audience(): + payload = {"sub": "user-1", "iss": "https://platform.example.com"} + token = Registration.encode_and_sign(payload, RSA_PRIVATE_KEY_PEM, expiration=60) + decoded = Registration.decode_and_verify(token, RSA_PUBLIC_KEY_PEM) + assert decoded["sub"] == "user-1" + + +def test_platform_encode_and_sign(): + reg = _make_registration() + token = reg.platform_encode_and_sign({"custom": "value"}, expiration=60) + decoded = Registration.decode_and_verify(token, RSA_PUBLIC_KEY_PEM) + assert decoded["custom"] == "value" + + +def test_get_jwks_returns_list_with_key(): + reg = _make_registration() + jwks = reg.get_jwks() + assert isinstance(jwks, list) + assert len(jwks) == 1 + assert jwks[0].get("kty") == "RSA" From b5aa03494237d87df16616507460235b7cc653e7 Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Sat, 4 Apr 2026 14:38:31 +1100 Subject: [PATCH 6/9] support python 3.11 --- .github/workflows/commit-checks.yml | 2 +- .pylintrc | 1 + lti1p3platform/ags.py | 46 +++++----- lti1p3platform/deep_linking.py | 28 +++---- lti1p3platform/jwt_helper.py | 14 ++-- lti1p3platform/message_launch.py | 62 +++++++------- lti1p3platform/nrps.py | 46 +++++----- lti1p3platform/oidc_login.py | 61 +++++++------- tests/test_deep_linking.py | 69 +++++++++------ tests/test_ltiplatform_coverage.py | 126 ++++++++++------------------ tests/test_registration.py | 10 +-- tox.ini | 3 +- 12 files changed, 225 insertions(+), 243 deletions(-) diff --git a/.github/workflows/commit-checks.yml b/.github/workflows/commit-checks.yml index 63532ea..4ddb8db 100644 --- a/.github/workflows/commit-checks.yml +++ b/.github/workflows/commit-checks.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 diff --git a/.pylintrc b/.pylintrc index 84d8740..a264e56 100644 --- a/.pylintrc +++ b/.pylintrc @@ -4,6 +4,7 @@ disable= C0115, # missing-class-docstring C0116, # missing-function-docstring W0511, # fixme + R0801, # duplicate-code extension-pkg-whitelist=pydantic ignore=framework, migrations diff --git a/lti1p3platform/ags.py b/lti1p3platform/ags.py index 3e70324..d54111b 100644 --- a/lti1p3platform/ags.py +++ b/lti1p3platform/ags.py @@ -36,45 +36,45 @@ class LtiAgs: """ LTI 1.3 Advantage Services - Assignments and Grades Service Configuration - + AGS provides three main APIs: - + 1. LineItem API (Assignment Management API): - GET /lineitems: List all grading items (assignments) - POST /lineitems: Create new grading item (if allowed) - GET /lineitems/{id}: Get specific grading item details - PUT /lineitems/{id}: Update grading item - DELETE /lineitems/{id}: Delete grading item - + Scopes required: - lineitem.readonly: View only - lineitem: Create/modify/delete - + 2. Score API (Grade Submission API): - POST /lineitems/{id}/scores: Submit student grade - Scopes: score (most restrictive, recommended) - Allows tool to submit grades without modifying items - + 3. Result API (Detailed Grade Query API): - GET /lineitems/{id}/results: Retrieve all results for an item - GET /lineitems/{id}/results/{user_id}: Get specific student's result - Scopes: result.readonly (view) or result (modify) - + This class configures which AGS capabilities are available to tools. - + Parameters: - lineitems_url: Platform's API endpoint for listing/creating assignments - lineitem_url: Template URL for accessing specific assignment (contains {id}) - allow_creating_lineitems: If False, tool can only see existing items (not create) - results_service_enabled: If True, tool can query student results - scores_service_enabled: If True, tool can submit grades - + Platform Security Considerations: - Only enable services actually used by this tool - Restrict scopes to minimum needed - Monitor tool's API usage for suspicious patterns - Default: conservative (results=true, scores=true, creation=false) - + Reference: https://www.imsglobal.org/spec/lti-ags/v2p0/ """ @@ -89,28 +89,28 @@ def __init__( ) -> None: """ Initialize AGS configuration for a tool integration - + Parameters: lineitems_url: Platform's API endpoint for line item list/creation - Format: "https://platform.edu/lti/ags/lineitems" - Tool makes GET/POST requests to this endpoint - Required if scores/results services enabled - + lineitem_url: Template URL for accessing individual line item - Format: "https://platform.edu/lti/ags/lineitems/123" - Contains {id} placeholder replaced with item ID - Required if scores/results services enabled - + allow_creating_lineitems: Allow tool to create new assignments - Default: False (tool can only use existing items) - Set True for content creation tools - False prevents tool from cluttering gradebook - + results_service_enabled: Allow tool to query student results - Default: True (most tools need this) - Disabled if tool only submits grades (no results lookup) - Results API requires 'result.readonly' or 'result' scope - + scores_service_enabled: Allow tool to submit grades/scores - Default: True (most tools need this) - Disabled if tool is view-only @@ -134,47 +134,47 @@ def __init__( def get_available_scopes(self) -> t.List[str]: """ Retrieves list of available OAuth 2.0 scopes for this AGS configuration - + OAuth 2.0 Scopes determine what the tool is allowed to do on the platform. Scopes are included in the access_token JWT and validated by the platform. - + Available AGS Scopes: - https://purl.imsglobal.org/spec/lti-ags/scope/lineitem * Create/modify/delete line items (assignments) * Requires: allow_creating_lineitems=True * Only included if tool needs to create items - + - https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly * View line items only, cannot modify * Less restrictive than lineitem * Default for tools that don't create items - + - https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly * View student results/grades only * Cannot modify or change grades * Safest scope for read-only tools - + - https://purl.imsglobal.org/spec/lti-ags/scope/result * View and modify student results/grades * More permissive than result.readonly * Used by tools that need full result access - + - https://purl.imsglobal.org/spec/lti-ags/scope/score * Submit grades for students * Most restrictive scope (RECOMMENDED!) * Used by quiz/homework tools * Cannot view other students' scores - + Scope Selection Best Practice: - Use 'score' if only submitting grades (quiz tools, most secure) - Use 'lineitem.readonly' if only viewing assignments - Use 'result.readonly' if only viewing grades - Use 'result' only if truly needing result modification - Use 'lineitem' only for content creation tools - + Returns: List of scope URIs the platform will provide tokens for - + Reference: - Scope descriptions: https://www.imsglobal.org/spec/lti-ags/v2p0/#scopes - OAuth 2.0 Scopes: https://tools.ietf.org/html/rfc6749#section-3.3 diff --git a/lti1p3platform/deep_linking.py b/lti1p3platform/deep_linking.py index 52d9a55..27d7d04 100644 --- a/lti1p3platform/deep_linking.py +++ b/lti1p3platform/deep_linking.py @@ -25,7 +25,7 @@ class LtiDeepLinking: """ LTI 1.3 Advantage - Deep Linking Service Handler - + Deep Linking Launch Flow: 1. Instructor clicks "Select Content from External Tool" in LMS 2. Platform sends special LTI message to tool (includes deep_linking data) @@ -35,12 +35,12 @@ class LtiDeepLinking: 6. Tool redirects back to platform with response 7. Platform validates response and records selection 8. Content appears in course - + This class handles: - Creating Deep Linking launch claims (Step 2) - Validating Deep Linking responses (Step 6) - Creating Deep Linking response returns (Step 5) - + Deep Linking Claims in Launch Message: - accept_types: What types of content the platform can accept - accept_media_types: What media types are acceptable @@ -52,7 +52,7 @@ class LtiDeepLinking: - accept_unsigned: Allow unsigned deep links (default: false, require signature) - accept_multiple: Allow selecting multiple items (default: false, single item) - accept_lineitem: Platform accepts a grading item (AGS) - + Deep Linking Response Format: - Signed JWT with claims: * https://purl.imsglobal.org/spec/lti-dl/claim/content_items: @@ -61,7 +61,7 @@ class LtiDeepLinking: * nonce: Echoed from request (replay protection) * aud: Platform's deep link return URL * Plus standard claims (iss, sub, iat, exp, jti) - + Security Mechanisms: - Only platform's registered deep_linking_launch_url can receive launch - Tool must validate authorization before showing content browser @@ -69,7 +69,7 @@ class LtiDeepLinking: - Nonce prevents replay of old responses - JTI prevents token reuse - Return URL (aud claim) prevents sending response to wrong platform - + Reference: - LTI Advantage Services: https://www.imsglobal.org/spec/lti/v1p3/#lti-advantage-services - Deep Linking Spec: https://www.imsglobal.org/spec/lti-dl/v2p0/ @@ -81,7 +81,7 @@ def __init__( ) -> None: """ Initialize Deep Linking response handler - + Parameters: deep_linking_return_url: URL where to POST Deep Link Response - Is provided by platform in the launch message @@ -99,14 +99,14 @@ def get_lti_deep_linking_launch_claim( ) -> t.Dict[str, t.Any]: """ Generate Deep Linking Launch Claim for LTI message - + This claim is included in the LTI launch message when the platform wants the tool to display a content selection interface (Deep Linking). - + Platform -> Tool Communication: The platform includes this claim in the id_token JWT to tell the tool: "Display content selection UI and return what the user selects" - + Claim Structure: ================ { @@ -123,7 +123,7 @@ def get_lti_deep_linking_launch_claim( "data": "Custom data to echo back" # Platform can pass arbitrary data } } - + Parameters: title: Prefill title in tool's content browser UI description: Prefill description/text in tool's content browser UI @@ -134,15 +134,15 @@ def get_lti_deep_linking_launch_claim( - "image": Image content - "link": External URL/link - If None: all types accepted - + extra_data: Custom data returned in Deep Link Response - Platform can include any opaque data - Tool must echo this back in response - Allows platform to remember context (course ID, module ID, etc.) - + Returns: dict: The deep_linking_settings claim to inject into LTI launch message - + Raises: LtiDeepLinkingContentTypeNotSupported: If invalid content types requested """ diff --git a/lti1p3platform/jwt_helper.py b/lti1p3platform/jwt_helper.py index 2f4aca2..6c5fd79 100644 --- a/lti1p3platform/jwt_helper.py +++ b/lti1p3platform/jwt_helper.py @@ -59,19 +59,19 @@ def jwt_encode( ) -> str: """ Encode a JWT token with the given payload and key - + PyJWT Wrapper: Handles compatibility between PyJWT versions - PyJWT 2.0.0+: Returns string (UTF-8 encoded) - PyJWT < 2.0.0: Returns bytes - This function always returns a string for consistency - + LTI 1.3 JWT Encoding Process: 1. Create payload dict with all necessary claims 2. Call this function with payload and platform's private key 3. PyJWT creates signature using RS256 algorithm 4. Returns signed JWT string (three dot-separated base64url-encoded parts) 5. Platform sends this JWT to tool (usually as parameter in redirect) - + JWT Payload Example (LTI Launch Message): { "iss": "https://platform.trucut.com", @@ -84,7 +84,7 @@ def jwt_encode( "https://purl.imsglobal.org/spec/lti/claim/user_id": "user-123", ... } - + Parameters: payload: Dictionary of claims to include in JWT key: Private key in PEM format (string) @@ -94,10 +94,10 @@ def jwt_encode( - LTI 1.3 uses RS256 for security headers: Additional JWT headers (e.g., key ID 'kid' for key rotation) json_encoder: Custom JSON encoder if needed - + Returns: str: The encoded JWT token (three dot-separated parts) - + Example Use: >>> payload = { ... "iss": "https://platform.com", @@ -108,7 +108,7 @@ def jwt_encode( ... } >>> token = jwt_encode(payload, private_key_pem, "RS256") >>> # token = "eyJ0eXAiOiJKV1QiLCJhbGc..." - + Reference: - JWT Format: https://tools.ietf.org/html/rfc7519#section-3 - RS256 Algorithm: https://tools.ietf.org/html/rfc7518#section-3.3 diff --git a/lti1p3platform/message_launch.py b/lti1p3platform/message_launch.py index 12da676..292b02f 100644 --- a/lti1p3platform/message_launch.py +++ b/lti1p3platform/message_launch.py @@ -27,21 +27,21 @@ class LaunchData(TypedDict): class MessageLaunchAbstract(ABC): """ Abstract base class for LTI 1.3 Message Launch handling - + LTI 1.3 Launch Process (simplified): 1. User clicks "launch tool" in platform 2. Platform creates LTI message with user/content context (JWT) 3. Platform sends POST with id_token + state to tool's launch_url 4. Tool validates JWT signature and claims 5. Tool displays interface with user's context - + This class handles: - Receiving and parsing launch requests - Validating JWT signatures and claims - Extracting LTI claims (user roles, resource context, etc.) - Building response data - Interfacing with LTI Advantage services (AGS, NRPS) - + Message Contents: The id_token JWT contains many claims about the launch context: - User identity: sub (subject/user ID), email, name, roles @@ -50,16 +50,17 @@ class MessageLaunchAbstract(ABC): - Custom parameters: Custom data platform passes to tool - Roles: User roles (e.g., 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor') - + HTTPS Requirement: - All Platform/Tool URLs must use HTTPS for production - Except localhost (127.0.0.1, ::1) allowed for development - Prevents man-in-the-middle attacks on tokens - + Reference: - LTI 1.3 Resource Link Request: https://www.imsglobal.org/spec/lti/v1p3/#resource-link-request - LTI Deep Link Launch: https://www.imsglobal.org/spec/lti-dl/v2p0/#lifecycle """ + _request = None _registration: t.Optional[Registration] = None @@ -102,18 +103,18 @@ def set_user_data( ) -> None: """ Set user data/roles and convert to IMS LTI 1.3 Standard Claims - + LTI 1.3 User Claims: The platform includes these claims in the LTI launch JWT to tell the tool about the user launching the tool. - + User Identity Claims: - sub: Locally stable opaque user identifier * Does NOT need to be user's actual username * Must be stable (same user always gets same sub value) * Example: "user-123" (platform-specific ID) * Tool uses this to correlate requests from same user - + Roles Claim: - https://purl.imsglobal.org/spec/lti/claim/roles - Array of URIs representing user's roles in the context @@ -122,31 +123,31 @@ def set_user_data( * http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student * http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator - Tool uses roles to determine what user can do (e.g., grading UI for instructors only) - + Optional Identity Claims: - name: Full name of the user - email: Email address (if available and privacy rules allow) - preferred_username: Username if appropriate - + Privacy Considerations: - Platform should only include claims that: 1. User has agreed to share 2. Tool legitimately needs to function 3. Comply with institutional privacy policies - PII (Personally Identifiable Information) should be minimal and necessary - + Usage by Tool: 1. Tool receives JWT with these claims 2. Tool validates JWT signature (verify platform signed it) 3. Tool extracts user claims to identify user 4. Tool can check roles to enable/disable features 5. Tool registers/creates user account if first time - + Reference (User Identity Claims): - https://www.imsglobal.org/spec/lti/v1p3/#user-identity-claims - https://www.imsglobal.org/spec/lti/v1p3/#roles-claim - https://www.imsglobal.org/spec/lti/v1p3/#core-recommended-claims - + Parameters: user_id: Unique, stable user identifier (sub claim) lis_roles: List of role URIs from IMS LIS vocabulary @@ -380,38 +381,38 @@ def validate_preflight_response( ) -> None: """ Validate LTI launch preflight response from platform's authorization endpoint - + OpenID Connect Authorization Endpoint Response: When the platform's OIDC authorization endpoint processes the login request, it returns parameters that the tool must validate before granting access. - + Validation Checks: ================== - + 1. response_type = "id_token" - OIDC Implicit Flow: Request JWT token directly, no authorization code exchange - Alternative flows use "code" (Authorization Code Flow) - LTI 1.3 uses "id_token" response type - + 2. scope = "openid" - OIDC scope indicating OpenID Connect authentication - (Not the same as OAuth 2.0 scope for API permissions) - Tell authorization server we want OIDC identity token - + 3. nonce present and valid - Random value generated by tool before redirect to platform - Platform must include exact same nonce in response - Prevents authorization code/token interception attacks - Replay attack protection (See OIDC spec) - Example: Tool generates nonce='abc123', validates response has nonce='abc123' - + 4. state present and valid - Random value generated by tool before redirect to platform - Platform must include exact same state in response - Prevents Cross-Site Request Forgery (CSRF) attacks - Example attack prevented: Attacker tricks user into visiting evil.com which sends them to wrong authorization endpoint, tries to get them logged in there - + 5. redirect_uri validation - Must match one of tool's pre-registered redirect URIs - HTTPS REQUIRED in production @@ -420,18 +421,18 @@ def validate_preflight_response( - Allows http://localhost for local development - Allows http://127.0.0.1 for local development - Allows http://::1 for local IPv6 localhost development - + 6. client_id validation - Must match tool's registered client_id at platform - Ensures response is for correct tool instance - + HTTPS Requirement (Production Security): ========================================= - All redirect_uri values must use HTTPS - Prevents man-in-the-middle attacks - Attackers cannot intercept tokens in transit - Exceptions: localhost addresses for development/testing - + Token Flow After Validation: ============================= After these validations pass, the tool will: @@ -440,24 +441,24 @@ def validate_preflight_response( 3. Verify all claims in id_token 4. Extract user/context information from claims 5. Grant access to user - + Security Considerations: ======================== - Validate AGAINST a whitelist of known-good values - Never trust input parameters directly - HTTPS encryption prevents token interception - JWT signature proves platform created this response - + Reference: - OIDC Implicit Flow: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlow - OIDC Nonce: https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes - OAuth 2.0 CSRF: https://tools.ietf.org/html/rfc6749#section-10.12 - LTI 1.3 Preflight Response Validation: # pylint: disable=line-too-long https://www.imsglobal.org/spec/lti/v1p3/#authorization - + Parameters: preflight_response: Dict with response parameters from authorization endpoint - + Raises: PreflightRequestValidationException: If any validation fails """ @@ -473,13 +474,16 @@ def validate_preflight_response( assert preflight_response.get("nonce") assert preflight_response.get("state") redirect_uri = preflight_response.get("redirect_uri") - assert redirect_uri and redirect_uri in self._registration.get_tool_redirect_uris() # pylint: disable=line-too-long + assert redirect_uri and redirect_uri in ( + self._registration.get_tool_redirect_uris() or [] + ) # pylint: disable=line-too-long parsed_redirect_uri = urlparse(redirect_uri) if parsed_redirect_uri.scheme != "https": is_allowed_loopback = ( parsed_redirect_uri.scheme == "http" - and parsed_redirect_uri.hostname in {"localhost", "127.0.0.1", "::1"} + and parsed_redirect_uri.hostname + in {"localhost", "127.0.0.1", "::1"} ) assert is_allowed_loopback diff --git a/lti1p3platform/nrps.py b/lti1p3platform/nrps.py index 8b96a84..f95aa0a 100644 --- a/lti1p3platform/nrps.py +++ b/lti1p3platform/nrps.py @@ -53,35 +53,35 @@ class LtiNrps: """ LTI 1.3 Advantage Services - Names and Role Provisioning Service Configuration - + NRPS (Names and Role Provisioning Service) Overview: ==================================================== - + Purpose: - Enables tools to query course membership information from the platform - Alternative to separate directory integrations (LDAP, AD, etc.) - Dynamically fetches roster without pre-synced data - + Context Membership Service: - Provides list of users enrolled in a course/context - Includes user identifiers and role information - Supports pagination for large courses (thousands of students) - + Platform Role Examples: - http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor - http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student - http://purl.imsglobal.org/vocab/lis/v2/institution/person#Learner - http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator - + Tool Security Considerations: - Cache data locally if possible (reduce API calls) - Respect privacy settings and role filtering - Use appropriate scopes - Handle pagination for large courses - Handle errors gracefully if roster API unavailable - + This class configures NRPS access for a tool integration. - + Reference: https://www.imsglobal.org/spec/lti-nrps/v2p0/ """ @@ -91,7 +91,7 @@ def __init__( ): """ Initialize NRPS configuration for a tool integration - + Parameters: context_memberships_url: Platform's API endpoint for roster/memberships - Format: "https://platform.edu/lti/nrps/memberships" @@ -104,36 +104,36 @@ def __init__( def get_available_scopes(self) -> t.List[str]: """ Retrieves list of available OAuth 2.0 scopes for NRPS - + OAuth 2.0 Scopes for NRPS: - + - https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly * Access permission for Context Membership Service * Read-only: can only fetch roster data * Cannot modify/delete members (NRPS is read-only) * Included in access_token if enabled - + Scope Usage: - Platform includes this scope in access_token if NRPS enabled for tool - Tool includes this scope in token request when calling APIs - Platform validates token has required scope before returning roster - + No 'write' Scope: - NRPS is intentionally read-only - Tools cannot add/remove/modify course members via NRPS - Member management handled through platform UI - Prevents accidental/malicious roster changes from tools - + Typical Scope in Access Token (JWT): { "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly", ... } - + Returns: List containing the NRPS contextmembership.readonly scope URI - + Reference: - Scope specification: https://www.imsglobal.org/spec/lti-nrps/v2p0/#scopes """ @@ -145,10 +145,10 @@ def get_available_scopes(self) -> t.List[str]: def get_lti_nrps_launch_claim(self) -> t.Dict[str, t.Any]: """ Generate NRPS Launch Claim for LTI message - + This claim is included in the LTI launch message to tell the tool where to call to fetch the course roster (member list). - + Claim Structure: { "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { @@ -156,18 +156,18 @@ def get_lti_nrps_launch_claim(self) -> t.Dict[str, t.Any]: "service_versions": ["2.0"] } } - + Usage: 1. Tool receives launch message with NRPS claim 2. Tool extracts context_memberships_url from claim 3. Tool calls API: GET context_memberships_url?context_id= 4. Platform returns JSON list of members with roles 5. Tool processes roster data for its features - + API Call Example: GET /lti/nrps/memberships?context_id=course-123&limit=50&offset=0 With Authorization: Bearer - + API Response Example: { "context": { @@ -200,16 +200,16 @@ def get_lti_nrps_launch_claim(self) -> t.Dict[str, t.Any]: "pageSize": 50, "pageCount": 1 } - + Pagination: - pageNumber: Current page (1-indexed) - pageSize: Number of members per page - pageCount: Total number of pages - Control pagination via ?limit=50&offset=0 parameters - + Returns: dict: The namesroleservice claim to inject into LTI launch message - + Reference: - Context Membership Service: # pylint: disable=line-too-long https://www.imsglobal.org/spec/lti-nrps/v2p0/#context-memberships-service diff --git a/lti1p3platform/oidc_login.py b/lti1p3platform/oidc_login.py index d0794d2..2405f95 100644 --- a/lti1p3platform/oidc_login.py +++ b/lti1p3platform/oidc_login.py @@ -15,13 +15,13 @@ class OIDCLoginAbstract(ABC): """ Abstract base class for OIDC (OpenID Connect) Login Initiation - + LTI 1.3 Launch Flow Overview (Step 1: OIDC Login Initiation): ============================================================ - + The LTI 1.3 specification uses OpenID Connect 3rd-Party-Initiated Login. This is how the launch process begins. - + Flow: 1. User visits platform, clicks "Launch Tool" button 2. Platform initiates OIDC login by redirecting to tool's OIDC login endpoint @@ -34,9 +34,9 @@ class OIDCLoginAbstract(ABC): POST /tool/callback with id_token=&state= 7. Tool validates id_token JWT signature and claims 8. Tool grants access to user with appropriate context - + This class handles Step 2 (prepare the login request) and Step 3 (initiate redirect). - + OpenID Connect 3rd-Party-Initiated Login Details: - 'iss' (Issuer): Identifies the platform doing the login - 'client_id' (optional): If multiple registrations, tells tool which one to use @@ -44,20 +44,21 @@ class OIDCLoginAbstract(ABC): - 'lti_message_hint': Identifies the message/resource being launched - 'target_link_uri': Where tool should take user after launch (platform's tool URL) - 'lti_deployment_id' (optional): If issuer has multiple deployments - + Security: - HTTPS only (production) - State/nonce parameters prevent CSRF attacks - ID Token JWT must be validated before granting access - + References: - - OpenID Connect Core (3rd-Party-Initiated): + - OpenID Connect Core (3rd-Party-Initiated): https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin - LTI 1.3 Deep Linking: https://www.imsglobal.org/spec/lti/v1p3/#third-party-initiated-login - LTI Deep Linking Launch: https://www.imsglobal.org/spec/lti-dl/v2p0/#lifecycle """ + _request = None _platform_config = None _registration = None # type: Registration @@ -103,54 +104,54 @@ def get_launch_url(self) -> t.Optional[str]: def prepare_preflight_url(self, user_id: str) -> str: """ Prepare OIDC preflight url for 3rd-party-initiated login - + This creates the URL that redirects the user to the platform's OIDC login endpoint. This is Step 2 in the LTI launch flow. - + URL Parameters: =============== - + REQUIRED: - iss: Issuer (platform) identifier * Uniquely identifies learning platform * Example: "https://canvas.instructure.com" or "https://moodle.example.edu" * Tool uses this to look up platform configuration - + - target_link_uri: The tool URL to launch * Where user should be taken after successful launch * Should match the tool's registered URLs * Must use HTTPS in production * Example: "https://tool.example.com/launch/resource/123" - + - lti_message_hint: Identifier for the message/resource * Helps platform remember which content triggered the launch * Opaque to platform, meaningful to tool * Platform generated value (e.g., "resource-link-id-456") * Prevents mix-ups if user trying to launch different content - + - login_hint: User identifier * Helps platform identify user without additional authentication * Often opaque ID assigned by platform (not email/username) * Used to prevent "username confusion" attacks * Format depends on platform (could be "user:12345" or UUID) - + OPTIONAL: - client_id: Platform's OAuth client ID at tool * Used when tool has multiple registrations from same platform * Allows tool to select correct configuration * If not provided, tool uses first registration for issuer - + - lti_deployment_id: Deployment identifier * Used when single platform instance supports multiple orgs * Helps tool route to correct customer/tenant config * Example: university with multiple campuses - + CSRF Protection: - Platform will add 'state' parameter before redirect to authorization endpoint - Tool receives state back in callback, validates it matches - Prevents Cross-Site Request Forgery attacks - Example attack prevented: attacker tricks user into launching tool from wrong platform - + Example Flow: 1. User at https://myuniversity.edu clicks "Launch Tool" 2. Platform creates login URL: @@ -161,28 +162,28 @@ def prepare_preflight_url(self, user_id: str) -> str: 4. Tool receives these parameters, validates platform is registered 5. Tool generates state + nonce for security 6. Tool redirects to: https://myuniversity.edu/auth?client_id=uni-client-123&...&state=... - 7. University authenticates the user (already logged in? quick redirect) + 7. University authenticates the user (already logged in? quick redirect) 8. University redirects back to tool with id_token containing user/context claims - + Security Considerations: - Validate iss is registered before processing - Validate target_link_uri matches platform's list of allowed URLs - Check that user (from login_hint) is allowed to access resource (lti_message_hint) - Use HTTPS to prevent credential theft - Validate state parameter when receiving callback - + Reference: - OpenID Connect 3rd-Party-Initiated Login: https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin - LTI 1.3 Step 1 (3rd-Party-Initiated Login): https://www.imsglobal.org/spec/lti/v1p3/#step-1-third-party-initiated-login - + Parameters: user_id: Opaque user identifier from platform - + Returns: str: The complete preflight URL (includes iss, client_id, target_link_uri, etc.) - + Raises: PreflightRequestValidationException: If required fields not configured """ @@ -234,26 +235,26 @@ def get_redirect(self, url: str) -> t.Any: def initiate_login(self, user_id: str) -> t.Any: """ Initiate OIDC login by redirecting to platform's OIDC login endpoint - + This is the main entry point for starting an LTI launch. - + Process: 1. Prepare login URL with all required parameters (iss, client_id, target_link_uri, etc.) 2. Redirect user's browser to platform's login endpoint - + The platform's login endpoint will: 1. Validate all parameters 2. Authenticate user if needed 3. Generate state + nonce for CSRF/replay protection 4. Redirect to authorization endpoint 5. Eventually redirect back to tool's callback with id_token - + This is an abstract method; implementing frameworks (Django, FastAPI, Flask) override this to return appropriate HTTP redirects. - + Returns: HTTP redirect response (specific to framework) - + Raises: PreflightRequestValidationException: If configuration validation fails """ diff --git a/tests/test_deep_linking.py b/tests/test_deep_linking.py index a76267c..1760f3e 100644 --- a/tests/test_deep_linking.py +++ b/tests/test_deep_linking.py @@ -10,20 +10,27 @@ def test_get_lti_deep_linking_launch_claim_defaults(): """No accept_types → all types included.""" - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") - claim = dl.get_lti_deep_linking_launch_claim() - settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] - for t in LTI_DEEP_LINKING_ACCEPTED_TYPES: - assert t in settings["accept_types"] - assert settings["deep_link_return_url"] == "https://platform.example.com/deeplink_return" + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = deep_linker.get_lti_deep_linking_launch_claim() + settings = claim[ + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ] + for item_type in LTI_DEEP_LINKING_ACCEPTED_TYPES: + assert item_type in settings["accept_types"] + assert ( + settings["deep_link_return_url"] + == "https://platform.example.com/deeplink_return" + ) def test_get_lti_deep_linking_launch_claim_custom_types(): - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") - claim = dl.get_lti_deep_linking_launch_claim( + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = deep_linker.get_lti_deep_linking_launch_claim( accept_types={"ltiResourceLink", "link"} ) - settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + settings = claim[ + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ] assert "ltiResourceLink" in settings["accept_types"] assert "link" in settings["accept_types"] # Others should not be present @@ -31,47 +38,57 @@ def test_get_lti_deep_linking_launch_claim_custom_types(): def test_get_lti_deep_linking_launch_claim_invalid_type_raises(): - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") with pytest.raises(LtiDeepLinkingContentTypeNotSupported): - dl.get_lti_deep_linking_launch_claim(accept_types={"bogusType"}) + deep_linker.get_lti_deep_linking_launch_claim(accept_types={"bogusType"}) def test_get_lti_deep_linking_launch_claim_with_title_and_description(): - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") - claim = dl.get_lti_deep_linking_launch_claim( + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = deep_linker.get_lti_deep_linking_launch_claim( title="My Title", description="My Description" ) - settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + settings = claim[ + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ] assert settings["title"] == "My Title" assert settings["text"] == "My Description" def test_get_lti_deep_linking_launch_claim_with_extra_data(): - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") extra = {"course_id": "course-123", "module_id": "module-456"} - claim = dl.get_lti_deep_linking_launch_claim(extra_data=extra) - settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + claim = deep_linker.get_lti_deep_linking_launch_claim(extra_data=extra) + settings = claim[ + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ] assert settings["data"] == extra def test_get_lti_deep_linking_launch_claim_without_extra_data(): - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") - claim = dl.get_lti_deep_linking_launch_claim() - settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = deep_linker.get_lti_deep_linking_launch_claim() + settings = claim[ + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ] assert "data" not in settings def test_get_lti_deep_linking_launch_claim_accept_multiple_and_auto_create(): - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") - claim = dl.get_lti_deep_linking_launch_claim() - settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = deep_linker.get_lti_deep_linking_launch_claim() + settings = claim[ + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ] assert settings["accept_multiple"] is True assert settings["auto_create"] is True def test_get_lti_deep_linking_launch_claim_presentation_targets(): - dl = LtiDeepLinking("https://platform.example.com/deeplink_return") - claim = dl.get_lti_deep_linking_launch_claim() - settings = claim["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"] + deep_linker = LtiDeepLinking("https://platform.example.com/deeplink_return") + claim = deep_linker.get_lti_deep_linking_launch_claim() + settings = claim[ + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ] assert "iframe" in settings["accept_presentation_document_targets"] assert "window" in settings["accept_presentation_document_targets"] diff --git a/tests/test_ltiplatform_coverage.py b/tests/test_ltiplatform_coverage.py index 81c0651..ab66e95 100644 --- a/tests/test_ltiplatform_coverage.py +++ b/tests/test_ltiplatform_coverage.py @@ -13,14 +13,18 @@ - validate_deeplinking_resp (all paths) - validate_token """ +# pylint: disable=protected-access import time +import typing as t import uuid from unittest.mock import patch, MagicMock import pytest +import requests -from lti1p3platform.registration import Registration from lti1p3platform.jwt_helper import jwt_encode +from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract +from lti1p3platform.registration import Registration as _Registration from lti1p3platform.exceptions import ( InvalidKeySetUrl, LtiException, @@ -35,71 +39,12 @@ TOOL_KEY_SET, PLATFORM_CONFIG, RSA_PRIVATE_KEY_PEM, + RSA_PUBLIC_KEY_PEM, ) - # --------------------------------------------------------------------------- - # Minimal platform variant helpers for edge-case coverage - # --------------------------------------------------------------------------- - -import typing as t -from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract -from lti1p3platform.registration import Registration as _Registration -from tests.platform_config import ( # noqa: F401 (used below) - RSA_PUBLIC_KEY_PEM as _PUB, - RSA_PRIVATE_KEY_PEM as _PRIV, - TOOL_KEY_SET as _TKS, -) - - -class _NullRegistrationPlatform(LTI1P3PlatformConfAbstract): - """Platform whose _registration is initially None; returned by get_registration_by_params.""" - - def __init__(self) -> None: - self._cache: t.Dict[str, int] = {} - super().__init__() - - def cache_get(self, key: str) -> t.Optional[int]: - return self._cache.get(key) - - def cache_set(self, key: str, exp: int) -> None: - self._cache[key] = exp - - def init_platform_config(self, **kwargs: t.Any) -> None: - pass # _registration stays None - - def get_registration_by_params(self, **kwargs: t.Any) -> _Registration: - reg = ( - _Registration() - .set_iss(PLATFORM_CONFIG["iss"]) - .set_client_id(PLATFORM_CONFIG["client_id"]) - .set_access_token_url(PLATFORM_CONFIG["access_token_url"]) - .set_platform_public_key(_PUB) - .set_platform_private_key(_PRIV) - .set_tool_key_set(_TKS) - ) - return reg - - -class _NoClientIdPlatform(PlatformConf): - """Platform with no client_id in registration (triggers line 537 in ltiplatform.py).""" - - def init_platform_config(self, **kwargs: t.Any) -> None: - super().init_platform_config(**kwargs) - self._registration.set_client_id(None) # type: ignore[arg-type] - - -class _NoAudiencePlatform(PlatformConf): - """Platform with no access_token_url (triggers line 658 in ltiplatform.py).""" - - def init_platform_config(self, **kwargs: t.Any) -> None: - super().init_platform_config(**kwargs) - self._registration.set_access_token_url(None) # type: ignore[arg-type] - -import typing as t - -from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract -from lti1p3platform.registration import Registration as _Registration -from .platform_config import RSA_PUBLIC_KEY_PEM as _PUB # noqa: F401 +# --------------------------------------------------------------------------- +# Minimal platform variant helpers for edge-case coverage +# --------------------------------------------------------------------------- class _NullRegistrationPlatform(LTI1P3PlatformConfAbstract): @@ -124,7 +69,7 @@ def get_registration_by_params(self, **kwargs: t.Any) -> _Registration: .set_iss(PLATFORM_CONFIG["iss"]) .set_client_id(PLATFORM_CONFIG["client_id"]) .set_access_token_url(PLATFORM_CONFIG["access_token_url"]) - .set_platform_public_key(_PUB) + .set_platform_public_key(RSA_PUBLIC_KEY_PEM) .set_platform_private_key(RSA_PRIVATE_KEY_PEM) .set_tool_key_set(TOOL_KEY_SET) ) @@ -150,12 +95,13 @@ def init_platform_config(self, **kwargs: t.Any) -> None: # Helpers # --------------------------------------------------------------------------- + def _make_tool_jwt(claims, private_key=None, headers=None): """Return a JWT signed with the tool's private key.""" if private_key is None: private_key = TOOL_PRIVATE_KEY_PEM if headers is None: - jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + jwk = _Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) headers = {"kid": jwk.get("kid")} return jwt_encode(claims, private_key, algorithm="RS256", headers=headers) @@ -196,6 +142,7 @@ def _make_deeplink_jwt(extra_claims=None): # set_accepted_deeplinking_types # --------------------------------------------------------------------------- + def test_set_accepted_types_filters_valid(): platform = PlatformConf() platform.set_accepted_deeplinking_types(["ltiResourceLink", "link"]) @@ -220,6 +167,7 @@ def test_set_accepted_types_empty_list(): # fetch_public_key – security validation # --------------------------------------------------------------------------- + def test_fetch_public_key_http_raises(): platform = PlatformConf() with pytest.raises(InvalidKeySetUrl): @@ -266,11 +214,10 @@ def test_fetch_public_key_valid_url_returns_jwks(): def test_fetch_public_key_request_error_raises(): - import requests as req platform = PlatformConf() with patch( "lti1p3platform.ltiplatform.requests.get", - side_effect=req.exceptions.ConnectionError("network failure"), + side_effect=requests.exceptions.ConnectionError("network failure"), ): with pytest.raises(LtiException): platform.fetch_public_key("https://example.com/jwks") @@ -290,6 +237,7 @@ def test_fetch_public_key_invalid_json_raises(): # get_tool_key_set – non-HTTPS URL # --------------------------------------------------------------------------- + def test_get_tool_key_set_non_https_url_raises(): platform = PlatformConf() # Replace key set with None, set an HTTP URL @@ -317,6 +265,7 @@ def test_get_tool_key_set_https_url_returns_and_caches(): # validate_jwt_format – error paths # --------------------------------------------------------------------------- + def test_validate_jwt_format_wrong_parts(): platform = PlatformConf() with pytest.raises(LtiException, match="JWT must contain 3 parts"): @@ -333,6 +282,7 @@ def test_validate_jwt_format_invalid_base64(): # get_tool_public_key – error paths # --------------------------------------------------------------------------- + def test_get_tool_public_key_no_kid_raises(): platform = PlatformConf() claims = { @@ -350,7 +300,7 @@ def test_get_tool_public_key_no_kid_raises(): def test_get_tool_public_key_no_alg_raises(): platform = PlatformConf() - jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + jwk = _Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) claims = { "iss": "tool", "aud": "platform", @@ -368,7 +318,7 @@ def test_get_tool_public_key_no_alg_raises(): def test_get_tool_public_key_kid_not_found_raises(): platform = PlatformConf() - jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + jwk = _Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) claims = { "iss": "tool", "aud": "platform", @@ -388,6 +338,7 @@ def test_get_tool_public_key_kid_not_found_raises(): # _is_token_replay / _is_nonce_replay # --------------------------------------------------------------------------- + def test_token_replay_first_use_returns_false(): platform = PlatformConf() assert platform._is_token_replay(str(uuid.uuid4()), int(time.time()) + 60) is False @@ -418,6 +369,7 @@ def test_nonce_replay_second_use_returns_true(): # _validate_tool_access_token_assertion – error paths # --------------------------------------------------------------------------- + def test_assertion_missing_jti_raises(): platform = PlatformConf() decoded = { @@ -573,6 +525,7 @@ def test_assertion_jti_replay_raises(): # get_access_token – error paths # --------------------------------------------------------------------------- + def test_get_access_token_missing_claim_raises(): platform = PlatformConf() with pytest.raises(MissingRequiredClaim): @@ -636,6 +589,7 @@ def test_get_access_token_unsupported_scope_returns_empty(): # validate_deeplinking_resp # --------------------------------------------------------------------------- + def test_validate_deeplinking_resp_empty_content_items(): platform = PlatformConf() token = _make_deeplink_jwt() @@ -658,7 +612,7 @@ def test_validate_deeplinking_resp_with_link_items(): def test_validate_deeplinking_resp_missing_nonce_raises(): platform = PlatformConf() - jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + jwk = _Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) claims = { "iss": "https://tool.example.com", "sub": PLATFORM_CONFIG["client_id"], @@ -679,7 +633,7 @@ def test_validate_deeplinking_resp_missing_nonce_raises(): def test_validate_deeplinking_resp_missing_exp_raises(): """Token without exp claim should raise LtiDeepLinkingResponseException.""" platform = PlatformConf() - jwk = Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) + jwk = _Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) claims = { "iss": "https://tool.example.com", "sub": PLATFORM_CONFIG["client_id"], @@ -741,6 +695,7 @@ def test_validate_deeplinking_resp_unsupported_content_type_raises(): # get_registration – _registration initially None path (lines 178-181) # --------------------------------------------------------------------------- + def test_get_registration_lazy_loads_when_none(): platform = _NullRegistrationPlatform() # _registration is None initially @@ -755,6 +710,7 @@ def test_get_registration_lazy_loads_when_none(): # _validate_tool_access_token_assertion – no client_id (line 537) # --------------------------------------------------------------------------- + def test_assertion_no_client_id_raises(): platform = _NoClientIdPlatform() decoded = { @@ -775,6 +731,7 @@ def test_assertion_no_client_id_raises(): # get_access_token – no expected audience (line 658) # --------------------------------------------------------------------------- + def test_get_access_token_no_audience_raises(): platform = _NoAudiencePlatform() encoded_jwt = _make_valid_assertion() @@ -790,6 +747,7 @@ def test_get_access_token_no_audience_raises(): } ) + def _make_platform_token(extra_claims=None): """Return a JWT signed by the platform's private key.""" platform = PlatformConf() @@ -812,25 +770,31 @@ def test_validate_token_valid_returns_true(): def test_validate_token_matching_scope_returns_true(): platform = PlatformConf() token = _make_platform_token() - assert platform.validate_token( - token, - allowed_scopes=["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"], - ) is True + assert ( + platform.validate_token( + token, + allowed_scopes=["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"], + ) + is True + ) def test_validate_token_non_matching_scope_returns_false(): platform = PlatformConf() token = _make_platform_token() - assert platform.validate_token( - token, - allowed_scopes=["https://purl.imsglobal.org/spec/lti-ags/scope/score"], - ) is False + assert ( + platform.validate_token( + token, + allowed_scopes=["https://purl.imsglobal.org/spec/lti-ags/scope/score"], + ) + is False + ) def test_validate_token_invalid_iss_raises(): """Token signed by platform key but with wrong iss should raise.""" platform = PlatformConf() - token = Registration.encode_and_sign( + token = _Registration.encode_and_sign( { "sub": PLATFORM_CONFIG["client_id"], "iss": "https://wrong-issuer.example/", diff --git a/tests/test_registration.py b/tests/test_registration.py index ca0b366..52194fa 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -6,8 +6,6 @@ """ import time -import pytest - from lti1p3platform.registration import Registration from .platform_config import ( @@ -130,9 +128,7 @@ def test_encode_and_sign_without_expiration(): """encode_and_sign without expiration should not add iat/exp.""" payload = {"sub": "user-1", "iss": "https://platform.example.com"} token = Registration.encode_and_sign(payload, RSA_PRIVATE_KEY_PEM) - decoded = Registration.decode_and_verify( - token, RSA_PUBLIC_KEY_PEM, audience=None - ) + decoded = Registration.decode_and_verify(token, RSA_PUBLIC_KEY_PEM, audience=None) assert decoded["sub"] == "user-1" assert "exp" not in decoded @@ -140,9 +136,7 @@ def test_encode_and_sign_without_expiration(): def test_encode_and_sign_with_expiration(): payload = {"sub": "user-1", "iss": "https://platform.example.com"} token = Registration.encode_and_sign(payload, RSA_PRIVATE_KEY_PEM, expiration=3600) - decoded = Registration.decode_and_verify( - token, RSA_PUBLIC_KEY_PEM, audience=None - ) + decoded = Registration.decode_and_verify(token, RSA_PUBLIC_KEY_PEM, audience=None) assert decoded["sub"] == "user-1" assert "exp" in decoded assert decoded["exp"] > int(time.time()) diff --git a/tox.ini b/tox.ini index b0acbfc..19d48b7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,13 @@ [tox] isolated_build = True -envlist = py38, py39, py310 +envlist = py38, py39, py310, py311 [gh-actions] python = 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 [testenv] allowlist_externals = poetry From 02abc9c71b2277440a8444b5de90b2d977ecca1e Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Sat, 4 Apr 2026 15:10:21 +1100 Subject: [PATCH 7/9] prevent token-substitution / replay attacks --- lti1p3platform/ltiplatform.py | 8 +++++ lti1p3platform/message_launch.py | 8 +++++ tests/test_ltiplatform_coverage.py | 48 ++++++++++++++++++++++++------ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/lti1p3platform/ltiplatform.py b/lti1p3platform/ltiplatform.py index cb89390..bcfaea8 100644 --- a/lti1p3platform/ltiplatform.py +++ b/lti1p3platform/ltiplatform.py @@ -715,6 +715,14 @@ def validate_deeplinking_resp( if not nonce: raise LtiDeepLinkingResponseException("Token nonce is missing") + # OIDC Core Section 3.1.3.7(11): the nonce in the response MUST equal the + # value that was sent in the authentication request. Reject any nonce we + # did not originate to prevent token-substitution / replay attacks. + if self.cache_get(f"sent_nonce:{str(nonce)}") is None: + raise LtiDeepLinkingResponseException( + "Nonce was not issued by this platform" + ) + exp = deep_link_response.get("exp") if not exp: raise LtiDeepLinkingResponseException("Token exp is missing") diff --git a/lti1p3platform/message_launch.py b/lti1p3platform/message_launch.py index 292b02f..f81c27c 100644 --- a/lti1p3platform/message_launch.py +++ b/lti1p3platform/message_launch.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time import typing as t from urllib.parse import urlparse @@ -625,6 +626,13 @@ def generate_launch_request(self) -> LaunchData: if self._deep_linking_launch_data: launch_message.update(self._deep_linking_launch_data) + # OIDC Section 3.1.3.7(11): store sent nonce so the response can be + # verified against the value we originated (prevents replay/forgery). + nonce = launch_message.get("nonce") + if nonce: + sent_exp = int(time.time()) + self.id_token_expiration + self._platform_config.cache_set(f"sent_nonce:{nonce}", sent_exp) + return { "state": state, "id_token": self._registration.platform_encode_and_sign( diff --git a/tests/test_ltiplatform_coverage.py b/tests/test_ltiplatform_coverage.py index ab66e95..84932dc 100644 --- a/tests/test_ltiplatform_coverage.py +++ b/tests/test_ltiplatform_coverage.py @@ -121,7 +121,7 @@ def _make_valid_assertion(jti=None, extra=None): return _make_tool_jwt(claims) -def _make_deeplink_jwt(extra_claims=None): +def _make_deeplink_jwt(extra_claims=None, nonce=None): """Return a valid deep-link response JWT signed by the tool.""" claims = { "iss": "https://tool.example.com", @@ -129,7 +129,7 @@ def _make_deeplink_jwt(extra_claims=None): "aud": PLATFORM_CONFIG["iss"], "iat": int(time.time()) - 5, "exp": int(time.time()) + 60, - "nonce": str(uuid.uuid4()), + "nonce": nonce if nonce is not None else str(uuid.uuid4()), "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [], } @@ -138,6 +138,11 @@ def _make_deeplink_jwt(extra_claims=None): return _make_tool_jwt(claims) +def _register_sent_nonce(platform, nonce: str, ttl: int = 300) -> None: + """Simulate the platform having sent this nonce in a deep-link request.""" + platform.cache_set(f"sent_nonce:{nonce}", int(time.time()) + ttl) + + # --------------------------------------------------------------------------- # set_accepted_deeplinking_types # --------------------------------------------------------------------------- @@ -592,18 +597,23 @@ def test_get_access_token_unsupported_scope_returns_empty(): def test_validate_deeplinking_resp_empty_content_items(): platform = PlatformConf() - token = _make_deeplink_jwt() + nonce = str(uuid.uuid4()) + _register_sent_nonce(platform, nonce) + token = _make_deeplink_jwt(nonce=nonce) result = platform.validate_deeplinking_resp({"JWT": token}) assert result == [] def test_validate_deeplinking_resp_with_link_items(): platform = PlatformConf() + nonce = str(uuid.uuid4()) + _register_sent_nonce(platform, nonce) items = [{"type": "ltiResourceLink", "url": "https://tool.example.com/resource"}] token = _make_deeplink_jwt( + nonce=nonce, extra_claims={ "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": items - } + }, ) result = platform.validate_deeplinking_resp({"JWT": token}) assert len(result) == 1 @@ -633,6 +643,8 @@ def test_validate_deeplinking_resp_missing_nonce_raises(): def test_validate_deeplinking_resp_missing_exp_raises(): """Token without exp claim should raise LtiDeepLinkingResponseException.""" platform = PlatformConf() + nonce = str(uuid.uuid4()) + _register_sent_nonce(platform, nonce) jwk = _Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) claims = { "iss": "https://tool.example.com", @@ -640,7 +652,7 @@ def test_validate_deeplinking_resp_missing_exp_raises(): "aud": PLATFORM_CONFIG["iss"], "iat": int(time.time()) - 5, # No "exp" claim - "nonce": str(uuid.uuid4()), + "nonce": nonce, "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [], } @@ -654,21 +666,25 @@ def test_validate_deeplinking_resp_missing_exp_raises(): def test_validate_deeplinking_resp_nonce_replay_raises(): platform = PlatformConf() nonce = str(uuid.uuid4()) + _register_sent_nonce(platform, nonce) # First request succeeds - token1 = _make_deeplink_jwt(extra_claims={"nonce": nonce}) + token1 = _make_deeplink_jwt(nonce=nonce) platform.validate_deeplinking_resp({"JWT": token1}) # Second request with same nonce must be rejected - token2 = _make_deeplink_jwt(extra_claims={"nonce": nonce}) + token2 = _make_deeplink_jwt(nonce=nonce) with pytest.raises(LtiDeepLinkingResponseException, match="Replay detected"): platform.validate_deeplinking_resp({"JWT": token2}) def test_validate_deeplinking_resp_wrong_message_type_raises(): platform = PlatformConf() + nonce = str(uuid.uuid4()) + _register_sent_nonce(platform, nonce) token = _make_deeplink_jwt( + nonce=nonce, extra_claims={ "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest" - } + }, ) with pytest.raises(LtiDeepLinkingResponseException, match="Deep Linking Response"): platform.validate_deeplinking_resp({"JWT": token}) @@ -676,17 +692,31 @@ def test_validate_deeplinking_resp_wrong_message_type_raises(): def test_validate_deeplinking_resp_unsupported_content_type_raises(): platform = PlatformConf() + nonce = str(uuid.uuid4()) + _register_sent_nonce(platform, nonce) token = _make_deeplink_jwt( + nonce=nonce, extra_claims={ "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [ {"type": "unsupported_content_type"} ] - } + }, ) with pytest.raises(LtiDeepLinkingResponseException, match="not supported"): platform.validate_deeplinking_resp({"JWT": token}) +def test_validate_deeplinking_resp_unknown_nonce_raises(): + """Nonce not previously sent by the platform must be rejected (OIDC §3.1.3.7).""" + platform = PlatformConf() + # Nonce is NOT registered as sent — simulates a forged / replayed response + token = _make_deeplink_jwt() + with pytest.raises( + LtiDeepLinkingResponseException, match="not issued by this platform" + ): + platform.validate_deeplinking_resp({"JWT": token}) + + # --------------------------------------------------------------------------- # validate_token # --------------------------------------------------------------------------- From c7dc8895881fdac6e8aa064588465075f181b9c2 Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Sun, 5 Apr 2026 08:12:14 +1000 Subject: [PATCH 8/9] update redirect-vs-error-page behavior --- README.md | 57 +++- lti1p3platform/error_codes.py | 102 +++++++ lti1p3platform/exceptions.py | 130 +++++++- .../framework/django/message_launch.py | 8 +- lti1p3platform/framework/django/oidc_login.py | 5 +- .../framework/fastapi/message_launch.py | 8 +- .../framework/fastapi/oidc_login.py | 5 +- lti1p3platform/ltiplatform.py | 77 +++-- lti1p3platform/message_launch.py | 162 +++++++--- lti1p3platform/oidc_login.py | 81 ++++- lti1p3platform/registration.py | 17 +- lti1p3platform/service_connector.py | 31 +- tests/test_ltiplatform_coverage.py | 32 +- tests/test_oidc_error_mapping.py | 279 ++++++++++++++++++ tests/test_registration.py | 21 ++ 15 files changed, 882 insertions(+), 133 deletions(-) create mode 100644 lti1p3platform/error_codes.py create mode 100644 tests/test_oidc_error_mapping.py diff --git a/README.md b/README.md index c148119..21cc901 100644 --- a/README.md +++ b/README.md @@ -64,16 +64,44 @@ class OIDCLogin(OIDCLoginAbstract): """ return HttpResponseRedirect(url) + def render_error_page(self, message, status_code): + """ + This will be invoked when the library decides the error must not be + returned as an OAuth redirect. + """ + return HttpResponse(message, status=status_code) + # Initiate login endpoint def preflight_lti_1p3_launch(request, user_id, *args, **kwargs): platform = get_registered_platform(*args, **kwargs) - oidc_login = OLOIDCLogin(request, platform) + oidc_login = OIDCLogin(request, platform) # Redirect the current login user to the tool provider, - return redirect_url.initiate_login(user_id) + return oidc_login.initiate_login(user_id) ``` +### OIDC error response behavior + +The library decides whether an OIDC/login error should be returned as a redirect +to the tool or rendered locally as an error page. + +| Scenario | Error code | Behavior | +|----------|------------|----------| +| Unknown `client_id` | `unauthorized_client` | Redirect to `redirect_uri` with OAuth error params | +| Missing required params | `invalid_request` | Redirect to `redirect_uri` with OAuth error params | +| Wrong `response_type` | `unsupported_response_type` | Redirect to `redirect_uri` with OAuth error params | +| Missing `openid` scope | `invalid_scope` | Redirect to `redirect_uri` with OAuth error params | +| Bad `login_hint` | `invalid_request` | Redirect to `redirect_uri` with OAuth error params | +| Expired `lti_message_hint` | `invalid_request` | Redirect to `redirect_uri` with OAuth error params | +| User not authorized | `access_denied` | Redirect to `redirect_uri` with OAuth error params | +| User not logged in | `login_required` | Redirect to `redirect_uri` with OAuth error params | +| Invalid `redirect_uri` | `invalid_request_uri` | Render local error page | +| Internal signing/config error | `server_error` or `temporarily_unavailable` | Render local error page | + +For redirectable errors, the library appends `error`, `error_description`, and +`state` when available. For non-redirectable errors, `render_error_page()` is used. + ## LTI Message launch The tool provider redirect to the platform's OIDC auth request endpoint. The platform received the auth request and it will do some little bit of validation, it needs to ensure user is login, also check the `login_hint` is matched with the `user_id`. The platform also could get the context from the `lti_message_hint` which is sent in the initiating request and do some other validation. @@ -91,6 +119,20 @@ class LTI1p3MessageLaunch(MessageLaunchAbstract): """ pass + def get_redirect(self, url): + """ + This will be invoked when launch validation fails with a redirectable + OAuth/OIDC error. + """ + return HttpResponseRedirect(url) + + def render_error_page(self, message, status_code): + """ + This will be invoked when launch validation fails with a local-only + error such as `invalid_request_uri` or `server_error`. + """ + return HttpResponse(message, status=status_code) + def prepare_launch(self, preflight_response, **kwargs): """ You could do some other checks and get some contexts from `lti_message_hint` you've set in previous request @@ -106,11 +148,18 @@ class LTI1p3MessageLaunch(MessageLaunchAbstract): def lti_resource_link_launch(request, *args, **kwargs): platform = get_registered_platform(*args, **kwargs) - message_launch = LTI1p3MessageLaunch(request, *args, **kwargs) + message_launch = LTI1p3MessageLaunch(request, platform) - return launch.lti_launch(*args, **kwargs) + return message_launch.lti_launch(*args, **kwargs) ``` +### Launch error response behavior + +`lti_launch()` uses the same policy as OIDC login: + +- Redirect to the supplied `redirect_uri` for redirectable OAuth/OIDC errors such as `invalid_request`, `unauthorized_client`, `unsupported_response_type`, `invalid_scope`, `access_denied`, and `login_required`. +- Render a local error page for non-redirectable or server-side failures such as `invalid_request_uri`, `server_error`, and `temporarily_unavailable`. + ## Examples [Django example](examples/django_platform/README.md) diff --git a/lti1p3platform/error_codes.py b/lti1p3platform/error_codes.py new file mode 100644 index 0000000..12d3878 --- /dev/null +++ b/lti1p3platform/error_codes.py @@ -0,0 +1,102 @@ +# OAuth 2.0 and OpenID Connect Error Codes +# Sources: +# - OAuth 2.0 RFC 6749 §4.1.2.1 (Authorization Endpoint errors) +# - OAuth 2.0 RFC 6749 §5.2 (Token Endpoint errors) +# - OAuth 2.0 Bearer Token Usage RFC 6750 §3 (Resource Server errors) +# - OpenID Connect Core 1.0 §3.1.2.6 and §18.3.1 (IANA OAuth Extensions Error Registry) +# https://openid.net/specs/openid-connect-core-1_0.html#OAuthErrorRegistry + +# --------------------------------------------------------------------------- +# OAuth 2.0 RFC 6749 – Authorization Endpoint errors (§4.1.2.1) +# --------------------------------------------------------------------------- + +# The request is missing a required parameter, includes an invalid parameter +# value, includes a parameter more than once, or is otherwise malformed. +INVALID_REQUEST = "invalid_request" + +# The client is not authorized to request an authorization code using this +# method. +UNAUTHORIZED_CLIENT = "unauthorized_client" + +# The resource owner or authorization server denied the request. +ACCESS_DENIED = "access_denied" + +# The authorization server does not support obtaining an authorization code +# using this method. +UNSUPPORTED_RESPONSE_TYPE = "unsupported_response_type" + +# The requested scope is invalid, unknown, or malformed. +INVALID_SCOPE = "invalid_scope" + +# The authorization server encountered an unexpected condition that prevented +# it from fulfilling the request. +SERVER_ERROR = "server_error" + +# The authorization server is currently unable to handle the request due to a +# temporary overloading or maintenance of the server. +TEMPORARILY_UNAVAILABLE = "temporarily_unavailable" + +# --------------------------------------------------------------------------- +# OAuth 2.0 RFC 6749 – Token Endpoint errors (§5.2) +# --------------------------------------------------------------------------- + +# Client authentication failed (e.g., unknown client, no client authentication +# included, or unsupported authentication method). +INVALID_CLIENT = "invalid_client" + +# The provided authorization grant (e.g., authorization code, resource owner +# credentials) or refresh token is invalid, expired, revoked, does not match +# the redirection URI used in the authorization request, or was issued to +# another client. +INVALID_GRANT = "invalid_grant" + +# The authorization grant type is not supported by the authorization server. +UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type" + +# --------------------------------------------------------------------------- +# OAuth 2.0 Bearer Token Usage RFC 6750 – Resource Server / UserInfo errors (§3) +# --------------------------------------------------------------------------- + +# The access token provided is expired, revoked, malformed, or invalid for +# other reasons. +INVALID_TOKEN = "invalid_token" + +# The request requires higher privileges than provided by the access token. +INSUFFICIENT_SCOPE = "insufficient_scope" + +# --------------------------------------------------------------------------- +# OpenID Connect Core 1.0 – IANA OAuth Extensions Error Registry (§18.3.1) +# Used in Authentication Error Responses (§3.1.2.6) +# --------------------------------------------------------------------------- + +# The Authorization Server requires End-User interaction of some form to +# proceed. Returned when prompt=none but interaction is required. +INTERACTION_REQUIRED = "interaction_required" + +# The Authorization Server requires End-User authentication. Returned when +# prompt=none but the End-User is not authenticated. +LOGIN_REQUIRED = "login_required" + +# The End-User is required to select a session at the Authorization Server. +# Returned when prompt=none but account selection is required. +ACCOUNT_SELECTION_REQUIRED = "account_selection_required" + +# The Authorization Server requires End-User consent. Returned when +# prompt=none but consent has not been given. +CONSENT_REQUIRED = "consent_required" + +# The request_uri in the Authorization Request returns an error or contains +# invalid data. +INVALID_REQUEST_URI = "invalid_request_uri" + +# The request parameter contains an invalid Request Object. +INVALID_REQUEST_OBJECT = "invalid_request_object" + +# The OP does not support use of the request parameter defined in §6. +REQUEST_NOT_SUPPORTED = "request_not_supported" + +# The OP does not support use of the request_uri parameter defined in §6. +REQUEST_URI_NOT_SUPPORTED = "request_uri_not_supported" + +# The OP does not support use of the registration parameter defined in §7.2.1. +REGISTRATION_NOT_SUPPORTED = "registration_not_supported" diff --git a/lti1p3platform/exceptions.py b/lti1p3platform/exceptions.py index 41128b0..94bafe9 100644 --- a/lti1p3platform/exceptions.py +++ b/lti1p3platform/exceptions.py @@ -1,32 +1,146 @@ +from lti1p3platform import error_codes + + +REDIRECT_ERROR_CODES = { + error_codes.INVALID_REQUEST, + error_codes.UNAUTHORIZED_CLIENT, + error_codes.ACCESS_DENIED, + error_codes.UNSUPPORTED_RESPONSE_TYPE, + error_codes.INVALID_SCOPE, + error_codes.LOGIN_REQUIRED, + error_codes.INTERACTION_REQUIRED, + error_codes.ACCOUNT_SELECTION_REQUIRED, + error_codes.CONSENT_REQUIRED, +} + + +ERROR_PAGE_STATUS_CODES = { + error_codes.INVALID_REQUEST_URI: 400, + error_codes.INVALID_REQUEST_OBJECT: 400, + error_codes.REQUEST_NOT_SUPPORTED: 400, + error_codes.REQUEST_URI_NOT_SUPPORTED: 400, + error_codes.SERVER_ERROR: 500, + error_codes.TEMPORARILY_UNAVAILABLE: 503, +} + + +def get_error_code(error: object) -> str: + if isinstance(error, str): + return error + + code = getattr(error, "code", None) + if isinstance(code, str): + return code + + return error_codes.SERVER_ERROR + + +def get_error_response_behavior(error: object) -> str: + code = get_error_code(error) + if code in REDIRECT_ERROR_CODES: + return "redirect" + return "error_page" + + +def get_error_page_status_code(error: object) -> int: + code = get_error_code(error) + return ERROR_PAGE_STATUS_CODES.get(code, 500) + + class PreflightRequestValidationException(Exception): - pass + code = error_codes.INVALID_REQUEST class LtiDeepLinkingContentTypeNotSupported(Exception): - pass + code = error_codes.INVALID_REQUEST class MissingRequiredClaim(Exception): - pass + code = error_codes.INVALID_REQUEST class UnsupportedGrantType(Exception): - pass + code = error_codes.UNSUPPORTED_GRANT_TYPE + + +class UnsupportedResponseType(Exception): + code = error_codes.UNSUPPORTED_RESPONSE_TYPE + + +class UnauthorizedClient(Exception): + """Canonical exception name kept for API compatibility.""" + + code = error_codes.UNAUTHORIZED_CLIENT class InvalidKeySetUrl(Exception): - pass + code = error_codes.INVALID_REQUEST_URI + + +class InvalidRequestUri(Exception): + code = error_codes.INVALID_REQUEST_URI class LtiException(Exception): - pass + code = error_codes.SERVER_ERROR class LtiDeepLinkingResponseException(Exception): - pass + code = error_codes.INVALID_REQUEST + + +class InvalidJwtToken(Exception): + code = error_codes.INVALID_TOKEN + + +class InvalidClientAssertion(Exception): + code = error_codes.INVALID_CLIENT + + +class PlatformNotReadyException(Exception): + """ + Raised when platform is not yet initialized/configured. + Per OAuth 2.0 RFC 6749 §5.2: server is unable to handle request due to + temporary overloading or maintenance. + """ + + code = error_codes.TEMPORARILY_UNAVAILABLE + + +class InvalidRequestData(Exception): + """ + Raised when request data is missing required fields or contains invalid values. + Per OAuth 2.0 RFC 6749 §4.1.2.1: request is missing required parameter, + includes invalid parameter, or is otherwise malformed. + """ + + code = error_codes.INVALID_REQUEST + + +class InvalidScopeException(Exception): + """ + Raised when requested scope is invalid, unknown, or malformed. + Per OAuth 2.0 RFC 6749 §4.1.2.1: scope parameter validation failure. + """ + + code = error_codes.INVALID_SCOPE + + +class AccessDeniedException(Exception): + code = error_codes.ACCESS_DENIED + + +class LoginRequiredException(Exception): + code = error_codes.LOGIN_REQUIRED + + +class InternalSigningError(Exception): + code = error_codes.SERVER_ERROR class LtiServiceException(Exception): + code = error_codes.SERVER_ERROR + def __init__(self, message: str, status_code: int) -> None: super().__init__(message) @@ -35,4 +149,4 @@ def __init__(self, message: str, status_code: int) -> None: class LineItemNotFoundException(LtiException): - pass + code = error_codes.INVALID_REQUEST diff --git a/lti1p3platform/framework/django/message_launch.py b/lti1p3platform/framework/django/message_launch.py index 6054dd1..86e37df 100644 --- a/lti1p3platform/framework/django/message_launch.py +++ b/lti1p3platform/framework/django/message_launch.py @@ -1,6 +1,6 @@ import typing as t -from django.http import HttpRequest, HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from lti1p3platform.message_launch import LTIAdvantageMessageLaunchAbstract from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract @@ -23,3 +23,9 @@ def render_launch_form( self, launch_data: t.Dict[str, t.Any], **kwargs: t.Any ) -> HttpResponse: return HttpResponse(template.render(launch_data)) + + def get_redirect(self, url: str) -> HttpResponseRedirect: + return HttpResponseRedirect(url) + + def render_error_page(self, message: str, status_code: int) -> HttpResponse: + return HttpResponse(message, status=status_code) diff --git a/lti1p3platform/framework/django/oidc_login.py b/lti1p3platform/framework/django/oidc_login.py index c2ccc70..cf9a0f3 100644 --- a/lti1p3platform/framework/django/oidc_login.py +++ b/lti1p3platform/framework/django/oidc_login.py @@ -1,7 +1,10 @@ -from django.http import HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect from lti1p3platform.oidc_login import OIDCLoginAbstract class DjangoAPIOIDCLogin(OIDCLoginAbstract): def get_redirect(self, url: str) -> HttpResponseRedirect: return HttpResponseRedirect(url) + + def render_error_page(self, message: str, status_code: int) -> HttpResponse: + return HttpResponse(message, status=status_code) diff --git a/lti1p3platform/framework/fastapi/message_launch.py b/lti1p3platform/framework/fastapi/message_launch.py index e2abf3a..6ea81fa 100644 --- a/lti1p3platform/framework/fastapi/message_launch.py +++ b/lti1p3platform/framework/fastapi/message_launch.py @@ -1,7 +1,7 @@ import typing as t from fastapi.requests import Request -from fastapi.responses import Response +from fastapi.responses import PlainTextResponse, RedirectResponse, Response from lti1p3platform.message_launch import MessageLaunchAbstract @@ -19,3 +19,9 @@ def render_launch_form( self, launch_data: t.Dict[str, t.Any], **kwargs: t.Any ) -> Response: return Response(template.render(launch_data)) + + def get_redirect(self, url: str) -> RedirectResponse: + return RedirectResponse(url) + + def render_error_page(self, message: str, status_code: int) -> PlainTextResponse: + return PlainTextResponse(message, status_code=status_code) diff --git a/lti1p3platform/framework/fastapi/oidc_login.py b/lti1p3platform/framework/fastapi/oidc_login.py index a5169dc..1f18175 100644 --- a/lti1p3platform/framework/fastapi/oidc_login.py +++ b/lti1p3platform/framework/fastapi/oidc_login.py @@ -1,7 +1,10 @@ -from fastapi.responses import RedirectResponse +from fastapi.responses import PlainTextResponse, RedirectResponse from lti1p3platform.oidc_login import OIDCLoginAbstract class FastAPIOIDCLogin(OIDCLoginAbstract): def get_redirect(self, url: str) -> RedirectResponse: return RedirectResponse(url) + + def render_error_page(self, message: str, status_code: int) -> PlainTextResponse: + return PlainTextResponse(message, status_code=status_code) diff --git a/lti1p3platform/ltiplatform.py b/lti1p3platform/ltiplatform.py index bcfaea8..251857b 100644 --- a/lti1p3platform/ltiplatform.py +++ b/lti1p3platform/ltiplatform.py @@ -12,6 +12,7 @@ import jwt from jwcrypto.jwk import JWK # type: ignore +from . import exceptions from .registration import Registration from .constants import ( LTI_1P3_ACCESS_TOKEN_SCOPES, @@ -22,6 +23,8 @@ MissingRequiredClaim, UnsupportedGrantType, InvalidKeySetUrl, + InvalidJwtToken, + InvalidClientAssertion, LtiException, LtiDeepLinkingResponseException, ) @@ -192,7 +195,8 @@ def get_jwks(self) -> JWKS: Returns: JWKS: Dictionary with 'keys' list containing JWK objects """ - assert self._registration is not None, "Registration not yet set" + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") return {"keys": self._registration.get_jwks()} @@ -272,15 +276,17 @@ def get_tool_key_set(self) -> JWKS: """ Get tool public key """ - assert self._registration is not None, "Registration not yet set" + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") tool_key_set = self._registration.get_tool_key_set() tool_key_set_url = self._registration.get_tool_key_set_url() if not tool_key_set: - assert ( - tool_key_set_url is not None - ), "If public_key_set is not set, public_set_url should be set" + if tool_key_set_url is None: + raise exceptions.PlatformNotReadyException( + "If public_key_set is not set, public_set_url should be set" + ) if tool_key_set_url.startswith("https://"): tool_key_set = self.fetch_public_key(tool_key_set_url) self._registration.set_tool_key_set(tool_key_set) @@ -304,7 +310,7 @@ def validate_jwt_format(self, jwt_token_string: str) -> None: if len(jwt_parts) != 3: # Invalid number of parts in JWT. - raise LtiException("Invalid id_token, JWT must contain 3 parts") + raise InvalidJwtToken("Invalid id_token, JWT must contain 3 parts") try: # Decode JWT headers. @@ -315,7 +321,7 @@ def validate_jwt_format(self, jwt_token_string: str) -> None: body = self.urlsafe_b64decode(jwt_parts[1]) self._jwt["body"] = json.loads(body) except Exception as exc: - raise LtiException("Invalid JWT format, can't be decoded") from exc + raise InvalidJwtToken("Invalid JWT format, can't be decoded") from exc def get_tool_public_key(self) -> bytes: tool_key_set = self.get_tool_key_set() @@ -325,9 +331,9 @@ def get_tool_public_key(self) -> bytes: alg = self._jwt.get("header", {}).get("alg", None) if not kid: - raise LtiException("JWT KID not found") + raise InvalidJwtToken("JWT KID not found") if not alg: - raise LtiException("JWT ALG not found") + raise InvalidJwtToken("JWT ALG not found") for key in tool_key_set["keys"]: key_kid = key.get("kid") @@ -341,7 +347,7 @@ def get_tool_public_key(self) -> bytes: raise LtiException("Can't convert JWT key to PEM format") from error # Could not find public key with a matching kid and alg. - raise LtiException("Unable to find public key") + raise InvalidJwtToken("Unable to find public key") def tool_validate_and_decode( self, jwt_token_string: str, audience: str @@ -471,6 +477,7 @@ def _is_nonce_replay(self, nonce: str, exp: int) -> bool: self.cache_set(cache_key, exp) return False + # pylint: disable=too-many-branches def _validate_tool_access_token_assertion( self, decoded_assertion: t.Dict[str, t.Any], expected_audience: str ) -> None: @@ -523,7 +530,8 @@ def _validate_tool_access_token_assertion( MissingRequiredClaim: If required header/claim is missing LtiException: If any semantic validation fails (iss, sub, aud, timing, jti) """ - assert self._registration is not None, "Registration not yet set" + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") required_claims = ["iss", "sub", "aud", "iat", "exp", "jti"] for required_claim in required_claims: @@ -536,12 +544,11 @@ def _validate_tool_access_token_assertion( if not client_id: raise LtiException("Client ID is not set") - print(decoded_assertion) if decoded_assertion["iss"] != client_id: - raise LtiException("Invalid client_assertion iss") + raise InvalidClientAssertion("Invalid client_assertion iss") if decoded_assertion["sub"] != client_id: - raise LtiException("Invalid client_assertion sub") + raise InvalidClientAssertion("Invalid client_assertion sub") aud_claim = decoded_assertion.get("aud") if isinstance(aud_claim, str): @@ -549,22 +556,22 @@ def _validate_tool_access_token_assertion( elif isinstance(aud_claim, list): aud_values = aud_claim else: - raise LtiException("Invalid client_assertion aud") + raise InvalidClientAssertion("Invalid client_assertion aud") if expected_audience not in aud_values: - raise LtiException("Invalid client_assertion audience") + raise InvalidClientAssertion("Invalid client_assertion audience") now = int(time.time()) iat = int(decoded_assertion["iat"]) exp = int(decoded_assertion["exp"]) if iat > now + 60: - raise LtiException("Invalid client_assertion iat") + raise InvalidClientAssertion("Invalid client_assertion iat") if exp <= now: - raise LtiException("Invalid client_assertion exp") + raise InvalidClientAssertion("Invalid client_assertion exp") jti = str(decoded_assertion["jti"]) if self._is_token_replay(jti, exp): - raise LtiException("Replay detected for client_assertion jti") + raise InvalidClientAssertion("Replay detected for client_assertion jti") def get_access_token( self, token_request_data: t.Dict[str, t.Any] @@ -629,13 +636,15 @@ def get_access_token( UnsupportedGrantType: If grant_type is not client_credentials LtiException: Various validation failures (see _validate_tool_access_token_assertion) """ - assert self._registration is not None, "Registration not yet set" + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") private_key = self._registration.get_platform_private_key() - assert private_key is not None, ( - "Platform private key not yet set. " - "Please set it with set_platform_private_key()" - ) + if private_key is None: + raise exceptions.PlatformNotReadyException( + "Platform private key not yet set. " + "Please set it with set_platform_private_key()" + ) # Check if all required claims are present for required_claim in LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS: @@ -651,7 +660,7 @@ def get_access_token( if token_request_data["client_assertion_type"] != ( "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" ): - raise LtiException("Invalid client_assertion_type") + raise InvalidClientAssertion("Invalid client_assertion_type") expected_audience = self._registration.get_access_token_url() if not expected_audience: @@ -702,10 +711,12 @@ def validate_deeplinking_resp( self, token_request_data: t.Dict[str, t.Any] ) -> t.List[t.Dict[str, t.Any]]: jwt_token_string = token_request_data["JWT"] - assert self._registration is not None, "Registration not yet set" + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") expected_audience = self._registration.get_iss() - assert expected_audience is not None + if expected_audience is None: + raise exceptions.PlatformNotReadyException("Issuer (iss) not configured") deep_link_response = self.tool_validate_and_decode( jwt_token_string, audience=expected_audience @@ -769,20 +780,24 @@ def validate_token( Returns: is_valid: True if token is valid, False otherwise """ - assert self._registration is not None, "Registration not yet set" + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") public_key = self._registration.get_platform_public_key() - assert public_key is not None + if public_key is None: + raise exceptions.PlatformNotReadyException( + "Platform public key not configured" + ) token_contents = Registration.decode_and_verify( token, public_key, audience=audience ) if token_contents.get("iss") != self._registration.get_iss(): - raise LtiException("Invalid issuer") + raise InvalidJwtToken("Invalid issuer") if "exp" in token_contents and token_contents["exp"] < time.time(): - raise LtiException("Token expired") + raise InvalidJwtToken("Token expired") token_scopes = token_contents.get("scopes", "").split(" ") diff --git a/lti1p3platform/message_launch.py b/lti1p3platform/message_launch.py index f81c27c..9a71334 100644 --- a/lti1p3platform/message_launch.py +++ b/lti1p3platform/message_launch.py @@ -2,7 +2,7 @@ import time import typing as t -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse from abc import ABC, abstractmethod from typing_extensions import TypedDict @@ -24,7 +24,7 @@ class LaunchData(TypedDict): state: str -# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-instance-attributes,too-many-public-methods class MessageLaunchAbstract(ABC): """ Abstract base class for LTI 1.3 Message Launch handling @@ -86,7 +86,8 @@ def __init__( self.id_token_expiration = 5 * 60 def get_preflight_response(self) -> t.Dict[str, t.Any]: - assert self._request is not None + if self._request is None: + raise exceptions.PlatformNotReadyException("Request context not available") # pylint: disable=protected-access return self._request.get_data or self._request.form_data @@ -320,7 +321,8 @@ def set_id_token_expiration( return self def get_launch_url(self) -> t.Optional[str]: - assert self._registration + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") if not self._launch_url: self._launch_url = self._registration.get_launch_url() @@ -330,7 +332,8 @@ def get_launch_url(self) -> t.Optional[str]: def get_launch_message( self, include_extra_claims: bool = True ) -> t.Dict[str, t.Any]: - assert self._registration + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") launch_message: t.Dict[str, t.Any] = LTI_BASE_MESSAGE.copy() @@ -461,41 +464,62 @@ def validate_preflight_response( preflight_response: Dict with response parameters from authorization endpoint Raises: - PreflightRequestValidationException: If any validation fails + InvalidRequestData: If required parameters are missing + UnauthorizedClient: If client_id is unknown + UnsupportedResponseType: If response_type is invalid + InvalidScopeException: If scope is invalid + InvalidRequestUri: If redirect_uri is invalid """ - assert self._registration + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") + + required_params = [ + "client_id", + "response_type", + "scope", + "nonce", + "state", + "redirect_uri", + ] + missing_params = [ + param for param in required_params if not preflight_response.get(param) + ] + if missing_params: + raise exceptions.InvalidRequestData( + f"Missing required parameters: {', '.join(missing_params)}" + ) - try: - assert ( - preflight_response.get("response_type") == "id_token" - ), "Invalid response type in preflight response" - assert ( - preflight_response.get("scope") == "openid" - ), "Invalid scope in preflight response" - assert preflight_response.get("nonce") - assert preflight_response.get("state") - redirect_uri = preflight_response.get("redirect_uri") - assert redirect_uri and redirect_uri in ( - self._registration.get_tool_redirect_uris() or [] - ) # pylint: disable=line-too-long - - parsed_redirect_uri = urlparse(redirect_uri) - if parsed_redirect_uri.scheme != "https": - is_allowed_loopback = ( - parsed_redirect_uri.scheme == "http" - and parsed_redirect_uri.hostname - in {"localhost", "127.0.0.1", "::1"} - ) - assert is_allowed_loopback + if preflight_response.get("client_id") != self._registration.get_client_id(): + raise exceptions.UnauthorizedClient( + "Invalid client_id in preflight response" + ) - assert ( - preflight_response.get("client_id") - == self._registration.get_client_id() + if preflight_response.get("response_type") != "id_token": + raise exceptions.UnsupportedResponseType( + "Invalid response_type: expected 'id_token'" ) - self._redirect_url = redirect_uri - except AssertionError as err: - raise exceptions.PreflightRequestValidationException from err + if preflight_response.get("scope") != "openid": + raise exceptions.InvalidScopeException("Invalid scope: expected 'openid'") + + redirect_uri = preflight_response.get("redirect_uri") + if redirect_uri not in (self._registration.get_tool_redirect_uris() or []): + raise exceptions.InvalidRequestUri( + f"redirect_uri '{redirect_uri}' not registered for this tool" + ) + + parsed_redirect_uri = urlparse(redirect_uri) + if parsed_redirect_uri.scheme != "https": + is_allowed_loopback = ( + parsed_redirect_uri.scheme == "http" + and parsed_redirect_uri.hostname in {"localhost", "127.0.0.1", "::1"} + ) + if not is_allowed_loopback: + raise exceptions.InvalidRequestUri( + "redirect_uri must use HTTPS (except localhost for development)" + ) + + self._redirect_url = redirect_uri def get_launch_data(self) -> t.Tuple[t.Dict[str, t.Any], str]: preflight_response = self.get_preflight_response() @@ -516,7 +540,8 @@ def generate_launch_request(self) -> LaunchData: """ launch_message, state = self.get_launch_data() - assert self._registration + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration not yet set") # sign launch message with private key id_token = self._registration.platform_encode_and_sign( @@ -531,25 +556,71 @@ def render_launch_form( ) -> t.Any: raise NotImplementedError + @abstractmethod + def get_redirect(self, url: str) -> t.Any: + raise NotImplementedError + + @abstractmethod + def render_error_page(self, message: str, status_code: int) -> t.Any: + raise NotImplementedError + + def build_error_redirect_url( + self, + redirect_uri: str, + error: Exception, + state: t.Optional[str] = None, + ) -> str: + params = { + "error": exceptions.get_error_code(error), + "error_description": str(error), + } + if state: + params["state"] = state + + separator = "&" if urlparse(redirect_uri).query else "?" + return f"{redirect_uri}{separator}{urlencode(params)}" + + def get_error_response( + self, + error: Exception, + redirect_uri: t.Optional[str] = None, + state: t.Optional[str] = None, + ) -> t.Any: + if redirect_uri and exceptions.get_error_response_behavior(error) == "redirect": + return self.get_redirect( + self.build_error_redirect_url(redirect_uri, error, state) + ) + + return self.render_error_page( + str(error), + exceptions.get_error_page_status_code(error), + ) + def lti_launch(self, **kwargs: t.Any) -> t.Any: # This should render a form, and then submit it to the tool's launch URL, as # described in http://www.imsglobal.org/spec/lti/v1p3/#lti-message-general-details self._registration = self._platform_config.get_registration() + preflight_response: t.Dict[str, t.Any] = {} - preflight_response = self.get_preflight_response() + try: + preflight_response = self.get_preflight_response() - # validate preflight request response from tool - self.validate_preflight_response(preflight_response) + # validate preflight request response from tool + self.validate_preflight_response(preflight_response) - self.prepare_launch(preflight_response) + self.prepare_launch(preflight_response) - launch_data = self.generate_launch_request() + launch_data = self.generate_launch_request() - launch_data_copy = dict(launch_data) - launch_data_copy.update({"launch_url": self._redirect_url}) + launch_data_copy = dict(launch_data) + launch_data_copy.update({"launch_url": self._redirect_url}) - return self.render_launch_form(launch_data_copy, **kwargs) + return self.render_launch_form(launch_data_copy, **kwargs) + except Exception as err: # pylint: disable=broad-exception-caught + redirect_uri = preflight_response.get("redirect_uri") + state = preflight_response.get("state") + return self.get_error_response(err, redirect_uri=redirect_uri, state=state) class LTIAdvantageMessageLaunchAbstract(MessageLaunchAbstract): @@ -607,7 +678,8 @@ def set_nrps( return self def generate_launch_request(self) -> LaunchData: - assert self._registration, "Registration is required" + if self._registration is None: + raise exceptions.PlatformNotReadyException("Registration is required") deep_linking_launch_url = self._registration.get_deeplink_launch_url() diff --git a/lti1p3platform/oidc_login.py b/lti1p3platform/oidc_login.py index 2405f95..4789db2 100644 --- a/lti1p3platform/oidc_login.py +++ b/lti1p3platform/oidc_login.py @@ -96,7 +96,10 @@ def get_launch_url(self) -> t.Optional[str]: if not self._launch_url: launch_url = self._registration.get_launch_url() - assert launch_url, "Launch url is not set" + if not launch_url: + raise exceptions.InvalidRequestUri( + "Launch URL is not configured in registration" + ) self.set_launch_url(launch_url) return self._launch_url @@ -188,13 +191,24 @@ def prepare_preflight_url(self, user_id: str) -> str: PreflightRequestValidationException: If required fields not configured """ launch_url = self.get_launch_url() - try: - assert self._registration.get_iss() - assert launch_url - assert self.get_lti_message_hint() - assert user_id - except AssertionError as err: - raise exceptions.PreflightRequestValidationException from err + + if not self._registration.get_iss(): + raise exceptions.PlatformNotReadyException( + "Issuer (iss) is not configured in registration" + ) + + if not launch_url: + raise exceptions.InvalidRequestUri("Launch URL is not configured") + + if not self.get_lti_message_hint(): + raise exceptions.InvalidRequestData( + "LTI message hint (lti_message_hint) is not set" + ) + + if not user_id: + raise exceptions.InvalidRequestData( + "User ID is required for preflight request" + ) params = { "iss": self._registration.get_iss(), @@ -215,6 +229,10 @@ def prepare_preflight_url(self, user_id: str) -> str: encoded_params = urlencode(params) oidc_login_url = self._registration.get_oidc_login_url() + if not oidc_login_url: + raise exceptions.PlatformNotReadyException( + "OIDC login URL is not configured in registration" + ) parsed_url = urlparse(oidc_login_url) query = parsed_url.query @@ -232,6 +250,42 @@ def prepare_preflight_url(self, user_id: str) -> str: def get_redirect(self, url: str) -> t.Any: raise NotImplementedError + @abstractmethod + def render_error_page(self, message: str, status_code: int) -> t.Any: + raise NotImplementedError + + def build_error_redirect_url( + self, + redirect_uri: str, + error: Exception, + state: t.Optional[str] = None, + ) -> str: + params = { + "error": exceptions.get_error_code(error), + "error_description": str(error), + } + if state: + params["state"] = state + + separator = "&" if urlparse(redirect_uri).query else "?" + return f"{redirect_uri}{separator}{urlencode(params)}" + + def get_error_response( + self, + error: Exception, + redirect_uri: t.Optional[str] = None, + state: t.Optional[str] = None, + ) -> t.Any: + if redirect_uri and exceptions.get_error_response_behavior(error) == "redirect": + return self.get_redirect( + self.build_error_redirect_url(redirect_uri, error, state) + ) + + return self.render_error_page( + str(error), + exceptions.get_error_page_status_code(error), + ) + def initiate_login(self, user_id: str) -> t.Any: """ Initiate OIDC login by redirecting to platform's OIDC login endpoint @@ -258,8 +312,9 @@ def initiate_login(self, user_id: str) -> t.Any: Raises: PreflightRequestValidationException: If configuration validation fails """ - # prepare preflight url - preflight_url = self.prepare_preflight_url(user_id) - - # redirect to preflight url - return self.get_redirect(preflight_url) + try: + preflight_url = self.prepare_preflight_url(user_id) + return self.get_redirect(preflight_url) + except Exception as err: # pylint: disable=broad-exception-caught + launch_url = self._launch_url or self._registration.get_launch_url() + return self.get_error_response(err, redirect_uri=launch_url) diff --git a/lti1p3platform/registration.py b/lti1p3platform/registration.py index b29224d..69add63 100644 --- a/lti1p3platform/registration.py +++ b/lti1p3platform/registration.py @@ -8,6 +8,7 @@ import jwt +from . import exceptions from .jwt_helper import jwt_encode if t.TYPE_CHECKING: @@ -394,7 +395,10 @@ def platform_encode_and_sign( ) -> str: platform_private_key = self.get_platform_private_key() - assert platform_private_key is not None, "Platform private key is not set" + if platform_private_key is None: + raise exceptions.PlatformNotReadyException( + "Platform private key is not set" + ) headers = None kid = self.get_kid() @@ -402,6 +406,11 @@ def platform_encode_and_sign( if kid: headers = {"kid": kid} - return Registration.encode_and_sign( - payload, platform_private_key, headers, expiration=expiration - ) + try: + return Registration.encode_and_sign( + payload, platform_private_key, headers, expiration=expiration + ) + except Exception as err: + raise exceptions.InternalSigningError( + "Failed to sign platform JWT" + ) from err diff --git a/lti1p3platform/service_connector.py b/lti1p3platform/service_connector.py index 479951b..654c8b7 100644 --- a/lti1p3platform/service_connector.py +++ b/lti1p3platform/service_connector.py @@ -3,7 +3,11 @@ from urllib.parse import urlencode import typing_extensions as te -from .exceptions import LtiServiceException, LineItemNotFoundException +from .exceptions import ( + LtiServiceException, + LineItemNotFoundException, + InvalidRequestData, +) from .lineitem import TLineItem from .score import TScore, UpdateScoreStatus, UPDATE_SCORE_STATUSCODE from .request import Request @@ -37,7 +41,8 @@ def wrapper( def inner( service: "AssignmentsGradesService", *args: t.Any, **kwargs: t.Any ) -> Response: - assert service.request + if service.request is None: + raise LtiServiceException("Request context not available", 400) auth = service.request.headers.get("Authorization", "").split() @@ -171,7 +176,8 @@ def handle_get_results(self, line_item_id: str) -> Response: # The results service endpoint is a subpath of the line item # resource URL: it MUST be the line item resource URL with the # path appended with '/results'. - assert self.request is not None + if self.request is None: + raise LtiServiceException("Request context not available", 500) lti_params = self.request.get_data @@ -197,7 +203,8 @@ def handle_update_score(self, line_item_id: str) -> Response: # The scores service endpoint is a subpath of the line item # resource URL: it MUST be the line item resource URL with the # path appended with '/scores'. - assert self.request is not None + if self.request is None: + raise LtiServiceException("Request context not available", 500) score = self.request.json @@ -214,7 +221,8 @@ def handle_update_score(self, line_item_id: str) -> Response: accept="application/vnd.ims.lis.v2.lineitemcontainer+json", ) def handle_get_lineitems(self) -> Response: - assert self.request is not None + if self.request is None: + raise LtiServiceException("Request context not available", 500) lti_params = self.request.get_data @@ -235,7 +243,8 @@ def handle_get_lineitems(self) -> Response: allow_methods=["POST"], accept="application/vnd.ims.lis.v2.lineitem+json" ) def handle_create_lineitem(self) -> Response: - assert self.request is not None + if self.request is None: + raise LtiServiceException("Request context not available", 500) lineitem = self.request.json @@ -256,7 +265,8 @@ def handle_get_lineitem(self, line_item_id: str) -> Response: allow_methods=["PUT"], accept="application/vnd.ims.lis.v2.lineitem+json" ) def handle_update_lineitem(self, line_item_id: str) -> Response: - assert self.request is not None + if self.request is None: + raise LtiServiceException("Request context not available", 500) try: update_data = self.request.json @@ -317,9 +327,9 @@ def clean_members(self, members: t.List[t.Any]) -> t.List[t.Any]: for member in members: if "user_id" not in member: - raise LtiServiceException("No user_id", 400) + raise InvalidRequestData("No user_id in member data") if "roles" not in member: - raise LtiServiceException("No roles", 400) + raise InvalidRequestData("No roles in member data") if not member.get("status"): member["status"] = Status.ACTIVE.value @@ -333,7 +343,8 @@ def clean_members(self, members: t.List[t.Any]) -> t.List[t.Any]: accept="application/vnd.ims.lti-nrps.v2.membershipcontainer+json", ) def handle_get_members(self) -> Response: - assert self.request is not None + if self.request is None: + raise LtiServiceException("Request context not available", 500) query_params = self.request.get_data diff --git a/tests/test_ltiplatform_coverage.py b/tests/test_ltiplatform_coverage.py index 84932dc..589766e 100644 --- a/tests/test_ltiplatform_coverage.py +++ b/tests/test_ltiplatform_coverage.py @@ -26,6 +26,8 @@ from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract from lti1p3platform.registration import Registration as _Registration from lti1p3platform.exceptions import ( + InvalidClientAssertion, + InvalidJwtToken, InvalidKeySetUrl, LtiException, MissingRequiredClaim, @@ -273,13 +275,13 @@ def test_get_tool_key_set_https_url_returns_and_caches(): def test_validate_jwt_format_wrong_parts(): platform = PlatformConf() - with pytest.raises(LtiException, match="JWT must contain 3 parts"): + with pytest.raises(InvalidJwtToken, match="JWT must contain 3 parts"): platform.validate_jwt_format("only.two") def test_validate_jwt_format_invalid_base64(): platform = PlatformConf() - with pytest.raises(LtiException, match="can't be decoded"): + with pytest.raises(InvalidJwtToken, match="can't be decoded"): platform.validate_jwt_format("!@#$.!@#$.!@#$") @@ -299,7 +301,7 @@ def test_get_tool_public_key_no_kid_raises(): token = jwt_encode(claims, TOOL_PRIVATE_KEY_PEM, algorithm="RS256") platform.validate_jwt_format(token) platform._jwt["header"].pop("kid", None) - with pytest.raises(LtiException, match="KID not found"): + with pytest.raises(InvalidJwtToken, match="KID not found"): platform.get_tool_public_key() @@ -317,7 +319,7 @@ def test_get_tool_public_key_no_alg_raises(): ) platform.validate_jwt_format(token) platform._jwt["header"].pop("alg", None) - with pytest.raises(LtiException, match="ALG not found"): + with pytest.raises(InvalidJwtToken, match="ALG not found"): platform.get_tool_public_key() @@ -335,7 +337,7 @@ def test_get_tool_public_key_kid_not_found_raises(): ) platform.validate_jwt_format(token) platform._jwt["header"]["kid"] = "nonexistent-kid" - with pytest.raises(LtiException, match="Unable to find public key"): + with pytest.raises(InvalidJwtToken, match="Unable to find public key"): platform.get_tool_public_key() @@ -401,7 +403,7 @@ def test_assertion_invalid_iss_raises(): "exp": int(time.time()) + 60, "jti": str(uuid.uuid4()), } - with pytest.raises(LtiException, match="Invalid client_assertion iss"): + with pytest.raises(InvalidClientAssertion, match="Invalid client_assertion iss"): platform._validate_tool_access_token_assertion( decoded, PLATFORM_CONFIG["access_token_url"] ) @@ -417,7 +419,7 @@ def test_assertion_invalid_sub_raises(): "exp": int(time.time()) + 60, "jti": str(uuid.uuid4()), } - with pytest.raises(LtiException, match="Invalid client_assertion sub"): + with pytest.raises(InvalidClientAssertion, match="Invalid client_assertion sub"): platform._validate_tool_access_token_assertion( decoded, PLATFORM_CONFIG["access_token_url"] ) @@ -433,7 +435,9 @@ def test_assertion_aud_string_wrong_raises(): "exp": int(time.time()) + 60, "jti": str(uuid.uuid4()), } - with pytest.raises(LtiException, match="Invalid client_assertion audience"): + with pytest.raises( + InvalidClientAssertion, match="Invalid client_assertion audience" + ): platform._validate_tool_access_token_assertion( decoded, PLATFORM_CONFIG["access_token_url"] ) @@ -465,7 +469,7 @@ def test_assertion_aud_invalid_type_raises(): "exp": int(time.time()) + 60, "jti": str(uuid.uuid4()), } - with pytest.raises(LtiException, match="Invalid client_assertion aud"): + with pytest.raises(InvalidClientAssertion, match="Invalid client_assertion aud"): platform._validate_tool_access_token_assertion( decoded, PLATFORM_CONFIG["access_token_url"] ) @@ -481,7 +485,7 @@ def test_assertion_iat_far_future_raises(): "exp": int(time.time()) + 600, "jti": str(uuid.uuid4()), } - with pytest.raises(LtiException, match="Invalid client_assertion iat"): + with pytest.raises(InvalidClientAssertion, match="Invalid client_assertion iat"): platform._validate_tool_access_token_assertion( decoded, PLATFORM_CONFIG["access_token_url"] ) @@ -497,7 +501,7 @@ def test_assertion_exp_in_past_raises(): "exp": int(time.time()) - 60, # Already expired "jti": str(uuid.uuid4()), } - with pytest.raises(LtiException, match="Invalid client_assertion exp"): + with pytest.raises(InvalidClientAssertion, match="Invalid client_assertion exp"): platform._validate_tool_access_token_assertion( decoded, PLATFORM_CONFIG["access_token_url"] ) @@ -520,7 +524,7 @@ def test_assertion_jti_replay_raises(): ) # Second call with same JTI must fail decoded2 = dict(decoded) - with pytest.raises(LtiException, match="Replay detected"): + with pytest.raises(InvalidClientAssertion, match="Replay detected"): platform._validate_tool_access_token_assertion( decoded2, PLATFORM_CONFIG["access_token_url"] ) @@ -561,7 +565,7 @@ def test_get_access_token_wrong_grant_type_raises(): def test_get_access_token_wrong_assertion_type_raises(): platform = PlatformConf() - with pytest.raises(LtiException, match="Invalid client_assertion_type"): + with pytest.raises(InvalidClientAssertion, match="Invalid client_assertion_type"): platform.get_access_token( { "grant_type": "client_credentials", @@ -833,5 +837,5 @@ def test_validate_token_invalid_iss_raises(): RSA_PRIVATE_KEY_PEM, expiration=3600, ) - with pytest.raises(LtiException, match="Invalid issuer"): + with pytest.raises(InvalidJwtToken, match="Invalid issuer"): platform.validate_token(token) diff --git a/tests/test_oidc_error_mapping.py b/tests/test_oidc_error_mapping.py new file mode 100644 index 0000000..64d8303 --- /dev/null +++ b/tests/test_oidc_error_mapping.py @@ -0,0 +1,279 @@ +import typing as t + +import pytest + +# pylint: disable=protected-access + +from lti1p3platform.exceptions import ( + InvalidRequestData, + InvalidRequestUri, + InvalidScopeException, + PlatformNotReadyException, + InternalSigningError, + UnauthorizedClient, + UnsupportedResponseType, +) +from lti1p3platform.message_launch import MessageLaunchAbstract +from lti1p3platform.oidc_login import OIDCLoginAbstract +from lti1p3platform.request import Request + +from .platform_config import PLATFORM_CONFIG, PlatformConf + + +class _DummyRequest(Request): + def build_metadata(self, request: t.Any) -> t.Dict[str, t.Any]: + return request + + +class _DummyMessageLaunch(MessageLaunchAbstract): + def render_launch_form( + self, launch_data: t.Dict[str, t.Any], **kwargs: t.Any + ) -> t.Any: + return launch_data + + def get_redirect(self, url: str) -> t.Any: + return {"type": "redirect", "url": url} + + def render_error_page(self, message: str, status_code: int) -> t.Any: + return {"type": "error_page", "message": message, "status_code": status_code} + + +class _DummyOIDCLogin(OIDCLoginAbstract): + def set_lti_message_hint(self, **kwargs: t.Any) -> None: + self._lti_message_hint = kwargs.get("message_hint") + + def get_redirect(self, url: str) -> t.Any: + return {"type": "redirect", "url": url} + + def render_error_page(self, message: str, status_code: int) -> t.Any: + return {"type": "error_page", "message": message, "status_code": status_code} + + +def _make_message_launch() -> _DummyMessageLaunch: + request = _DummyRequest( + { + "method": "POST", + "form_data": {}, + "get_data": {}, + "headers": {}, + "content_type": None, + "path": "/launch", + "json": None, + } + ) + platform = PlatformConf() + registration = platform.get_registration() + registration.set_tool_redirect_uris( + [ + PLATFORM_CONFIG["launch_url"], + PLATFORM_CONFIG["deeplink_launch_url"], + ] + ) + + launch = _DummyMessageLaunch(request, platform) + launch._registration = registration + return launch + + +def _make_oidc_login() -> _DummyOIDCLogin: + request = _DummyRequest( + { + "method": "GET", + "form_data": {}, + "get_data": {}, + "headers": {}, + "content_type": None, + "path": "/oidc", + "json": None, + } + ) + login = _DummyOIDCLogin(request, PlatformConf()) + login.set_lti_message_hint(message_hint="resource-link-123") + return login + + +def test_validate_preflight_response_missing_client_id_raises_invalid_request(): + launch = _make_message_launch() + + with pytest.raises(InvalidRequestData, match="client_id"): + launch.validate_preflight_response( + { + "response_type": "id_token", + "scope": "openid", + "nonce": "nonce-123", + "state": "state-123", + "redirect_uri": PLATFORM_CONFIG["launch_url"], + } + ) + + +def test_validate_preflight_response_unknown_client_id_raises_unauthorized_client(): + launch = _make_message_launch() + + with pytest.raises(UnauthorizedClient, match="client_id"): + launch.validate_preflight_response( + { + "client_id": "wrong-client", + "response_type": "id_token", + "scope": "openid", + "nonce": "nonce-123", + "state": "state-123", + "redirect_uri": PLATFORM_CONFIG["launch_url"], + } + ) + + +def test_validate_preflight_response_wrong_response_type_raises_unsupported_response_type(): + launch = _make_message_launch() + + with pytest.raises(UnsupportedResponseType, match="response_type"): + launch.validate_preflight_response( + { + "client_id": PLATFORM_CONFIG["client_id"], + "response_type": "code", + "scope": "openid", + "nonce": "nonce-123", + "state": "state-123", + "redirect_uri": PLATFORM_CONFIG["launch_url"], + } + ) + + +def test_validate_preflight_response_wrong_scope_raises_invalid_scope(): + launch = _make_message_launch() + + with pytest.raises(InvalidScopeException, match="scope"): + launch.validate_preflight_response( + { + "client_id": PLATFORM_CONFIG["client_id"], + "response_type": "id_token", + "scope": "profile", + "nonce": "nonce-123", + "state": "state-123", + "redirect_uri": PLATFORM_CONFIG["launch_url"], + } + ) + + +def test_validate_preflight_response_invalid_redirect_uri_raises_invalid_request_uri(): + launch = _make_message_launch() + + with pytest.raises(InvalidRequestUri, match="redirect_uri"): + launch.validate_preflight_response( + { + "client_id": PLATFORM_CONFIG["client_id"], + "response_type": "id_token", + "scope": "openid", + "nonce": "nonce-123", + "state": "state-123", + "redirect_uri": "https://evil.example.com/callback", + } + ) + + +def test_prepare_preflight_url_missing_message_hint_raises_invalid_request(): + login = _make_oidc_login() + login._lti_message_hint = None + + with pytest.raises(InvalidRequestData, match="lti_message_hint"): + login.prepare_preflight_url("user-123") + + +def test_prepare_preflight_url_missing_oidc_login_url_raises_platform_not_ready(): + login = _make_oidc_login() + login._registration.set_oidc_login_url(None) + + with pytest.raises(PlatformNotReadyException, match="OIDC login URL"): + login.prepare_preflight_url("user-123") + + +def test_get_error_response_redirects_redirectable_oauth_errors(): + launch = _make_message_launch() + + response = launch.get_error_response( + InvalidScopeException("Invalid scope"), + redirect_uri=PLATFORM_CONFIG["launch_url"], + state="state-123", + ) + + assert response["type"] == "redirect" + assert "error=invalid_scope" in response["url"] + assert "state=state-123" in response["url"] + + +def test_get_error_response_renders_page_for_non_redirectable_errors(): + launch = _make_message_launch() + + response = launch.get_error_response( + InvalidRequestUri("Bad redirect URI"), + redirect_uri=PLATFORM_CONFIG["launch_url"], + ) + + assert response["type"] == "error_page" + assert response["status_code"] == 400 + + +def test_lti_launch_redirects_when_validation_error_is_redirectable(): + request = _DummyRequest( + { + "method": "POST", + "form_data": { + "client_id": PLATFORM_CONFIG["client_id"], + "response_type": "id_token", + "scope": "profile", + "nonce": "nonce-123", + "state": "state-123", + "redirect_uri": PLATFORM_CONFIG["launch_url"], + }, + "get_data": {}, + "headers": {}, + "content_type": None, + "path": "/launch", + "json": None, + } + ) + platform = PlatformConf() + platform.get_registration().set_tool_redirect_uris([PLATFORM_CONFIG["launch_url"]]) + launch = _DummyMessageLaunch(request, platform) + + response = launch.lti_launch() + + assert response["type"] == "redirect" + assert "error=invalid_scope" in response["url"] + + +def test_lti_launch_renders_page_when_validation_error_is_not_redirectable(): + request = _DummyRequest( + { + "method": "POST", + "form_data": { + "client_id": PLATFORM_CONFIG["client_id"], + "response_type": "id_token", + "scope": "openid", + "nonce": "nonce-123", + "state": "state-123", + "redirect_uri": "https://evil.example.com/callback", + }, + "get_data": {}, + "headers": {}, + "content_type": None, + "path": "/launch", + "json": None, + } + ) + launch = _DummyMessageLaunch(request, PlatformConf()) + + response = launch.lti_launch() + + assert response["type"] == "error_page" + assert response["status_code"] == 400 + + +def test_initiate_login_renders_page_for_internal_server_errors(): + login = _make_oidc_login() + login._registration.set_platform_private_key(None) + + response = login.get_error_response(InternalSigningError("Signing failed")) + + assert response["type"] == "error_page" + assert response["status_code"] == 500 diff --git a/tests/test_registration.py b/tests/test_registration.py index 52194fa..0501ab0 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -5,7 +5,11 @@ encode_and_sign, decode_and_verify, and platform_encode_and_sign. """ import time +from unittest.mock import patch +import pytest + +from lti1p3platform.exceptions import InternalSigningError, PlatformNotReadyException from lti1p3platform.registration import Registration from .platform_config import ( @@ -156,6 +160,23 @@ def test_platform_encode_and_sign(): assert decoded["custom"] == "value" +def test_platform_encode_and_sign_without_private_key_raises(): + reg = Registration() + + with pytest.raises(PlatformNotReadyException, match="private key"): + reg.platform_encode_and_sign({"custom": "value"}, expiration=60) + + +def test_platform_encode_and_sign_wraps_signing_errors(): + reg = _make_registration() + + with patch.object( + Registration, "encode_and_sign", side_effect=RuntimeError("boom") + ): + with pytest.raises(InternalSigningError, match="Failed to sign platform JWT"): + reg.platform_encode_and_sign({"custom": "value"}, expiration=60) + + def test_get_jwks_returns_list_with_key(): reg = _make_registration() jwks = reg.get_jwks() From 5000ebcf92b9aa418c17979abce090aef6f00ab6 Mon Sep 17 00:00:00 2001 From: Jun Tu Date: Tue, 7 Apr 2026 12:37:16 +1000 Subject: [PATCH 9/9] fix nonce replay check --- lti1p3platform/ltiplatform.py | 23 +++++++---------------- lti1p3platform/message_launch.py | 8 -------- tests/test_ltiplatform_coverage.py | 22 ++++------------------ 3 files changed, 11 insertions(+), 42 deletions(-) diff --git a/lti1p3platform/ltiplatform.py b/lti1p3platform/ltiplatform.py index 251857b..de68b96 100644 --- a/lti1p3platform/ltiplatform.py +++ b/lti1p3platform/ltiplatform.py @@ -432,6 +432,7 @@ def _is_token_replay(self, jti: str, exp: int) -> bool: cache_key = f"jti:{jti}" if self.cache_get(cache_key) is not None: return True + self.cache_set(cache_key, exp) return False @@ -440,10 +441,9 @@ def _is_nonce_replay(self, nonce: str, exp: int) -> bool: Detect and prevent nonce replay attacks in deep linking responses Nonce (Number Used Once) Security: - - Platform generates a random nonce for each deep linking request - - Tool includes this nonce in the deep linking response - - Platform validates that the received nonce matches what it sent - - This is a Cross-Site Request Forgery (CSRF) protection mechanism + - Tool generates a unique nonce for each deep linking response JWT + - Platform verifies the nonce has not been seen before + - Prevents replaying the same deep linking response token Replay Attack Prevention: - Platform caches all nonces received from tools @@ -452,10 +452,9 @@ def _is_nonce_replay(self, nonce: str, exp: int) -> bool: - Prevents attacker from reusing old deep linking messages Flow Example: - 1. Platform generates nonce='xyz789' and sends to tool - 2. Tool includes nonce='xyz789' in deep linking response - 3. Platform caches nonce='xyz789' with expiration time - 4. If nonce='xyz789' appears again, it's rejected as replay + 1. Tool sends deep linking response with nonce='xyz789' + 2. Platform caches nonce='xyz789' with expiration time + 3. If nonce='xyz789' appears again, it's rejected as replay OpenID Connect Specification: - Nonce validation prevents authorization code/token replay attacks @@ -726,14 +725,6 @@ def validate_deeplinking_resp( if not nonce: raise LtiDeepLinkingResponseException("Token nonce is missing") - # OIDC Core Section 3.1.3.7(11): the nonce in the response MUST equal the - # value that was sent in the authentication request. Reject any nonce we - # did not originate to prevent token-substitution / replay attacks. - if self.cache_get(f"sent_nonce:{str(nonce)}") is None: - raise LtiDeepLinkingResponseException( - "Nonce was not issued by this platform" - ) - exp = deep_link_response.get("exp") if not exp: raise LtiDeepLinkingResponseException("Token exp is missing") diff --git a/lti1p3platform/message_launch.py b/lti1p3platform/message_launch.py index 9a71334..1333619 100644 --- a/lti1p3platform/message_launch.py +++ b/lti1p3platform/message_launch.py @@ -1,6 +1,5 @@ from __future__ import annotations -import time import typing as t from urllib.parse import urlencode, urlparse @@ -698,13 +697,6 @@ def generate_launch_request(self) -> LaunchData: if self._deep_linking_launch_data: launch_message.update(self._deep_linking_launch_data) - # OIDC Section 3.1.3.7(11): store sent nonce so the response can be - # verified against the value we originated (prevents replay/forgery). - nonce = launch_message.get("nonce") - if nonce: - sent_exp = int(time.time()) + self.id_token_expiration - self._platform_config.cache_set(f"sent_nonce:{nonce}", sent_exp) - return { "state": state, "id_token": self._registration.platform_encode_and_sign( diff --git a/tests/test_ltiplatform_coverage.py b/tests/test_ltiplatform_coverage.py index 589766e..ee72ee7 100644 --- a/tests/test_ltiplatform_coverage.py +++ b/tests/test_ltiplatform_coverage.py @@ -140,11 +140,6 @@ def _make_deeplink_jwt(extra_claims=None, nonce=None): return _make_tool_jwt(claims) -def _register_sent_nonce(platform, nonce: str, ttl: int = 300) -> None: - """Simulate the platform having sent this nonce in a deep-link request.""" - platform.cache_set(f"sent_nonce:{nonce}", int(time.time()) + ttl) - - # --------------------------------------------------------------------------- # set_accepted_deeplinking_types # --------------------------------------------------------------------------- @@ -602,7 +597,6 @@ def test_get_access_token_unsupported_scope_returns_empty(): def test_validate_deeplinking_resp_empty_content_items(): platform = PlatformConf() nonce = str(uuid.uuid4()) - _register_sent_nonce(platform, nonce) token = _make_deeplink_jwt(nonce=nonce) result = platform.validate_deeplinking_resp({"JWT": token}) assert result == [] @@ -611,7 +605,6 @@ def test_validate_deeplinking_resp_empty_content_items(): def test_validate_deeplinking_resp_with_link_items(): platform = PlatformConf() nonce = str(uuid.uuid4()) - _register_sent_nonce(platform, nonce) items = [{"type": "ltiResourceLink", "url": "https://tool.example.com/resource"}] token = _make_deeplink_jwt( nonce=nonce, @@ -648,7 +641,6 @@ def test_validate_deeplinking_resp_missing_exp_raises(): """Token without exp claim should raise LtiDeepLinkingResponseException.""" platform = PlatformConf() nonce = str(uuid.uuid4()) - _register_sent_nonce(platform, nonce) jwk = _Registration.get_jwk(TOOL_PRIVATE_KEY_PEM) claims = { "iss": "https://tool.example.com", @@ -670,7 +662,6 @@ def test_validate_deeplinking_resp_missing_exp_raises(): def test_validate_deeplinking_resp_nonce_replay_raises(): platform = PlatformConf() nonce = str(uuid.uuid4()) - _register_sent_nonce(platform, nonce) # First request succeeds token1 = _make_deeplink_jwt(nonce=nonce) platform.validate_deeplinking_resp({"JWT": token1}) @@ -683,7 +674,6 @@ def test_validate_deeplinking_resp_nonce_replay_raises(): def test_validate_deeplinking_resp_wrong_message_type_raises(): platform = PlatformConf() nonce = str(uuid.uuid4()) - _register_sent_nonce(platform, nonce) token = _make_deeplink_jwt( nonce=nonce, extra_claims={ @@ -697,7 +687,6 @@ def test_validate_deeplinking_resp_wrong_message_type_raises(): def test_validate_deeplinking_resp_unsupported_content_type_raises(): platform = PlatformConf() nonce = str(uuid.uuid4()) - _register_sent_nonce(platform, nonce) token = _make_deeplink_jwt( nonce=nonce, extra_claims={ @@ -710,15 +699,12 @@ def test_validate_deeplinking_resp_unsupported_content_type_raises(): platform.validate_deeplinking_resp({"JWT": token}) -def test_validate_deeplinking_resp_unknown_nonce_raises(): - """Nonce not previously sent by the platform must be rejected (OIDC §3.1.3.7).""" +def test_validate_deeplinking_resp_fresh_tool_nonce_is_accepted(): + """Deep Linking responses accept fresh tool-generated nonces.""" platform = PlatformConf() - # Nonce is NOT registered as sent — simulates a forged / replayed response token = _make_deeplink_jwt() - with pytest.raises( - LtiDeepLinkingResponseException, match="not issued by this platform" - ): - platform.validate_deeplinking_resp({"JWT": token}) + result = platform.validate_deeplinking_resp({"JWT": token}) + assert result == [] # ---------------------------------------------------------------------------