From c7b730b37d171b1425e2959cc18ca63e129b9a1d Mon Sep 17 00:00:00 2001 From: Patrick Cockwell <pcockwell@gmail.com> Date: Wed, 15 Jul 2020 20:20:16 +0700 Subject: [PATCH] TNL-7316 Implement context claim on LTI 1.3 --- README.rst | 70 ++++++----- lti_consumer/lti_1p3/constants.py | 10 ++ lti_consumer/lti_1p3/consumer.py | 57 +++++++++ lti_consumer/lti_1p3/tests/test_consumer.py | 117 ++++++++++++++++++ lti_consumer/lti_xblock.py | 14 +++ ...est_lti_consumer.py => test_lti_xblock.py} | 8 +- lti_consumer/tests/unit/test_outcomes.py | 2 +- setup.py | 2 +- 8 files changed, 249 insertions(+), 31 deletions(-) rename lti_consumer/tests/unit/{test_lti_consumer.py => test_lti_xblock.py} (99%) diff --git a/README.rst b/README.rst index 03ca18b..07e31f5 100644 --- a/README.rst +++ b/README.rst @@ -15,7 +15,7 @@ root folder: $ pip install -r requirements/base.txt -Addtitionally, to enable LTI 1.3 Launch support, the following FEATURE flag needs to be set in `studio.yml`: +Addtitionally, to enable LTI 1.3 Launch support, the following FEATURE flag needs to be set in `/edx/etc/studio.yml` in your LMS container: .. code:: yaml @@ -98,36 +98,50 @@ On LTI 1.3 the authentication mechanism used is OAuth2 using the Client Credenti that to configure the tool, the LMS needs to know the Keyset URL or public key of the tool, and the tool needs to know the LMS's one. -Intructions: +Instructions: 1. Set up a local tunnel tunneling the LMS (using `ngrok` or a similar tool) to get a URL accessible from the internet. -3. Create a new course, and add the `lti_consumer` block to the advanced modules list. -4. In the course, create a new unit and add the LTI block. -5. In studio, you'll see a few parameters being displayed in the preview: -``` -Client: f0532860-cb34-47a9-b16c-53deb077d4de -Deployment ID: 1 -# Note that these are LMS URLS -Keyset URL: http://localhost:18000/api/lti_consumer/v1/public_keysets/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@efc55c7abb87430883433bfafb83f054 -OAuth Token URL: http://localhost:18000/api/lti_consumer/v1/token/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@efc55c7abb87430883433bfafb83f054 -OIDC Callback URL: http://localhost:18000/api/lti_consumer/v1/launch/ -``` -6. Add the tunnel url to the keyset url as it'll need to be accessed by the tool (hosted externally). -``` -# This is <LMS_URL>/api/lti_consumer/v1/launch/<BLOCK_LOCATION> -https://647dd2e1.ngrok.io/api/lti_consumer/v1/public_keysets/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@996c72b16070434098bc598bd7d6dbde -``` -7. Set up a tool in the IMS Global reference implementation (https://lti-ri.imsglobal.org/lti/tools/). - * Click on __Add tool__ at the top of the page (https://lti-ri.imsglobal.org/lti/tools). - * Add the parameters and URLs provided by the block, and generate a private key on https://lti-ri.imsglobal.org/keygen/index and paste it there (don't close the tab, you'll need the public key later). -8. Go back to Studio, and edit the block adding it's settings (you'll find them by scrolling down https://lti-ri.imsglobal.org/lti/tools/ until you find the tool you just created): -``` -Tool launch URL: https://lti-ri.imsglobal.org/lti/tools/[tool_id]/launches -Tool OIDC Login Initiation URL: https://lti-ri.imsglobal.org/lti/tools/[tool_id]/login_initiations -Tool public key: Public key from key page. -``` +2. Create a new course, and add the `lti_consumer` block to the advanced modules list. +3. In the course, create a new unit and add the LTI block. + + * Set ``LTI Version`` to ``LTI 1.3``. + * Set the ``LTI 1.3 Tool Launch URL`` to ``https://lti-ri.imsglobal.org/lti/tools/`` + +4. In studio, you'll see a few parameters being displayed in the preview: + +.. code:: + + Client: f0532860-cb34-47a9-b16c-53deb077d4de + Deployment ID: 1 + # Note that these are LMS URLS + Keyset URL: http://localhost:18000/api/lti_consumer/v1/public_keysets/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@efc55c7abb87430883433bfafb83f054 + OAuth Token URL: http://localhost:18000/api/lti_consumer/v1/token/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@efc55c7abb87430883433bfafb83f054 + OIDC Callback URL: http://localhost:18000/api/lti_consumer/v1/launch/ + + +5. Add the tunnel URL to the each of these URLs as it'll need to be accessed by the tool (hosted externally). + +.. code:: + + # This is <LMS_URL>/api/lti_consumer/v1/public_keysets/<BLOCK_LOCATION> + https://647dd2e1.ngrok.io/api/lti_consumer/v1/public_keysets/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@996c72b16070434098bc598bd7d6dbde + + +6. Set up a tool in the IMS Global reference implementation (https://lti-ri.imsglobal.org/lti/tools/). + + * Click on ``Add tool`` at the top of the page (https://lti-ri.imsglobal.org/lti/tools). + * Add the parameters and URLs provided by the block, and generate a private key on https://lti-ri.imsglobal.org/keygen/index and paste it there (don't close the tab, you'll need the public key later). + +7. Go back to Studio, and edit the block adding its settings (you'll find them by scrolling down https://lti-ri.imsglobal.org/lti/tools/ until you find the tool you just created): + +.. code:: + + LTI 1.3 Tool Launch URL: https://lti-ri.imsglobal.org/lti/tools/[tool_id]/launches + LTI 1.3 OIDC URL: https://lti-ri.imsglobal.org/lti/tools/[tool_id]/login_initiations + LTI 1.3 Tool Public key: Public key from key page. + 8. Publish block, log into LMS and navigate to the LTI block page. -9. Check that the LTI launch was successful. +9. Click ``Send Request`` and verify that the LTI launch was successful. Custom LTI Parameters --------------------- diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py index d6b26e8..dcf4c42 100644 --- a/lti_consumer/lti_1p3/constants.py +++ b/lti_consumer/lti_1p3/constants.py @@ -5,6 +5,8 @@ This includes the LTI Base message, OAuth2 scopes, and lists of required and optional parameters required for LTI message generation and validation. """ +from enum import Enum + LTI_BASE_MESSAGE = { # Claim type: fixed key with value `LtiResourceLinkRequest` @@ -43,3 +45,11 @@ LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS = set([ ]) LTI_1P3_ACCESS_TOKEN_SCOPES = [] + + +class LTI_1P3_CONTEXT_TYPE(Enum): # pylint: disable=invalid-name + """ LTI 1.3 Context Claim Types """ + group = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseGroup' + course_offering = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering' + course_section = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection' + course_template = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseTemplate' diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py index f10a96e..ce6282c 100644 --- a/lti_consumer/lti_1p3/consumer.py +++ b/lti_consumer/lti_1p3/consumer.py @@ -9,6 +9,7 @@ from .constants import ( LTI_BASE_MESSAGE, LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS, LTI_1P3_ACCESS_TOKEN_SCOPES, + LTI_1P3_CONTEXT_TYPE, ) from .key_handlers import ToolKeyHandler, PlatformKeyHandler @@ -50,6 +51,7 @@ class LtiConsumer1p3: # IMS LTI Claim data self.lti_claim_user_data = None self.lti_claim_launch_presentation = None + self.lti_claim_context = None self.lti_claim_custom_parameters = None @staticmethod @@ -161,6 +163,57 @@ class LtiConsumer1p3: }, } + def set_context_claim( + self, + context_id, + context_types=None, + context_title=None, + context_label=None + ): + """ + Optional: Set context claims + + https://www.imsglobal.org/spec/lti/v1p3/#context-claim + + Arguments: + context_id (string): Unique value identifying the user + context_types (list): A list of context type values for the claim + context_title (string): Plain text title of the context + context_label (string): Plain text label for the context + """ + # Set basic claim data + context_claim_data = { + "id": context_id, + } + + # Default context_types to a list if nothing is passed in + context_types = context_types or [] + + # Ensure the value of context_types is a list + if not isinstance(context_types, list): + raise TypeError("Invalid type for context_types. It must be a list.") + + # Explicitly ignoring any custom context types + context_claim_types = [ + context_type.value + for context_type in context_types + if isinstance(context_type, LTI_1P3_CONTEXT_TYPE) + ] + + if context_claim_types: + context_claim_data["type"] = context_claim_types + + if context_title: + context_claim_data["title"] = context_title + + if context_label: + context_claim_data["label"] = context_label + + self.lti_claim_context = { + # Context claim + "https://purl.imsglobal.org/spec/lti/claim/context": context_claim_data + } + def set_custom_parameters( self, custom_parameters @@ -240,6 +293,10 @@ class LtiConsumer1p3: if self.lti_claim_launch_presentation: lti_message.update(self.lti_claim_launch_presentation) + # Context claim + if self.lti_claim_context: + lti_message.update(self.lti_claim_context) + # Custom variables claim if self.lti_claim_custom_parameters: lti_message.update(self.lti_claim_custom_parameters) diff --git a/lti_consumer/lti_1p3/tests/test_consumer.py b/lti_consumer/lti_1p3/tests/test_consumer.py index 36a29a4..502d0b2 100644 --- a/lti_consumer/lti_1p3/tests/test_consumer.py +++ b/lti_consumer/lti_1p3/tests/test_consumer.py @@ -14,6 +14,7 @@ from Crypto.PublicKey import RSA from jwkest.jwk import load_jwks from jwkest.jws import JWS +from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_TYPE from lti_consumer.lti_1p3.consumer import LtiConsumer1p3 from lti_consumer.lti_1p3 import exceptions @@ -261,6 +262,122 @@ class TestLti1p3Consumer(TestCase): with self.assertRaises(ValueError): self.lti_consumer.set_launch_presentation_claim(document_target="invalid") + @ddt.data( + *[context_type for context_type in LTI_1P3_CONTEXT_TYPE] # pylint: disable=unnecessary-comprehension + ) + def test_set_valid_context_claim(self, context_type): + """ + Check if setting context claim data works + """ + self._setup_lti_user() + self.lti_consumer.set_context_claim( + "context_id", + context_types=[context_type], + context_title="context_title", + context_label="context_label" + ) + + expected_claim_data = { + "id": "context_id", + "type": [context_type.value], + "title": "context_title", + "label": "context_label", + } + + self.assertEqual( + self.lti_consumer.lti_claim_context, + { + "https://purl.imsglobal.org/spec/lti/claim/context": expected_claim_data + } + ) + + # Prepare LTI message + launch_request = self._get_lti_message() + + # Decode and verify message + decoded = self._decode_token(launch_request['id_token']) + self.assertIn( + "https://purl.imsglobal.org/spec/lti/claim/context", + decoded.keys() + ) + self.assertEqual( + decoded["https://purl.imsglobal.org/spec/lti/claim/context"], + expected_claim_data + ) + + def test_set_invalid_context_claim_type(self): + """ + Check if setting invalid context claim type omits type attribute + """ + self._setup_lti_user() + self.lti_consumer.set_context_claim( + "context_id", + context_types=["invalid"], + context_title="context_title", + context_label="context_label" + ) + + expected_claim_data = { + "id": "context_id", + "title": "context_title", + "label": "context_label", + } + + self.assertEqual( + self.lti_consumer.lti_claim_context, + { + "https://purl.imsglobal.org/spec/lti/claim/context": expected_claim_data + } + ) + + # Prepare LTI message + launch_request = self._get_lti_message() + + # Decode and verify message + decoded = self._decode_token(launch_request['id_token']) + self.assertIn( + "https://purl.imsglobal.org/spec/lti/claim/context", + decoded.keys() + ) + self.assertEqual( + decoded["https://purl.imsglobal.org/spec/lti/claim/context"], + expected_claim_data + ) + + def test_set_context_claim_with_only_id(self): + """ + Check if setting no context claim type works + """ + self._setup_lti_user() + self.lti_consumer.set_context_claim( + "context_id" + ) + + expected_claim_data = { + "id": "context_id", + } + + self.assertEqual( + self.lti_consumer.lti_claim_context, + { + "https://purl.imsglobal.org/spec/lti/claim/context": expected_claim_data + } + ) + + # Prepare LTI message + launch_request = self._get_lti_message() + + # Decode and verify message + decoded = self._decode_token(launch_request['id_token']) + self.assertIn( + "https://purl.imsglobal.org/spec/lti/claim/context", + decoded.keys() + ) + self.assertEqual( + decoded["https://purl.imsglobal.org/spec/lti/claim/context"], + expected_claim_data + ) + def test_check_no_user_data_error(self): """ Check if the launch request fails if no user data is set. diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py index f5b923b..2c85e8c 100644 --- a/lti_consumer/lti_xblock.py +++ b/lti_consumer/lti_xblock.py @@ -81,6 +81,7 @@ from .lti_1p3.exceptions import ( TokenSignatureExpired, UnknownClientId, ) +from .lti_1p3.constants import LTI_1P3_CONTEXT_TYPE from .lti_1p3.consumer import LtiConsumer1p3 from .outcomes import OutcomeService from .utils import ( @@ -1085,6 +1086,19 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): # This is optional though lti_consumer.set_launch_presentation_claim('iframe') + # Set context claim + # This is optional + context_title = " - ".join([ + self.course.display_name_with_default, + self.course.display_org_with_default + ]) + lti_consumer.set_context_claim( + self.context_id, + context_types=[LTI_1P3_CONTEXT_TYPE.course_offering], + context_title=context_title, + context_label=self.context_id + ) + context.update({ "preflight_response": dict(request.GET), "launch_request": lti_consumer.generate_launch_request( diff --git a/lti_consumer/tests/unit/test_lti_consumer.py b/lti_consumer/tests/unit/test_lti_xblock.py similarity index 99% rename from lti_consumer/tests/unit/test_lti_consumer.py rename to lti_consumer/tests/unit/test_lti_xblock.py index 4c4a913..4eca499 100644 --- a/lti_consumer/tests/unit/test_lti_consumer.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -1169,13 +1169,16 @@ class TestLtiConsumer1p3XBlock(TestCase): @patch('lti_consumer.lti_xblock.get_lms_base', return_value="https://example.com") def test_launch_callback_endpoint(self, mock_url, mock_url_2): """ - Test that the LTI 1.3 callback endpoind. + Test the LTI 1.3 callback endpoint. """ self.xblock.runtime.get_user_role.return_value = 'student' mock_user_service = Mock() mock_user_service.get_external_user_id.return_value = 2 self.xblock.runtime.service.return_value = mock_user_service + self.xblock.course.display_name_with_default = 'course_display_name' + self.xblock.course.display_org_with_default = 'course_display_org' + # Craft request sent back by LTI tool request = make_request('', 'GET') request.query_string = ( @@ -1207,6 +1210,9 @@ class TestLtiConsumer1p3XBlock(TestCase): mock_user_service.get_external_user_id.return_value = 2 self.xblock.runtime.service.return_value = mock_user_service + self.xblock.course.display_name_with_default = 'course_display_name' + self.xblock.course.display_org_with_default = 'course_display_org' + # Make a fake invalid preflight request, with empty parameters request = make_request('', 'GET') response = self.xblock.lti_1p3_launch_callback(request) diff --git a/lti_consumer/tests/unit/test_outcomes.py b/lti_consumer/tests/unit/test_outcomes.py index c2c73d4..361566b 100644 --- a/lti_consumer/tests/unit/test_outcomes.py +++ b/lti_consumer/tests/unit/test_outcomes.py @@ -11,7 +11,7 @@ from mock import Mock, PropertyMock, patch from lti_consumer.exceptions import LtiError from lti_consumer.outcomes import OutcomeService, parse_grade_xml_body -from lti_consumer.tests.unit.test_lti_consumer import TestLtiConsumerXBlock +from lti_consumer.tests.unit.test_lti_xblock import TestLtiConsumerXBlock from lti_consumer.tests.unit.test_utils import make_request REQUEST_BODY_TEMPLATE_VALID = textwrap.dedent(""" diff --git a/setup.py b/setup.py index 4a57c18..0940c91 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ with open('README.rst') as _f: setup( name='lti-consumer-xblock', - version='2.0.2', + version='2.0.3', description='This XBlock implements the consumer side of the LTI specification.', long_description=long_description, long_description_content_type='text/markdown', -- GitLab