Skip to content

Commit 0b254d9

Browse files
authored
fix: Multiple bug fixes (#643)
* fix: ags result endpoint to work with or without ending slash Fixes: #628 * fix: allow blank comment in scores api endpoint Fixes: #637 * fix: use correct deep linking launch uri as target_link_uri * fix: lint issues * fix: use correct key-secret pair for lti 1.1 * fix: tests * chore: bump version and update changelog
1 parent 11d7df5 commit 0b254d9

13 files changed

Lines changed: 205 additions & 48 deletions

File tree

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ Please See the `releases tab <https://github.com/openedx/xblock-lti-consumer/rel
1616
Unreleased
1717
~~~~~~~~~~
1818

19+
11.0.1 - 2026-04-23
20+
--------------------
21+
* Fix LTI 1.3 deep linking `target_link_uri` handling in both preflight and launch token generation.
22+
* Fix AGS results endpoint/serializer URL generation for optional `user_id`, including trailing-slash compatibility.
23+
* Allow AGS score `comment` to be blank and improve related API test coverage.
24+
* Use `get_lti_consumer()` OAuth credentials for LTI 1.1 signature/logging paths and align LTI 1.1 errors with shared `LtiError`.
25+
* Minor internal cleanup: public `get_lti_consumer()` rename, launch URL typing/casting, and fallback to block `lti_version` when config version is missing.
26+
1927
11.0.0 - 2026-04-20
2028
--------------------
2129
* Split LTI 1.3 Configuration into Passport Model

lti_consumer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
from .apps import LTIConsumerApp
55
from .lti_xblock import LtiConsumerXBlock
66

7-
__version__ = '11.0.0'
7+
__version__ = '11.0.1'

lti_consumer/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ def _get_or_create_local_lti_config(lti_version, block, config_store=LtiConfigur
107107
updates = {
108108
'config_store': config_store,
109109
'external_id': block.external_config,
110-
'version': lti_version,
110+
# fallback on block lti_version if lti_version is None
111+
'version': lti_version or block.lti_version,
111112
}
112113
if passport:
113114
updates['lti_1p3_passport'] = passport

lti_consumer/lti_1p1/exceptions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""
22
Exceptions for the LTI1.1 Consumer.
33
"""
4+
from lti_consumer.exceptions import LtiError
45

56

6-
class Lti1p1Error(Exception):
7+
class Lti1p1Error(LtiError):
78
"""
89
General error class for LTI1.1 Consumer usage.
910
"""

lti_consumer/lti_1p3/consumer.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ def __init__(
7777
# Extra claims - used by LTI Advantage
7878
self.extra_claims = {}
7979

80+
def _get_target_link_uri(self, launch_data): # pylint: disable=unused-argument
81+
"""
82+
Return the target_link_uri to use for the provided launch data.
83+
84+
Subclasses can override this to customize the target link selection for
85+
different message types.
86+
"""
87+
return self.launch_url
88+
8089
@staticmethod
8190
def _get_user_roles(role):
8291
"""
@@ -125,12 +134,14 @@ def prepare_preflight_url(
125134

126135
oidc_url = self.oidc_url + "?"
127136

137+
target_link_uri = self._get_target_link_uri(launch_data)
138+
128139
login_hint = user_id
129140
parameters = {
130141
"iss": self.iss,
131142
"client_id": self.client_id,
132143
"lti_deployment_id": self.deployment_id,
133-
"target_link_uri": self.launch_url,
144+
"target_link_uri": target_link_uri,
134145
"login_hint": login_hint,
135146
"lti_message_hint": launch_data_key,
136147
}
@@ -302,6 +313,7 @@ def set_custom_parameters(
302313
def get_lti_launch_message(
303314
self,
304315
include_extra_claims=True,
316+
target_link_uri=None,
305317
):
306318
"""
307319
Build LTI message from class parameters
@@ -312,6 +324,8 @@ def get_lti_launch_message(
312324
# Start from base message
313325
lti_message = LTI_BASE_MESSAGE.copy()
314326

327+
target_link_uri = target_link_uri or self.launch_url
328+
315329
# Add base parameters
316330
lti_message.update({
317331
# Issuer
@@ -329,7 +343,7 @@ def get_lti_launch_message(
329343
# Target Link URI: actual endpoint for the LTI resource to display
330344
# MUST be the same value as the target_link_uri passed by the platform in the OIDC login request
331345
# http://www.imsglobal.org/spec/lti/v1p3/#target-link-uri
332-
"https://purl.imsglobal.org/spec/lti/claim/target_link_uri": self.launch_url,
346+
"https://purl.imsglobal.org/spec/lti/claim/target_link_uri": target_link_uri,
333347
})
334348

335349
# Check if user data is set, then append it to lti message
@@ -609,6 +623,19 @@ def lti_dl_enabled(self):
609623
else:
610624
return False
611625

626+
def _get_target_link_uri(self, launch_data):
627+
"""
628+
Use the deep linking launch URL for deep linking requests.
629+
"""
630+
if (
631+
getattr(self, 'dl', None) and
632+
launch_data and
633+
getattr(launch_data, 'message_type', None) == 'LtiDeepLinkingRequest'
634+
):
635+
return self.dl.deep_linking_launch_url
636+
637+
return super()._get_target_link_uri(launch_data)
638+
612639
def enable_ags(
613640
self,
614641
lineitems_url,
@@ -663,12 +690,14 @@ def generate_launch_request(
663690

664691
# Check if Deep Linking is enabled and that this is a Deep Link Launch
665692
if self.dl and launch_data.message_type == "LtiDeepLinkingRequest":
693+
target_link_uri = self._get_target_link_uri(launch_data)
666694
# Validate preflight response
667695
self._validate_preflight_response(preflight_response)
668696

669697
# Get LTI Launch Message
670698
lti_launch_message = self.get_lti_launch_message(
671699
include_extra_claims=False,
700+
target_link_uri=target_link_uri,
672701
)
673702

674703
# Update message type to LtiDeepLinkingRequest,

lti_consumer/lti_1p3/extensions/rest_framework/serializers.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ class LtiAgsScoreSerializer(serializers.ModelSerializer):
125125
timestamp = serializers.DateTimeField(input_formats=[ISO_8601], format=ISO_8601, default_timezone=timezone.utc)
126126
scoreGiven = serializers.FloatField(source='score_given', required=False, allow_null=True, default=None)
127127
scoreMaximum = serializers.FloatField(source='score_maximum', required=False, allow_null=True, default=None)
128-
comment = serializers.CharField(required=False, allow_null=True)
128+
comment = serializers.CharField(required=False, allow_null=True, allow_blank=True)
129129
activityProgress = serializers.CharField(source='activity_progress')
130130
gradingProgress = serializers.CharField(source='grading_progress')
131131
userId = serializers.CharField(source='user_id')
@@ -193,14 +193,19 @@ class LtiAgsResultSerializer(serializers.ModelSerializer):
193193
comment = serializers.CharField()
194194

195195
def get_id(self, obj):
196+
"""
197+
Return result URL for score. Include user_id when score scoped to learner.
198+
"""
196199
request = self.context.get('request')
200+
kwargs = {
201+
'lti_config_id': obj.line_item.lti_configuration.id,
202+
'pk': obj.line_item.pk,
203+
}
204+
if obj.user_id:
205+
kwargs['user_id'] = obj.user_id
197206
return reverse(
198207
'lti_consumer:lti-ags-view-results',
199-
kwargs={
200-
'lti_config_id': obj.line_item.lti_configuration.id,
201-
'pk': obj.line_item.pk,
202-
'user_id': obj.user_id,
203-
},
208+
kwargs=kwargs,
204209
request=request,
205210
)
206211

lti_consumer/lti_1p3/tests/test_consumer.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
ISS = "http://test-platform.example/"
2929
OIDC_URL = "http://test-platform/oidc"
3030
LAUNCH_URL = "http://test-platform/launch"
31-
REDIRECT_URIS = [LAUNCH_URL]
31+
DEEP_LINK_LAUNCH_URL = "http://test-platform/deep_link_launch"
32+
REDIRECT_URIS = [LAUNCH_URL, DEEP_LINK_LAUNCH_URL]
3233
CLIENT_ID = "1"
3334
DEPLOYMENT_ID = "1"
3435
NONCE = "1234"
@@ -739,14 +740,14 @@ def _setup_deep_linking(self):
739740
"""
740741
Set's up deep linking class in LTI consumer.
741742
"""
742-
self.lti_consumer.enable_deep_linking("launch-url", "return-url")
743+
self.lti_consumer.enable_deep_linking(DEEP_LINK_LAUNCH_URL, "return-url")
743744

744745
lti_message_hint = self._setup_lti_message_hint()
745746

746747
# Set LTI Consumer parameters
747748
self.preflight_response = {
748749
"client_id": CLIENT_ID,
749-
"redirect_uri": LAUNCH_URL,
750+
"redirect_uri": DEEP_LINK_LAUNCH_URL,
750751
"nonce": NONCE,
751752
"state": STATE,
752753
"lti_message_hint": lti_message_hint,
@@ -819,6 +820,40 @@ def test_deep_linking_enabled_launch_request(self):
819820
"return-url"
820821
)
821822

823+
def test_deep_linking_preflight_uses_deep_link_launch_url(self):
824+
"""
825+
Ensure deep linking launches send the deep linking launch URL as target_link_uri during login initiation.
826+
"""
827+
self.lti_consumer.enable_deep_linking(DEEP_LINK_LAUNCH_URL, "return-url")
828+
launch_data = Lti1p3LaunchData(
829+
user_id="1",
830+
user_role="student",
831+
config_id="1",
832+
resource_link_id="resource_link_id",
833+
message_type="LtiDeepLinkingRequest",
834+
)
835+
836+
preflight_request_data = self.lti_consumer.prepare_preflight_url(launch_data)
837+
parameters = parse_qs(urlparse(preflight_request_data).query)
838+
839+
self.assertEqual(parameters['target_link_uri'][0], DEEP_LINK_LAUNCH_URL)
840+
841+
def test_deep_linking_launch_request_sets_target_link_uri(self):
842+
"""
843+
Ensure the ID token for deep linking launches uses the deep linking launch URL as target_link_uri.
844+
"""
845+
self._setup_deep_linking()
846+
847+
token = self.lti_consumer.generate_launch_request(
848+
self.preflight_response,
849+
)['id_token']
850+
851+
decoded_token = self.lti_consumer.key_handler.validate_and_decode(token)
852+
self.assertEqual(
853+
decoded_token['https://purl.imsglobal.org/spec/lti/claim/target_link_uri'],
854+
DEEP_LINK_LAUNCH_URL,
855+
)
856+
822857
def test_deep_linking_token_decode_no_dl(self):
823858
"""
824859
Check that trying to run the Deep Linking decoding fails if service is not set up.

lti_consumer/lti_xblock.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,7 +1109,7 @@ def is_past_due(self):
11091109
close_date = due_date
11101110
return close_date is not None and timezone.now() > close_date
11111111

1112-
def _get_lti_consumer(self):
1112+
def get_lti_consumer(self):
11131113
"""
11141114
Returns a preconfigured LTI consumer depending on the value.
11151115
@@ -1282,7 +1282,7 @@ def lti_launch_handler(self, request, suffix=''): # pylint: disable=unused-argu
12821282
Returns:
12831283
webob.response: HTML LTI launch form
12841284
"""
1285-
lti_consumer = self._get_lti_consumer()
1285+
lti_consumer = self.get_lti_consumer()
12861286

12871287
# Occassionally, users try to do an LTI launch while they are unauthenticated. It is not known why this occurs.
12881288
# Sometimes, it is due to a web crawlers; other times, it is due to actual users of the platform. Regardless,
@@ -1385,7 +1385,7 @@ def lti_1p3_access_token(self, request, suffix=''): # pylint: disable=unused-ar
13851385

13861386
# Asserting that the consumer can be created. This makes sure that the LtiConfiguration
13871387
# object exists before calling the Django View
1388-
assert self._get_lti_consumer()
1388+
assert self.get_lti_consumer()
13891389
# Runtime import because this can only be run in the LMS/Studio Django
13901390
# environments. Importing the views on the top level will cause RuntimeErorr
13911391
from lti_consumer.plugin.views import access_token_endpoint # pylint: disable=import-outside-toplevel
@@ -1441,12 +1441,11 @@ def result_service_handler(self, request, suffix=''):
14411441
Returns:
14421442
webob.response: response to this request. See above for details.
14431443
"""
1444-
lti_consumer = self._get_lti_consumer()
1444+
lti_consumer = self.get_lti_consumer()
14451445
lti_consumer.set_outcome_service_url(self.outcome_service_url)
14461446

14471447
if settings.DEBUG:
1448-
lti_provider_key, lti_provider_secret = self.lti_provider_key_secret
1449-
log_authorization_header(request, lti_provider_key, lti_provider_secret)
1448+
log_authorization_header(request, lti_consumer.oauth_key, lti_consumer.oauth_secret)
14501449

14511450
if not self.accept_grades_past_due and self.is_past_due:
14521451
return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body
@@ -1601,11 +1600,11 @@ def set_user_module_score(self, user, score, max_score, comment=''):
16011600
self.module_score = scaled_score
16021601
self.score_comment = comment
16031602

1604-
def _get_lti_launch_url(self, consumer):
1603+
def _get_lti_launch_url(self, consumer) -> str:
16051604
"""
16061605
Return the LTI launch URL.
16071606
"""
1608-
launch_url = self.launch_url
1607+
launch_url = str(self.launch_url)
16091608

16101609
# The lti_launch_url property only exists on the LtiConsumer1p1. The LtiConsumer1p3 does not have an
16111610
# attribute with this name, so ensure that we're accessing it on the appropriate consumer class.
@@ -1758,7 +1757,7 @@ def _get_context_for_template(self):
17581757
# Don't pull from the Django database unless the config_type is one that stores the LTI configuration in the
17591758
# database.
17601759
if self.config_type in ("database", "external"):
1761-
lti_consumer = self._get_lti_consumer()
1760+
lti_consumer = self.get_lti_consumer()
17621761

17631762
launch_url = self._get_lti_launch_url(lti_consumer)
17641763
lti_block_launch_handler = self._get_lti_block_launch_handler()

lti_consumer/outcomes.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
"""
77

88
import logging
9-
from xml.sax.saxutils import escape
109
from urllib.parse import unquote
10+
from xml.sax.saxutils import escape
1111

12+
from django.conf import settings
1213
from lxml import etree
14+
1315
try:
1416
from xblock.utils.resources import ResourceLoader
1517
except ModuleNotFoundError: # For backward compatibility with releases older than Quince.
@@ -176,7 +178,13 @@ def handle_request(self, request):
176178
return response_xml_template.format(**failure_values)
177179

178180
# Verify OAuth signing.
179-
__, secret = self.xblock.lti_provider_key_secret
181+
secret = self.xblock.get_lti_consumer().oauth_secret
182+
if settings.DEBUG:
183+
log.debug(
184+
"[LTI]: verifying OAuth body signature for outcomes. service_url=%s request.url=%s",
185+
self.xblock.outcome_service_url,
186+
request.url,
187+
)
180188
try:
181189
verify_oauth_body_signature(request, secret, self.xblock.outcome_service_url)
182190
except (ValueError, LtiError) as ex:

lti_consumer/plugin/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,7 @@ def perform_create(self, serializer):
748748
@action(
749749
detail=True,
750750
methods=['GET'],
751-
url_path='results/(?P<user_id>[^/.]+)?',
751+
url_path=r'results(?:/(?P<user_id>[^/.]+))?/?',
752752
renderer_classes=[LineItemResultsRenderer],
753753
content_negotiation_class=IgnoreContentNegotiation,
754754
)

0 commit comments

Comments
 (0)