Skip to content

Commit

Permalink
feat: LTI 1.3 reusable configuration (#390)
Browse files Browse the repository at this point in the history
Co-authored-by: Squirrel18 <[email protected]>
Co-authored-by: alexjmpb <[email protected]>
  • Loading branch information
3 people committed Oct 24, 2023
1 parent 3d4221c commit f36725a
Show file tree
Hide file tree
Showing 15 changed files with 507 additions and 111 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Please See the `releases tab <https://github.com/openedx/xblock-lti-consumer/rel
Unreleased
~~~~~~~~~~

9.7.0 - 2023-10-23
------------------
* Added LTI 1.3 reusable configuration compatibility.

9.6.2 - 2023-08-22
------------------
* Fix extra claims and custom parameters for LTI 1.3.
Expand Down
25 changes: 25 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,31 @@ This XBlock supports `LTI 2.0 Result Service 2.0 <https://www.imsglobal.org/lti/
Please see the `LTI 2.0 Result Service 2.0 instructions <https://github.com/openedx/xblock-lti-consumer/tree/master/docs/result_service.rst>`_
for testing the LTI 2.0 Result Service 2.0 implementation.

LTI Reusable configuration
**************************

The LTI Consumer XBlock supports configuration reusability via plugins.
It is compatible with both LTI 1.1 and LTI 1.3.
All values (including the access token and keyset URL for LTI 1.3)
are shared across the XBlocks with the same external configuration ID.
This eliminates the need to have a tool deployment for each XBlock.

How to Setup
============

1. Install and setup the `openedx-ltistore`_ plugin on the LMS and Studio.
2. Go to LMS admin -> WAFFLE_UTILS -> Waffle flag course override
(http://localhost:18000/admin/waffle_utils/waffleflagcourseoverridemodel/).
3. Create a waffle flag course override with these values:
- Waffle flag: lti_consumer.enable_external_config_filter
- Course id: <your course id>
- Override choice: Force On
- Enabled: True
4. Create a new external LTI configuration and use it in the XBlock.
This is explained in the README of the `openedx-ltistore`_ repository.

.. _openedx-ltistore: https://github.com/open-craft/openedx-ltistore

Getting Help
************

Expand Down
2 changes: 1 addition & 1 deletion lti_consumer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
from .apps import LTIConsumerApp
from .lti_xblock import LtiConsumerXBlock

__version__ = '9.6.2'
__version__ = '9.7.0'
10 changes: 9 additions & 1 deletion lti_consumer/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,18 @@ def get_lti_1p3_launch_info(
deep_linking_content_items = [item.attributes for item in dl_content_items]

config_id = lti_config.config_id
client_id = lti_config.lti_1p3_client_id

# Display LTI launch information from external configuration.
# if an external configuration is being used.
if lti_config.config_store == lti_config.CONFIG_EXTERNAL:
external_config = get_external_config_from_filter({}, lti_config.external_id)
config_id = lti_config.external_id.replace(':', '/')
client_id = external_config.get('lti_1p3_client_id')

# Return LTI launch information for end user configuration
return {
'client_id': lti_config.lti_1p3_client_id,
'client_id': client_id,
'keyset_url': get_lms_lti_keyset_link(config_id),
'deployment_id': '1',
'oidc_callback': get_lms_lti_launch_link(),
Expand Down
7 changes: 7 additions & 0 deletions lti_consumer/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ class LtiError(Exception):
"""
General error class for LTI Consumer usage.
"""


class ExternalConfigurationNotFound(Exception):
"""
This exception is used when a reusable external configuration
is not found for a given external ID.
"""
22 changes: 14 additions & 8 deletions lti_consumer/lti_xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
external_config_filter_enabled,
external_user_id_1p1_launches_enabled,
database_config_enabled,
EXTERNAL_ID_REGEX,
)

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -691,6 +692,16 @@ def validate_field_data(self, validation, data):
_('Custom Parameters should be strings in "x=y" format.'),
)))

# Validate the external config ID.
if (
data.config_type == 'external' and not
(data.external_config and EXTERNAL_ID_REGEX.match(str(data.external_config)))
):
_ = self.runtime.service(self, 'i18n').ugettext
validation.add(ValidationMessage(ValidationMessage.ERROR, str(
_('Reusable configuration ID must be set when using external config (Example: "x:y").'),
)))

