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,77 @@
1
+ """Starlette/FastAPI request adapter for the vendored LTI core.
2
+
3
+ The core's :class:`ltitoolkit.core.request.Request` exposes a *synchronous*
4
+ ``get_param()``. Starlette, however, reads the form body asynchronously
5
+ (``await request.form()``). We bridge this by extracting all request data
6
+ *eagerly* in :meth:`FastApiRequest.from_request` (inside the async route) and
7
+ then serving it synchronously from an in-memory mapping — so the core never has
8
+ to ``await`` anything.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import typing as t
14
+
15
+ from starlette.requests import Request as StarletteRequest
16
+
17
+ from ltitoolkit.core.request import Request
18
+
19
+
20
+ class FastApiRequest(Request):
21
+ """A core ``Request`` backed by data pulled from a Starlette request.
22
+
23
+ Construct it with :meth:`from_request` from inside an async handler; that is
24
+ the only place the (async) form body can be read.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ cookies: t.Mapping[str, str],
31
+ session: t.MutableMapping[str, t.Any],
32
+ request_data: t.Mapping[str, t.Any],
33
+ request_is_secure: bool,
34
+ ) -> None:
35
+ super().__init__()
36
+ self._cookies = cookies
37
+ self._session = session
38
+ self._request_data = request_data
39
+ self._request_is_secure = request_is_secure
40
+
41
+ @classmethod
42
+ async def from_request(cls, request: StarletteRequest) -> FastApiRequest:
43
+ """Eagerly read query/form params, cookies and session from Starlette.
44
+
45
+ The default launch-data storage writes to ``request.session``, which
46
+ requires Starlette's ``SessionMiddleware`` to be installed. If it is not,
47
+ a throwaway dict is used (fine for cache-backed storage, but session
48
+ storage will not persist across requests).
49
+ """
50
+ request_data: dict[str, t.Any] = dict(request.query_params)
51
+ if request.method != "GET":
52
+ form = await request.form()
53
+ request_data.update({key: form[key] for key in form})
54
+
55
+ # request.session only exists when SessionMiddleware is active.
56
+ session: t.MutableMapping[str, t.Any]
57
+ session = request.session if "session" in request.scope else {}
58
+
59
+ return cls(
60
+ cookies=dict(request.cookies),
61
+ session=session,
62
+ request_data=request_data,
63
+ request_is_secure=request.url.scheme == "https",
64
+ )
65
+
66
+ @property
67
+ def session(self) -> t.MutableMapping[str, t.Any]:
68
+ return self._session
69
+
70
+ def get_param(self, key: str) -> t.Any:
71
+ return self._request_data.get(key)
72
+
73
+ def get_cookie(self, key: str) -> str | None:
74
+ return self._cookies.get(key)
75
+
76
+ def is_secure(self) -> bool:
77
+ return self._request_is_secure
@@ -0,0 +1,13 @@
1
+ """Session service adapter.
2
+
3
+ The core's :class:`ltitoolkit.core.session.SessionService` is framework-neutral:
4
+ its default backend (``SessionDataStorage``) simply reads and writes
5
+ ``request.session``. For FastAPI that is the dict provided by Starlette's
6
+ ``SessionMiddleware``, so no behaviour needs to change here.
7
+ """
8
+
9
+ from ltitoolkit.core.session import SessionService
10
+
11
+
12
+ class FastApiSessionService(SessionService):
13
+ pass
ltitoolkit/http.py ADDED
@@ -0,0 +1,80 @@
1
+ """HTTP session construction with sane, safe defaults.
2
+
3
+ The vendored core issues requests with **no timeout**, which can hang a worker
4
+ indefinitely if the LMS stalls. Rather than patch the vendored code, we provide
5
+ a session whose adapter injects a default timeout on every request, and we pass
6
+ that session into the core services and our own token service.
7
+
8
+ Retries are **disabled by default** and, when enabled, are restricted to
9
+ idempotent methods (GET/HEAD/OPTIONS). LTI score submission and token requests
10
+ are POSTs and must never be retried blindly (risk of duplicate side effects).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import typing as t
16
+
17
+ import requests
18
+ from requests.adapters import HTTPAdapter
19
+ from requests.models import PreparedRequest
20
+ from urllib3.util.retry import Retry
21
+
22
+ # (connect timeout, read timeout) in seconds.
23
+ DEFAULT_TIMEOUT: tuple[float, float] = (10.0, 30.0)
24
+
25
+ _IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
26
+
27
+
28
+ class TimeoutHTTPAdapter(HTTPAdapter):
29
+ """An adapter that applies a default timeout when a call omits one."""
30
+
31
+ def __init__(
32
+ self, *args: t.Any, timeout: float | tuple[float, float] = DEFAULT_TIMEOUT, **kwargs: t.Any
33
+ ) -> None:
34
+ self._timeout = timeout
35
+ super().__init__(*args, **kwargs)
36
+
37
+ def send( # noqa: PLR0913 - signature mirrors requests' HTTPAdapter.send
38
+ self,
39
+ request: PreparedRequest,
40
+ stream: bool = False,
41
+ timeout: t.Any = None,
42
+ verify: t.Any = True,
43
+ cert: t.Any = None,
44
+ proxies: t.Any = None,
45
+ ) -> t.Any:
46
+ if timeout is None:
47
+ timeout = self._timeout
48
+ return super().send(
49
+ request, stream=stream, timeout=timeout, verify=verify, cert=cert, proxies=proxies
50
+ )
51
+
52
+
53
+ def build_session(
54
+ *,
55
+ timeout: float | tuple[float, float] = DEFAULT_TIMEOUT,
56
+ retries: int = 0,
57
+ backoff_factor: float = 0.3,
58
+ user_agent: str = "ltitoolkit",
59
+ ) -> requests.Session:
60
+ """Create a ``requests.Session`` with a default timeout and optional retries.
61
+
62
+ :param timeout: default ``(connect, read)`` timeout for every request.
63
+ :param retries: max retries for *idempotent* methods only (0 disables them).
64
+ :param backoff_factor: exponential backoff between retries.
65
+ :param user_agent: ``User-Agent`` header sent with every request.
66
+ """
67
+ session = requests.Session()
68
+ session.headers["User-Agent"] = user_agent
69
+
70
+ retry = Retry(
71
+ total=retries,
72
+ backoff_factor=backoff_factor,
73
+ status_forcelist=(429, 500, 502, 503, 504),
74
+ allowed_methods=_IDEMPOTENT_METHODS,
75
+ raise_on_status=False,
76
+ )
77
+ adapter = TimeoutHTTPAdapter(timeout=timeout, max_retries=retry)
78
+ session.mount("https://", adapter)
79
+ session.mount("http://", adapter)
80
+ return session
@@ -0,0 +1,20 @@
1
+ """Generic OAuth2 client-credentials token minting.
2
+
3
+ The tool authenticates to an LMS by signing a JWT with its *own* private key
4
+ (the key behind its JWKS) and exchanging it for an access token via the
5
+ client-credentials grant — no user login, no copied credentials.
6
+
7
+ This is reused for both LTI Advantage service calls (AGS/NRPS — portable) and
8
+ LMS-proprietary API calls (Canvas etc. — used by per-LMS adapters). Only the
9
+ *scopes* and *endpoints* differ per LMS; the auth mechanism here is shared.
10
+ """
11
+
12
+ from .cache import AccessToken, InMemoryTokenCache, TokenCache
13
+ from .service import AccessTokenService
14
+
15
+ __all__ = [
16
+ "AccessToken",
17
+ "TokenCache",
18
+ "InMemoryTokenCache",
19
+ "AccessTokenService",
20
+ ]
@@ -0,0 +1,47 @@
1
+ """Access-token value object and pluggable cache.
2
+
3
+ Tokens are cached per scope-set and reused until they are near expiry. The cache
4
+ is an interface (:class:`TokenCache`) so applications can swap the default
5
+ in-process dict for Redis, a database, etc. — important when running multiple
6
+ workers that should share tokens (the default cache is per-process only).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typing as t
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AccessToken:
17
+ """An OAuth2 access token with the scopes it was issued for and its expiry."""
18
+
19
+ value: str
20
+ scopes: tuple[str, ...]
21
+ expires_at: float # epoch seconds
22
+
23
+ def is_expired(self, *, leeway: float = 60.0, now: float) -> bool:
24
+ """True if the token is at/after its expiry (minus a safety ``leeway``)."""
25
+ return now >= (self.expires_at - leeway)
26
+
27
+
28
+ @t.runtime_checkable
29
+ class TokenCache(t.Protocol):
30
+ """Minimal cache interface for storing access tokens by key."""
31
+
32
+ def get(self, key: str) -> AccessToken | None: ...
33
+
34
+ def set(self, key: str, token: AccessToken) -> None: ...
35
+
36
+
37
+ class InMemoryTokenCache:
38
+ """Default per-process cache. Not shared across workers/processes."""
39
+
40
+ def __init__(self) -> None:
41
+ self._store: dict[str, AccessToken] = {}
42
+
43
+ def get(self, key: str) -> AccessToken | None:
44
+ return self._store.get(key)
45
+
46
+ def set(self, key: str, token: AccessToken) -> None:
47
+ self._store[key] = token
@@ -0,0 +1,165 @@
1
+ """Client-credentials access-token service.
2
+
3
+ The tool authenticates to an LMS by signing a short-lived *client assertion* JWT
4
+ with its own private key and exchanging it (``grant_type=client_credentials``)
5
+ for an access token — no user login, no copied secrets. This is the auth half of
6
+ both LTI Advantage service calls and LMS-proprietary API calls (e.g. listing
7
+ Canvas files); only the requested *scopes* differ.
8
+
9
+ This service adds, on top of the vendored core's signing:
10
+
11
+ - **expiry-aware caching** (reuse a token until it nears expiry), and
12
+ - **explicit timeouts** and **typed errors**
13
+
14
+ which the core's in-memory token cache lacks. The security-critical RSA signing
15
+ is delegated to PyJWT exactly as the core does it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import time
22
+ import typing as t
23
+ import uuid
24
+
25
+ import jwt
26
+ import requests
27
+
28
+ from ..exceptions import AccessTokenError
29
+ from ..http import build_session
30
+ from .cache import AccessToken, InMemoryTokenCache, TokenCache
31
+
32
+ if t.TYPE_CHECKING:
33
+ from ..core.registration import Registration
34
+
35
+ _JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
36
+ # Client-assertion lifetime. Kept short per the LTI security spec.
37
+ _ASSERTION_LIFETIME = 60
38
+ # Fallback token lifetime if the platform omits ``expires_in``.
39
+ _DEFAULT_TOKEN_LIFETIME = 3600
40
+
41
+
42
+ class AccessTokenService:
43
+ """Mint and cache client-credentials access tokens for a registration."""
44
+
45
+ def __init__(
46
+ self,
47
+ registration: Registration,
48
+ *,
49
+ session: requests.Session | None = None,
50
+ cache: TokenCache | None = None,
51
+ timeout: float | tuple[float, float] = (10.0, 30.0),
52
+ expiry_leeway: float = 60.0,
53
+ clock: t.Callable[[], float] = time.time,
54
+ ) -> None:
55
+ self._registration = registration
56
+ self._session = session if session is not None else build_session(timeout=timeout)
57
+ self._cache: TokenCache = cache if cache is not None else InMemoryTokenCache()
58
+ self._timeout = timeout
59
+ self._leeway = expiry_leeway
60
+ self._clock = clock
61
+
62
+ def get_token(self, scopes: t.Sequence[str]) -> str:
63
+ """Return a valid access token for ``scopes``, minting one if needed."""
64
+ if not scopes:
65
+ raise ValueError("At least one scope is required")
66
+
67
+ key = self._cache_key(scopes)
68
+ cached = self._cache.get(key)
69
+ if cached is not None and not cached.is_expired(
70
+ leeway=self._leeway, now=self._clock()
71
+ ):
72
+ return cached.value
73
+
74
+ token = self._request_token(scopes)
75
+ self._cache.set(key, token)
76
+ return token.value
77
+
78
+ # -- internals ---------------------------------------------------------
79
+
80
+ @staticmethod
81
+ def _cache_key(scopes: t.Sequence[str]) -> str:
82
+ canonical = "|".join(sorted(scopes)).encode("utf-8")
83
+ return hashlib.sha256(canonical).hexdigest()
84
+
85
+ def _build_client_assertion(self) -> str:
86
+ client_id = self._registration.get_client_id()
87
+ token_url = self._registration.get_auth_token_url()
88
+ assert client_id is not None, "Registration is missing client_id"
89
+ assert token_url is not None, "Registration is missing auth_token_url"
90
+ audience = self._registration.get_auth_audience() or token_url
91
+ private_key = self._registration.get_tool_private_key()
92
+ assert private_key is not None, "Registration is missing the tool private key"
93
+
94
+ now = int(self._clock())
95
+ claims: dict[str, t.Any] = {
96
+ "iss": str(client_id),
97
+ "sub": str(client_id),
98
+ "aud": str(audience),
99
+ "iat": now - 5,
100
+ "exp": now + _ASSERTION_LIFETIME,
101
+ "jti": "ltitoolkit-" + uuid.uuid4().hex,
102
+ }
103
+ headers: dict[str, str] = {}
104
+ kid = self._registration.get_kid()
105
+ if kid:
106
+ headers["kid"] = kid
107
+
108
+ encoded = jwt.encode(claims, private_key, algorithm="RS256", headers=headers)
109
+ # PyJWT < 2 returned bytes; normalise to str.
110
+ return encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
111
+
112
+ def _request_token(self, scopes: t.Sequence[str]) -> AccessToken:
113
+ token_url = self._registration.get_auth_token_url()
114
+ assert token_url is not None, "Registration is missing auth_token_url"
115
+
116
+ payload = {
117
+ "grant_type": "client_credentials",
118
+ "client_assertion_type": _JWT_BEARER,
119
+ "client_assertion": self._build_client_assertion(),
120
+ "scope": " ".join(scopes),
121
+ }
122
+
123
+ try:
124
+ response = self._session.post(token_url, data=payload, timeout=self._timeout)
125
+ except requests.Timeout as exc:
126
+ raise AccessTokenError(
127
+ f"Timed out requesting access token: {exc}",
128
+ url=token_url,
129
+ is_timeout=True,
130
+ ) from exc
131
+ except requests.RequestException as exc:
132
+ raise AccessTokenError(
133
+ f"Error requesting access token: {exc}", url=token_url
134
+ ) from exc
135
+
136
+ if not response.ok:
137
+ raise AccessTokenError(
138
+ "Access token request rejected by the platform",
139
+ status_code=response.status_code,
140
+ url=token_url,
141
+ response_text=response.text,
142
+ )
143
+
144
+ try:
145
+ body = response.json()
146
+ access_token = body["access_token"]
147
+ except (ValueError, KeyError, TypeError) as exc:
148
+ raise AccessTokenError(
149
+ "Malformed access token response from the platform",
150
+ status_code=response.status_code,
151
+ url=token_url,
152
+ response_text=response.text,
153
+ ) from exc
154
+
155
+ expires_in = body.get("expires_in", _DEFAULT_TOKEN_LIFETIME)
156
+ try:
157
+ expires_at = self._clock() + float(expires_in)
158
+ except (TypeError, ValueError):
159
+ expires_at = self._clock() + _DEFAULT_TOKEN_LIFETIME
160
+
161
+ return AccessToken(
162
+ value=access_token,
163
+ scopes=tuple(sorted(scopes)),
164
+ expires_at=expires_at,
165
+ )