ff-ltitoolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
- ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
- ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
- ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ltitoolkit/__init__.py +20 -0
- ltitoolkit/adapters/__init__.py +11 -0
- ltitoolkit/adapters/brightspace/__init__.py +35 -0
- ltitoolkit/adapters/brightspace/client.py +176 -0
- ltitoolkit/adapters/canvas/__init__.py +27 -0
- ltitoolkit/adapters/canvas/client.py +142 -0
- ltitoolkit/advantage/__init__.py +9 -0
- ltitoolkit/advantage/service.py +96 -0
- ltitoolkit/core/__init__.py +19 -0
- ltitoolkit/core/actions.py +6 -0
- ltitoolkit/core/assignments_grades.py +300 -0
- ltitoolkit/core/contrib/__init__.py +0 -0
- ltitoolkit/core/contrib/django/__init__.py +5 -0
- ltitoolkit/core/contrib/django/cookie.py +56 -0
- ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
- ltitoolkit/core/contrib/django/message_launch.py +39 -0
- ltitoolkit/core/contrib/django/oidc_login.py +41 -0
- ltitoolkit/core/contrib/django/redirect.py +34 -0
- ltitoolkit/core/contrib/django/request.py +32 -0
- ltitoolkit/core/contrib/django/session.py +5 -0
- ltitoolkit/core/contrib/flask/__init__.py +7 -0
- ltitoolkit/core/contrib/flask/cookie.py +34 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
- ltitoolkit/core/contrib/flask/message_launch.py +32 -0
- ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
- ltitoolkit/core/contrib/flask/redirect.py +34 -0
- ltitoolkit/core/contrib/flask/request.py +40 -0
- ltitoolkit/core/contrib/flask/session.py +5 -0
- ltitoolkit/core/contrib/py.typed +0 -0
- ltitoolkit/core/cookie.py +17 -0
- ltitoolkit/core/cookies_allowed_check.py +151 -0
- ltitoolkit/core/course_groups.py +115 -0
- ltitoolkit/core/deep_link.py +100 -0
- ltitoolkit/core/deep_link_resource.py +96 -0
- ltitoolkit/core/deployment.py +13 -0
- ltitoolkit/core/exception.py +16 -0
- ltitoolkit/core/grade.py +143 -0
- ltitoolkit/core/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/launch_data_storage/base.py +75 -0
- ltitoolkit/core/launch_data_storage/cache.py +43 -0
- ltitoolkit/core/launch_data_storage/session.py +29 -0
- ltitoolkit/core/lineitem.py +205 -0
- ltitoolkit/core/message_launch.py +828 -0
- ltitoolkit/core/message_validators/__init__.py +13 -0
- ltitoolkit/core/message_validators/abstract.py +25 -0
- ltitoolkit/core/message_validators/deep_link.py +34 -0
- ltitoolkit/core/message_validators/privacy_launch.py +40 -0
- ltitoolkit/core/message_validators/resource_message.py +21 -0
- ltitoolkit/core/message_validators/submission_review.py +45 -0
- ltitoolkit/core/names_roles.py +97 -0
- ltitoolkit/core/oidc_login.py +275 -0
- ltitoolkit/core/py.typed +0 -0
- ltitoolkit/core/redirect.py +24 -0
- ltitoolkit/core/registration.py +119 -0
- ltitoolkit/core/request.py +17 -0
- ltitoolkit/core/roles.py +109 -0
- ltitoolkit/core/service_connector.py +144 -0
- ltitoolkit/core/session.py +70 -0
- ltitoolkit/core/tool_config/__init__.py +4 -0
- ltitoolkit/core/tool_config/abstract.py +117 -0
- ltitoolkit/core/tool_config/dict.py +253 -0
- ltitoolkit/core/tool_config/json_file.py +100 -0
- ltitoolkit/core/tool_config/py.typed +0 -0
- ltitoolkit/core/utils.py +10 -0
- ltitoolkit/dynamic_registration/__init__.py +39 -0
- ltitoolkit/dynamic_registration/models.py +192 -0
- ltitoolkit/dynamic_registration/service.py +156 -0
- ltitoolkit/dynamic_registration/store.py +40 -0
- ltitoolkit/dynamic_registration/tool_conf.py +102 -0
- ltitoolkit/exceptions.py +42 -0
- ltitoolkit/fastapi/__init__.py +30 -0
- ltitoolkit/fastapi/cookie.py +53 -0
- ltitoolkit/fastapi/dynamic_registration.py +40 -0
- ltitoolkit/fastapi/message_launch.py +60 -0
- ltitoolkit/fastapi/oidc_login.py +47 -0
- ltitoolkit/fastapi/redirect.py +54 -0
- ltitoolkit/fastapi/request.py +77 -0
- ltitoolkit/fastapi/session.py +13 -0
- ltitoolkit/http.py +80 -0
- ltitoolkit/token/__init__.py +20 -0
- ltitoolkit/token/cache.py +47 -0
- ltitoolkit/token/service.py +165 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The Dynamic Registration flow.
|
|
2
|
+
|
|
3
|
+
One method, :meth:`DynamicRegistrationService.register`, performs the whole
|
|
4
|
+
exchange: fetch the platform's OpenID configuration, POST a tool registration to
|
|
5
|
+
the platform, capture the returned ``client_id``/``deployment_id``, and persist
|
|
6
|
+
the result. The admin only ever pastes a single URL — this is what makes
|
|
7
|
+
"install on any LMS without re-reading the docs" real.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typing as t
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from ..exceptions import ExternalRequestError
|
|
17
|
+
from ..http import build_session
|
|
18
|
+
from .models import (
|
|
19
|
+
CLOSE_SUBJECT,
|
|
20
|
+
LTI_TOOL_CONFIGURATION,
|
|
21
|
+
PlatformConfiguration,
|
|
22
|
+
ToolRegistration,
|
|
23
|
+
ToolRegistrationConfig,
|
|
24
|
+
)
|
|
25
|
+
from .store import RegistrationStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DynamicRegistrationService:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
tool_config: ToolRegistrationConfig,
|
|
32
|
+
store: RegistrationStore,
|
|
33
|
+
*,
|
|
34
|
+
session: requests.Session | None = None,
|
|
35
|
+
timeout: float | tuple[float, float] = (10.0, 30.0),
|
|
36
|
+
) -> None:
|
|
37
|
+
self._config = tool_config
|
|
38
|
+
self._store = store
|
|
39
|
+
self._session = session if session is not None else build_session(timeout=timeout)
|
|
40
|
+
self._timeout = timeout
|
|
41
|
+
|
|
42
|
+
def register(
|
|
43
|
+
self, openid_configuration_url: str, registration_token: str | None = None
|
|
44
|
+
) -> ToolRegistration:
|
|
45
|
+
"""Run the full registration handshake and persist the result."""
|
|
46
|
+
platform = self._fetch_platform_configuration(openid_configuration_url)
|
|
47
|
+
request_body = self._config.build_request(platform)
|
|
48
|
+
response_body = self._post_registration(platform, request_body, registration_token)
|
|
49
|
+
registration = self._to_registration(platform, response_body)
|
|
50
|
+
self._store.save(registration)
|
|
51
|
+
return registration
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def completion_html(
|
|
55
|
+
message: str = "Registration complete. You may close this window."
|
|
56
|
+
) -> str:
|
|
57
|
+
"""HTML that signals the platform (via postMessage) that we're done."""
|
|
58
|
+
return (
|
|
59
|
+
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
60
|
+
"<title>Registration complete</title></head><body>"
|
|
61
|
+
f"<p>{message}</p>"
|
|
62
|
+
"<script>(window.opener || window.parent)."
|
|
63
|
+
f"postMessage({{subject:'{CLOSE_SUBJECT}'}}, '*');</script>"
|
|
64
|
+
"</body></html>"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# -- internals ---------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _fetch_platform_configuration(self, url: str) -> PlatformConfiguration:
|
|
70
|
+
try:
|
|
71
|
+
response = self._session.get(url, timeout=self._timeout)
|
|
72
|
+
except requests.Timeout as exc:
|
|
73
|
+
raise ExternalRequestError(
|
|
74
|
+
f"Timed out fetching OpenID configuration: {exc}", url=url, is_timeout=True
|
|
75
|
+
) from exc
|
|
76
|
+
except requests.RequestException as exc:
|
|
77
|
+
raise ExternalRequestError(
|
|
78
|
+
f"Error fetching OpenID configuration: {exc}", url=url
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
if not response.ok:
|
|
82
|
+
raise ExternalRequestError(
|
|
83
|
+
"Platform rejected the OpenID configuration request",
|
|
84
|
+
status_code=response.status_code,
|
|
85
|
+
url=url,
|
|
86
|
+
response_text=response.text,
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
return PlatformConfiguration.from_dict(response.json())
|
|
90
|
+
except (ValueError, TypeError) as exc:
|
|
91
|
+
raise ExternalRequestError(
|
|
92
|
+
f"Invalid OpenID configuration document: {exc}", url=url
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
def _post_registration(
|
|
96
|
+
self,
|
|
97
|
+
platform: PlatformConfiguration,
|
|
98
|
+
body: dict[str, t.Any],
|
|
99
|
+
registration_token: str | None,
|
|
100
|
+
) -> dict[str, t.Any]:
|
|
101
|
+
headers = {"Accept": "application/json"}
|
|
102
|
+
if registration_token:
|
|
103
|
+
headers["Authorization"] = f"Bearer {registration_token}"
|
|
104
|
+
|
|
105
|
+
url = platform.registration_endpoint
|
|
106
|
+
try:
|
|
107
|
+
response = self._session.post(
|
|
108
|
+
url, json=body, headers=headers, timeout=self._timeout
|
|
109
|
+
)
|
|
110
|
+
except requests.Timeout as exc:
|
|
111
|
+
raise ExternalRequestError(
|
|
112
|
+
f"Timed out posting registration: {exc}", url=url, is_timeout=True
|
|
113
|
+
) from exc
|
|
114
|
+
except requests.RequestException as exc:
|
|
115
|
+
raise ExternalRequestError(
|
|
116
|
+
f"Error posting registration: {exc}", url=url
|
|
117
|
+
) from exc
|
|
118
|
+
|
|
119
|
+
if not response.ok:
|
|
120
|
+
raise ExternalRequestError(
|
|
121
|
+
"Platform rejected the tool registration",
|
|
122
|
+
status_code=response.status_code,
|
|
123
|
+
url=url,
|
|
124
|
+
response_text=response.text,
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
return response.json()
|
|
128
|
+
except (ValueError, TypeError) as exc:
|
|
129
|
+
raise ExternalRequestError(
|
|
130
|
+
f"Invalid registration response: {exc}", url=url
|
|
131
|
+
) from exc
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _to_registration(
|
|
135
|
+
platform: PlatformConfiguration, response_body: t.Mapping[str, t.Any]
|
|
136
|
+
) -> ToolRegistration:
|
|
137
|
+
client_id = response_body.get("client_id")
|
|
138
|
+
if not client_id:
|
|
139
|
+
raise ExternalRequestError(
|
|
140
|
+
"Registration response did not include a client_id",
|
|
141
|
+
url=platform.registration_endpoint,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
lti_config = response_body.get(LTI_TOOL_CONFIGURATION, {}) or {}
|
|
145
|
+
deployment_id = lti_config.get("deployment_id")
|
|
146
|
+
deployment_ids = (deployment_id,) if deployment_id else ()
|
|
147
|
+
|
|
148
|
+
return ToolRegistration(
|
|
149
|
+
issuer=platform.issuer,
|
|
150
|
+
client_id=str(client_id),
|
|
151
|
+
auth_login_url=platform.authorization_endpoint,
|
|
152
|
+
auth_token_url=platform.token_endpoint,
|
|
153
|
+
key_set_url=platform.jwks_uri,
|
|
154
|
+
auth_audience=platform.authorization_server,
|
|
155
|
+
deployment_ids=deployment_ids,
|
|
156
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Persistence interface for dynamically-registered platforms.
|
|
2
|
+
|
|
3
|
+
After a successful registration the resulting :class:`ToolRegistration` must be
|
|
4
|
+
stored so later launches can resolve ``(issuer, client_id)`` to its config. The
|
|
5
|
+
store is an interface so applications can back it with a database; the default
|
|
6
|
+
is in-process only (fine for a single worker / tests).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typing as t
|
|
12
|
+
|
|
13
|
+
from .models import ToolRegistration
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@t.runtime_checkable
|
|
17
|
+
class RegistrationStore(t.Protocol):
|
|
18
|
+
"""Stores and retrieves tool registrations keyed by issuer + client_id."""
|
|
19
|
+
|
|
20
|
+
def save(self, registration: ToolRegistration) -> None: ...
|
|
21
|
+
|
|
22
|
+
def get(self, issuer: str, client_id: str) -> ToolRegistration | None: ...
|
|
23
|
+
|
|
24
|
+
def find_by_issuer(self, issuer: str) -> list[ToolRegistration]: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InMemoryRegistrationStore:
|
|
28
|
+
"""Default per-process store. Swap for a DB-backed store in production."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._by_key: dict[tuple[str, str], ToolRegistration] = {}
|
|
32
|
+
|
|
33
|
+
def save(self, registration: ToolRegistration) -> None:
|
|
34
|
+
self._by_key[(registration.issuer, registration.client_id)] = registration
|
|
35
|
+
|
|
36
|
+
def get(self, issuer: str, client_id: str) -> ToolRegistration | None:
|
|
37
|
+
return self._by_key.get((issuer, client_id))
|
|
38
|
+
|
|
39
|
+
def find_by_issuer(self, issuer: str) -> list[ToolRegistration]:
|
|
40
|
+
return [reg for (iss, _), reg in self._by_key.items() if iss == issuer]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Bridge dynamically-registered platforms into the launch machinery.
|
|
2
|
+
|
|
3
|
+
``StoredToolConf`` is a :class:`ToolConfAbstract` backed by a
|
|
4
|
+
:class:`RegistrationStore`, so the same OIDC login / message launch flow that
|
|
5
|
+
works with a static ``ToolConfDict`` also works for platforms that registered
|
|
6
|
+
themselves via Dynamic Registration. The tool's own key pair is supplied once
|
|
7
|
+
and attached to every resolved registration (used to sign client assertions).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ..core.deployment import Deployment
|
|
13
|
+
from ..core.exception import LtiException
|
|
14
|
+
from ..core.registration import Registration
|
|
15
|
+
from ..core.tool_config.abstract import ToolConfAbstract
|
|
16
|
+
from .models import ToolRegistration
|
|
17
|
+
from .store import RegistrationStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StoredToolConf(ToolConfAbstract):
|
|
21
|
+
"""Tool configuration resolved from a registration store at runtime."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
store: RegistrationStore,
|
|
26
|
+
*,
|
|
27
|
+
private_key: str,
|
|
28
|
+
public_key: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._store = store
|
|
32
|
+
self._private_key = private_key
|
|
33
|
+
self._public_key = public_key
|
|
34
|
+
|
|
35
|
+
# Dynamic registration always works in the (recommended) many-clients model:
|
|
36
|
+
# an issuer may have multiple client_ids, and we always know the client_id.
|
|
37
|
+
def check_iss_has_one_client(self, iss: str) -> bool:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def check_iss_has_many_clients(self, iss: str) -> bool:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
# -- registration resolution ------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _build_registration(self, record: ToolRegistration) -> Registration:
|
|
46
|
+
registration = (
|
|
47
|
+
Registration()
|
|
48
|
+
.set_issuer(record.issuer)
|
|
49
|
+
.set_client_id(record.client_id)
|
|
50
|
+
.set_auth_login_url(record.auth_login_url)
|
|
51
|
+
.set_auth_token_url(record.auth_token_url)
|
|
52
|
+
.set_key_set_url(record.key_set_url)
|
|
53
|
+
.set_tool_private_key(self._private_key)
|
|
54
|
+
)
|
|
55
|
+
if record.auth_audience:
|
|
56
|
+
registration.set_auth_audience(record.auth_audience)
|
|
57
|
+
if self._public_key:
|
|
58
|
+
registration.set_tool_public_key(self._public_key)
|
|
59
|
+
return registration
|
|
60
|
+
|
|
61
|
+
def _lookup(self, iss: str, client_id: str | None) -> ToolRegistration:
|
|
62
|
+
record = None
|
|
63
|
+
if client_id:
|
|
64
|
+
record = self._store.get(iss, client_id)
|
|
65
|
+
else:
|
|
66
|
+
candidates = self._store.find_by_issuer(iss)
|
|
67
|
+
record = candidates[0] if candidates else None
|
|
68
|
+
if record is None:
|
|
69
|
+
raise LtiException(
|
|
70
|
+
f"No registration found for issuer={iss} client_id={client_id}"
|
|
71
|
+
)
|
|
72
|
+
return record
|
|
73
|
+
|
|
74
|
+
def find_registration_by_issuer(self, iss, *args, **kwargs) -> Registration:
|
|
75
|
+
return self._build_registration(self._lookup(iss, None))
|
|
76
|
+
|
|
77
|
+
def find_registration_by_params(self, iss, client_id, *args, **kwargs) -> Registration:
|
|
78
|
+
return self._build_registration(self._lookup(iss, client_id))
|
|
79
|
+
|
|
80
|
+
# -- deployment resolution --------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _deployment(self, record: ToolRegistration, deployment_id: str):
|
|
83
|
+
# If the registration captured specific deployment_ids, enforce them;
|
|
84
|
+
# otherwise accept the launch's deployment_id (deployments are often
|
|
85
|
+
# created separately from registration, e.g. on Canvas).
|
|
86
|
+
if record.deployment_ids and deployment_id not in record.deployment_ids:
|
|
87
|
+
return None
|
|
88
|
+
return Deployment().set_deployment_id(deployment_id)
|
|
89
|
+
|
|
90
|
+
def find_deployment(self, iss, deployment_id):
|
|
91
|
+
try:
|
|
92
|
+
record = self._lookup(iss, None)
|
|
93
|
+
except LtiException:
|
|
94
|
+
return None
|
|
95
|
+
return self._deployment(record, deployment_id)
|
|
96
|
+
|
|
97
|
+
def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwargs):
|
|
98
|
+
try:
|
|
99
|
+
record = self._lookup(iss, client_id)
|
|
100
|
+
except LtiException:
|
|
101
|
+
return None
|
|
102
|
+
return self._deployment(record, deployment_id)
|
ltitoolkit/exceptions.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Typed exception taxonomy for ltitoolkit.
|
|
2
|
+
|
|
3
|
+
A small, explicit hierarchy so callers can catch precisely (``except
|
|
4
|
+
AccessTokenError``) or broadly (``except LtiToolkitError``). External HTTP
|
|
5
|
+
failures are wrapped in rich exceptions that carry the status code and response
|
|
6
|
+
body, mirroring the practice used by production LTI tools.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LtiToolkitError(Exception):
|
|
13
|
+
"""Base class for every error raised by ltitoolkit."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExternalRequestError(LtiToolkitError):
|
|
17
|
+
"""An HTTP request to the LMS failed (network error or non-2xx response)."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
status_code: int | None = None,
|
|
24
|
+
url: str | None = None,
|
|
25
|
+
response_text: str | None = None,
|
|
26
|
+
is_timeout: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.url = url
|
|
31
|
+
self.response_text = response_text
|
|
32
|
+
self.is_timeout = is_timeout
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
35
|
+
base = super().__str__()
|
|
36
|
+
if self.status_code is not None:
|
|
37
|
+
base = f"{base} (status={self.status_code}, url={self.url})"
|
|
38
|
+
return base
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AccessTokenError(ExternalRequestError):
|
|
42
|
+
"""Failed to obtain a client-credentials access token from the platform."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""FastAPI adapter for ltitoolkit.
|
|
2
|
+
|
|
3
|
+
PyLTI1p3 ships Django and Flask adapters only; FastAPI is the gap this package
|
|
4
|
+
fills. This subpackage maps FastAPI/Starlette ``Request``/``Response`` semantics
|
|
5
|
+
(query/form params, cookies, sessions, redirects) onto the vendored core's
|
|
6
|
+
abstractions, and adds async-aware helpers
|
|
7
|
+
(:meth:`FastApiRequest.from_request`, :meth:`FastApiMessageLaunch.validate_async`).
|
|
8
|
+
|
|
9
|
+
Requirements:
|
|
10
|
+
- ``SessionMiddleware`` must be installed for the default session-backed launch
|
|
11
|
+
storage to persist data across the login → launch round trip.
|
|
12
|
+
- ``python-multipart`` must be installed to read the form-encoded ``id_token``
|
|
13
|
+
POST (pulled in by the ``ltitoolkit[fastapi]`` extra).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .cookie import FastApiCookieService
|
|
17
|
+
from .message_launch import FastApiMessageLaunch
|
|
18
|
+
from .oidc_login import FastApiOIDCLogin
|
|
19
|
+
from .redirect import FastApiRedirect
|
|
20
|
+
from .request import FastApiRequest
|
|
21
|
+
from .session import FastApiSessionService
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"FastApiRequest",
|
|
25
|
+
"FastApiCookieService",
|
|
26
|
+
"FastApiSessionService",
|
|
27
|
+
"FastApiRedirect",
|
|
28
|
+
"FastApiOIDCLogin",
|
|
29
|
+
"FastApiMessageLaunch",
|
|
30
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Cookie service adapter for FastAPI/Starlette responses.
|
|
2
|
+
|
|
3
|
+
Cookies cannot be written until we have a response object, so ``set_cookie``
|
|
4
|
+
queues them and :meth:`FastApiCookieService.update_response` flushes them onto a
|
|
5
|
+
Starlette ``Response`` just before it is returned.
|
|
6
|
+
|
|
7
|
+
LTI launches are cross-site POSTs (the LMS posts to the tool), so state cookies
|
|
8
|
+
must be sent in that context: ``SameSite=None; Secure``. When the request is not
|
|
9
|
+
secure (e.g. local ``http`` development) we fall back to ``SameSite=Lax``, since
|
|
10
|
+
browsers reject ``SameSite=None`` without ``Secure``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import typing as t
|
|
16
|
+
|
|
17
|
+
from starlette.responses import Response
|
|
18
|
+
|
|
19
|
+
from ltitoolkit.core.cookie import CookieService
|
|
20
|
+
|
|
21
|
+
if t.TYPE_CHECKING:
|
|
22
|
+
from .request import FastApiRequest
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FastApiCookieService(CookieService):
|
|
26
|
+
def __init__(self, request: FastApiRequest) -> None:
|
|
27
|
+
self._request = request
|
|
28
|
+
self._cookie_data_to_set: dict[str, dict[str, t.Any]] = {}
|
|
29
|
+
|
|
30
|
+
def _get_key(self, key: str) -> str:
|
|
31
|
+
return self._cookie_prefix + "-" + key
|
|
32
|
+
|
|
33
|
+
def get_cookie(self, name: str) -> str | None:
|
|
34
|
+
return self._request.get_cookie(self._get_key(name))
|
|
35
|
+
|
|
36
|
+
def set_cookie(
|
|
37
|
+
self, name: str, value: str | int, exp: int | None = 3600
|
|
38
|
+
) -> None:
|
|
39
|
+
self._cookie_data_to_set[self._get_key(name)] = {"value": value, "exp": exp}
|
|
40
|
+
|
|
41
|
+
def update_response(self, response: Response) -> None:
|
|
42
|
+
"""Flush all queued cookies onto ``response``."""
|
|
43
|
+
secure = self._request.is_secure()
|
|
44
|
+
for key, cookie_data in self._cookie_data_to_set.items():
|
|
45
|
+
response.set_cookie(
|
|
46
|
+
key=key,
|
|
47
|
+
value=str(cookie_data["value"]),
|
|
48
|
+
max_age=cookie_data["exp"],
|
|
49
|
+
path="/",
|
|
50
|
+
secure=secure,
|
|
51
|
+
httponly=True,
|
|
52
|
+
samesite="none" if secure else "lax",
|
|
53
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""FastAPI binding for the Dynamic Registration endpoint.
|
|
2
|
+
|
|
3
|
+
Wire this to a single route (e.g. ``/lti/register``); that URL is what an LMS
|
|
4
|
+
admin pastes to install the tool. The platform may use GET or POST and supplies
|
|
5
|
+
``openid_configuration`` and (optionally) ``registration_token``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from starlette.concurrency import run_in_threadpool
|
|
11
|
+
from starlette.requests import Request as StarletteRequest
|
|
12
|
+
from starlette.responses import HTMLResponse, PlainTextResponse, Response
|
|
13
|
+
|
|
14
|
+
from ..dynamic_registration.service import DynamicRegistrationService
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def handle_dynamic_registration(
|
|
18
|
+
request: StarletteRequest, service: DynamicRegistrationService
|
|
19
|
+
) -> Response:
|
|
20
|
+
"""Perform registration for an incoming initiation request.
|
|
21
|
+
|
|
22
|
+
Returns the completion HTML (which closes the registration window via
|
|
23
|
+
postMessage) on success, or a 400 if ``openid_configuration`` is missing.
|
|
24
|
+
"""
|
|
25
|
+
params: dict[str, str] = dict(request.query_params)
|
|
26
|
+
if request.method == "POST":
|
|
27
|
+
form = await request.form()
|
|
28
|
+
for key in ("openid_configuration", "registration_token"):
|
|
29
|
+
if key in form and key not in params:
|
|
30
|
+
params[key] = str(form[key])
|
|
31
|
+
|
|
32
|
+
openid_configuration = params.get("openid_configuration")
|
|
33
|
+
if not openid_configuration:
|
|
34
|
+
return PlainTextResponse("Missing 'openid_configuration'", status_code=400)
|
|
35
|
+
|
|
36
|
+
# Registration performs blocking HTTP; keep the event loop free.
|
|
37
|
+
await run_in_threadpool(
|
|
38
|
+
service.register, openid_configuration, params.get("registration_token")
|
|
39
|
+
)
|
|
40
|
+
return HTMLResponse(service.completion_html())
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Message launch adapter — step 2 of an LTI 1.3 launch.
|
|
2
|
+
|
|
3
|
+
Validates the ``id_token`` posted back by the platform: state, JWT format,
|
|
4
|
+
nonce, registration, signature, deployment, and message type.
|
|
5
|
+
|
|
6
|
+
Validation performs **blocking** network I/O (fetching the platform's JWKS via
|
|
7
|
+
``requests``). Calling it directly in an async route would block the event loop,
|
|
8
|
+
so :meth:`validate_async` runs the synchronous validation in a threadpool. Use
|
|
9
|
+
it from async handlers; the inherited synchronous :meth:`validate` remains
|
|
10
|
+
available for non-async contexts.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import typing as t
|
|
16
|
+
|
|
17
|
+
from starlette.concurrency import run_in_threadpool
|
|
18
|
+
|
|
19
|
+
from ltitoolkit.core.message_launch import MessageLaunch
|
|
20
|
+
|
|
21
|
+
from .cookie import FastApiCookieService
|
|
22
|
+
from .session import FastApiSessionService
|
|
23
|
+
|
|
24
|
+
if t.TYPE_CHECKING:
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
from ltitoolkit.core.launch_data_storage.base import LaunchDataStorage
|
|
28
|
+
from ltitoolkit.core.tool_config import ToolConfAbstract
|
|
29
|
+
|
|
30
|
+
from .request import FastApiRequest
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FastApiMessageLaunch(MessageLaunch):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
request: FastApiRequest,
|
|
37
|
+
tool_config: ToolConfAbstract,
|
|
38
|
+
session_service: FastApiSessionService | None = None,
|
|
39
|
+
cookie_service: FastApiCookieService | None = None,
|
|
40
|
+
launch_data_storage: LaunchDataStorage[t.Any] | None = None,
|
|
41
|
+
requests_session: requests.Session | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
cookie_service = cookie_service or FastApiCookieService(request)
|
|
44
|
+
session_service = session_service or FastApiSessionService(request)
|
|
45
|
+
super().__init__(
|
|
46
|
+
request,
|
|
47
|
+
tool_config,
|
|
48
|
+
session_service,
|
|
49
|
+
cookie_service,
|
|
50
|
+
launch_data_storage,
|
|
51
|
+
requests_session,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def _get_request_param(self, key: str) -> t.Any:
|
|
55
|
+
return self._request.get_param(key)
|
|
56
|
+
|
|
57
|
+
async def validate_async(self) -> FastApiMessageLaunch:
|
|
58
|
+
"""Run the (blocking) launch validation without blocking the event loop."""
|
|
59
|
+
await run_in_threadpool(self.validate)
|
|
60
|
+
return self
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""OIDC login adapter — step 1 of an LTI 1.3 launch.
|
|
2
|
+
|
|
3
|
+
Handles the platform's *third-party initiated login*: validate the request,
|
|
4
|
+
mint state/nonce, set the state cookie, and redirect back to the platform's
|
|
5
|
+
authorization endpoint. Wraps the framework-specific pieces (redirect + raw HTML
|
|
6
|
+
response) around the core's :class:`ltitoolkit.core.oidc_login.OIDCLogin`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typing as t
|
|
12
|
+
|
|
13
|
+
from starlette.responses import HTMLResponse, Response
|
|
14
|
+
|
|
15
|
+
from ltitoolkit.core.oidc_login import OIDCLogin
|
|
16
|
+
|
|
17
|
+
from .cookie import FastApiCookieService
|
|
18
|
+
from .redirect import FastApiRedirect
|
|
19
|
+
from .session import FastApiSessionService
|
|
20
|
+
|
|
21
|
+
if t.TYPE_CHECKING:
|
|
22
|
+
from ltitoolkit.core.launch_data_storage.base import LaunchDataStorage
|
|
23
|
+
from ltitoolkit.core.tool_config import ToolConfAbstract
|
|
24
|
+
|
|
25
|
+
from .request import FastApiRequest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FastApiOIDCLogin(OIDCLogin):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
request: FastApiRequest,
|
|
32
|
+
tool_config: ToolConfAbstract,
|
|
33
|
+
session_service: FastApiSessionService | None = None,
|
|
34
|
+
cookie_service: FastApiCookieService | None = None,
|
|
35
|
+
launch_data_storage: LaunchDataStorage[t.Any] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
cookie_service = cookie_service or FastApiCookieService(request)
|
|
38
|
+
session_service = session_service or FastApiSessionService(request)
|
|
39
|
+
super().__init__(
|
|
40
|
+
request, tool_config, session_service, cookie_service, launch_data_storage
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def get_redirect(self, url: str) -> FastApiRedirect:
|
|
44
|
+
return FastApiRedirect(url, self._cookie_service)
|
|
45
|
+
|
|
46
|
+
def get_response(self, html: str) -> Response:
|
|
47
|
+
return HTMLResponse(html)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Redirect adapter producing Starlette responses.
|
|
2
|
+
|
|
3
|
+
The core asks for two redirect styles: a normal HTTP 302 redirect and a
|
|
4
|
+
JavaScript redirect (used to break out of an iframe / re-assert cookies). Both
|
|
5
|
+
flush any queued cookies (e.g. the short-lived OIDC ``state`` cookie) onto the
|
|
6
|
+
outgoing response.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import typing as t
|
|
13
|
+
|
|
14
|
+
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
|
15
|
+
|
|
16
|
+
from ltitoolkit.core.redirect import Redirect
|
|
17
|
+
|
|
18
|
+
if t.TYPE_CHECKING:
|
|
19
|
+
from .cookie import FastApiCookieService
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FastApiRedirect(Redirect):
|
|
23
|
+
def __init__(
|
|
24
|
+
self, location: str, cookie_service: FastApiCookieService | None = None
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__()
|
|
27
|
+
self._location = location
|
|
28
|
+
self._cookie_service = cookie_service
|
|
29
|
+
|
|
30
|
+
def do_redirect(self) -> Response:
|
|
31
|
+
return self._process_response(
|
|
32
|
+
RedirectResponse(url=self._location, status_code=302)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def do_js_redirect(self) -> Response:
|
|
36
|
+
safe_location = html.escape(self._location, quote=True)
|
|
37
|
+
return self._process_response(
|
|
38
|
+
HTMLResponse(
|
|
39
|
+
f'<script type="text/javascript">'
|
|
40
|
+
f'window.location="{safe_location}";'
|
|
41
|
+
f"</script>"
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def set_redirect_url(self, location: str) -> None:
|
|
46
|
+
self._location = location
|
|
47
|
+
|
|
48
|
+
def get_redirect_url(self) -> str:
|
|
49
|
+
return self._location
|
|
50
|
+
|
|
51
|
+
def _process_response(self, response: Response) -> Response:
|
|
52
|
+
if self._cookie_service:
|
|
53
|
+
self._cookie_service.update_response(response)
|
|
54
|
+
return response
|