# keyset URL and public key are mutually exclusive
if data.lti_1p3_tool_key_mode == 'keyset_url':
data.lti_1p3_tool_public_key = ''
Expand Down Expand Up @@ -1664,10 +1675,7 @@ def _get_lti_block_launch_handler(self):
"""
Return the LTI block launch handler.
"""
# The "external" config_type is not supported for LTI 1.3, only LTI 1.1. Therefore, ensure that we define
# the lti_block_launch_handler using LTI 1.1 logic for "external" config_types.
# TODO: This needs to change when the LTI 1.3 support is added to the external config_type in the future.
if self.lti_version == 'lti_1p1' or self.config_type == "external":
if self.lti_version == 'lti_1p1':
lti_block_launch_handler = self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?')
else:
launch_data = self.get_lti_1p3_launch_data()
Expand All @@ -1687,10 +1695,8 @@ def _get_lti_1p3_launch_url(self, consumer):
"""
lti_1p3_launch_url = self.lti_1p3_launch_url.strip()

# The "external" config_type is not supported for LTI 1.3, only LTI 1.1. Therefore, ensure that we define
# the lti_1p3_launch_url using the LTI 1.3 consumer only for config_types that support LTI 1.3.
# TODO: This needs to change when the LTI 1.3 support is added to the external config_type in the future.
if consumer and self.lti_version == "lti_1p3" and self.config_type == "database":
# Get LTI launch URL from consumer if using database or external configuration type.
if consumer and self.lti_version == 'lti_1p3' and self.config_type in ('database', 'external'):
lti_1p3_launch_url = consumer.launch_url

return lti_1p3_launch_url
Expand Down
73 changes: 50 additions & 23 deletions lti_consumer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
from opaque_keys.edx.keys import CourseKey
from config_models.models import ConfigurationModel
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from lti_consumer.filters import get_external_config_from_filter

Expand All @@ -31,6 +32,7 @@
get_lti_nrps_context_membership_url,
choose_lti_1p3_redirect_uris,
model_to_dict,
EXTERNAL_ID_REGEX,
)

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -251,6 +253,12 @@ def clean(self):
raise ValidationError({
"config_store": _("LTI Configuration stores on XBlock needs a block location set."),
})
if self.config_store == self.CONFIG_EXTERNAL and not EXTERNAL_ID_REGEX.match(str(self.external_id)):
raise ValidationError({
"config_store": _(
'LTI Configuration using reusable configuration needs a external ID in "x:y" format.',
),
})
if self.version == self.LTI_1P3 and self.config_store == self.CONFIG_ON_DB:
if self.lti_1p3_tool_public_key == "" and self.lti_1p3_tool_keyset_url == "":
raise ValidationError({
Expand Down Expand Up @@ -280,7 +288,7 @@ def sync_configurations(self):
otherwise, it will try to query any children configuration and update their fields using
the current configuration values.
"""
EXCLUDED_FIELDS = ['id', 'config_id', 'location']
EXCLUDED_FIELDS = ['id', 'config_id', 'location', 'external_config']

if isinstance(self.location, CCXBlockUsageLocator):
# Query main configuration using main location.
Expand Down Expand Up @@ -364,6 +372,13 @@ def lti_1p3_public_jwk(self):
self._generate_lti_1p3_keys_if_missing()
return json.loads(self.lti_1p3_internal_public_jwk)

@cached_property
def external_config(self):
"""
Return the external resuable configuration.
"""
return get_external_config_from_filter({}, self.external_id)

