Skip to content

Commit ec43c30

Browse files
authored
feat: Decouple LTI 1.3 from LTI Consumer XBlock functionality
Move XBlock endpoints to Django models and implement backwards compatible views. Relevant commits: * refactor: move LTI 1.3 access token endpoint to plugin view * refactor: remove the xblock handler and add tests to api view * refactor: move the lti_1p3_launch_callback logic to the django view * feat: adds access token view for backward compatibility * refactor: make launch urls use config_id when block is missing * refactor: remove launch_callback_handler from XBlock
1 parent 06c08bd commit ec43c30

15 files changed

Lines changed: 815 additions & 598 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ venv/
2424
.python-version
2525

2626
.tox
27+
28+
# VS Code
29+
.vscode

README.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,11 @@ Changelog
369369

370370
Please See the [releases tab](https://github.com/edx/xblock-lti-consumer/releases) for the complete changelog.
371371

372+
4.4.0 - 2022-08-17
373+
------------------
374+
* Move the LTI 1.3 Access Token and Launch Callback endpoint logic from the XBlock to the Django views
375+
* Adds support for accessing LTI 1.3 URLs using both location and the lti_config_id
376+
372377
4.2.2 - 2022-06-30
373378
------------------
374379
* Fix server 500 error when using names/roles and grades services, due to not returning a user during auth.

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__ = '4.3.3'
7+
__version__ = '4.4.0'

lti_consumer/api.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,15 @@ def get_lti_1p3_launch_info(config_id=None, block=None):
129129
if dl_content_items.exists():
130130
deep_linking_content_items = [item.attributes for item in dl_content_items]
131131

132+
link_location = lti_config.location if lti_config.location else lti_config.config_id
133+
132134
# Return LTI launch information for end user configuration
133135
return {
134136
'client_id': lti_config.lti_1p3_client_id,
135-
'keyset_url': get_lms_lti_keyset_link(lti_config.location),
137+
'keyset_url': get_lms_lti_keyset_link(link_location),
136138
'deployment_id': '1',
137139
'oidc_callback': get_lms_lti_launch_link(),
138-
'token_url': get_lms_lti_access_token_link(lti_config.location),
140+
'token_url': get_lms_lti_access_token_link(link_location),
139141
'deep_linking_launch_url': deep_linking_launch_url,
140142
'deep_linking_content_items':
141143
json.dumps(deep_linking_content_items, indent=4) if deep_linking_content_items else None,

lti_consumer/lti_xblock.py

Lines changed: 8 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,6 @@
7171
from .exceptions import LtiError
7272
from .lti_1p1.consumer import LtiConsumer1p1, parse_result_json, LTI_PARAMETERS
7373
from .lti_1p1.oauth import log_authorization_header
74-
from .lti_1p3.exceptions import (
75-
Lti1p3Exception,
76-
UnsupportedGrantType,
77-
MalformedJwtToken,
78-
MissingRequiredClaim,
79-
NoSuitableKeys,
80-
TokenSignatureExpired,
81-
UnknownClientId,
82-
)
83-
from .lti_1p3.constants import LTI_1P3_CONTEXT_TYPE
8474
from .outcomes import OutcomeService
8575
from .track import track_event
8676
from .utils import _, resolve_custom_parameter_template, external_config_filter_enabled, database_config_enabled
@@ -1168,187 +1158,29 @@ def lti_launch_handler(self, request, suffix=''): # pylint: disable=unused-argu
11681158
template = loader.render_django_template('/templates/html/lti_launch.html', context)
11691159
return Response(template, content_type='text/html')
11701160

1171-
@XBlock.handler
1172-
def lti_1p3_launch_callback(self, request, suffix=''): # pylint: disable=unused-argument
1173-
"""
1174-
XBlock handler for launching the LTI 1.3 tool.
1175-
1176-
This endpoint is only valid when a LTI 1.3 tool is being used.
1177-
1178-
Returns:
1179-
webob.response: HTML LTI launch form or error page if misconfigured
1180-
"""
1181-
if self.lti_version != "lti_1p3":
1182-
return Response(status=404)
1183-
1184-
loader = ResourceLoader(__name__)
1185-
context = {}
1186-
1187-
user_role = self.runtime.get_user_role()
1188-
lti_consumer = self._get_lti_consumer()
1189-
1190-
try:
1191-
# Pass user data
1192-
lti_consumer.set_user_data(
1193-
user_id=self.external_user_id,
1194-
# Pass django user role to library
1195-
role=user_role
1196-
)
1197-
1198-
# Set launch context
1199-
# Hardcoded for now, but we need to translate from
1200-
# self.launch_target to one of the LTI compliant names,
1201-
# either `iframe`, `frame` or `window`
1202-
# This is optional though
1203-
lti_consumer.set_launch_presentation_claim('iframe')
1204-
1205-
# Set context claim
1206-
# This is optional
1207-
context_title = " - ".join([
1208-
self.course.display_name_with_default,
1209-
self.course.display_org_with_default
1210-
])
1211-
lti_consumer.set_context_claim(
1212-
self.context_id,
1213-
context_types=[LTI_1P3_CONTEXT_TYPE.course_offering],
1214-
context_title=context_title,
1215-
context_label=self.context_id
1216-
)
1217-
1218-
# Retrieve preflight response
1219-
preflight_response = dict(request.GET)
1220-
lti_message_hint = preflight_response.get('lti_message_hint', '')
1221-
1222-
# Set LTI Launch URL
1223-
launch_url = self.lti_1p3_launch_url
1224-
if self.config_type == 'database':
1225-
launch_url = lti_consumer.launch_url
1226-
context.update({'launch_url': launch_url})
1227-
1228-
# Modify LTI Launch URL dependind on launch type
1229-
# Deep Linking Launch - Configuration flow launched by
1230-
# course creators to set up content.
1231-
lti_advantage_deep_linking_enabled = lti_consumer.lti_dl_enabled()
1232-
if lti_advantage_deep_linking_enabled and lti_message_hint == 'deep_linking_launch':
1233-
# Check if the user is staff before LTI doing deep linking launch.
1234-
# If not, raise exception and display error page
1235-
if user_role not in ['instructor', 'staff']:
1236-
raise AssertionError('Deep Linking can only be performed by instructors and staff.')
1237-
# Set deep linking launch
1238-
context.update({'launch_url': lti_consumer.lti_dl.deep_linking_launch_url})
1239-
1240-
# Deep Linking ltiResourceLink content presentation
1241-
# When content type is `ltiResourceLink`, the tool will be launched with
1242-
# different parameters, set by instructors when running the DL configuration flow.
1243-
elif lti_advantage_deep_linking_enabled and 'deep_linking_content_launch' in lti_message_hint:
1244-
# Retrieve Deep Linking parameters using lti_message_hint parameter.
1245-
deep_linking_id = lti_message_hint.split(':')[1]
1246-
from lti_consumer.api import get_deep_linking_data # pylint: disable=import-outside-toplevel
1247-
dl_params = get_deep_linking_data(deep_linking_id, block=self)
1248-
1249-
# Modify LTI launch and set ltiResourceLink parameters
1250-
lti_consumer.set_dl_content_launch_parameters(
1251-
url=dl_params.get('url'),
1252-
custom=dl_params.get('custom')
1253-
)
1254-
1255-
# Update context with LTI launch parameters
1256-
context.update({
1257-
"preflight_response": preflight_response,
1258-
"launch_request": lti_consumer.generate_launch_request(
1259-
resource_link=str(self.location), # pylint: disable=no-member
1260-
preflight_response=preflight_response
1261-
)
1262-
})
1263-
1264-
# emit tracking event
1265-
event = {
1266-
'lti_version': self.lti_version,
1267-
'user_roles': user_role,
1268-
'launch_url': self.lti_1p3_launch_url,
1269-
}
1270-
track_event('xblock.launch_request', event)
1271-
1272-
template = loader.render_mako_template('/templates/html/lti_1p3_launch.html', context)
1273-
return Response(template, content_type='text/html')
1274-
except (Lti1p3Exception, LtiError, NotImplementedError, TypeError, ValueError) as exc:
1275-
log.warning(
1276-
"Error preparing LTI 1.3 launch for block %r: %s",
1277-
str(self.location), # pylint: disable=no-member
1278-
exc,
1279-
exc_info=True,
1280-
)
1281-
template = loader.render_django_template('/templates/html/lti_1p3_launch_error.html', context)
1282-
return Response(template, status=400, content_type='text/html')
1283-
except AssertionError as exc:
1284-
log.warning(
1285-
"Permission on LTI block %r denied for user %r: %s",
1286-
str(self.location), # pylint: disable=no-member
1287-
self.external_user_id,
1288-
exc,
1289-
exc_info=True
1290-
)
1291-
template = loader.render_django_template('/templates/html/lti_1p3_permission_error.html', context)
1292-
return Response(template, status=403, content_type='text/html')
1293-
12941161
@XBlock.handler
12951162
def lti_1p3_access_token(self, request, suffix=''): # pylint: disable=unused-argument
12961163
"""
12971164
XBlock handler for creating access tokens for the LTI 1.3 tool.
1298-
12991165
This endpoint is only valid when a LTI 1.3 tool is being used.
1300-
13011166
Returns:
1302-
webob.response:
1167+
django.http.HttpResponse:
13031168
Either an access token or error message detailing the failure.
13041169
All responses are RFC 6749 compliant.
1305-
13061170
References:
13071171
Sucess: https://tools.ietf.org/html/rfc6749#section-4.4.3
13081172
Failure: https://tools.ietf.org/html/rfc6749#section-5.2
13091173
"""
13101174
if self.lti_version != "lti_1p3":
13111175
return Response(status=404)
1312-
if request.method != "POST":
1313-
return Response(status=405)
13141176

1315-
lti_consumer = self._get_lti_consumer()
1316-
try:
1317-
token = lti_consumer.access_token(
1318-
dict(urllib.parse.parse_qsl(
1319-
request.body.decode('utf-8'),
1320-
keep_blank_values=True
1321-
))
1322-
)
1323-
# The returned `token` is compliant with RFC 6749 so we just
1324-
# need to return a 200 OK response with the token as Json body
1325-
return Response(json_body=token, content_type="application/json")
1326-
1327-
# Handle errors and return a proper response
1328-
except MissingRequiredClaim:
1329-
# Missing request attibutes
1330-
return Response(
1331-
json_body={"error": "invalid_request"},
1332-
status=400
1333-
)
1334-
except (MalformedJwtToken, TokenSignatureExpired):
1335-
# Triggered when a invalid grant token is used
1336-
return Response(
1337-
json_body={"error": "invalid_grant"},
1338-
status=400,
1339-
)
1340-
except (NoSuitableKeys, UnknownClientId):
1341-
# Client ID is not registered in the block or
1342-
# isn't possible to validate token using available keys.
1343-
return Response(
1344-
json_body={"error": "invalid_client"},
1345-
status=400,
1346-
)
1347-
except UnsupportedGrantType:
1348-
return Response(
1349-
json_body={"error": "unsupported_grant_type"},
1350-
status=400,
1351-
)
1177+
# Asserting that the consumer can be created. This makes sure that the LtiConfiguration
1178+
# object exists before calling the Django View
1179+
assert self._get_lti_consumer()
1180+
# Runtime import because this can only be run in the LMS/Studio Django
1181+
# environments. Importing the views on the top level will cause RuntimeErorr
1182+
from lti_consumer.plugin.views import access_token_endpoint # pylint: disable=import-outside-toplevel
1183+
return access_token_endpoint(request, usage_id=str(self.location)) # pylint: disable=no-member
13521184

13531185
@XBlock.handler
13541186
def outcome_service_handler(self, request, suffix=''): # pylint: disable=unused-argument

lti_consumer/models.py

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import uuid
66
import json
7+
78
from django.db import models
89
from django.core.validators import MinValueValidator
910
from django.core.exceptions import ValidationError
@@ -401,45 +402,60 @@ def _setup_lti_1p3_ags(self, consumer):
401402
"""
402403
Set up LTI 1.3 Advantage Assigment and Grades Services.
403404
"""
405+
404406
try:
405407
lti_advantage_ags_mode = self.get_lti_advantage_ags_mode()
406408
except NotImplementedError as exc:
407409
log.exception("Error setting up LTI 1.3 Advantage Assignment and Grade Services: %s", exc)
408410
return
409411

410-
if lti_advantage_ags_mode != self.LTI_ADVANTAGE_AGS_DISABLED:
411-
lineitem = None
412-
# If using the declarative approach, we should create a LineItem if it
413-
# doesn't exist. This is because on this mode the tool is not able to create
414-
# and manage lineitems using the AGS endpoints.
415-
if lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_DECLARATIVE:
416-
# Set grade attributes
412+
if lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_DISABLED:
413+
log.info('LTI Advantage AGS is disabled for %s', self)
414+
return
415+
416+
lineitem = self.ltiagslineitem_set.first()
417+
# If using the declarative approach, we should create a LineItem if it
418+
# doesn't exist. This is because on this mode the tool is not able to create
419+
# and manage lineitems using the AGS endpoints.
420+
if not lineitem and lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_DECLARATIVE:
421+
try:
422+
block = self.block
423+
except ValueError: # There is no location to load the block
424+
block = None
425+
426+
if block:
417427
default_values = {
418-
'resource_id': self.block.location,
428+
'resource_id': self.location,
419429
'score_maximum': self.block.weight,
420430
'label': self.block.display_name,
421431
}
422-
423432
if hasattr(self.block, 'start'):
424433
default_values['start_date_time'] = self.block.start
425434

426435
if hasattr(self.block, 'due'):
427436
default_values['end_date_time'] = self.block.due
437+
else:
438+
# TODO find a way to make these defaults more sensible
439+
default_values = {
440+
'resource_id': self.location,
441+
'score_maximum': 100,
442+
'label': 'LTI Consumer at ' + str(self.location)
443+
}
428444

429-
# create LineItem if there is none for current lti configuration
430-
lineitem, _ = LtiAgsLineItem.objects.get_or_create(
431-
lti_configuration=self,
432-
resource_link_id=self.block.location,
433-
defaults=default_values
434-
)
445+
# create LineItem if there is none for current lti configuration
446+
lineitem = LtiAgsLineItem.objects.create(
447+
lti_configuration=self,
448+
resource_link_id=self.location,
449+
**default_values
450+
)
435451

436-
consumer.enable_ags(
437-
lineitems_url=get_lti_ags_lineitems_url(self.id),
438-
lineitem_url=get_lti_ags_lineitems_url(self.id, lineitem.id) if lineitem else None,
439-
allow_programmatic_grade_interaction=(
440-
lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_PROGRAMMATIC
441-
)
452+
consumer.enable_ags(
453+
lineitems_url=get_lti_ags_lineitems_url(self.id),
454+
lineitem_url=get_lti_ags_lineitems_url(self.id, lineitem.id) if lineitem else None,
455+
allow_programmatic_grade_interaction=(
456+
lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_PROGRAMMATIC
442457
)
458+
)
443459

444460
def _setup_lti_1p3_deep_linking(self, consumer):
445461
"""
@@ -471,7 +487,6 @@ def _get_lti_1p3_consumer(self):
471487
Uses the `config_store` variable to determine where to
472488
look for the configuration and instance the class.
473489
"""
474-
# If LTI configuration is stored in the XBlock.
475490
if self.config_store == self.CONFIG_ON_XBLOCK:
476491
consumer = LtiAdvantageConsumer(
477492
iss=get_lms_base(),
@@ -505,13 +520,13 @@ def _get_lti_1p3_consumer(self):
505520
tool_keyset_url=self.lti_1p3_tool_keyset_url,
506521
)
507522
else:
508-
# This should not occur, but raise an error if self.config_store is not CONFIG_ON_XBLOCK or CONFIG_ON_DB.
523+
# This should not occur, but raise an error if self.config_store is not CONFIG_ON_XBLOCK
524+
# or CONFIG_ON_DB.
509525
raise NotImplementedError
510526

511527
self._setup_lti_1p3_ags(consumer)
512528
self._setup_lti_1p3_deep_linking(consumer)
513529
self._setup_lti_1p3_nrps(consumer)
514-
515530
return consumer
516531

517532
def get_lti_consumer(self):

0 commit comments

Comments
 (0)