From 5296d260308fc4957d3d8f6972f295dbe45980d8 Mon Sep 17 00:00:00 2001
From: Giovanni Cimolin da Silva <giovannicimolin@gmail.com>
Date: Mon, 7 Dec 2020 12:15:45 -0300
Subject: [PATCH] Simplify LTI 1.3 Launches

Remove the OIDC view from XBlock and return OIDC url directly.
---
 README.rst                                    |  5 ++
 lti_consumer/api.py                           | 56 ++++++++++++-------
 lti_consumer/lti_1p3/consumer.py              |  4 +-
 lti_consumer/lti_1p3/tests/test_consumer.py   |  2 +-
 lti_consumer/lti_xblock.py                    | 56 ++++++++-----------
 lti_consumer/templates/html/lti_1p3_oidc.html | 20 -------
 lti_consumer/tests/unit/test_api.py           | 46 +++++++++++++++
 lti_consumer/tests/unit/test_lti_xblock.py    | 31 +++-------
 setup.py                                      |  2 +-
 test_settings.py                              |  7 ++-
 10 files changed, 127 insertions(+), 102 deletions(-)
 delete mode 100644 lti_consumer/templates/html/lti_1p3_oidc.html

diff --git a/README.rst b/README.rst
index 99e4c68..9007d3e 100644
--- a/README.rst
+++ b/README.rst
@@ -298,6 +298,11 @@ Please do not report security issues in public. Send security concerns via email
 Changelog
 =========
 
+2.4.1 - 2020-12-07
+------------------
+
+* Simplify LTI 1.3 launches by removing OIDC launch start view.
+
 2.4.0 - 2020-12-02
 ------------------
 
diff --git a/lti_consumer/api.py b/lti_consumer/api.py
index 0a793eb..ce544f3 100644
--- a/lti_consumer/api.py
+++ b/lti_consumer/api.py
@@ -4,6 +4,7 @@ Python APIs used to handle LTI configuration and launches.
 Some methods are meant to be used inside the XBlock, so they
 return plaintext to allow easy testing/mocking.
 """
+from .exceptions import LtiError
 from .models import LtiConfiguration
 from .utils import (
     get_lms_lti_keyset_link,
@@ -36,12 +37,12 @@ def _get_or_create_local_lti_config(lti_version, block_location):
     return lti_config
 
 
-def get_lti_consumer(config_id=None, block=None):
+def _get_lti_config(config_id=None, block=None):
     """
-    Retrieves an LTI Consumer instance for a given configuration.
+    Retrieves or creates a LTI Configuration using either block or LTI Config ID.
 
