diff --git a/README.rst b/README.rst index 03ca18bfc1be0da225db80583ba55ac9d8563373..07e31f537e2d600e600954caaff6667095aa3ef9 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 d6b26e85cdaca677106b7c3dcd99010acbe9be84..dcf4c429abb678d501b35e151037f3a60c5fee02 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 f10a96e5b28b737d4a007f50a9ccf0d4fb9a7ab3..ce6282c465c65eb0f39792bdbd919f3413b8efa8 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 36a29a4b4955adf593d071c7b584dbb2ed698ca9..502d0b2b1034a985b1fc639e2b786cc3510e3c9e 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 f5b923bfdae004ad417ebd6c6595de905b195997..2c85e8cc17213b13057345f613596dcae2ae1eec 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 4c4a91315254df3d4e674e6e471382a79f36ccf6..4eca4990145ca335a5c81d315a01a9d67353bfe2 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 c2c73d4b61222c84161653c743ac7fcbe35615a1..361566be2018db45349080292d00b7423a9aa8a4 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 4a57c18943325eeb0aca9280daec25a7df4023c6..0940c91402f13e7c67730852b3599f13f1475fdb 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',