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,828 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import typing as t
5
+ import uuid
6
+ from abc import ABCMeta, abstractmethod
7
+
8
+ import jwt # type: ignore
9
+ import requests
10
+ import typing_extensions as te
11
+ from jwcrypto.jwk import JWK # type: ignore
12
+
13
+ from .actions import Action
14
+ from .assignments_grades import AssignmentsGradesService, TAssignmentsGradersData
15
+ from .cookie import CookieService
16
+ from .course_groups import CourseGroupsService, TGroupsServiceData
17
+ from .deep_link import DeepLink, TDeepLinkData
18
+ from .exception import LtiException
19
+ from .launch_data_storage.base import DisableSessionId, LaunchDataStorage
20
+ from .message_validators import get_validators
21
+ from .message_validators.deep_link import DeepLinkMessageValidator
22
+ from .message_validators.privacy_launch import PrivacyLaunchValidator
23
+ from .message_validators.resource_message import ResourceMessageValidator
24
+ from .message_validators.submission_review import SubmissionReviewLaunchValidator
25
+ from .names_roles import NamesRolesProvisioningService, TNamesAndRolesData
26
+ from .roles import (
27
+ StaffRole,
28
+ StudentRole,
29
+ TeacherRole,
30
+ TeachingAssistantRole,
31
+ DesignerRole,
32
+ ObserverRole,
33
+ TransientRole,
34
+ )
35
+ from .registration import Registration, TKeySet
36
+ from .request import Request
37
+ from .session import SessionService
38
+ from .service_connector import ServiceConnector, REQUESTS_USER_AGENT
39
+ from .tool_config import ToolConfAbstract
40
+
41
+
42
+ TResourceLinkClaim = te.TypedDict(
43
+ "TResourceLinkClaim",
44
+ {
45
+ # Required data
46
+ "id": str,
47
+ # Optional data
48
+ "description": str,
49
+ "title": str,
50
+ },
51
+ total=False,
52
+ )
53
+
54
+ TContextClaim = te.TypedDict(
55
+ "TContextClaim",
56
+ {
57
+ # Required data
58
+ "id": str,
59
+ # Optional data
60
+ "label": str,
61
+ "title": str,
62
+ "type": t.List[str],
63
+ },
64
+ total=False,
65
+ )
66
+
67
+ TToolPlatformClaim = te.TypedDict(
68
+ "TToolPlatformClaim",
69
+ {
70
+ # Required data
71
+ "guid": str,
72
+ # Optional data
73
+ "contact_email": str,
74
+ "description": str,
75
+ "name": str,
76
+ "url": str,
77
+ "product_family_code": str,
78
+ "version": str,
79
+ },
80
+ total=False,
81
+ )
82
+
83
+ TLearningInformationServicesClaim = te.TypedDict(
84
+ "TLearningInformationServicesClaim",
85
+ {
86
+ "person_sourcedid": str,
87
+ "course_offering_sourcedid": str,
88
+ "course_section_sourcedid": str,
89
+ },
90
+ total=False,
91
+ )
92
+
93
+ TMigrationClaim = te.TypedDict(
94
+ "TMigrationClaim",
95
+ {
96
+ # Required data
97
+ "oauth_consumer_key": str,
98
+ # Optional data
99
+ "oauth_consumer_key_sign": str,
100
+ "user_id": str,
101
+ "context_id": str,
102
+ "tool_consumer_instance_guid ": str,
103
+ "resource_link_id": str,
104
+ },
105
+ total=False,
106
+ )
107
+
108
+ TForUserClaim = te.TypedDict(
109
+ "TForUserClaim",
110
+ {
111
+ # Required data
112
+ "user_id": str,
113
+ # Optional data
114
+ "person_sourcedId": str,
115
+ "given_name": str,
116
+ "family_name": str,
117
+ "name": str,
118
+ "email": str,
119
+ "roles": t.List[str],
120
+ },
121
+ )
122
+
123
+ TLaunchData = te.TypedDict(
124
+ "TLaunchData",
125
+ {
126
+ # Required data
127
+ "iss": str,
128
+ "nonce": str,
129
+ "aud": t.Union[t.List[str], str],
130
+ "https://purl.imsglobal.org/spec/lti/claim/message_type": te.Literal[
131
+ "LtiResourceLinkRequest",
132
+ "LtiDeepLinkingRequest",
133
+ "DataPrivacyLaunchRequest",
134
+ "LtiSubmissionReviewRequest",
135
+ ],
136
+ "https://purl.imsglobal.org/spec/lti/claim/version": te.Literal["1.3.0"],
137
+ "https://purl.imsglobal.org/spec/lti/claim/deployment_id": str,
138
+ "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": str,
139
+ "https://purl.imsglobal.org/spec/lti/claim/resource_link": TResourceLinkClaim,
140
+ "https://purl.imsglobal.org/spec/lti/claim/roles": t.List[str],
141
+ "sub": str,
142
+ # Optional data
143
+ "given_name": str,
144
+ "family_name": str,
145
+ "name": str,
146
+ "email": str,
147
+ "https://purl.imsglobal.org/spec/lti/claim/context": TContextClaim,
148
+ "https://purl.imsglobal.org/spec/lti/claim/lis": TLearningInformationServicesClaim,
149
+ "https://purl.imsglobal.org/spec/lti/claim/custom": t.Mapping[str, str],
150
+ "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings": TDeepLinkData,
151
+ "https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice": TGroupsServiceData,
152
+ "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": TNamesAndRolesData,
153
+ "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": TAssignmentsGradersData,
154
+ "https://purl.imsglobal.org/spec/lti/claim/tool_platform": TToolPlatformClaim,
155
+ "https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor": t.List[str],
156
+ "https://purl.imsglobal.org/spec/lti/claim/lti1p1": TMigrationClaim,
157
+ "https://purl.imsglobal.org/spec/lti/claim/for_user": TForUserClaim,
158
+ },
159
+ total=False,
160
+ )
161
+
162
+ TJwtHeader = te.TypedDict(
163
+ "TJwtHeader",
164
+ {
165
+ "kid": str,
166
+ "alg": str,
167
+ },
168
+ total=False,
169
+ )
170
+
171
+ TJwtData = te.TypedDict(
172
+ "TJwtData",
173
+ {
174
+ "header": TJwtHeader,
175
+ "body": TLaunchData,
176
+ },
177
+ total=False,
178
+ )
179
+
180
+ REQ = t.TypeVar("REQ", bound=Request)
181
+ TCONF = t.TypeVar("TCONF", bound=ToolConfAbstract)
182
+ SES = t.TypeVar("SES", bound=SessionService)
183
+ COOK = t.TypeVar("COOK", bound=CookieService)
184
+
185
+
186
+ class MessageLaunch(t.Generic[REQ, TCONF, SES, COOK]):
187
+ __metaclass__ = ABCMeta
188
+ _request: REQ
189
+ _tool_config: TCONF
190
+ _session_service: SES
191
+ _cookie_service: COOK
192
+ _jwt: TJwtData
193
+ _jwt_verify_options: t.Dict[str, bool]
194
+ _registration: t.Optional[Registration]
195
+ _launch_id: str
196
+ _validated: bool = False
197
+ _auto_validation: bool = True
198
+ _restored: bool = False
199
+ _id_token_hash: t.Optional[str]
200
+ _public_key_cache_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None
201
+ _public_key_cache_lifetime: t.Optional[int] = None
202
+
203
+ def __init__(
204
+ self,
205
+ request: REQ,
206
+ tool_config: TCONF,
207
+ session_service: t.Optional[SES] = None,
208
+ cookie_service: t.Optional[COOK] = None,
209
+ launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None,
210
+ requests_session: t.Optional[requests.Session] = None,
211
+ ):
212
+ self._request = request
213
+ self._tool_config = tool_config
214
+
215
+ assert session_service is not None, "Session Service must be set"
216
+ assert cookie_service is not None, "Cookie Service must be set"
217
+
218
+ self._session_service = session_service
219
+ self._cookie_service = cookie_service
220
+ self._launch_id = "lti1p3-launch-" + str(uuid.uuid4())
221
+ self._jwt = {}
222
+ self._jwt_verify_options = {"verify_aud": False}
223
+ self._id_token_hash = None
224
+ self._validated = False
225
+ self._auto_validation = True
226
+ self._restored = False
227
+ self._public_key_cache_data_storage = None
228
+ self._public_key_cache_lifetime = None
229
+ if requests_session:
230
+ self._requests_session = requests_session
231
+ else:
232
+ self._requests_session = requests.Session()
233
+ self._requests_session.headers["User-Agent"] = REQUESTS_USER_AGENT
234
+
235
+ if launch_data_storage:
236
+ self.set_launch_data_storage(launch_data_storage)
237
+
238
+ @abstractmethod
239
+ def _get_request_param(self, key: str) -> str:
240
+ raise NotImplementedError
241
+
242
+ def set_launch_id(self, launch_id: str) -> "MessageLaunch":
243
+ self._launch_id = launch_id
244
+ return self
245
+
246
+ def set_auto_validation(self, enable: bool) -> "MessageLaunch":
247
+ self._auto_validation = enable
248
+ return self
249
+
250
+ def set_jwt(self, val: TJwtData) -> "MessageLaunch":
251
+ self._jwt = val
252
+ return self
253
+
254
+ def set_jwt_verify_options(self, val: t.Dict[str, bool]) -> "MessageLaunch":
255
+ self._jwt_verify_options = val
256
+ return self
257
+
258
+ def set_restored(self) -> "MessageLaunch":
259
+ self._restored = True
260
+ return self
261
+
262
+ def get_session_service(self) -> SES:
263
+ return self._session_service
264
+
265
+ def get_iss(self) -> str:
266
+ iss = self._get_jwt_body().get("iss")
267
+ if not iss:
268
+ raise LtiException('"iss" is empty')
269
+ return iss
270
+
271
+ def get_client_id(self) -> str:
272
+ jwt_body = self._get_jwt_body()
273
+ aud = jwt_body.get("aud")
274
+ return aud[0] if isinstance(aud, list) else aud # type: ignore
275
+
276
+ @classmethod
277
+ def from_cache(
278
+ cls,
279
+ launch_id: str,
280
+ request: REQ,
281
+ tool_config: TCONF,
282
+ session_service: t.Optional[SES] = None,
283
+ cookie_service: t.Optional[COOK] = None,
284
+ launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None,
285
+ requests_session: t.Optional[requests.Session] = None,
286
+ ) -> "MessageLaunch":
287
+ obj = cls(
288
+ request,
289
+ tool_config,
290
+ session_service=session_service,
291
+ cookie_service=cookie_service,
292
+ launch_data_storage=launch_data_storage,
293
+ requests_session=requests_session,
294
+ )
295
+ launch_data = obj.get_session_service().get_launch_data(launch_id)
296
+ if not launch_data:
297
+ raise LtiException("Launch data not found")
298
+ return (
299
+ obj.set_launch_id(launch_id)
300
+ .set_auto_validation(enable=False)
301
+ .set_jwt(t.cast(TJwtData, {"body": launch_data}))
302
+ .set_restored()
303
+ .validate_registration()
304
+ )
305
+
306
+ def validate(self) -> "MessageLaunch":
307
+ """
308
+ Validates all aspects of an incoming LTI message launch and caches the launch if successful.
309
+ """
310
+ if self._restored:
311
+ raise LtiException("Can't validate restored launch")
312
+ self._validated = True
313
+ try:
314
+ return (
315
+ self.validate_state()
316
+ .validate_jwt_format()
317
+ .validate_nonce()
318
+ .validate_registration()
319
+ .validate_jwt_signature()
320
+ .validate_deployment()
321
+ .validate_message()
322
+ .save_launch_data()
323
+ )
324
+ except Exception:
325
+ self._validated = False
326
+ raise
327
+
328
+ def _get_jwt_body(self) -> TLaunchData:
329
+ if not self._validated and self._auto_validation:
330
+ self.validate()
331
+ return self._jwt.get("body", {})
332
+
333
+ def _get_iss(self) -> str:
334
+ iss = self._get_jwt_body().get("iss")
335
+ if not iss:
336
+ raise LtiException('"iss" is empty')
337
+ return iss
338
+
339
+ def _get_id_token(self) -> str:
340
+ id_token = self._get_request_param("id_token")
341
+ if not id_token:
342
+ raise LtiException("Missing id_token")
343
+ return id_token
344
+
345
+ def _get_id_token_hash(self) -> str:
346
+ if not self._id_token_hash:
347
+ id_token = self._get_id_token()
348
+ id_token_param = id_token.encode("utf8")
349
+ self._id_token_hash = hashlib.md5(id_token_param).hexdigest()
350
+ return self._id_token_hash
351
+
352
+ def _get_deployment_id(self) -> str:
353
+ deployment_id = self._get_jwt_body().get(
354
+ "https://purl.imsglobal.org/spec/lti/claim/deployment_id"
355
+ )
356
+ if not deployment_id:
357
+ raise LtiException("deployment_id is not set in jwt body")
358
+ return deployment_id
359
+
360
+ def get_service_connector(self) -> ServiceConnector:
361
+ assert self._registration is not None, "Registration not yet set"
362
+ return ServiceConnector(self._registration, self._requests_session)
363
+
364
+ def has_nrps(self) -> bool:
365
+ """
366
+ Returns whether or not the current launch can use the names and roles service.
367
+
368
+ :return: bool Returns a boolean indicating the availability of names and roles.
369
+ """
370
+ return (
371
+ self._get_jwt_body()
372
+ .get("https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice", {})
373
+ .get("context_memberships_url", None)
374
+ is not None
375
+ )
376
+
377
+ def get_nrps(self) -> NamesRolesProvisioningService:
378
+ """
379
+ Fetches an instance of the names and roles service for the current launch.
380
+
381
+ :return: NamesRolesProvisioningService
382
+ """
383
+ assert self._registration is not None, "Registration not yet set"
384
+ connector = self.get_service_connector()
385
+ names_role_service = self._get_jwt_body().get(
386
+ "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice"
387
+ )
388
+ if not names_role_service:
389
+ raise LtiException("namesroleservice is not set in jwt body")
390
+ return NamesRolesProvisioningService(connector, names_role_service)
391
+
392
+ def has_ags(self) -> bool:
393
+ """
394
+ Returns whether or not the current launch can use the assignments and grades service.
395
+
396
+ :return: bool Returns a boolean indicating the availability of assignments and grades.
397
+ """
398
+ return (
399
+ self._get_jwt_body().get(
400
+ "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint", None
401
+ )
402
+ is not None
403
+ )
404
+
405
+ def get_ags(self) -> AssignmentsGradesService:
406
+ """
407
+ Fetches an instance of the assignments and grades service for the current launch.
408
+
409
+ :return: AssignmentsGradesService
410
+ """
411
+ assert self._registration is not None, "Registration not yet set"
412
+ connector = self.get_service_connector()
413
+ endpoint = self._get_jwt_body().get(
414
+ "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"
415
+ )
416
+ if not endpoint:
417
+ raise LtiException("endpoint is not set in jwt body")
418
+ return AssignmentsGradesService(connector, endpoint)
419
+
420
+ def has_cgs(self) -> bool:
421
+ """
422
+ Returns whether or not the current launch can use the course groups service.
423
+
424
+ :return: bool Returns a boolean indicating the availability of groups.
425
+ """
426
+ groups_service_data = self._get_jwt_body().get(
427
+ "https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice", {}
428
+ )
429
+ return groups_service_data.get("context_groups_url", None) is not None
430
+
431
+ def get_cgs(self) -> CourseGroupsService:
432
+ """
433
+ Fetches an instance of the course groups service for the current launch.
434
+
435
+ :return:
436
+ """
437
+ assert self._registration is not None, "Registration not yet set"
438
+ connector = self.get_service_connector()
439
+ groups_service_data = self._get_jwt_body().get(
440
+ "https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice"
441
+ )
442
+ if not groups_service_data:
443
+ raise LtiException("groupsservice is not set in jwt body")
444
+ context_groups_url = groups_service_data.get("context_groups_url", None)
445
+ if not context_groups_url:
446
+ raise LtiException("context_groups_url is not set in groupsservice section")
447
+ return CourseGroupsService(connector, groups_service_data)
448
+
449
+ def get_deep_link(self) -> DeepLink:
450
+ """
451
+ Fetches a deep link that can be used to construct a deep linking response.
452
+
453
+ :return: DeepLink
454
+ """
455
+ assert self._registration is not None, "Registration not yet set"
456
+
457
+ deployment_id = self._get_deployment_id()
458
+ deep_linking_settings = self._get_jwt_body().get(
459
+ "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"
460
+ )
461
+ if not deep_linking_settings:
462
+ raise LtiException("deep_linking_settings is not set in jwt body")
463
+
464
+ return DeepLink(self._registration, deployment_id, deep_linking_settings)
465
+
466
+ def get_data_privacy_launch_user(self) -> t.Optional[TForUserClaim]:
467
+ """
468
+ Applicable for DataPrivacyLaunchRequest only. Returns information about user
469
+ who's data the launch is intended to action upon, for instance the student
470
+ who has requested their data be removed under GDPR's right to be forgotten.
471
+
472
+ :return: dict
473
+ """
474
+ jwt_body = self._get_jwt_body()
475
+ return jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/for_user")
476
+
477
+ def get_submission_review_user(self) -> t.Optional[TForUserClaim]:
478
+ """
479
+ Applicable for LtiSubmissionReviewRequest only. Returns information about user
480
+ who's submission should be displayed for review.
481
+
482
+ :return: dict
483
+ """
484
+ jwt_body = self._get_jwt_body()
485
+ return jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/for_user")
486
+
487
+ def is_deep_link_launch(self) -> bool:
488
+ """
489
+ Returns whether or not the current launch is a deep linking launch.
490
+
491
+ :return: bool Returns true if the current launch is a deep linking launch.
492
+ """
493
+ jwt_body = self._get_jwt_body()
494
+ return DeepLinkMessageValidator().can_validate(jwt_body)
495
+
496
+ def is_resource_launch(self) -> bool:
497
+ """
498
+ Returns whether or not the current launch is a resource launch.
499
+
500
+ :return: bool Returns true if the current launch is a resource launch.
501
+ """
502
+ jwt_body = self._get_jwt_body()
503
+ return ResourceMessageValidator().can_validate(jwt_body)
504
+
505
+ def is_data_privacy_launch(self) -> bool:
506
+ """
507
+ Returns whether or not the current launch is a data privacy launch.
508
+
509
+ :return: bool Returns true if the current launch is a data privacy launch.
510
+ """
511
+ jwt_body = self._get_jwt_body()
512
+ return PrivacyLaunchValidator().can_validate(jwt_body)
513
+
514
+ def is_submission_review_launch(self) -> bool:
515
+ """
516
+ Returns whether or not the current launch is a submission review launch.
517
+
518
+ :return: bool Returns true if the current launch is a submission review launch.
519
+ """
520
+ jwt_body = self._get_jwt_body()
521
+ return SubmissionReviewLaunchValidator().can_validate(jwt_body)
522
+
523
+ def get_launch_data(self) -> TLaunchData:
524
+ """
525
+ Fetches the decoded body of the JWT used in the current launch.
526
+
527
+ :return: dict Returns the decoded json body of the launch
528
+ """
529
+ return self._get_jwt_body()
530
+
531
+ def get_launch_id(self) -> str:
532
+ """
533
+ Get the unique launch id for the current launch.
534
+
535
+ :return: str A unique identifier used to re-reference the current launch in subsequent requests.
536
+ """
537
+ return self._launch_id
538
+
539
+ def get_tool_conf(self) -> TCONF:
540
+ return self._tool_config
541
+
542
+ @staticmethod
543
+ def urlsafe_b64decode(val: str) -> str:
544
+ remainder = len(val) % 4
545
+ if remainder > 0:
546
+ padlen = 4 - remainder
547
+ val = val + ("=" * padlen)
548
+ tmp = val.translate(str.maketrans("-_", "+/")) # type: ignore
549
+ return base64.b64decode(tmp).decode("utf-8") # type: ignore
550
+
551
+ def set_public_key_caching(
552
+ self, data_storage: LaunchDataStorage[t.Any], cache_lifetime: int = 7200
553
+ ):
554
+ self._public_key_cache_data_storage = data_storage
555
+ self._public_key_cache_lifetime = cache_lifetime
556
+
557
+ def fetch_public_key(self, key_set_url: str) -> TKeySet:
558
+ cache_key = (
559
+ "key-set-url-" + hashlib.md5(key_set_url.encode("utf-8")).hexdigest()
560
+ )
561
+
562
+ with DisableSessionId(self._public_key_cache_data_storage):
563
+ if self._public_key_cache_data_storage:
564
+ public_key = self._public_key_cache_data_storage.get_value(cache_key)
565
+ if public_key:
566
+ return public_key
567
+
568
+ try:
569
+ resp = self._requests_session.get(key_set_url)
570
+ except requests.exceptions.RequestException as e:
571
+ raise LtiException(
572
+ f"Error during fetch URL {key_set_url}: {str(e)}"
573
+ ) from e
574
+ try:
575
+ public_key = resp.json()
576
+ if self._public_key_cache_data_storage:
577
+ self._public_key_cache_data_storage.set_value(
578
+ cache_key, public_key, self._public_key_cache_lifetime
579
+ )
580
+ return public_key
581
+ except ValueError as e:
582
+ raise LtiException(
583
+ f"Invalid response from {key_set_url}. Must be JSON: {resp.text}"
584
+ ) from e
585
+
586
+ def get_public_key(self) -> t.Tuple[str, str]:
587
+ assert self._registration is not None, "Registration not yet set"
588
+ public_key_set = self._registration.get_key_set()
589
+ key_set_url = self._registration.get_key_set_url()
590
+
591
+ if not public_key_set:
592
+ assert (
593
+ key_set_url is not None
594
+ ), "If public_key_set is not set, public_set_url should be set"
595
+ if key_set_url.startswith(("http://", "https://")):
596
+ public_key_set = self.fetch_public_key(key_set_url)
597
+ self._registration.set_key_set(public_key_set)
598
+ else:
599
+ raise LtiException("Invalid URL: " + key_set_url)
600
+
601
+ # Find key used to sign the JWT (matches the KID in the header)
602
+ kid = self._jwt.get("header", {}).get("kid", None)
603
+ alg = self._jwt.get("header", {}).get("alg", None)
604
+
605
+ if not kid:
606
+ raise LtiException("JWT KID not found")
607
+ if not alg:
608
+ raise LtiException("JWT ALG not found")
609
+
610
+ for key in public_key_set["keys"]:
611
+ key_kid = key.get("kid")
612
+ key_alg = key.get("alg", "RS256")
613
+ if key_kid and key_kid == kid and key_alg == alg:
614
+ try:
615
+ key_json = json.dumps(key)
616
+ jwk_obj = JWK.from_json(key_json)
617
+ public_key = jwk_obj.export_to_pem()
618
+ return public_key, key_alg
619
+ except (ValueError, TypeError) as e:
620
+ raise LtiException("Can't convert JWT key to PEM format") from e
621
+
622
+ # Could not find public key with a matching kid and alg.
623
+ raise LtiException("Unable to find public key")
624
+
625
+ def validate_state(self) -> "MessageLaunch":
626
+ # Check State for OIDC.
627
+ state_from_request = self._get_request_param("state")
628
+ if not state_from_request:
629
+ raise LtiException("Missing state param")
630
+
631
+ id_token_hash = self._get_id_token_hash()
632
+ if not self._session_service.check_state_is_valid(
633
+ state_from_request, id_token_hash
634
+ ):
635
+ state_from_cookie = self._cookie_service.get_cookie(state_from_request)
636
+ if state_from_request != state_from_cookie:
637
+ # Error if state doesn't match.
638
+ raise LtiException("State not found")
639
+
640
+ return self
641
+
642
+ def validate_jwt_format(self) -> "MessageLaunch":
643
+ id_token = self._get_id_token()
644
+ jwt_parts = id_token.split(".")
645
+
646
+ if len(jwt_parts) != 3:
647
+ # Invalid number of parts in JWT.
648
+ raise LtiException("Invalid id_token, JWT must contain 3 parts")
649
+
650
+ try:
651
+ # Decode JWT headers.
652
+ header = self.urlsafe_b64decode(jwt_parts[0])
653
+ self._jwt["header"] = json.loads(header)
654
+
655
+ # Decode JWT body.
656
+ body = self.urlsafe_b64decode(jwt_parts[1])
657
+ self._jwt["body"] = json.loads(body)
658
+ except Exception as e:
659
+ raise LtiException("Invalid JWT format, can't be decoded") from e
660
+
661
+ return self
662
+
663
+ def validate_nonce(self) -> "MessageLaunch":
664
+ nonce = self._get_jwt_body().get("nonce")
665
+ if not nonce:
666
+ raise LtiException('"nonce" is empty')
667
+
668
+ res = self._session_service.check_nonce(nonce)
669
+ if not res:
670
+ raise LtiException("Invalid Nonce")
671
+
672
+ return self
673
+
674
+ def validate_registration(self) -> "MessageLaunch":
675
+ iss = self.get_iss()
676
+ jwt_body = self._get_jwt_body()
677
+ client_id = self.get_client_id()
678
+
679
+ # Mypy doesn't support higher kinded types yet so it thinks that all
680
+ # generic attrs have type `Any`. See issue:
681
+ # https://github.com/python/mypy/issues/8228
682
+ config: ToolConfAbstract[REQ] = self._tool_config
683
+ req: REQ = self._request
684
+
685
+ # Find registration
686
+ if config.check_iss_has_one_client(iss):
687
+ self._registration = config.find_registration(
688
+ iss, action=Action.MESSAGE_LAUNCH, request=req, jwt_body=jwt_body
689
+ )
690
+ else:
691
+ self._registration = config.find_registration_by_params(
692
+ iss,
693
+ client_id,
694
+ action=Action.MESSAGE_LAUNCH,
695
+ request=req,
696
+ jwt_body=jwt_body,
697
+ )
698
+
699
+ if not self._registration:
700
+ raise LtiException("Registration not found.")
701
+
702
+ # Check client id
703
+ if client_id != self._registration.get_client_id():
704
+ raise LtiException("Client id not registered for this issuer")
705
+
706
+ return self
707
+
708
+ def validate_jwt_signature(self) -> "MessageLaunch":
709
+ id_token = self._get_id_token()
710
+
711
+ # Fetch public key object
712
+ public_key, key_alg = self.get_public_key()
713
+
714
+ try:
715
+ jwt.decode(
716
+ id_token,
717
+ public_key,
718
+ algorithms=[key_alg],
719
+ options=self._jwt_verify_options,
720
+ )
721
+ except jwt.InvalidTokenError as e:
722
+ raise LtiException(f"Can't decode id_token: {str(e)}") from e
723
+
724
+ return self
725
+
726
+ def validate_deployment(self) -> "MessageLaunch":
727
+ iss = self.get_iss()
728
+ client_id = self.get_client_id()
729
+ deployment_id = self._get_deployment_id()
730
+ tool_config: ToolConfAbstract = self._tool_config
731
+
732
+ # Find deployment.
733
+ if tool_config.check_iss_has_one_client(iss):
734
+ deployment = tool_config.find_deployment(iss, deployment_id)
735
+ else:
736
+ deployment = tool_config.find_deployment_by_params(
737
+ iss, deployment_id, client_id
738
+ )
739
+ if not deployment:
740
+ raise LtiException("Unable to find deployment")
741
+
742
+ return self
743
+
744
+ def validate_message(self) -> "MessageLaunch":
745
+ jwt_body = self._get_jwt_body()
746
+ message_type = jwt_body.get(
747
+ "https://purl.imsglobal.org/spec/lti/claim/message_type", None
748
+ )
749
+ if not message_type:
750
+ raise LtiException("Invalid message type")
751
+
752
+ validators = get_validators()
753
+ validated = False
754
+ for validator in validators:
755
+ if validator.can_validate(jwt_body):
756
+ if validated:
757
+ raise LtiException("Validator conflict")
758
+ validated = True
759
+ res = validator.validate(jwt_body)
760
+ if not res:
761
+ raise LtiException("Message validation failed")
762
+
763
+ if not validated:
764
+ raise LtiException("Unrecognized message type")
765
+
766
+ return self
767
+
768
+ def set_launch_data_storage(
769
+ self, data_storage: LaunchDataStorage[t.Any]
770
+ ) -> "MessageLaunch":
771
+ data_storage.set_request(self._request)
772
+ session_cookie_name = data_storage.get_session_cookie_name()
773
+ if session_cookie_name:
774
+ session_id = self._cookie_service.get_cookie(session_cookie_name)
775
+ if session_id:
776
+ data_storage.set_session_id(session_id)
777
+ else:
778
+ raise LtiException(f"Missing %s cookie {session_cookie_name}")
779
+ self._session_service.set_data_storage(data_storage)
780
+ return self
781
+
782
+ def set_launch_data_lifetime(self, time_sec: int) -> "MessageLaunch":
783
+ self._session_service.set_launch_data_lifetime(time_sec)
784
+ return self
785
+
786
+ def save_launch_data(self) -> "MessageLaunch":
787
+ state_from_request = self._get_request_param("state")
788
+ id_token_hash = self._get_id_token_hash()
789
+
790
+ self._session_service.save_launch_data(self._launch_id, self._jwt["body"])
791
+ self._session_service.set_state_valid(state_from_request, id_token_hash)
792
+ return self
793
+
794
+ def get_params_from_login(self):
795
+ state = self._get_request_param("state")
796
+ return self._session_service.get_state_params(state)
797
+
798
+ def check_jwt_body_is_empty(self) -> bool:
799
+ jwt_body = self._get_jwt_body()
800
+ return not jwt_body
801
+
802
+ def check_staff_access(self) -> bool:
803
+ jwt_body = self._get_jwt_body()
804
+ return StaffRole(jwt_body).check()
805
+
806
+ def check_student_access(self) -> bool:
807
+ jwt_body = self._get_jwt_body()
808
+ return StudentRole(jwt_body).check()
809
+
810
+ def check_teacher_access(self) -> bool:
811
+ jwt_body = self._get_jwt_body()
812
+ return TeacherRole(jwt_body).check()
813
+
814
+ def check_teaching_assistant_access(self) -> bool:
815
+ jwt_body = self._get_jwt_body()
816
+ return TeachingAssistantRole(jwt_body).check()
817
+
818
+ def check_designer_access(self) -> bool:
819
+ jwt_body = self._get_jwt_body()
820
+ return DesignerRole(jwt_body).check()
821
+
822
+ def check_observer_access(self) -> bool:
823
+ jwt_body = self._get_jwt_body()
824
+ return ObserverRole(jwt_body).check()
825
+
826
+ def check_transient(self) -> bool:
827
+ jwt_body = self._get_jwt_body()
828
+ return TransientRole(jwt_body).check()