-    Returns an instance of LtiConsumer1p1 or LtiConsumer1p3 depending
-    on the configuration.
+    This wraps around `_get_or_create_local_lti_config` and handles the block and modulestore
+    bits of configuration.
     """
     if config_id:
         lti_config = LtiConfiguration.objects.get(pk=config_id)
@@ -53,31 +54,33 @@ def get_lti_consumer(config_id=None, block=None):
         # Since the block was passed, preload it to avoid
         # having to instance the modulestore and fetch it again.
         lti_config.block = block
+    else:
+        raise LtiError('Either a config_id or block is required to get or create an LTI Configuration.')
 
+    return lti_config
+
+
+def get_lti_consumer(config_id=None, block=None):
+    """
+    Retrieves an LTI Consumer instance for a given configuration.
+
+    Returns an instance of LtiConsumer1p1 or LtiConsumer1p3 depending
+    on the configuration.
+    """
     # Return an instance of LTI 1.1 or 1.3 consumer, depending
     # on the configuration stored in the model.
-    return lti_config.get_lti_consumer()
+    return _get_lti_config(config_id, block).get_lti_consumer()
 
 
 def get_lti_1p3_launch_info(config_id=None, block=None):
     """
     Retrieves the Client ID, Keyset URL and other urls used to configure a LTI tool.
     """
-    if not config_id and not block:
-        raise Exception("You need to pass either a config_id or a block.")
+    # Retrieve LTI Config
+    lti_config = _get_lti_config(config_id, block)
 
-    if config_id:
-        lti_config = LtiConfiguration.objects.get(pk=config_id)
-    elif block:
-        lti_config = _get_or_create_local_lti_config(
-            block.lti_version,
-            block.location,
-        )
-        # Since the block was passed, preload it to avoid
-        # having to instance the modulestore and fetch it again.
-        lti_config.block = block
-
-    launch_info = {
+    # Return LTI launch information for end user configuration
+    return {
         'client_id': lti_config.lti_1p3_client_id,
         'keyset_url': get_lms_lti_keyset_link(lti_config.location),
         'deployment_id': '1',
@@ -85,4 +88,17 @@ def get_lti_1p3_launch_info(config_id=None, block=None):
         'token_url': get_lms_lti_access_token_link(lti_config.location),
     }
 
-    return launch_info
+
+def get_lti_1p3_launch_start_url(config_id=None, block=None, deep_link_launch=False, hint=""):
+    """
+    Computes and retrieves the LTI URL that starts the OIDC flow.
+    """
+    # Retrieve LTI consumer
+    lti_consumer = get_lti_consumer(config_id, block)
+
+    # Prepare and return OIDC flow start url
+    return lti_consumer.prepare_preflight_url(
+        callback_url=get_lms_lti_launch_link(),
+        hint=hint,
+        lti_hint=("deep_linking_launch" if deep_link_launch else "")
+    )
diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py
index ca641d1..7f15873 100644
--- a/lti_consumer/lti_1p3/consumer.py
+++ b/lti_consumer/lti_1p3/consumer.py
@@ -105,9 +105,7 @@ class LtiConsumer1p3:
             "lti_message_hint": lti_hint
         }
 
-        return {
-            "oidc_url": oidc_url + urlencode(parameters),
-        }
+        return oidc_url + urlencode(parameters)
 
     def set_user_data(
             self,
diff --git a/lti_consumer/lti_1p3/tests/test_consumer.py b/lti_consumer/lti_1p3/tests/test_consumer.py
index edfc248..65e7956 100644
--- a/lti_consumer/lti_1p3/tests/test_consumer.py
+++ b/lti_consumer/lti_1p3/tests/test_consumer.py
@@ -158,7 +158,7 @@ class TestLti1p3Consumer(TestCase):
         )
 
         # Extract and check parameters from OIDC launch request url
-        parameters = parse_qs(urlparse(preflight_request_data['oidc_url']).query)
+        parameters = parse_qs(urlparse(preflight_request_data).query)
         self.assertCountEqual(
             parameters.keys(),
             [
diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py
index 7fd6aca..8bb2454 100644
--- a/lti_consumer/lti_xblock.py
+++ b/lti_consumer/lti_xblock.py
@@ -83,7 +83,6 @@ from .lti_1p3.constants import LTI_1P3_CONTEXT_TYPE
 from .outcomes import OutcomeService
 from .utils import (
     _,
-    get_lms_lti_launch_link,
     lti_1p3_enabled,
 )
 
@@ -983,32 +982,6 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
         template = loader.render_mako_template('/templates/html/lti_launch.html', context)
         return Response(template, content_type='text/html')
 
-    @XBlock.handler
-    def lti_1p3_launch_handler(self, request, suffix=''):  # pylint: disable=unused-argument
-        """
-        XBlock handler for launching the LTI 1.3 tools.
-
-        Displays a form with the OIDC preflight request and
-        submits it to the tool.
-
-        Arguments:
-            request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request
-
-        Returns:
-            webob.response: HTML LTI launch form
-        """
-        lti_consumer = self._get_lti_consumer()
-
-        context = lti_consumer.prepare_preflight_url(
-            callback_url=get_lms_lti_launch_link(),
-            hint=str(self.location),  # pylint: disable=no-member
-            lti_hint=str(self.location)  # pylint: disable=no-member
-        )
-
-        loader = ResourceLoader(__name__)
-        template = loader.render_mako_template('/templates/html/lti_1p3_oidc.html', context)
-        return Response(template, content_type='text/html')
-
     @XBlock.handler
     def lti_1p3_launch_callback(self, request, suffix=''):  # pylint: disable=unused-argument
         """