def _get_lti_1p1_consumer(self):
"""
Return a class of LTI 1.1 consumer.
Expand All @@ -374,10 +389,9 @@ def _get_lti_1p1_consumer(self):
key, secret = block.lti_provider_key_secret
launch_url = block.launch_url
elif self.config_store == self.CONFIG_EXTERNAL:
config = get_external_config_from_filter({}, self.external_id)
key = config.get("lti_1p1_client_key")
secret = config.get("lti_1p1_client_secret")
launch_url = config.get("lti_1p1_launch_url")
key = self.external_config.get("lti_1p1_client_key")
secret = self.external_config.get("lti_1p1_client_secret")
launch_url = self.external_config.get("lti_1p1_launch_url")
else:
key = self.lti_1p1_client_key
secret = self.lti_1p1_client_secret
Expand All @@ -389,11 +403,10 @@ def get_lti_advantage_ags_mode(self):
"""
Return LTI 1.3 Advantage Assignment and Grade Services mode.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_ags_mode
elif self.config_store == self.CONFIG_EXTERNAL:
return self.external_config.get('lti_advantage_ags_mode')
else:
block = compat.load_enough_xblock(self.location)
return block.lti_advantage_ags_mode
Expand All @@ -402,11 +415,10 @@ def get_lti_advantage_deep_linking_enabled(self):
"""
Return whether LTI 1.3 Advantage Deep Linking is enabled.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_deep_linking_enabled
elif self.config_store == self.CONFIG_EXTERNAL:
return self.external_config.get('lti_advantage_deep_linking_enabled')
else:
block = compat.load_enough_xblock(self.location)
return block.lti_advantage_deep_linking_enabled
Expand All @@ -415,11 +427,10 @@ def get_lti_advantage_deep_linking_launch_url(self):
"""
Return the LTI 1.3 Advantage Deep Linking launch URL.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_deep_linking_launch_url
elif self.config_store == self.CONFIG_EXTERNAL:
return self.external_config.get('lti_advantage_deep_linking_launch_url')
else:
block = compat.load_enough_xblock(self.location)
return block.lti_advantage_deep_linking_launch_url
Expand All @@ -428,11 +439,10 @@ def get_lti_advantage_nrps_enabled(self):
"""
Return whether LTI 1.3 Advantage Names and Role Provisioning Services is enabled.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_enable_nrps
elif self.config_store == self.CONFIG_EXTERNAL:
return self.external_config.get('lti_advantage_enable_nrps')
else:
block = compat.load_enough_xblock(self.location)
return block.lti_1p3_enable_nrps
Expand All @@ -453,6 +463,7 @@ def _setup_lti_1p3_ags(self, consumer):
return

lineitem = self.ltiagslineitem_set.first()

# If using the declarative approach, we should create a LineItem if it
# doesn't exist. This is because on this mode the tool is not able to create
# and manage lineitems using the AGS endpoints.
Expand Down Expand Up @@ -572,9 +583,25 @@ def _get_lti_1p3_consumer(self):
tool_key=self.lti_1p3_tool_public_key,
tool_keyset_url=self.lti_1p3_tool_keyset_url,
)
elif self.config_store == self.CONFIG_EXTERNAL:
consumer = consumer_class(
iss=get_lti_api_base(),
lti_oidc_url=self.external_config.get('lti_1p3_oidc_url'),
lti_launch_url=self.external_config.get('lti_1p3_launch_url'),
client_id=self.external_config.get('lti_1p3_client_id'),
# Deployment ID hardcoded to 1 since
# we're not using multi-tenancy.
deployment_id='1',
rsa_key=self.external_config.get('lti_1p3_private_key'),
rsa_key_id=self.external_config.get('lti_1p3_private_key_id'),
# Registered redirect uris
redirect_uris=self.get_lti_1p3_redirect_uris(),
tool_key=self.external_config.get('lti_1p3_tool_public_key'),
tool_keyset_url=self.external_config.get('lti_1p3_tool_keyset_url'),
)
else:
# This should not occur, but raise an error if self.config_store is not CONFIG_ON_XBLOCK
# or CONFIG_ON_DB.
# This should not occur, but raise an error if self.config_store is not
# CONFIG_ON_XBLOCK, CONFIG_ON_DB or CONFIG_EXTERNAL.
raise NotImplementedError

if isinstance(consumer, LtiAdvantageConsumer):
Expand All @@ -598,10 +625,10 @@ def get_lti_1p3_redirect_uris(self):
Return pre-registered redirect uris or sensible defaults
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError

if self.config_store == self.CONFIG_ON_DB:
redirect_uris = self.external_config.get('lti_1p3_redirect_uris')
launch_url = self.external_config.get('lti_1p3_launch_url')
deep_link_launch_url = self.external_config.get('lti_advantage_deep_linking_launch_url')
elif self.config_store == self.CONFIG_ON_DB:
redirect_uris = self.lti_1p3_redirect_uris
launch_url = self.lti_1p3_launch_url
deep_link_launch_url = self.lti_advantage_deep_linking_launch_url
Expand Down
14 changes: 14 additions & 0 deletions lti_consumer/plugin/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
public_keyset_endpoint,
name='lti_consumer.public_keyset_endpoint_via_location'
),
# The external ID is split into slashes to make the URL more readable
# and avoid clashing with USAGE_ID_PATTERN.
path(
'lti_consumer/v1/public_keysets/<slug:external_app>/<slug:external_slug>',
public_keyset_endpoint,
name='lti_consumer.public_keyset_endpoint_via_external_id'
),
re_path(
'lti_consumer/v1/launch/(?:/(?P<suffix>.*))?$',
launch_gate_endpoint,
Expand All @@ -46,6 +53,13 @@
access_token_endpoint,
name='lti_consumer.access_token_via_location'
),
# The external ID is split into slashes to make the URL more readable
# and avoid clashing with USAGE_ID_PATTERN.
path(
'lti_consumer/v1/token/<slug:external_app>/<slug:external_slug>',
access_token_endpoint,
name='lti_consumer.access_token_via_external_id'
),
re_path(
r'lti_consumer/v1/lti/(?P<lti_config_id>[-\w]+)/lti-dl/response',
deep_linking_response_endpoint,
Expand Down
Loading

0 comments on commit f36725a

Please sign in to comment.