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,253 @@
1
+ import typing as t
2
+ import typing_extensions as te
3
+ from ..deployment import Deployment
4
+ from ..registration import Registration, TKeySet
5
+ from ..request import Request
6
+ from .abstract import ToolConfAbstract
7
+
8
+ TIssConf = te.TypedDict(
9
+ "TIssConf",
10
+ {
11
+ "default": bool,
12
+ "client_id": str,
13
+ "auth_login_url": str,
14
+ "auth_token_url": str,
15
+ "auth_audience": t.Optional[str],
16
+ "key_set_url": t.Optional[str],
17
+ "key_set": t.Optional[TKeySet],
18
+ "deployment_ids": t.List[str],
19
+ "private_key_file": t.Optional[str],
20
+ "public_key_file": t.Optional[str],
21
+ },
22
+ total=False,
23
+ )
24
+
25
+ TJsonData = t.Dict[str, t.Union[t.List[TIssConf], TIssConf]]
26
+
27
+
28
+ class ToolConfDict(ToolConfAbstract[Request]):
29
+ _config = None
30
+ _private_key_one_client: t.Dict[str, str]
31
+ _public_key_one_client: t.Dict[str, str]
32
+ _private_key_many_clients: t.Dict[str, t.Dict[str, str]]
33
+ _public_key_many_clients: t.Dict[str, t.Dict[str, str]]
34
+
35
+ def __init__(self, json_data: TJsonData):
36
+ """
37
+ json_data is a dict where each key is issuer and value is issuer's configuration.
38
+ Configuration could be set in two formats:
39
+
40
+ 1. { ... "iss": { ... "client_id: "client" ... }, ... }
41
+ In this case the library will work in the concept: one issuer ~ one client-id
42
+
43
+ 2. { ... "iss": [ { ... "client_id: "client1" ... }, { ... "client_id: "client2" ... } ], ... }
44
+ In this case the library will work in concept: one issuer ~ many client-ids
45
+
46
+ Example:
47
+ {
48
+ "iss1": [{
49
+ "default": True,
50
+ "client_id": "client_id1",
51
+ "auth_login_url": "auth_login_url1",
52
+ "auth_token_url": "auth_token_url1",
53
+ "auth_audience": None,
54
+ "key_set_url": "key_set_url1",
55
+ "key_set": None,
56
+ "deployment_ids": ["deployment_id1", "deployment_id2"]
57
+ }, {
58
+ "default": False,
59
+ "client_id": "client_id2",
60
+ "auth_login_url": "auth_login_url2",
61
+ "auth_token_url": "auth_token_url2",
62
+ "auth_audience": None,
63
+ "key_set_url": "key_set_url2",
64
+ "key_set": None,
65
+ "deployment_ids": ["deployment_id3", "deployment_id4"]
66
+ }],
67
+ "iss2": [ .... ]
68
+ }
69
+
70
+ default (bool) - this iss config will be used in case if client-id was not passed on the login step
71
+ client_id - this is the id received in the 'aud' during a launch
72
+ auth_login_url - the platform's OIDC login endpoint
73
+ auth_token_url - the platform's service authorization endpoint
74
+ auth_audience - the platform's OAuth2 Audience (aud). Is used to get platform's access token,
75
+ Usually the same as "auth_token_url" but in the common case could be a different url
76
+ key_set_url - the platform's JWKS endpoint
77
+ key_set - in case if platform's JWKS endpoint somehow unavailable you may paste JWKS here
78
+ deployment_ids (list) - The deployment_id passed by the platform during launch
79
+ """
80
+ super().__init__()
81
+ if not isinstance(json_data, dict):
82
+ raise Exception("Invalid tool conf format. Must be dict")
83
+
84
+ for iss, iss_conf in json_data.items():
85
+ if isinstance(iss_conf, dict):
86
+ self.set_iss_has_one_client(iss)
87
+ self._validate_iss_config_item(iss, iss_conf)
88
+ elif isinstance(iss_conf, list):
89
+ self.set_iss_has_many_clients(iss)
90
+ for v in iss_conf:
91
+ self._validate_iss_config_item(iss, v)
92
+ else:
93
+ raise Exception(
94
+ "Invalid tool conf format. Allowed types of elements: list or dict"
95
+ )
96
+
97
+ self._config = json_data
98
+ self._private_key_one_client = {}
99
+ self._private_key_many_clients = {}
100
+ self._public_key_one_client = {}
101
+ self._public_key_many_clients = {}
102
+
103
+ def _validate_iss_config_item(self, iss: str, iss_conf: TIssConf):
104
+ if not isinstance(iss_conf, dict):
105
+ raise Exception(
106
+ f"Invalid configuration {iss} for the {str(iss_conf)} issuer. Must be dict"
107
+ )
108
+ required_keys = [
109
+ "auth_login_url",
110
+ "auth_token_url",
111
+ "client_id",
112
+ "deployment_ids",
113
+ ]
114
+ for key in required_keys:
115
+ if key not in iss_conf:
116
+ raise Exception(
117
+ f"Key '{key}' is missing in the {str(iss_conf)} config for the {iss} issuer"
118
+ )
119
+ if not isinstance(iss_conf["deployment_ids"], list):
120
+ raise Exception(
121
+ f"Invalid deployment_ids value in the {str(iss_conf)} config for the {iss} issuer. "
122
+ f"Must be a list"
123
+ )
124
+
125
+ def _get_registration(self, iss: str, iss_conf: TIssConf) -> Registration:
126
+ reg = Registration()
127
+ reg.set_auth_login_url(iss_conf["auth_login_url"]).set_auth_token_url(
128
+ iss_conf["auth_token_url"]
129
+ ).set_client_id(iss_conf["client_id"]).set_key_set(
130
+ iss_conf.get("key_set")
131
+ ).set_key_set_url(
132
+ iss_conf.get("key_set_url")
133
+ ).set_issuer(
134
+ iss
135
+ ).set_tool_private_key(
136
+ self.get_private_key(iss, iss_conf["client_id"])
137
+ )
138
+ auth_audience = iss_conf.get("auth_audience")
139
+ if auth_audience:
140
+ reg.set_auth_audience(auth_audience)
141
+ public_key = self.get_public_key(iss, iss_conf["client_id"])
142
+ if public_key:
143
+ reg.set_tool_public_key(public_key)
144
+ return reg
145
+
146
+ def _get_deployment(self, iss_conf: TIssConf, deployment_id: str):
147
+ if deployment_id not in iss_conf["deployment_ids"]:
148
+ return None
149
+ d = Deployment()
150
+ return d.set_deployment_id(deployment_id)
151
+
152
+ def find_registration_by_issuer(self, iss: str, *args, **kwargs):
153
+ # pylint: disable=unused-argument
154
+ iss_conf = self.get_iss_config(iss)
155
+ return self._get_registration(iss, iss_conf)
156
+
157
+ def find_registration_by_params(self, iss: str, client_id: str, *args, **kwargs):
158
+ # pylint: disable=unused-argument
159
+ iss_conf = self.get_iss_config(iss, client_id)
160
+ return self._get_registration(iss, iss_conf)
161
+
162
+ def find_deployment(self, iss: str, deployment_id: str):
163
+ iss_conf = self.get_iss_config(iss)
164
+ return self._get_deployment(iss_conf, deployment_id)
165
+
166
+ def find_deployment_by_params(
167
+ self, iss: str, deployment_id: str, client_id: str, *args, **kwargs
168
+ ):
169
+ # pylint: disable=unused-argument
170
+ iss_conf = self.get_iss_config(iss, client_id)
171
+ return self._get_deployment(iss_conf, deployment_id)
172
+
173
+ def set_public_key(
174
+ self, iss: str, key_content: str, client_id: t.Optional[str] = None
175
+ ):
176
+ if self.check_iss_has_many_clients(iss):
177
+ if not client_id:
178
+ raise Exception("Can't set public key: missing client_id")
179
+ if iss not in self._public_key_many_clients:
180
+ self._public_key_many_clients[iss] = {}
181
+ self._public_key_many_clients[iss][client_id] = key_content
182
+ else:
183
+ self._public_key_one_client[iss] = key_content
184
+
185
+ def get_public_key(self, iss: str, client_id: t.Optional[str] = None):
186
+ if self.check_iss_has_many_clients(iss):
187
+ if not client_id:
188
+ raise Exception("Can't get public key: missing client_id")
189
+ clients_dict = self._public_key_many_clients.get(iss, {})
190
+ if not isinstance(clients_dict, dict):
191
+ raise Exception("Invalid clients data")
192
+ return clients_dict.get(client_id)
193
+ return self._public_key_one_client.get(iss)
194
+
195
+ def set_private_key(
196
+ self, iss: str, key_content: str, client_id: t.Optional[str] = None
197
+ ):
198
+ if self.check_iss_has_many_clients(iss):
199
+ if not client_id:
200
+ raise Exception("Can't set private key: missing client_id")
201
+ if iss not in self._private_key_many_clients:
202
+ self._private_key_many_clients[iss] = {}
203
+ self._private_key_many_clients[iss][client_id] = key_content # type: ignore
204
+ else:
205
+ self._private_key_one_client[iss] = key_content
206
+
207
+ def get_private_key(self, iss: str, client_id: t.Optional[str] = None):
208
+ if self.check_iss_has_many_clients(iss):
209
+ if not client_id:
210
+ raise Exception("Can't get private key: missing client_id")
211
+ clients_dict = self._private_key_many_clients.get(iss, {})
212
+ if not isinstance(clients_dict, dict):
213
+ raise Exception("Invalid clients data")
214
+ return clients_dict.get(client_id)
215
+ return self._private_key_one_client.get(iss)
216
+
217
+ def get_iss_config(self, iss: str, client_id: t.Optional[str] = None):
218
+ if not self._config:
219
+ raise Exception("Config is not set")
220
+ if iss not in self._config:
221
+ raise Exception(f"iss {iss} not found in settings")
222
+ config_iss = self._config[iss]
223
+
224
+ if isinstance(config_iss, list):
225
+ items_len = len(config_iss)
226
+ for subitem in config_iss:
227
+ # pylint: disable=too-many-boolean-expressions
228
+ if (
229
+ (client_id and subitem["client_id"] == client_id)
230
+ or (not client_id and subitem.get("default", False))
231
+ or (not client_id and items_len == 1)
232
+ ):
233
+ return subitem
234
+ raise Exception(f"iss {iss} [client_id={client_id}] not found in settings")
235
+ return config_iss
236
+
237
+ def get_jwks(
238
+ self, iss: t.Optional[str] = None, client_id: t.Optional[str] = None, **kwargs
239
+ ):
240
+ # pylint: disable=unused-argument
241
+ if iss or client_id:
242
+ return super().get_jwks(iss, client_id)
243
+
244
+ public_keys = []
245
+ for iss_item1 in self._public_key_one_client.values():
246
+ if iss_item1 not in public_keys:
247
+ public_keys.append(iss_item1)
248
+ for iss_item2 in self._public_key_many_clients.values():
249
+ for pub_key in iss_item2.values():
250
+ if pub_key not in public_keys:
251
+ public_keys.append(pub_key)
252
+
253
+ return {"keys": [Registration.get_jwk(k) for k in public_keys]}
@@ -0,0 +1,100 @@
1
+ import typing as t
2
+ import json
3
+ import os
4
+
5
+ from .dict import ToolConfDict, TIssConf, TJsonData
6
+
7
+
8
+ class ToolConfJsonFile(ToolConfDict):
9
+ _configs_dir: str
10
+
11
+ def __init__(self, config_file: str):
12
+ """
13
+ config_file contains JSON with issuers settings.
14
+ Each key is issuer and value is issuer's configuration.
15
+ Configuration could be set in two formats:
16
+
17
+ 1. { ... "iss": { ... "client_id: "client" ... }, ... }
18
+ In this case the library will work in the concept: one issuer ~ one client-id
19
+
20
+ 2. { ... "iss": [ { ... "client_id: "client1" ... }, { ... "client_id: "client2" ... } ], ... }
21
+ In this case the library will work in concept: one issuer ~ many client-ids
22
+
23
+ Example:
24
+ {
25
+ "iss1": [{
26
+ "default": true,
27
+ "client_id": "client_id1",
28
+ "auth_login_url": "auth_login_url1",
29
+ "auth_token_url": "auth_token_url1",
30
+ "auth_audience": null,
31
+ "key_set_url": "key_set_url1",
32
+ "key_set": null,
33
+ "private_key_file": "private.key",
34
+ "public_key_file": "public.key",
35
+ "deployment_ids": ["deployment_id1", "deployment_id2"]
36
+ }, {
37
+ "default": false,
38
+ "client_id": "client_id2",
39
+ "auth_login_url": "auth_login_url2",
40
+ "auth_token_url": "auth_token_url2",
41
+ "auth_audience": null,
42
+ "key_set_url": "key_set_url2",
43
+ "key_set": null,
44
+ "private_key_file": "private.key",
45
+ "public_key_file": "public.key",
46
+ "deployment_ids": ["deployment_id3", "deployment_id4"]
47
+ }],
48
+ "iss2": [ .... ]
49
+ }
50
+
51
+ default (bool) - this iss config will be used in case if client-id was not passed on the login step
52
+ client_id - this is the id received in the 'aud' during a launch
53
+ auth_login_url - the platform's OIDC login endpoint
54
+ auth_token_url - the platform's service authorization endpoint
55
+ auth_audience - the platform's OAuth2 Audience (aud). Is used to get platform's access token,
56
+ Usually the same as "auth_token_url" but in the common case could be a different url
57
+ key_set_url - the platform's JWKS endpoint
58
+ key_set - in case if platform's JWKS endpoint somehow unavailable you may paste JWKS here
59
+ private_key_file - relative path to the tool's private key
60
+ public_key_file - relative path to the tool's public key
61
+ deployment_ids (list) - The deployment_id passed by the platform during launch
62
+ """
63
+ if not os.path.isfile(config_file):
64
+ raise Exception("LTI tool config file not found: " + config_file)
65
+ self._configs_dir = os.path.dirname(config_file)
66
+
67
+ with open(config_file, encoding="utf-8") as cfg:
68
+ iss_conf_dict: TJsonData = json.loads(cfg.read())
69
+ super().__init__(iss_conf_dict)
70
+
71
+ for iss in iss_conf_dict:
72
+ if isinstance(iss_conf_dict[iss], list):
73
+ for iss_conf in iss_conf_dict[iss]:
74
+ client_id = t.cast(TIssConf, iss_conf).get("client_id")
75
+ self._process_iss_conf_item(
76
+ t.cast(TIssConf, iss_conf), iss, client_id
77
+ )
78
+ else:
79
+ self._process_iss_conf_item(t.cast(TIssConf, iss_conf_dict[iss]), iss)
80
+
81
+ def _process_iss_conf_item(
82
+ self, iss_conf: TIssConf, iss: str, client_id: t.Optional[str] = None
83
+ ):
84
+ private_key_file = iss_conf.get("private_key_file")
85
+ if not private_key_file:
86
+ raise Exception("iss config error: private_key_file not found")
87
+
88
+ if not private_key_file.startswith("/"):
89
+ private_key_file = self._configs_dir + "/" + private_key_file
90
+
91
+ with open(private_key_file, encoding="utf-8") as prf:
92
+ self.set_private_key(iss, prf.read(), client_id=client_id)
93
+
94
+ public_key_file = iss_conf.get("public_key_file", None)
95
+ if public_key_file:
96
+ if not public_key_file.startswith("/"):
97
+ public_key_file = self._configs_dir + "/" + public_key_file
98
+
99
+ with open(public_key_file, encoding="utf-8") as pubf:
100
+ self.set_public_key(iss, pubf.read(), client_id=client_id)
File without changes
@@ -0,0 +1,10 @@
1
+ import urllib.parse as urlparse # type: ignore
2
+ from urllib.parse import urlencode # type: ignore
3
+
4
+
5
+ def add_param_to_url(url: str, param_name: str, param_value: object) -> str:
6
+ url_parts = list(urlparse.urlparse(url))
7
+ query = dict(urlparse.parse_qsl(url_parts[4]))
8
+ query[str(param_name)] = str(param_value)
9
+ url_parts[4] = urlencode(query)
10
+ return urlparse.urlunparse(url_parts)
@@ -0,0 +1,39 @@
1
+ """LTI 1.3 Dynamic Registration (1EdTech spec).
2
+
3
+ Lets an LMS admin install the tool by pasting a single URL and clicking submit:
4
+ the tool fetches the LMS's ``openid-configuration``, learns its endpoints
5
+ automatically, registers itself, and stores the returned ``client_id`` — no
6
+ manual copying of credentials. This is what makes "works with any LMS without
7
+ re-reading the docs" real.
8
+
9
+ Spec: https://www.imsglobal.org/spec/lti-dr/v1p0
10
+ """
11
+
12
+ from .models import (
13
+ DEFAULT_CLAIMS,
14
+ DEFAULT_SCOPES,
15
+ MESSAGE_DEEP_LINKING,
16
+ MESSAGE_RESOURCE_LINK,
17
+ PlatformConfiguration,
18
+ ToolMessage,
19
+ ToolRegistration,
20
+ ToolRegistrationConfig,
21
+ )
22
+ from .service import DynamicRegistrationService
23
+ from .store import InMemoryRegistrationStore, RegistrationStore
24
+ from .tool_conf import StoredToolConf
25
+
26
+ __all__ = [
27
+ "DynamicRegistrationService",
28
+ "ToolRegistrationConfig",
29
+ "ToolMessage",
30
+ "ToolRegistration",
31
+ "PlatformConfiguration",
32
+ "RegistrationStore",
33
+ "InMemoryRegistrationStore",
34
+ "StoredToolConf",
35
+ "DEFAULT_SCOPES",
36
+ "DEFAULT_CLAIMS",
37
+ "MESSAGE_RESOURCE_LINK",
38
+ "MESSAGE_DEEP_LINKING",
39
+ ]
@@ -0,0 +1,192 @@
1
+ """Data models and constants for LTI 1.3 Dynamic Registration.
2
+
3
+ Spec: https://www.imsglobal.org/spec/lti-dr/v1p0
4
+
5
+ The flow exchanges two JSON documents:
6
+ - the platform's *OpenID configuration* (:class:`PlatformConfiguration`), and
7
+ - the tool's *registration request* (built by :class:`ToolRegistrationConfig`).
8
+
9
+ The platform replies with a ``client_id`` (and possibly a ``deployment_id``),
10
+ captured as a :class:`ToolRegistration`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import typing as t
16
+ from dataclasses import dataclass, field
17
+ from urllib.parse import urlparse
18
+
19
+ # -- Spec claim URIs / constants -------------------------------------------
20
+
21
+ LTI_TOOL_CONFIGURATION = "https://purl.imsglobal.org/spec/lti-tool-configuration"
22
+ LTI_PLATFORM_CONFIGURATION = "https://purl.imsglobal.org/spec/lti-platform-configuration"
23
+
24
+ MESSAGE_RESOURCE_LINK = "LtiResourceLinkRequest"
25
+ MESSAGE_DEEP_LINKING = "LtiDeepLinkingRequest"
26
+
27
+ # Sent to the platform's HTML5 Web Message channel to end registration.
28
+ CLOSE_SUBJECT = "org.imsglobal.lti.close"
29
+
30
+ # LTI Advantage service scopes we request by default.
31
+ SCOPE_AGS_LINEITEM = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"
32
+ SCOPE_AGS_RESULT = "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly"
33
+ SCOPE_AGS_SCORE = "https://purl.imsglobal.org/spec/lti-ags/scope/score"
34
+ SCOPE_NRPS = "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"
35
+
36
+ DEFAULT_SCOPES: tuple[str, ...] = (
37
+ SCOPE_AGS_LINEITEM,
38
+ SCOPE_AGS_RESULT,
39
+ SCOPE_AGS_SCORE,
40
+ SCOPE_NRPS,
41
+ )
42
+
43
+ DEFAULT_CLAIMS: tuple[str, ...] = ("iss", "sub", "name", "given_name", "family_name", "email")
44
+
45
+
46
+ @dataclass
47
+ class ToolMessage:
48
+ """A message type the tool supports (resource link launch / deep linking)."""
49
+
50
+ type: str
51
+ target_link_uri: str | None = None
52
+ label: str | None = None
53
+ icon_uri: str | None = None
54
+ placements: tuple[str, ...] | None = None
55
+ custom_parameters: dict[str, str] | None = None
56
+
57
+ def to_dict(self) -> dict[str, t.Any]:
58
+ data: dict[str, t.Any] = {"type": self.type}
59
+ if self.target_link_uri:
60
+ data["target_link_uri"] = self.target_link_uri
61
+ if self.label:
62
+ data["label"] = self.label
63
+ if self.icon_uri:
64
+ data["icon_uri"] = self.icon_uri
65
+ if self.placements:
66
+ data["placements"] = list(self.placements)
67
+ if self.custom_parameters:
68
+ data["custom_parameters"] = dict(self.custom_parameters)
69
+ return data
70
+
71
+
72
+ @dataclass
73
+ class PlatformConfiguration:
74
+ """The platform's OpenID configuration document (only fields we need)."""
75
+
76
+ issuer: str
77
+ authorization_endpoint: str
78
+ token_endpoint: str
79
+ jwks_uri: str
80
+ registration_endpoint: str
81
+ authorization_server: str | None = None
82
+ scopes_supported: tuple[str, ...] = ()
83
+ product_family_code: str | None = None
84
+ raw: dict[str, t.Any] = field(default_factory=dict)
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: t.Mapping[str, t.Any]) -> PlatformConfiguration:
88
+ lti = data.get(LTI_PLATFORM_CONFIGURATION, {}) or {}
89
+ required = (
90
+ "issuer",
91
+ "authorization_endpoint",
92
+ "token_endpoint",
93
+ "jwks_uri",
94
+ "registration_endpoint",
95
+ )
96
+ missing = [k for k in required if not data.get(k)]
97
+ if missing:
98
+ raise ValueError(
99
+ f"Platform OpenID configuration is missing fields: {', '.join(missing)}"
100
+ )
101
+ return cls(
102
+ issuer=data["issuer"],
103
+ authorization_endpoint=data["authorization_endpoint"],
104
+ token_endpoint=data["token_endpoint"],
105
+ jwks_uri=data["jwks_uri"],
106
+ registration_endpoint=data["registration_endpoint"],
107
+ authorization_server=data.get("authorization_server"),
108
+ scopes_supported=tuple(data.get("scopes_supported", []) or []),
109
+ product_family_code=lti.get("product_family_code"),
110
+ raw=dict(data),
111
+ )
112
+
113
+
114
+ @dataclass
115
+ class ToolRegistration:
116
+ """The persisted result of a successful registration."""
117
+
118
+ issuer: str
119
+ client_id: str
120
+ auth_login_url: str
121
+ auth_token_url: str
122
+ key_set_url: str
123
+ auth_audience: str | None = None
124
+ deployment_ids: tuple[str, ...] = ()
125
+
126
+
127
+ @dataclass
128
+ class ToolRegistrationConfig:
129
+ """Static description of this tool, used to build the registration request."""
130
+
131
+ client_name: str
132
+ initiate_login_uri: str
133
+ redirect_uris: tuple[str, ...]
134
+ jwks_uri: str
135
+ target_link_uri: str
136
+ scopes: tuple[str, ...] = DEFAULT_SCOPES
137
+ claims: tuple[str, ...] = DEFAULT_CLAIMS
138
+ domain: str | None = None
139
+ logo_uri: str | None = None
140
+ description: str | None = None
141
+ custom_parameters: dict[str, str] | None = None
142
+ contacts: tuple[str, ...] | None = None
143
+ messages: tuple[ToolMessage, ...] = ()
144
+
145
+ def _domain(self) -> str:
146
+ return self.domain or urlparse(self.target_link_uri).netloc
147
+
148
+ def _messages(self) -> list[ToolMessage]:
149
+ if self.messages:
150
+ return list(self.messages)
151
+ # Sensible default: support a basic resource-link launch.
152
+ return [ToolMessage(type=MESSAGE_RESOURCE_LINK)]
153
+
154
+ def build_request(self, platform: PlatformConfiguration) -> dict[str, t.Any]:
155
+ """Build the OpenID client registration body to POST to the platform.
156
+
157
+ Requested scopes are intersected with the platform's
158
+ ``scopes_supported`` when the platform advertises them.
159
+ """
160
+ if platform.scopes_supported:
161
+ scopes = [s for s in self.scopes if s in platform.scopes_supported]
162
+ else:
163
+ scopes = list(self.scopes)
164
+
165
+ lti_config: dict[str, t.Any] = {
166
+ "domain": self._domain(),
167
+ "target_link_uri": self.target_link_uri,
168
+ "claims": list(self.claims),
169
+ "messages": [m.to_dict() for m in self._messages()],
170
+ }
171
+ if self.description:
172
+ lti_config["description"] = self.description
173
+ if self.custom_parameters:
174
+ lti_config["custom_parameters"] = dict(self.custom_parameters)
175
+
176
+ body: dict[str, t.Any] = {
177
+ "application_type": "web",
178
+ "response_types": ["id_token"],
179
+ "grant_types": ["client_credentials", "implicit"],
180
+ "initiate_login_uri": self.initiate_login_uri,
181
+ "redirect_uris": list(self.redirect_uris),
182
+ "client_name": self.client_name,
183
+ "jwks_uri": self.jwks_uri,
184
+ "token_endpoint_auth_method": "private_key_jwt",
185
+ "scope": " ".join(scopes),
186
+ LTI_TOOL_CONFIGURATION: lti_config,
187
+ }
188
+ if self.logo_uri:
189
+ body["logo_uri"] = self.logo_uri
190
+ if self.contacts:
191
+ body["contacts"] = list(self.contacts)
192
+ return body