@@ -1055,11 +1028,18 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
                 context_label=self.context_id
             )
 
+            # Retrieve preflight response
+            preflight_response = dict(request.GET)
+
+            # Set LTI Launch URL
+            context.update({'launch_url': self.lti_1p3_launch_url})
+
+            # Update context with LTI launch parameters
             context.update({
-                "preflight_response": dict(request.GET),
+                "preflight_response": preflight_response,
                 "launch_request": lti_consumer.generate_launch_request(
                     resource_link=str(self.location),  # pylint: disable=no-member
-                    preflight_response=dict(request.GET)
+                    preflight_response=preflight_response
                 )
             })
 
@@ -1357,10 +1337,20 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
         allowed_attributes = dict(bleach.sanitizer.ALLOWED_ATTRIBUTES, **{'img': ['src', 'alt']})
         sanitized_comment = bleach.clean(self.score_comment, tags=allowed_tags, attributes=allowed_attributes)
 
-        # Set launch handler depending on LTI version
-        lti_block_launch_handler = self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?')
-        if self.lti_version == 'lti_1p3':
-            lti_block_launch_handler = self.runtime.handler_url(self, 'lti_1p3_launch_handler').rstrip('/?')
+        if self.lti_version == 'lti_1p1':
+            # Set launch handler depending on LTI version
+            lti_block_launch_handler = self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?')
+        else:
+            # Runtime import since this will only run in the
+            # Open edX LMS/Studio environments.
+            from lti_consumer.api import get_lti_1p3_launch_start_url  # pylint: disable=import-outside-toplevel
+
+            # Retrieve and set LTI 1.3 Launch start URL
+            lti_block_launch_handler = get_lti_1p3_launch_start_url(
+                block=self,
+                deep_link_launch=False,
+                hint=str(self.location)  # pylint: disable=no-member
+            )
 
         return {
             'launch_url': self.launch_url.strip(),
diff --git a/lti_consumer/templates/html/lti_1p3_oidc.html b/lti_consumer/templates/html/lti_1p3_oidc.html
deleted file mode 100644
index 861b4f5..0000000
--- a/lti_consumer/templates/html/lti_1p3_oidc.html
+++ /dev/null
@@ -1,20 +0,0 @@
-<!DOCTYPE HTML>
-<html>
-    <head>
-        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
-        <title>LTI</title>
-    </head>
-    <body>
-        <a id="launch-link" href="${oidc_url}">
-            Click here to launch activity.
-        </a>
-        <script type="text/javascript">
-            (function (d) {
-                var element = d.getElementById("launch-link");
-                if (element) {
-                    element.click();
-                }
-            }(document));
-        </script>
-    </body>
-</html>
diff --git a/lti_consumer/tests/unit/test_api.py b/lti_consumer/tests/unit/test_api.py
index 0f705b3..e0b92e2 100644
--- a/lti_consumer/tests/unit/test_api.py
+++ b/lti_consumer/tests/unit/test_api.py
@@ -1,6 +1,7 @@
 """
 Tests for LTI API.
 """
+from Cryptodome.PublicKey import RSA
 from django.test.testcases import TestCase
 from mock import Mock, patch
 
@@ -8,8 +9,11 @@ from lti_consumer.api import (
     _get_or_create_local_lti_config,
     get_lti_consumer,
     get_lti_1p3_launch_info,
+    get_lti_1p3_launch_start_url,
 )
+from lti_consumer.lti_xblock import LtiConsumerXBlock
 from lti_consumer.models import LtiConfiguration
+from lti_consumer.tests.unit.test_utils import make_xblock
 
 
 class TestGetOrCreateLocalLtiConfiguration(TestCase):
@@ -166,3 +170,45 @@ class TestGetLti1p3LaunchInfo(TestCase):
                 ),
             }
         )
