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.
Files changed (94) hide show
  1. ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
  2. ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
  3. ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
  4. ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  5. ltitoolkit/__init__.py +20 -0
  6. ltitoolkit/adapters/__init__.py +11 -0
  7. ltitoolkit/adapters/brightspace/__init__.py +35 -0
  8. ltitoolkit/adapters/brightspace/client.py +176 -0
  9. ltitoolkit/adapters/canvas/__init__.py +27 -0
  10. ltitoolkit/adapters/canvas/client.py +142 -0
  11. ltitoolkit/advantage/__init__.py +9 -0
  12. ltitoolkit/advantage/service.py +96 -0
  13. ltitoolkit/core/__init__.py +19 -0
  14. ltitoolkit/core/actions.py +6 -0
  15. ltitoolkit/core/assignments_grades.py +300 -0
  16. ltitoolkit/core/contrib/__init__.py +0 -0
  17. ltitoolkit/core/contrib/django/__init__.py +5 -0
  18. ltitoolkit/core/contrib/django/cookie.py +56 -0
  19. ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
  20. ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
  21. ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
  22. ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
  23. ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
  24. ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
  25. ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
  26. ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
  27. ltitoolkit/core/contrib/django/message_launch.py +39 -0
  28. ltitoolkit/core/contrib/django/oidc_login.py +41 -0
  29. ltitoolkit/core/contrib/django/redirect.py +34 -0
  30. ltitoolkit/core/contrib/django/request.py +32 -0
  31. ltitoolkit/core/contrib/django/session.py +5 -0
  32. ltitoolkit/core/contrib/flask/__init__.py +7 -0
  33. ltitoolkit/core/contrib/flask/cookie.py +34 -0
  34. ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
  35. ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
  36. ltitoolkit/core/contrib/flask/message_launch.py +32 -0
  37. ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
  38. ltitoolkit/core/contrib/flask/redirect.py +34 -0
  39. ltitoolkit/core/contrib/flask/request.py +40 -0
  40. ltitoolkit/core/contrib/flask/session.py +5 -0
  41. ltitoolkit/core/contrib/py.typed +0 -0
  42. ltitoolkit/core/cookie.py +17 -0
  43. ltitoolkit/core/cookies_allowed_check.py +151 -0
  44. ltitoolkit/core/course_groups.py +115 -0
  45. ltitoolkit/core/deep_link.py +100 -0
  46. ltitoolkit/core/deep_link_resource.py +96 -0
  47. ltitoolkit/core/deployment.py +13 -0
  48. ltitoolkit/core/exception.py +16 -0
  49. ltitoolkit/core/grade.py +143 -0
  50. ltitoolkit/core/launch_data_storage/__init__.py +0 -0
  51. ltitoolkit/core/launch_data_storage/base.py +75 -0
  52. ltitoolkit/core/launch_data_storage/cache.py +43 -0
  53. ltitoolkit/core/launch_data_storage/session.py +29 -0
  54. ltitoolkit/core/lineitem.py +205 -0
  55. ltitoolkit/core/message_launch.py +828 -0
  56. ltitoolkit/core/message_validators/__init__.py +13 -0
  57. ltitoolkit/core/message_validators/abstract.py +25 -0
  58. ltitoolkit/core/message_validators/deep_link.py +34 -0
  59. ltitoolkit/core/message_validators/privacy_launch.py +40 -0
  60. ltitoolkit/core/message_validators/resource_message.py +21 -0
  61. ltitoolkit/core/message_validators/submission_review.py +45 -0
  62. ltitoolkit/core/names_roles.py +97 -0
  63. ltitoolkit/core/oidc_login.py +275 -0
  64. ltitoolkit/core/py.typed +0 -0
  65. ltitoolkit/core/redirect.py +24 -0
  66. ltitoolkit/core/registration.py +119 -0
  67. ltitoolkit/core/request.py +17 -0
  68. ltitoolkit/core/roles.py +109 -0
  69. ltitoolkit/core/service_connector.py +144 -0
  70. ltitoolkit/core/session.py +70 -0
  71. ltitoolkit/core/tool_config/__init__.py +4 -0
  72. ltitoolkit/core/tool_config/abstract.py +117 -0
  73. ltitoolkit/core/tool_config/dict.py +253 -0
  74. ltitoolkit/core/tool_config/json_file.py +100 -0
  75. ltitoolkit/core/tool_config/py.typed +0 -0
  76. ltitoolkit/core/utils.py +10 -0
  77. ltitoolkit/dynamic_registration/__init__.py +39 -0
  78. ltitoolkit/dynamic_registration/models.py +192 -0
  79. ltitoolkit/dynamic_registration/service.py +156 -0
  80. ltitoolkit/dynamic_registration/store.py +40 -0
  81. ltitoolkit/dynamic_registration/tool_conf.py +102 -0
  82. ltitoolkit/exceptions.py +42 -0
  83. ltitoolkit/fastapi/__init__.py +30 -0
  84. ltitoolkit/fastapi/cookie.py +53 -0
  85. ltitoolkit/fastapi/dynamic_registration.py +40 -0
  86. ltitoolkit/fastapi/message_launch.py +60 -0
  87. ltitoolkit/fastapi/oidc_login.py +47 -0
  88. ltitoolkit/fastapi/redirect.py +54 -0
  89. ltitoolkit/fastapi/request.py +77 -0
  90. ltitoolkit/fastapi/session.py +13 -0
  91. ltitoolkit/http.py +80 -0
  92. ltitoolkit/token/__init__.py +20 -0
  93. ltitoolkit/token/cache.py +47 -0
  94. 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)
@@ -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