ff-ltitoolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
- ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
- ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
- ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ltitoolkit/__init__.py +20 -0
- ltitoolkit/adapters/__init__.py +11 -0
- ltitoolkit/adapters/brightspace/__init__.py +35 -0
- ltitoolkit/adapters/brightspace/client.py +176 -0
- ltitoolkit/adapters/canvas/__init__.py +27 -0
- ltitoolkit/adapters/canvas/client.py +142 -0
- ltitoolkit/advantage/__init__.py +9 -0
- ltitoolkit/advantage/service.py +96 -0
- ltitoolkit/core/__init__.py +19 -0
- ltitoolkit/core/actions.py +6 -0
- ltitoolkit/core/assignments_grades.py +300 -0
- ltitoolkit/core/contrib/__init__.py +0 -0
- ltitoolkit/core/contrib/django/__init__.py +5 -0
- ltitoolkit/core/contrib/django/cookie.py +56 -0
- ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
- ltitoolkit/core/contrib/django/message_launch.py +39 -0
- ltitoolkit/core/contrib/django/oidc_login.py +41 -0
- ltitoolkit/core/contrib/django/redirect.py +34 -0
- ltitoolkit/core/contrib/django/request.py +32 -0
- ltitoolkit/core/contrib/django/session.py +5 -0
- ltitoolkit/core/contrib/flask/__init__.py +7 -0
- ltitoolkit/core/contrib/flask/cookie.py +34 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
- ltitoolkit/core/contrib/flask/message_launch.py +32 -0
- ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
- ltitoolkit/core/contrib/flask/redirect.py +34 -0
- ltitoolkit/core/contrib/flask/request.py +40 -0
- ltitoolkit/core/contrib/flask/session.py +5 -0
- ltitoolkit/core/contrib/py.typed +0 -0
- ltitoolkit/core/cookie.py +17 -0
- ltitoolkit/core/cookies_allowed_check.py +151 -0
- ltitoolkit/core/course_groups.py +115 -0
- ltitoolkit/core/deep_link.py +100 -0
- ltitoolkit/core/deep_link_resource.py +96 -0
- ltitoolkit/core/deployment.py +13 -0
- ltitoolkit/core/exception.py +16 -0
- ltitoolkit/core/grade.py +143 -0
- ltitoolkit/core/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/launch_data_storage/base.py +75 -0
- ltitoolkit/core/launch_data_storage/cache.py +43 -0
- ltitoolkit/core/launch_data_storage/session.py +29 -0
- ltitoolkit/core/lineitem.py +205 -0
- ltitoolkit/core/message_launch.py +828 -0
- ltitoolkit/core/message_validators/__init__.py +13 -0
- ltitoolkit/core/message_validators/abstract.py +25 -0
- ltitoolkit/core/message_validators/deep_link.py +34 -0
- ltitoolkit/core/message_validators/privacy_launch.py +40 -0
- ltitoolkit/core/message_validators/resource_message.py +21 -0
- ltitoolkit/core/message_validators/submission_review.py +45 -0
- ltitoolkit/core/names_roles.py +97 -0
- ltitoolkit/core/oidc_login.py +275 -0
- ltitoolkit/core/py.typed +0 -0
- ltitoolkit/core/redirect.py +24 -0
- ltitoolkit/core/registration.py +119 -0
- ltitoolkit/core/request.py +17 -0
- ltitoolkit/core/roles.py +109 -0
- ltitoolkit/core/service_connector.py +144 -0
- ltitoolkit/core/session.py +70 -0
- ltitoolkit/core/tool_config/__init__.py +4 -0
- ltitoolkit/core/tool_config/abstract.py +117 -0
- ltitoolkit/core/tool_config/dict.py +253 -0
- ltitoolkit/core/tool_config/json_file.py +100 -0
- ltitoolkit/core/tool_config/py.typed +0 -0
- ltitoolkit/core/utils.py +10 -0
- ltitoolkit/dynamic_registration/__init__.py +39 -0
- ltitoolkit/dynamic_registration/models.py +192 -0
- ltitoolkit/dynamic_registration/service.py +156 -0
- ltitoolkit/dynamic_registration/store.py +40 -0
- ltitoolkit/dynamic_registration/tool_conf.py +102 -0
- ltitoolkit/exceptions.py +42 -0
- ltitoolkit/fastapi/__init__.py +30 -0
- ltitoolkit/fastapi/cookie.py +53 -0
- ltitoolkit/fastapi/dynamic_registration.py +40 -0
- ltitoolkit/fastapi/message_launch.py +60 -0
- ltitoolkit/fastapi/oidc_login.py +47 -0
- ltitoolkit/fastapi/redirect.py +54 -0
- ltitoolkit/fastapi/request.py +77 -0
- ltitoolkit/fastapi/session.py +13 -0
- ltitoolkit/http.py +80 -0
- ltitoolkit/token/__init__.py +20 -0
- ltitoolkit/token/cache.py +47 -0
- ltitoolkit/token/service.py +165 -0
|
@@ -0,0 +1,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
|
ltitoolkit/core/utils.py
ADDED
|
@@ -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
|