+
+
+class TestGetLti1p3LaunchUrl(TestCase):
+    """
+    Unit tests for get_lti_1p3_launch_start_url API method.
+    """
+    def test_no_parameters(self):
+        """
+        Check if the API creates a model if no object matching properties is found.
+        """
+        with self.assertRaises(Exception):
+            get_lti_1p3_launch_start_url()
+
+    def test_retrieve_url(self):
+        """
+        Check if the correct launch url is retrieved
+        """
+        # Generate RSA and save exports
+        rsa_key = RSA.generate(1024)
+        public_key = rsa_key.publickey().export_key()
+
+        xblock_attributes = {
+            'lti_version': 'lti_1p3',
+            'lti_1p3_launch_url': 'http://tool.example/launch',
+            'lti_1p3_oidc_url': 'http://tool.example/oidc',
+            # We need to set the values below because they are not automatically
+            # generated until the user selects `lti_version == 'lti_1p3'` on the
+            # Studio configuration view.
+            'lti_1p3_tool_public_key': public_key,
+        }
+        xblock = make_xblock('lti_consumer', LtiConsumerXBlock, xblock_attributes)
+        # Set dummy location so that UsageKey lookup is valid
+        xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
+
+        # Call API for normal LTI launch initiation
+        launch_url = get_lti_1p3_launch_start_url(block=xblock, hint="test_hint")
+        self.assertIn('login_hint=test_hint', launch_url)
+        self.assertIn('lti_message_hint=', launch_url)
+
+        # Call API for deep link launch
+        launch_url = get_lti_1p3_launch_start_url(block=xblock, deep_link_launch=True)
+        self.assertIn('lti_message_hint=deep_linking_launch', launch_url)
diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py
index ebd60f8..bfbac79 100644
--- a/lti_consumer/tests/unit/test_lti_xblock.py
+++ b/lti_consumer/tests/unit/test_lti_xblock.py
@@ -388,8 +388,7 @@ class TestEditableFields(TestLtiConsumerXBlock):
         """
         return all(field in self.xblock.editable_fields for field in fields)
 
-    @patch('lti_consumer.lti_xblock.lti_1p3_enabled', return_value=False)
-    def test_editable_fields_with_no_config(self, lti_1p3_enabled_mock):
+    def test_editable_fields_with_no_config(self):
         """
         Test that LTI XBlock's fields (i.e. 'ask_to_send_username' and 'ask_to_send_email')
         are editable when lti-configuration service is not provided.
@@ -397,10 +396,8 @@ class TestEditableFields(TestLtiConsumerXBlock):
         self.xblock.runtime.service.return_value = None
         # Assert that 'ask_to_send_username' and 'ask_to_send_email' are editable.
         self.assertTrue(self.are_fields_editable(fields=['ask_to_send_username', 'ask_to_send_email']))
-        lti_1p3_enabled_mock.assert_called()
 
