From c30bfc4ac59e942e01334634a149a3712957edb6 Mon Sep 17 00:00:00 2001 From: Giovanni Cimolin da Silva <giovannicimolin@gmail.com> Date: Mon, 7 Dec 2020 19:50:46 -0300 Subject: [PATCH] Implement Deep Linking Launch flow Signed-off-by: Giovanni Cimolin da Silva <giovannicimolin@gmail.com> --- lti_consumer/lti_1p3/constants.py | 3 + lti_consumer/lti_1p3/consumer.py | 172 +++++++++++++++--- lti_consumer/lti_1p3/deep_linking.py | 75 ++++++++ lti_consumer/lti_1p3/exceptions.py | 4 + lti_consumer/lti_1p3/tests/test_consumer.py | 104 ++++++++++- .../lti_1p3/tests/test_deep_linking.py | 76 ++++++++ lti_consumer/lti_xblock.py | 69 +++++-- lti_consumer/models.py | 8 + lti_consumer/static/js/xblock_studio_view.js | 4 +- .../html/lti_1p3_permission_error.html | 15 ++ lti_consumer/tests/unit/test_lti_xblock.py | 85 +++++++++ lti_consumer/tests/unit/test_models.py | 13 ++ lti_consumer/utils.py | 24 +++ test_settings.py | 1 + 14 files changed, 610 insertions(+), 43 deletions(-) create mode 100644 lti_consumer/lti_1p3/deep_linking.py create mode 100644 lti_consumer/lti_1p3/tests/test_deep_linking.py create mode 100644 lti_consumer/templates/html/lti_1p3_permission_error.html diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py index 54e6781..af3550e 100644 --- a/lti_consumer/lti_1p3/constants.py +++ b/lti_consumer/lti_1p3/constants.py @@ -52,6 +52,9 @@ LTI_1P3_ACCESS_TOKEN_SCOPES = [ ] +LTI_DEEP_LINKING_ACCEPTED_TYPES = [] + + 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' diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py index 7f15873..2b88c7f 100644 --- a/lti_consumer/lti_1p3/consumer.py +++ b/lti_consumer/lti_1p3/consumer.py @@ -3,7 +3,7 @@ LTI 1.3 Consumer implementation """ from urllib.parse import urlencode -from . import exceptions +from . import constants, exceptions from .constants import ( LTI_1P3_ROLE_MAP, LTI_BASE_MESSAGE, @@ -13,6 +13,7 @@ from .constants import ( ) from .key_handlers import ToolKeyHandler, PlatformKeyHandler from .ags import LtiAgs +from .deep_linking import LtiDeepLinking class LtiConsumer1p3: @@ -230,10 +231,10 @@ class LtiConsumer1p3: "https://purl.imsglobal.org/spec/lti/claim/custom": custom_parameters } - def generate_launch_request( + def get_lti_launch_message( self, - preflight_response, - resource_link + resource_link, + include_extra_claims=True, ): """ Build LTI message from class parameters @@ -241,9 +242,6 @@ class LtiConsumer1p3: This will add all required parameters from the LTI 1.3 spec and any additional ones set in the configuration and JTW encode the message using the provided key. """ - # Validate preflight response - self._validate_preflight_response(preflight_response) - # Start from base message lti_message = LTI_BASE_MESSAGE.copy() @@ -252,9 +250,6 @@ class LtiConsumer1p3: # Issuer "iss": self.iss, - # Nonce from OIDC preflight launch request - "nonce": preflight_response.get("nonce"), - # JWT aud and azp "aud": [ self.client_id @@ -290,27 +285,53 @@ class LtiConsumer1p3: else: raise ValueError("Required user data isn't set.") - # Set optional claims - # Launch presentation claim - if self.lti_claim_launch_presentation: - lti_message.update(self.lti_claim_launch_presentation) + # Only used when doing normal LTI launches + if include_extra_claims: + # Set optional claims + # Launch presentation claim + 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) + + # Extra claims - From LTI Advantage extensions + if self.extra_claims: + lti_message.update(self.extra_claims) + + return lti_message + + def generate_launch_request( + self, + preflight_response, + resource_link + ): + """ + Build LTI message from class parameters - # Context claim - if self.lti_claim_context: - lti_message.update(self.lti_claim_context) + This will add all required parameters from the LTI 1.3 spec and any additional ones set in + the configuration and JTW encode the message using the provided key. + """ + # Validate preflight response + self._validate_preflight_response(preflight_response) - # Custom variables claim - if self.lti_claim_custom_parameters: - lti_message.update(self.lti_claim_custom_parameters) + # Get LTI Launch Message + lti_launch_message = self.get_lti_launch_message(resource_link=resource_link) - # Extra claims - From LTI Advantage extensions - if self.extra_claims: - lti_message.update(self.extra_claims) + # Nonce from OIDC preflight launch request + lti_launch_message.update({ + "nonce": preflight_response.get("nonce") + }) return { "state": preflight_response.get("state"), "id_token": self.key_handler.encode_and_sign( - message=lti_message, + message=lti_launch_message, expiration=300 ) } @@ -400,8 +421,8 @@ class LtiConsumer1p3: try: assert response.get("nonce") assert response.get("state") + assert response.get("redirect_uri") assert response.get("client_id") == self.client_id - assert response.get("redirect_uri") == self.launch_url except AssertionError as err: raise exceptions.PreflightRequestValidationFailure() from err @@ -455,8 +476,9 @@ class LtiAdvantageConsumer(LtiConsumer1p3): """ super().__init__(*args, **kwargs) - # LTI AGS Variables + # LTI Advantage services self.ags = None + self.dl = None @property def lti_ags(self): @@ -493,3 +515,101 @@ class LtiAdvantageConsumer(LtiConsumer1p3): # Include LTI AGS claim inside the LTI Launch message self.set_extra_claim(self.ags.get_lti_ags_launch_claim()) + + def enable_deep_linking( + self, + deep_linking_launch_url, + deep_linking_return_url, + ): + """ + Enable LTI Advantage Deep Linking Service. + + This will include the LTI DL Claim in the LTI message + and set up the required class. + """ + self.dl = LtiDeepLinking(deep_linking_launch_url, deep_linking_return_url) + + def generate_launch_request( + self, + preflight_response, + resource_link + ): + """ + Build LTI message for Deep linking launches. + + Overrides method from LtiConsumer1p3 to allow handling LTI Deep linking messages + """ + # Check if Deep Linking is enabled and that this is a Deep Link Launch + if self.dl and preflight_response.get("lti_message_hint") == "deep_linking_launch": + # Validate preflight response + self._validate_preflight_response(preflight_response) + + # Get LTI Launch Message + lti_launch_message = self.get_lti_launch_message( + resource_link=resource_link, + include_extra_claims=False, + ) + + # Update message type to LtiDeepLinkingRequest, + # replacing the normal launch request. + lti_launch_message.update({ + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingRequest", + }) + # Include deep linking claim + lti_launch_message.update( + # TODO: Add extra settings + self.dl.get_lti_deep_linking_launch_claim() + ) + + # Nonce from OIDC preflight launch request + lti_launch_message.update({ + "nonce": preflight_response.get("nonce") + }) + + # Return new lanch message, used by XBlock to present the launch + return { + "state": preflight_response.get("state"), + "id_token": self.key_handler.encode_and_sign( + message=lti_launch_message, + expiration=300 + ) + } + + # Call LTI Launch if Deep Linking is not + # set up or this isn't a Deep Link Launch + return super().generate_launch_request( + preflight_response, + resource_link + ) + + def check_and_decode_deep_linking_token(self, token): + """ + Check and decode Deep Linking response, return selected content items. + + This either returns a content item list or raises an exception. + """ + if not self.dl: + raise exceptions.LtiAdvantageServiceNotSetUp() + + # Decode token, check expiration + deep_link_response = self.tool_jwt.validate_and_decode(token) + + # Check the response is a Deep Linking response type + message_type = deep_link_response.get("https://purl.imsglobal.org/spec/lti/claim/message_type") + if not message_type == "LtiDeepLinkingResponse": + raise exceptions.InvalidClaimValue("Token isn't a Deep Linking Response message.") + + # Check if supported contentitems were returned + content_items = deep_link_response.get( + 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items', + # If not found, return empty list + [], + ) + if any([ + item['type'] not in constants.LTI_DEEP_LINKING_ACCEPTED_TYPES + for item in content_items + ]): + raise exceptions.LtiDeepLinkingContentTypeNotSupported() + + # Return contentitems + return content_items diff --git a/lti_consumer/lti_1p3/deep_linking.py b/lti_consumer/lti_1p3/deep_linking.py new file mode 100644 index 0000000..8183ff7 --- /dev/null +++ b/lti_consumer/lti_1p3/deep_linking.py @@ -0,0 +1,75 @@ +""" +LTI Deep Linking service implementation +""" +from lti_consumer.lti_1p3.constants import LTI_DEEP_LINKING_ACCEPTED_TYPES +from lti_consumer.lti_1p3 import exceptions + + +class LtiDeepLinking: + """ + LTI Advantage - Deep Linking Service + + Reference: + http://www.imsglobal.org/spec/lti-dl/v2p0#file + """ + def __init__( + self, + deep_linking_launch_url, + deep_linking_return_url, + ): + """ + Class initialization. + """ + self.deep_linking_launch_url = deep_linking_launch_url + self.deep_linking_return_url = deep_linking_return_url + + def get_lti_deep_linking_launch_claim( + self, + title="", + description="", + accept_types=None, + extra_data=None, + ): + """ + Returns LTI Deep Linking Claim to be injected in the LTI launch message. + """ + if not accept_types: + accept_types = LTI_DEEP_LINKING_ACCEPTED_TYPES + + # Check if required types are accepted, if not throw + accept_types_claim = [] + for content_type in accept_types: + if content_type in LTI_DEEP_LINKING_ACCEPTED_TYPES: + accept_types_claim.append(content_type) + else: + raise exceptions.LtiDeepLinkingContentTypeNotSupported() + + # Consctruct Deep Linking Claim + deep_linking_claim = { + "accept_types": accept_types_claim, + "accept_presentation_document_targets": [ + "iframe", + "window", + "embed" + ], + # Only accept a single item return from Deep Linking operation. + "accept_multiple": True, + # Automatically saves Content Items without asking to user + "auto_create": True, + # Other parameters + "title": title, + "text": description, + "deep_link_return_url": self.deep_linking_return_url + } + + # Extra data is an optional parameter that can be sent. + # It's opaque to the tool, but WILL be sent back in the + # deep link response. + if extra_data: + deep_linking_claim.update({ + "data": extra_data, + }) + + return { + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings": deep_linking_claim + } diff --git a/lti_consumer/lti_1p3/exceptions.py b/lti_consumer/lti_1p3/exceptions.py index e895567..a468987 100644 --- a/lti_consumer/lti_1p3/exceptions.py +++ b/lti_consumer/lti_1p3/exceptions.py @@ -55,3 +55,7 @@ class PreflightRequestValidationFailure(Lti1p3Exception): class LtiAdvantageServiceNotSetUp(Lti1p3Exception): pass + + +class LtiDeepLinkingContentTypeNotSupported(Lti1p3Exception): + pass diff --git a/lti_consumer/lti_1p3/tests/test_consumer.py b/lti_consumer/lti_1p3/tests/test_consumer.py index e748c30..6890916 100644 --- a/lti_consumer/lti_1p3/tests/test_consumer.py +++ b/lti_consumer/lti_1p3/tests/test_consumer.py @@ -103,7 +103,6 @@ class TestLti1p3Consumer(TestCase): @ddt.data( ({"client_id": CLIENT_ID, "redirect_uri": LAUNCH_URL, "nonce": STATE, "state": STATE}, True), ({"client_id": "2", "redirect_uri": LAUNCH_URL, "nonce": STATE, "state": STATE}, False), - ({"client_id": CLIENT_ID, "redirect_uri": LAUNCH_URL[::-1], "nonce": STATE, "state": STATE}, False), ({"redirect_uri": LAUNCH_URL, "nonce": NONCE, "state": STATE}, False), ({"client_id": CLIENT_ID, "nonce": NONCE, "state": STATE}, False), ({"client_id": CLIENT_ID, "redirect_uri": LAUNCH_URL, "state": STATE}, False), @@ -569,6 +568,24 @@ class TestLtiAdvantageConsumer(TestCase): tool_key=RSA_KEY ) + self.preflight_response = {} + + def _setup_deep_linking(self): + """ + Set's up deep linking class in LTI consumer. + """ + self.lti_consumer.enable_deep_linking("launch-url", "return-url") + + # Set LTI Consumer parameters + self.preflight_response = { + "client_id": CLIENT_ID, + "redirect_uri": LAUNCH_URL, + "nonce": NONCE, + "state": STATE, + "lti_message_hint": "deep_linking_launch", + } + self.lti_consumer.set_user_data("1", "student") + def test_no_ags_returns_failure(self): """ Test that when LTI-AGS isn't configured, the class yields an error. @@ -604,3 +621,88 @@ class TestLtiAdvantageConsumer(TestCase): } } ) + + def test_deep_linking_enabled_launch_request(self): + """ + Test that the `generate_launch_request` returns a deep linking launch message + when the preflight request indicates it. + """ + self._setup_deep_linking() + + # Retrieve LTI Deep Link Launch Message + token = self.lti_consumer.generate_launch_request( + self.preflight_response, + "resourceLink" + )['id_token'] + + # Decode and check + decoded_token = self.lti_consumer.key_handler.validate_and_decode(token) + self.assertEqual( + decoded_token['https://purl.imsglobal.org/spec/lti/claim/message_type'], + "LtiDeepLinkingRequest", + ) + self.assertEqual( + decoded_token['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings']['deep_link_return_url'], + "return-url" + ) + + def test_deep_linking_token_decode_no_dl(self): + """ + Check that trying to run the Deep Linking decoding fails if service is not set up. + """ + with self.assertRaises(exceptions.LtiAdvantageServiceNotSetUp): + self.lti_consumer.check_and_decode_deep_linking_token("token") + + def test_deep_linking_token_invalid_content_type(self): + """ + Check that trying to run the Deep Linking decoding fails if an invalid content type is passed. + """ + self._setup_deep_linking() + + # Dummy Deep linking response + lti_reponse = { + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [ + { + "type": "link", + "url": "https://something.example.com/page.html", + }, + ] + } + + with self.assertRaises(exceptions.LtiDeepLinkingContentTypeNotSupported): + self.lti_consumer.check_and_decode_deep_linking_token( + self.lti_consumer.key_handler.encode_and_sign(lti_reponse) + ) + + def test_deep_linking_token_wrong_message(self): + """ + Check that trying to run the Deep Linking decoding fails if a message with the wrong type is passed. + """ + self._setup_deep_linking() + + # Dummy Deep linking response + lti_reponse = {"https://purl.imsglobal.org/spec/lti/claim/message_type": "WrongType"} + + with self.assertRaises(exceptions.InvalidClaimValue): + self.lti_consumer.check_and_decode_deep_linking_token( + self.lti_consumer.key_handler.encode_and_sign(lti_reponse) + ) + + def test_deep_linking_token_returned(self): + """ + Check corect token decoding and retrieval of content_items. + """ + self._setup_deep_linking() + + # Dummy Deep linking response + lti_reponse = { + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [] + } + + content_items = self.lti_consumer.check_and_decode_deep_linking_token( + self.lti_consumer.key_handler.encode_and_sign(lti_reponse) + ) + + self.assertEqual(content_items, []) diff --git a/lti_consumer/lti_1p3/tests/test_deep_linking.py b/lti_consumer/lti_1p3/tests/test_deep_linking.py new file mode 100644 index 0000000..8c0b328 --- /dev/null +++ b/lti_consumer/lti_1p3/tests/test_deep_linking.py @@ -0,0 +1,76 @@ +""" +Unit tests for LTI 1.3 consumer implementation +""" +from __future__ import absolute_import, unicode_literals + +from django.test.testcases import TestCase +from mock import patch + +from lti_consumer.lti_1p3.deep_linking import LtiDeepLinking +from lti_consumer.lti_1p3 import exceptions + + +class TestLtiDeepLinking(TestCase): + """ + Unit tests for LtiDeepLinking class + """ + + def setUp(self): + """ + Instance Deep Linking Class for testing. + """ + super().setUp() + + self.dl = LtiDeepLinking( + deep_linking_launch_url="launch_url", + deep_linking_return_url="return_url" + ) + + def test_invalid_claim_type(self): + """ + Test DeepLinking claim when invalid type is passed. + """ + with self.assertRaises(exceptions.LtiDeepLinkingContentTypeNotSupported): + self.dl.get_lti_deep_linking_launch_claim( + accept_types=['invalid_type'] + ) + + def test_claim_type_validation(self): + """ + Test that claims are correctly passed back by the class. + """ + with patch( + 'lti_consumer.lti_1p3.deep_linking.LTI_DEEP_LINKING_ACCEPTED_TYPES', + ['test'] + ): + self.dl.get_lti_deep_linking_launch_claim( + accept_types=['test'] + ) + + def test_no_accepted_claim_types(self): + """ + Test DeepLinking when no claim data is passed. + """ + message = self.dl.get_lti_deep_linking_launch_claim( + extra_data="deep_linking_hint" + ) + + self.assertEqual( + { + 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings': { + 'accept_types': [], + 'accept_presentation_document_targets': [ + 'iframe', + 'window', + 'embed' + ], + 'accept_multiple': True, + 'auto_create': True, + 'title': '', + 'text': '', + 'deep_link_return_url': 'return_url', + 'data': "deep_linking_hint", + } + }, + message, + ) diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py index 523a1a8..01cf27f 100644 --- a/lti_consumer/lti_xblock.py +++ b/lti_consumer/lti_xblock.py @@ -84,6 +84,7 @@ from .outcomes import OutcomeService from .utils import ( _, lti_1p3_enabled, + lti_deeplinking_enabled, ) @@ -312,6 +313,20 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): scope=Scope.settings ) + # Switch to enable/disable the LTI Advantage Deep linking service + lti_advantage_deep_linking_enabled = Boolean( + display_name=_("Deep linking"), + help=_("Select True if you want to enable LTI Advantage Deep Linking."), + default=False, + scope=Scope.settings + ) + lti_advantage_deep_linking_launch_url = String( + display_name=_("LTI Advantage Deep Linking Launch URL"), + default='', + scope=Scope.settings, + help=_("Enter the LTI Advantage Deep Linking Launch URL. "), + ) + # LTI 1.1 fields lti_id = String( display_name=_("LTI ID"), @@ -483,6 +498,8 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): 'display_name', 'description', # LTI 1.3 variables 'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', 'lti_1p3_tool_public_key', + # LTI Advantage variables + 'lti_advantage_deep_linking_enabled', 'lti_advantage_deep_linking_launch_url', # LTI 1.1 variables 'lti_id', 'launch_url', # Other parameters @@ -581,19 +598,28 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): if field not in ('ask_to_send_username', 'ask_to_send_email') ) - # Hide LTI 1.3 fields if flag is disabled + # Hide LTI 1.3 fields depending on configuration flags + hide_fields = [] if not lti_1p3_enabled(): + hide_fields = [ + 'lti_version', + 'lti_1p3_launch_url', + 'lti_1p3_oidc_url', + 'lti_1p3_tool_public_key', + 'lti_advantage_deep_linking_enabled', + 'lti_advantage_deep_linking_launch_url', + ] + elif not lti_deeplinking_enabled(): + hide_fields = [ + 'lti_advantage_deep_linking_enabled', + 'lti_advantage_deep_linking_launch_url', + ] + + if hide_fields: + # Transform data from `editable_fields` not to override the fields + # settings applied above editable_fields = tuple( - field - # Transform data from `editable_fields` not to override the fields - # settings applied above - for field in editable_fields - if field not in ( - 'lti_version', - 'lti_1p3_launch_url', - 'lti_1p3_oidc_url', - 'lti_1p3_tool_public_key', - ) + field for field in editable_fields if field not in hide_fields ) return editable_fields @@ -998,6 +1024,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): loader = ResourceLoader(__name__) context = {} + user_role = self.runtime.get_user_role() lti_consumer = self._get_lti_consumer() try: @@ -1005,7 +1032,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): lti_consumer.set_user_data( user_id=self.external_user_id, # Pass django user role to library - role=self.runtime.get_user_role() + role=user_role ) # Set launch context @@ -1031,8 +1058,18 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): # Retrieve preflight response preflight_response = dict(request.GET) - # Set LTI Launch URL - context.update({'launch_url': self.lti_1p3_launch_url}) + # Set launch url depending on launch type + if self.lti_advantage_deep_linking_enabled and \ + preflight_response.get('lti_message_hint') == 'deep_linking_launch': + # Check if the user is staff before LTI doing deep linking launch. + # If not, raise exception and display error page + if user_role != 'staff': + raise Lti1p3Exception('Deep Linking can only be performed by instructors.') + # Set deep linking launch + context.update({'launch_url': self.lti_advantage_deep_linking_launch_url}) + else: + # Else just run a normal LTI launch + context.update({'launch_url': self.lti_1p3_launch_url}) # Update context with LTI launch parameters context.update({ @@ -1043,12 +1080,14 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): ) }) - context.update({'launch_url': self.lti_1p3_launch_url}) template = loader.render_mako_template('/templates/html/lti_1p3_launch.html', context) return Response(template, content_type='text/html') except Lti1p3Exception: template = loader.render_mako_template('/templates/html/lti_1p3_launch_error.html', context) return Response(template, status=400, content_type='text/html') + except AssertionError: + template = loader.render_mako_template('/templates/html/lti_1p3_permission_error.html', context) + return Response(template, status=403, content_type='text/html') @XBlock.handler def lti_1p3_access_token(self, request, suffix=''): # pylint: disable=unused-argument diff --git a/lti_consumer/models.py b/lti_consumer/models.py index bd162fa..b8b818f 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -21,6 +21,7 @@ from lti_consumer.plugin import compat from lti_consumer.utils import ( get_lms_base, get_lti_ags_lineitems_url, + get_lti_deeplinking_response_url, ) @@ -291,6 +292,13 @@ class LtiConfiguration(models.Model): lineitem_url=get_lti_ags_lineitems_url(self.id, lineitem.id), ) + # Check if enabled and setup LTI-DL + if self.block.lti_advantage_deep_linking_enabled: + consumer.enable_deep_linking( + self.block.lti_advantage_deep_linking_launch_url, + get_lti_deeplinking_response_url(self.id), + ) + return consumer # There's no configuration stored locally, so throw diff --git a/lti_consumer/static/js/xblock_studio_view.js b/lti_consumer/static/js/xblock_studio_view.js index 9f46d48..83925ba 100644 --- a/lti_consumer/static/js/xblock_studio_view.js +++ b/lti_consumer/static/js/xblock_studio_view.js @@ -14,7 +14,9 @@ function LtiConsumerXBlockInitStudio(runtime, element) { const lti1P3FieldList = [ "lti_1p3_launch_url", "lti_1p3_oidc_url", - "lti_1p3_tool_public_key" + "lti_1p3_tool_public_key", + "lti_advantage_deep_linking_enabled", + "lti_advantage_deep_linking_launch_url" ]; /** diff --git a/lti_consumer/templates/html/lti_1p3_permission_error.html b/lti_consumer/templates/html/lti_1p3_permission_error.html new file mode 100644 index 0000000..eb74976 --- /dev/null +++ b/lti_consumer/templates/html/lti_1p3_permission_error.html @@ -0,0 +1,15 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>LTI</title> + </head> + <body> + <p> + <b>Unauthorized.</b> + </p> + <p> + Students don't have permissions to perform LTI Deep Linking configuration launches. + </p> + </body> +</html> diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py index 9fb1358..d1ccd1d 100644 --- a/lti_consumer/tests/unit/test_lti_xblock.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -432,6 +432,23 @@ class TestEditableFields(TestLtiConsumerXBlock): ) lti_1p3_enabled_mock.assert_called() + @patch('lti_consumer.lti_xblock.lti_1p3_enabled', return_value=True) + @patch('lti_consumer.lti_xblock.lti_deeplinking_enabled', return_value=True) + def test_lti_deeplinking_fields_appear_when_enabled(self, lti_1p3_enabled_mock, lti_deeplinking_enabled_mock): + """ + Test that LTI 1.3 XBlock's fields appear when `lti_1p3_enabled` returns True. + """ + self.assertTrue( + self.are_fields_editable( + fields=[ + 'lti_advantage_deep_linking_enabled', + 'lti_advantage_deep_linking_launch_url' + ] + ) + ) + lti_1p3_enabled_mock.assert_called() + lti_deeplinking_enabled_mock.assert_called() + class TestGetLti1p1Consumer(TestLtiConsumerXBlock): """ @@ -1276,6 +1293,74 @@ class TestLtiConsumer1p3XBlock(TestCase): self.assertIn("mock-keyset_url", response.content) self.assertIn("mock-token_url", response.content) + def test_launch_callback_endpoint_deep_linking(self): + """ + Test the LTI 1.3 callback endpoint for deep linking requests. + """ + self.xblock.runtime.get_user_role.return_value = 'staff' + 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' + + # Enable deep linking + self.xblock.lti_advantage_deep_linking_enabled = True + + # Get LTI client_id + client_id = get_lti_1p3_launch_info(block=self.xblock)['client_id'] + + # Craft request sent back by LTI tool + request = make_request('', 'GET') + request.query_string = ( + "client_id={}&".format(client_id) + + "redirect_uri=http://tool.example/launch&" + + "state=state_test_123&" + + "nonce=nonce&" + + "login_hint=oidchint&" + + "lti_message_hint=deep_linking_launch" + ) + + response = self.xblock.lti_1p3_launch_callback(request) + + # Check response + self.assertEqual(response.status_code, 200) + + def test_launch_callback_endpoint_deep_linking_by_student(self): + """ + Test that the callback endpoint errors out if students try to do a deep link launch. + """ + 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' + + # Enable deep linking + self.xblock.lti_advantage_deep_linking_enabled = True + + # Get LTI client_id + client_id = get_lti_1p3_launch_info(block=self.xblock)['client_id'] + + # Craft request sent back by LTI tool + request = make_request('', 'GET') + request.query_string = ( + "client_id={}&".format(client_id) + + "redirect_uri=http://tool.example/launch&" + + "state=state_test_123&" + + "nonce=nonce&" + + "login_hint=oidchint&" + + "lti_message_hint=deep_linking_launch" + ) + + response = self.xblock.lti_1p3_launch_callback(request) + + # Check response + self.assertEqual(response.status_code, 403) + class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock): """ diff --git a/lti_consumer/tests/unit/test_models.py b/lti_consumer/tests/unit/test_models.py index 1a80791..bd56207 100644 --- a/lti_consumer/tests/unit/test_models.py +++ b/lti_consumer/tests/unit/test_models.py @@ -40,6 +40,7 @@ class TestLtiConfigurationModel(TestCase): # Studio configuration view. 'lti_1p3_tool_public_key': self.public_key, 'has_score': True, + 'lti_advantage_deep_linking_enabled': True, } self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes) # Set dummy location so that UsageKey lookup is valid @@ -131,6 +132,18 @@ class TestLtiConfigurationModel(TestCase): } ) + def test_lti_consumer_deep_linking_enabled(self): + """ + Check if LTI DL is properly instanced when configured. + """ + self.lti_1p3_config.block = self.xblock + + # Get LTI 1.3 consumer + consumer = self.lti_1p3_config.get_lti_consumer() + + # Check that LTI DL class is instanced. + self.assertTrue(consumer.dl) + @patch("lti_consumer.models.compat") def test_block_property(self, compat_mock): """ diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index 80fa8bf..0672218 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -18,6 +18,13 @@ def lti_1p3_enabled(): return settings.FEATURES.get('LTI_1P3_ENABLED', False) is True # pragma: no cover +def lti_deeplinking_enabled(): + """ + Returns `true` if LTI Advantage deep linking is enabled for instance. + """ + return settings.FEATURES.get('LTI_DEEP_LINKING_ENABLED', False) is True # pragma: no cover + + def get_lms_base(): """ Returns LMS base url to be used as issuer on OAuth2 flows @@ -83,3 +90,20 @@ def get_lti_ags_lineitems_url(lti_config_id, lineitem_id=None): url += "/" + str(lineitem_id) return url + + +def get_lti_deeplinking_response_url(lti_config_id): + """ + Return the LTI Deep Linking response endpoint + + This is just a dummy URL for now, until we implement the deep + linking response endpoint. + + # TODO: Implement Deep Linking Response endpoint + + :param lti_config_id: LTI configuration id + """ + return "{lms_base}/api/lti_consumer/v1/lti/{lti_config_id}/lti-dl/response".format( + lms_base=get_lms_base(), + lti_config_id=str(lti_config_id), + ) diff --git a/test_settings.py b/test_settings.py index 0e12c81..8d17c8d 100644 --- a/test_settings.py +++ b/test_settings.py @@ -16,4 +16,5 @@ LMS_ROOT_URL = "https://example.com" # Dummy FEATURES dict FEATURES = { 'LTI_1P3_ENABLED': False, + 'LTI_DEEPLINKING_ENABLED': False, } -- GitLab