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,13 @@
|
|
|
1
|
+
from .deep_link import DeepLinkMessageValidator
|
|
2
|
+
from .resource_message import ResourceMessageValidator
|
|
3
|
+
from .privacy_launch import PrivacyLaunchValidator
|
|
4
|
+
from .submission_review import SubmissionReviewLaunchValidator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_validators():
|
|
8
|
+
return [
|
|
9
|
+
DeepLinkMessageValidator(),
|
|
10
|
+
ResourceMessageValidator(),
|
|
11
|
+
PrivacyLaunchValidator(),
|
|
12
|
+
SubmissionReviewLaunchValidator(),
|
|
13
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
from ..exception import LtiException
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MessageValidatorAbstract:
|
|
6
|
+
__metaclass__ = ABCMeta
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def validate(self, jwt_body) -> bool:
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def can_validate(self, jwt_body) -> bool:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
def run_common_validators(self, jwt_body) -> None:
|
|
17
|
+
if not jwt_body.get("sub"):
|
|
18
|
+
raise LtiException("Must have a user (sub)")
|
|
19
|
+
|
|
20
|
+
if jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/version") != "1.3.0":
|
|
21
|
+
raise LtiException("Incorrect version, expected 1.3.0")
|
|
22
|
+
|
|
23
|
+
roles = jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/roles")
|
|
24
|
+
if roles is None:
|
|
25
|
+
raise LtiException("Missing Roles Claim")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from ..exception import LtiException
|
|
2
|
+
from .abstract import MessageValidatorAbstract
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DeepLinkMessageValidator(MessageValidatorAbstract):
|
|
6
|
+
def validate(self, jwt_body) -> bool:
|
|
7
|
+
self.run_common_validators(jwt_body)
|
|
8
|
+
|
|
9
|
+
if not jwt_body.get(
|
|
10
|
+
"https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"
|
|
11
|
+
):
|
|
12
|
+
raise LtiException("Missing Deep Linking Settings")
|
|
13
|
+
|
|
14
|
+
deep_link_settings = jwt_body.get(
|
|
15
|
+
"https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"
|
|
16
|
+
)
|
|
17
|
+
if not deep_link_settings:
|
|
18
|
+
raise LtiException("Missing Deep Linking Return URL")
|
|
19
|
+
|
|
20
|
+
accept_types = deep_link_settings.get("accept_types")
|
|
21
|
+
|
|
22
|
+
if not isinstance(accept_types, list) or "ltiResourceLink" not in accept_types:
|
|
23
|
+
raise LtiException("Must support resource link placement types")
|
|
24
|
+
|
|
25
|
+
if not deep_link_settings.get("accept_presentation_document_targets"):
|
|
26
|
+
raise LtiException("Must support a presentation type")
|
|
27
|
+
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
def can_validate(self, jwt_body) -> bool:
|
|
31
|
+
return (
|
|
32
|
+
jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type")
|
|
33
|
+
== "LtiDeepLinkingRequest"
|
|
34
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from ..exception import LtiException
|
|
2
|
+
from .abstract import MessageValidatorAbstract
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PrivacyLaunchValidator(MessageValidatorAbstract):
|
|
6
|
+
"""Validates the body of a LTI data privacy launch.
|
|
7
|
+
|
|
8
|
+
The launch must omit the context claim, and include
|
|
9
|
+
a for_user claim specifying the user that the launch
|
|
10
|
+
was made on behalf of.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def validate(self, jwt_body) -> bool:
|
|
14
|
+
self.run_common_validators(jwt_body)
|
|
15
|
+
|
|
16
|
+
if "https://purl.imsglobal.org/spec/lti/claim/resource_link" in jwt_body:
|
|
17
|
+
raise LtiException(
|
|
18
|
+
"Resource link claim must be omitted from a DataPrivacyLaunchRequest"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if "https://purl.imsglobal.org/spec/lti/claim/context" in jwt_body:
|
|
22
|
+
raise LtiException(
|
|
23
|
+
"Context claim must be omitted from a DataPrivacyLaunchRequest"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
for_user_claim = jwt_body.get(
|
|
27
|
+
"https://purl.imsglobal.org/spec/lti/claim/for_user"
|
|
28
|
+
)
|
|
29
|
+
if for_user_claim is None:
|
|
30
|
+
raise LtiException(
|
|
31
|
+
"For user claim must be included in a DataPrivacyLaunchRequest"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
def can_validate(self, jwt_body) -> bool:
|
|
37
|
+
return (
|
|
38
|
+
jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type")
|
|
39
|
+
== "DataPrivacyLaunchRequest"
|
|
40
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from ..exception import LtiException
|
|
2
|
+
from .abstract import MessageValidatorAbstract
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ResourceMessageValidator(MessageValidatorAbstract):
|
|
6
|
+
def validate(self, jwt_body) -> bool:
|
|
7
|
+
self.run_common_validators(jwt_body)
|
|
8
|
+
|
|
9
|
+
id_val = jwt_body.get(
|
|
10
|
+
"https://purl.imsglobal.org/spec/lti/claim/resource_link", {}
|
|
11
|
+
).get("id")
|
|
12
|
+
if not id_val:
|
|
13
|
+
raise LtiException("Missing Resource Link Id")
|
|
14
|
+
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
def can_validate(self, jwt_body) -> bool:
|
|
18
|
+
return (
|
|
19
|
+
jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type")
|
|
20
|
+
== "LtiResourceLinkRequest"
|
|
21
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from ..exception import LtiException
|
|
2
|
+
from .abstract import MessageValidatorAbstract
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SubmissionReviewLaunchValidator(MessageValidatorAbstract):
|
|
6
|
+
"""Validates the body of a LTI submission review launch.
|
|
7
|
+
|
|
8
|
+
The launch must include a for_user claim specifying the user
|
|
9
|
+
who's submission is being reviewed, as well as the line item
|
|
10
|
+
for the reviewed submission.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def validate(self, jwt_body) -> bool:
|
|
14
|
+
self.run_common_validators(jwt_body)
|
|
15
|
+
|
|
16
|
+
if "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" not in jwt_body:
|
|
17
|
+
raise LtiException(
|
|
18
|
+
"Grade services must be included in a LtiSubmissionReviewRequest"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
ags_endpoint_claim = jwt_body[
|
|
22
|
+
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"
|
|
23
|
+
]
|
|
24
|
+
if "lineitem" not in ags_endpoint_claim:
|
|
25
|
+
raise LtiException(
|
|
26
|
+
"A LtiSubmissionReviewRequest must specify the lineitem it was launched for"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
for_user_claim = jwt_body.get(
|
|
30
|
+
"https://purl.imsglobal.org/spec/lti/claim/for_user"
|
|
31
|
+
)
|
|
32
|
+
if for_user_claim is None:
|
|
33
|
+
raise LtiException(
|
|
34
|
+
"For user claim must be included in a LtiSubmissionReviewRequest"
|
|
35
|
+
)
|
|
36
|
+
if "user_id" not in for_user_claim:
|
|
37
|
+
raise LtiException("For user claim must include user_id")
|
|
38
|
+
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
def can_validate(self, jwt_body) -> bool:
|
|
42
|
+
return (
|
|
43
|
+
jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type")
|
|
44
|
+
== "LtiSubmissionReviewRequest"
|
|
45
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
import typing_extensions as te
|
|
3
|
+
from .utils import add_param_to_url
|
|
4
|
+
from .service_connector import ServiceConnector
|
|
5
|
+
|
|
6
|
+
TNamesAndRolesData = te.TypedDict(
|
|
7
|
+
"TNamesAndRolesData",
|
|
8
|
+
{
|
|
9
|
+
"context_memberships_url": str,
|
|
10
|
+
},
|
|
11
|
+
total=False,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
TMember = te.TypedDict(
|
|
15
|
+
"TMember",
|
|
16
|
+
{
|
|
17
|
+
"name": str,
|
|
18
|
+
"status": te.Literal["Active", "Inactive", "Deleted"],
|
|
19
|
+
"picture": str,
|
|
20
|
+
"given_name": str,
|
|
21
|
+
"family_name": str,
|
|
22
|
+
"middle_name": str,
|
|
23
|
+
"email": str,
|
|
24
|
+
"user_id": str,
|
|
25
|
+
"lis_person_sourcedid": str,
|
|
26
|
+
"roles": t.List[str],
|
|
27
|
+
"message": t.Union[t.List[t.Dict[str, object]], t.Dict[str, object]],
|
|
28
|
+
"lti11_legacy_user_id": t.Optional[str],
|
|
29
|
+
},
|
|
30
|
+
total=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NamesRolesProvisioningService:
|
|
35
|
+
_service_connector: ServiceConnector
|
|
36
|
+
_service_data: TNamesAndRolesData
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self, service_connector: ServiceConnector, service_data: TNamesAndRolesData
|
|
40
|
+
):
|
|
41
|
+
self._service_connector = service_connector
|
|
42
|
+
self._service_data = service_data
|
|
43
|
+
|
|
44
|
+
def get_nrps_data(self, members_url: t.Optional[str] = None):
|
|
45
|
+
if not members_url:
|
|
46
|
+
members_url = self._service_data["context_memberships_url"]
|
|
47
|
+
|
|
48
|
+
data = self._service_connector.make_service_request(
|
|
49
|
+
[
|
|
50
|
+
"https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"
|
|
51
|
+
],
|
|
52
|
+
members_url,
|
|
53
|
+
accept="application/vnd.ims.lti-nrps.v2.membershipcontainer+json",
|
|
54
|
+
)
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
def get_members_page(
|
|
58
|
+
self, members_url: t.Optional[str] = None
|
|
59
|
+
) -> t.Tuple[t.List[TMember], t.Optional[str]]:
|
|
60
|
+
"""
|
|
61
|
+
Get one page with the users.
|
|
62
|
+
|
|
63
|
+
:param members_url: LTI platform's URL (optional)
|
|
64
|
+
:return: tuple in format: (list with users, next page url)
|
|
65
|
+
"""
|
|
66
|
+
data = self.get_nrps_data(members_url=members_url)
|
|
67
|
+
data_body = t.cast(t.Any, data.get("body", {}))
|
|
68
|
+
return data_body.get("members", []), data["next_page_url"]
|
|
69
|
+
|
|
70
|
+
def get_members(self, resource_link_id: t.Optional[str] = None) -> t.List[TMember]:
|
|
71
|
+
"""
|
|
72
|
+
Get list with all users.
|
|
73
|
+
|
|
74
|
+
:param resource_link_id: resource link id (optional)
|
|
75
|
+
:return: list
|
|
76
|
+
"""
|
|
77
|
+
members_res_lst: t.List[TMember] = []
|
|
78
|
+
members_url: t.Optional[str] = self._service_data["context_memberships_url"]
|
|
79
|
+
|
|
80
|
+
if members_url and resource_link_id:
|
|
81
|
+
members_url = add_param_to_url(members_url, "rlid", resource_link_id)
|
|
82
|
+
|
|
83
|
+
while members_url:
|
|
84
|
+
members, members_url = self.get_members_page(members_url)
|
|
85
|
+
members_res_lst.extend(members)
|
|
86
|
+
|
|
87
|
+
return members_res_lst
|
|
88
|
+
|
|
89
|
+
def get_context(self):
|
|
90
|
+
"""
|
|
91
|
+
Get context data.
|
|
92
|
+
|
|
93
|
+
:return: dict
|
|
94
|
+
"""
|
|
95
|
+
data = self.get_nrps_data()
|
|
96
|
+
data_body = t.cast(t.Any, data.get("body", {}))
|
|
97
|
+
return data_body.get("context", {})
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
import uuid
|
|
3
|
+
from abc import ABCMeta, abstractmethod
|
|
4
|
+
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
from .actions import Action
|
|
7
|
+
from .cookie import CookieService
|
|
8
|
+
from .cookies_allowed_check import CookiesAllowedCheckPage
|
|
9
|
+
from .exception import OIDCException
|
|
10
|
+
from .launch_data_storage.base import LaunchDataStorage
|
|
11
|
+
from .session import SessionService
|
|
12
|
+
from .registration import Registration
|
|
13
|
+
from .redirect import Redirect
|
|
14
|
+
from .request import Request
|
|
15
|
+
from .tool_config import ToolConfAbstract
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
RED = t.TypeVar("RED")
|
|
19
|
+
REQ = t.TypeVar("REQ", bound=Request)
|
|
20
|
+
TCONF = t.TypeVar("TCONF", bound=ToolConfAbstract)
|
|
21
|
+
SES = t.TypeVar("SES", bound=SessionService)
|
|
22
|
+
COOK = t.TypeVar("COOK", bound=CookieService)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OIDCLogin(t.Generic[REQ, TCONF, SES, COOK, RED]):
|
|
26
|
+
__metaclass__ = ABCMeta
|
|
27
|
+
_request: REQ
|
|
28
|
+
_tool_config: TCONF
|
|
29
|
+
_session_service: SES
|
|
30
|
+
_cookie_service: COOK
|
|
31
|
+
_launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None
|
|
32
|
+
_registration: Registration
|
|
33
|
+
|
|
34
|
+
_cookies_check: bool = False
|
|
35
|
+
_cookies_check_loading_text: str = "Loading..."
|
|
36
|
+
_cookies_unavailable_msg_main_text: str = (
|
|
37
|
+
"Your browser prohibits to save cookies in the iframes."
|
|
38
|
+
)
|
|
39
|
+
_cookies_unavailable_msg_click_text: str = (
|
|
40
|
+
"Click here to open content in the new tab."
|
|
41
|
+
)
|
|
42
|
+
_state_params: t.Dict[str, object] = {}
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
request: REQ,
|
|
47
|
+
tool_config: TCONF,
|
|
48
|
+
session_service: SES,
|
|
49
|
+
cookie_service: COOK,
|
|
50
|
+
launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None,
|
|
51
|
+
):
|
|
52
|
+
self._request = request
|
|
53
|
+
self._tool_config = tool_config
|
|
54
|
+
self._session_service = session_service
|
|
55
|
+
self._cookie_service = cookie_service
|
|
56
|
+
self._launch_data_storage = launch_data_storage
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def get_redirect(self, url: str) -> Redirect[RED]:
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def get_response(self, html: str) -> RED: # pylint: disable=unused-argument
|
|
63
|
+
return "" # type: ignore
|
|
64
|
+
|
|
65
|
+
def get_iss(self) -> t.Optional[str]:
|
|
66
|
+
if self._registration:
|
|
67
|
+
return self._registration.get_issuer()
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def get_client_id(self) -> t.Optional[str]:
|
|
71
|
+
if self._registration:
|
|
72
|
+
return self._registration.get_client_id()
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def _get_request_param(self, key: str) -> str:
|
|
76
|
+
return self._request.get_param(key)
|
|
77
|
+
|
|
78
|
+
def _get_uuid(self) -> str:
|
|
79
|
+
return str(uuid.uuid4())
|
|
80
|
+
|
|
81
|
+
def _generate_nonce(self) -> str:
|
|
82
|
+
return uuid.uuid4().hex + uuid.uuid1().hex
|
|
83
|
+
|
|
84
|
+
def _is_new_window_request(self) -> bool:
|
|
85
|
+
lti_new_window = self._get_request_param("lti1p3_new_window")
|
|
86
|
+
return bool(lti_new_window)
|
|
87
|
+
|
|
88
|
+
def _prepare_redirect_url(self, launch_url: str) -> str:
|
|
89
|
+
if not launch_url:
|
|
90
|
+
raise OIDCException("No launch URL configured")
|
|
91
|
+
|
|
92
|
+
if self._launch_data_storage:
|
|
93
|
+
self.set_launch_data_storage(self._launch_data_storage)
|
|
94
|
+
|
|
95
|
+
# validate request
|
|
96
|
+
self._registration = self.validate_oidc_login()
|
|
97
|
+
|
|
98
|
+
# build OIDC Auth Response
|
|
99
|
+
|
|
100
|
+
# generate state
|
|
101
|
+
# set cookie (short lived)
|
|
102
|
+
state = "state-" + self._get_uuid()
|
|
103
|
+
self._cookie_service.set_cookie(state, state, 5 * 60) # 5 min
|
|
104
|
+
|
|
105
|
+
# generate nonce
|
|
106
|
+
nonce = self._generate_nonce()
|
|
107
|
+
self._session_service.save_nonce(nonce)
|
|
108
|
+
if self._state_params:
|
|
109
|
+
self._session_service.save_state_params(state, self._state_params)
|
|
110
|
+
|
|
111
|
+
# build Response
|
|
112
|
+
client_id = self._registration.get_client_id() # Registered client id
|
|
113
|
+
assert client_id is not None, "Client id should not be None"
|
|
114
|
+
auth_login_url = self._registration.get_auth_login_url()
|
|
115
|
+
assert auth_login_url is not None, "Auth login url should not be None"
|
|
116
|
+
|
|
117
|
+
auth_params = {
|
|
118
|
+
"scope": "openid", # OIDC Scope
|
|
119
|
+
"response_type": "id_token", # OIDC response is always an id token
|
|
120
|
+
"response_mode": "form_post", # OIDC response is always a form post
|
|
121
|
+
"prompt": "none", # Don't prompt user on redirect
|
|
122
|
+
"client_id": client_id, # Registered client id
|
|
123
|
+
"redirect_uri": launch_url, # URL to return to after login
|
|
124
|
+
"state": state, # State to identify browser session
|
|
125
|
+
"nonce": nonce, # Prevent replay attacks
|
|
126
|
+
"login_hint": self._get_request_param(
|
|
127
|
+
"login_hint"
|
|
128
|
+
), # Login hint to identify platform session
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# pass back LTI message hint if we have it
|
|
132
|
+
lti_message_hint = self._get_request_param("lti_message_hint")
|
|
133
|
+
if lti_message_hint:
|
|
134
|
+
# LTI message hint to identify LTI context within the platform
|
|
135
|
+
auth_params["lti_message_hint"] = lti_message_hint
|
|
136
|
+
|
|
137
|
+
auth_login_return_url = auth_login_url + "?" + urlencode(auth_params)
|
|
138
|
+
return auth_login_return_url
|
|
139
|
+
|
|
140
|
+
def _prepare_redirect(self, launch_url: str) -> Redirect[RED]:
|
|
141
|
+
auth_login_return_url = self._prepare_redirect_url(launch_url)
|
|
142
|
+
return self.get_redirect(auth_login_return_url)
|
|
143
|
+
|
|
144
|
+
def redirect(self, launch_url: str, js_redirect: bool = False) -> RED:
|
|
145
|
+
"""
|
|
146
|
+
Calculate the redirect location to return to based on an OIDC third party initiated login request.
|
|
147
|
+
|
|
148
|
+
:param launch_url: URL to redirect back to after the OIDC login.
|
|
149
|
+
This URL must match exactly a URL white listed in the platform.
|
|
150
|
+
:param js_redirect: Redirect through JS
|
|
151
|
+
:return: Returns a redirect object containing the fully formed OIDC login URL.
|
|
152
|
+
"""
|
|
153
|
+
if self._cookies_check:
|
|
154
|
+
if not self._is_new_window_request():
|
|
155
|
+
html = self.get_cookies_allowed_js_check()
|
|
156
|
+
return self.get_response(html)
|
|
157
|
+
|
|
158
|
+
redirect_obj = self._prepare_redirect(launch_url)
|
|
159
|
+
if js_redirect:
|
|
160
|
+
return redirect_obj.do_js_redirect()
|
|
161
|
+
return redirect_obj.do_redirect()
|
|
162
|
+
|
|
163
|
+
def get_redirect_object(self, launch_url: str) -> Redirect[RED]:
|
|
164
|
+
return self._prepare_redirect(launch_url)
|
|
165
|
+
|
|
166
|
+
def validate_oidc_login(self) -> Registration:
|
|
167
|
+
# validate Issuer
|
|
168
|
+
iss = self._get_request_param("iss")
|
|
169
|
+
if not iss:
|
|
170
|
+
raise OIDCException("Could not find issuer")
|
|
171
|
+
|
|
172
|
+
# validate login hint
|
|
173
|
+
login_hint = self._get_request_param("login_hint")
|
|
174
|
+
if not login_hint:
|
|
175
|
+
raise OIDCException("Could not find login hint")
|
|
176
|
+
|
|
177
|
+
client_id = self._get_request_param("client_id")
|
|
178
|
+
|
|
179
|
+
# fetch registration details
|
|
180
|
+
if self._tool_config.check_iss_has_one_client(iss):
|
|
181
|
+
registration = self._tool_config.find_registration(
|
|
182
|
+
iss, action=Action.OIDC_LOGIN, request=self._request
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
registration = self._tool_config.find_registration_by_params(
|
|
186
|
+
iss, client_id, action=Action.OIDC_LOGIN, request=self._request
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# check we got something
|
|
190
|
+
if not registration:
|
|
191
|
+
raise OIDCException("Could not find registration details")
|
|
192
|
+
|
|
193
|
+
return registration
|
|
194
|
+
|
|
195
|
+
def pass_params_to_launch(self, params: t.Dict[str, object]) -> "OIDCLogin":
|
|
196
|
+
"""
|
|
197
|
+
Ability to pass custom params from oidc login to launch.
|
|
198
|
+
"""
|
|
199
|
+
self._state_params = params
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def enable_check_cookies(
|
|
203
|
+
self,
|
|
204
|
+
main_msg: t.Optional[str] = None,
|
|
205
|
+
click_msg: t.Optional[str] = None,
|
|
206
|
+
loading_msg: t.Optional[str] = None,
|
|
207
|
+
**kwargs
|
|
208
|
+
) -> "OIDCLogin":
|
|
209
|
+
# pylint: disable=unused-argument
|
|
210
|
+
self._cookies_check = True
|
|
211
|
+
if main_msg:
|
|
212
|
+
self._cookies_unavailable_msg_main_text = main_msg
|
|
213
|
+
if click_msg:
|
|
214
|
+
self._cookies_unavailable_msg_click_text = click_msg
|
|
215
|
+
if loading_msg:
|
|
216
|
+
self._cookies_check_loading_text = loading_msg
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
def disable_check_cookies(self) -> "OIDCLogin":
|
|
220
|
+
self._cookies_check = False
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def get_additional_login_params(self) -> t.List[str]:
|
|
224
|
+
"""
|
|
225
|
+
You may add additional custom params in your own OIDCLogin class
|
|
226
|
+
:return: list
|
|
227
|
+
"""
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
def get_cookies_allowed_js_check(self) -> str:
|
|
231
|
+
protocol = "https" if self._request.is_secure() else "http"
|
|
232
|
+
params_lst = [
|
|
233
|
+
"iss",
|
|
234
|
+
"login_hint",
|
|
235
|
+
"target_link_uri",
|
|
236
|
+
"lti_message_hint",
|
|
237
|
+
"lti_deployment_id",
|
|
238
|
+
"client_id",
|
|
239
|
+
]
|
|
240
|
+
additional_login_params = self.get_additional_login_params()
|
|
241
|
+
params_lst.extend(additional_login_params)
|
|
242
|
+
|
|
243
|
+
params = {"lti1p3_new_window": "1"}
|
|
244
|
+
for param_key in params_lst:
|
|
245
|
+
param_value = self._get_request_param(param_key)
|
|
246
|
+
if param_value:
|
|
247
|
+
params[param_key] = param_value
|
|
248
|
+
|
|
249
|
+
page = CookiesAllowedCheckPage(
|
|
250
|
+
params,
|
|
251
|
+
protocol,
|
|
252
|
+
self._cookies_unavailable_msg_main_text,
|
|
253
|
+
self._cookies_unavailable_msg_click_text,
|
|
254
|
+
self._cookies_check_loading_text,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return page.get_html()
|
|
258
|
+
|
|
259
|
+
def set_launch_data_storage(
|
|
260
|
+
self, data_storage: LaunchDataStorage[t.Any]
|
|
261
|
+
) -> "OIDCLogin":
|
|
262
|
+
data_storage.set_request(self._request)
|
|
263
|
+
session_cookie_name = data_storage.get_session_cookie_name()
|
|
264
|
+
if session_cookie_name:
|
|
265
|
+
session_id = self._cookie_service.get_cookie(session_cookie_name)
|
|
266
|
+
if not session_id:
|
|
267
|
+
session_id = self._get_uuid()
|
|
268
|
+
self._cookie_service.set_cookie(session_cookie_name, session_id, None)
|
|
269
|
+
data_storage.set_session_id(session_id)
|
|
270
|
+
self._session_service.set_data_storage(data_storage)
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def set_launch_data_lifetime(self, time_sec: int) -> "OIDCLogin":
|
|
274
|
+
self._session_service.set_launch_data_lifetime(time_sec)
|
|
275
|
+
return self
|
ltitoolkit/core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from abc import ABCMeta, abstractmethod
|
|
3
|
+
|
|
4
|
+
T = t.TypeVar("T")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Redirect(t.Generic[T]):
|
|
8
|
+
__metaclass__ = ABCMeta
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def do_redirect(self) -> T:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def do_js_redirect(self) -> T:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def set_redirect_url(self, location: str):
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def get_redirect_url(self) -> str:
|
|
24
|
+
raise NotImplementedError
|