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