-    @patch('lti_consumer.lti_xblock.lti_1p3_enabled', return_value=False)
-    def test_editable_fields_when_editing_allowed(self, lti_1p3_enabled_mock):
+    def test_editable_fields_when_editing_allowed(self):
         """
         Test that LTI XBlock's fields (i.e. 'ask_to_send_username' and 'ask_to_send_email')
         are editable when this XBlock is configured to allow it.
@@ -409,10 +406,8 @@ class TestEditableFields(TestLtiConsumerXBlock):
         self.xblock.runtime.service.return_value = self.get_mock_lti_configuration(editable=True)
         # Assert that 'ask_to_send_username' and 'ask_to_send_email' are editable.
         self.assertTrue(self.are_fields_editable(fields=['ask_to_send_username', 'ask_to_send_email']))
-        lti_1p3_enabled_mock.assert_called()
 
-    @patch('lti_consumer.lti_xblock.lti_1p3_enabled', return_value=False)
-    def test_editable_fields_when_editing_not_allowed(self, lti_1p3_enabled_mock):
+    def test_editable_fields_when_editing_not_allowed(self):
         """
         Test that LTI XBlock's fields (i.e. 'ask_to_send_username' and 'ask_to_send_email')
         are not editable when this XBlock is configured to not to allow it.
@@ -421,7 +416,6 @@ class TestEditableFields(TestLtiConsumerXBlock):
         self.xblock.runtime.service.return_value = self.get_mock_lti_configuration(editable=False)
         # Assert that 'ask_to_send_username' and 'ask_to_send_email' are not editable.
         self.assertFalse(self.are_fields_editable(fields=['ask_to_send_username', 'ask_to_send_email']))
-        lti_1p3_enabled_mock.assert_called()
 
     @patch('lti_consumer.lti_xblock.lti_1p3_enabled', return_value=True)
     def test_lti_1p3_fields_appear_when_enabled(self, lti_1p3_enabled_mock):
@@ -1068,7 +1062,8 @@ class TestGetContext(TestLtiConsumerXBlock):
     """
 
     @ddt.data('lti_1p1', 'lti_1p3')
-    def test_context_keys(self, lti_version):
+    @patch('lti_consumer.api.get_lti_1p3_launch_start_url')
+    def test_context_keys(self, lti_version, lti_api_patch):
         """
         Test `_get_context_for_template` returns dict with correct keys
         """
@@ -1085,6 +1080,9 @@ class TestGetContext(TestLtiConsumerXBlock):
         for key in context_keys:
             self.assertIn(key, context)
 
+        if lti_version == 'lti_1p3':
+            lti_api_patch.assert_called_once()
+
     @ddt.data('a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'strong', 'ul', 'img')
     def test_comment_allowed_tags(self, tag):
         """
@@ -1188,19 +1186,6 @@ class TestLtiConsumer1p3XBlock(TestCase):
         # Set dummy location so that UsageKey lookup is valid
         self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
 
-    def test_launch_request(self):
-        """
-        Test LTI 1.3 launch request
-        """
-        response = self.xblock.lti_1p3_launch_handler(make_request('', 'GET'))
-        self.assertEqual(response.status_code, 200)
-
-        # Check if tool OIDC url is on page
-        self.assertIn(
-            self.xblock_attributes['lti_1p3_oidc_url'],
-            response.body.decode('utf-8')
-        )
-
     def test_launch_callback_endpoint(self):
         """
         Test the LTI 1.3 callback endpoint.
diff --git a/setup.py b/setup.py
index 658fb32..f82a42b 100644
--- a/setup.py
+++ b/setup.py
@@ -49,7 +49,7 @@ with open('README.rst') as _f:
 
 setup(
     name='lti-consumer-xblock',
-    version='2.5.0',
+    version='2.5.1',
     description='This XBlock implements the consumer side of the LTI specification.',
     long_description=long_description,
     long_description_content_type='text/x-rst',
diff --git a/test_settings.py b/test_settings.py
index 47f7852..0e12c81 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -11,4 +11,9 @@ USAGE_ID_PATTERN = r'(?P<usage_id>(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|
 ROOT_URLCONF = 'test_urls'
 
 # LMS Urls - for LTI 1.3 testing
-LMS_ROOT_URL = "https://example.com"
\ No newline at end of file
+LMS_ROOT_URL = "https://example.com"
+
+# Dummy FEATURES dict
+FEATURES = {
+    'LTI_1P3_ENABLED': False,
+}
-- 
GitLab