Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BB-5559] Decouple LTI 1.3 from LTI Consumer XBlock functionality #254

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
47539e7
refactor: move LTI 1.3 access token endpoint to plugin view
tecoholic May 27, 2022
d0bc890
refactor: remove the xblock handler and add tests to api view
tecoholic Jun 3, 2022
6ce56f4
refactor: move the lti_1p3_launch_callback logic to the django view
tecoholic Jun 10, 2022
8dbba87
fix: restore the tests for access token handler of xblock
tecoholic Jun 15, 2022
26b1a9d
refactor: make access token endpoint backwards compatible
tecoholic Jun 16, 2022
9389346
refactor: make LTI 1.3 launch callback backwards compatible
tecoholic Jun 17, 2022
a8cf540
feat: adds access token view for backward compatibility
tecoholic Jun 22, 2022
b43aadc
refactor: update token url displayed in studio to use config id
tecoholic Jun 22, 2022
c01e262
refactor: migrate the keyset url from usage_id to config_id
tecoholic Jun 22, 2022
df9aa0a
feat: override XBlock settings save handler to update LTIConfiguration
tecoholic Jun 22, 2022
6cd24cb
test: adds missing tests for lti 1.3 launch gate
tecoholic Jun 23, 2022
595243a
refactor: improve syncing of LTI 1.3 config from XBlock to DB
tecoholic Jul 6, 2022
29c856e
feat: use the pre_save singal to update the XBlock LTI config
tecoholic Jul 14, 2022
6675800
temp: commit to discuss how to obtain LTI 1.3 config values
tecoholic Jul 20, 2022
1c8486a
fix: all the broken tests after merging
tecoholic Jul 21, 2022
2a3d200
fix: handle invalid block condition in the pre_save signal
tecoholic Jul 21, 2022
f5e96e5
fix: skip saving config in db when source is external
tecoholic Jul 22, 2022
d70a110
refactor: make launch urls use config_id when block is missing
tecoholic Jul 22, 2022
bf52be4
chore: fix linting
tecoholic Jul 22, 2022
9e1ea25
fix: incompatibility between weobob and django request GET params
tecoholic Jul 23, 2022
93999bc
fix: merge the access token view of location and config_id
tecoholic Jul 23, 2022
de9cdec
chore: linting fixes
tecoholic Jul 23, 2022
78e4e7f
chore: move lti launch tracking from xblock to django view
tecoholic Jul 23, 2022
9c2e441
refactor: remove config syncing between xblock & models
tecoholic Aug 4, 2022
1684785
refactor: improve logging and fix quality issues
tecoholic Aug 4, 2022
c71a68a
refactor: remove launch_callback_handler from XBlock
tecoholic Aug 11, 2022
c89887d
chore: version bump
tecoholic Aug 16, 2022
5dda795
docs: adds changelog for version 4.4.0
tecoholic Aug 17, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ venv/
.python-version

.tox

# VS Code
.vscode
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ Changelog

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

4.4.0 - 2022-08-17
------------------
* Move the LTI 1.3 Access Token and Launch Callback endpoint logic from the XBlock to the Django views
* Adds support for accessing LTI 1.3 URLs using both location and the lti_config_id

4.2.2 - 2022-06-30
------------------
* Fix server 500 error when using names/roles and grades services, due to not returning a user during auth.
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__ = '4.3.3'
__version__ = '4.4.0'
6 changes: 4 additions & 2 deletions lti_consumer/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,15 @@ def get_lti_1p3_launch_info(config_id=None, block=None):
if dl_content_items.exists():
deep_linking_content_items = [item.attributes for item in dl_content_items]

link_location = lti_config.location if lti_config.location else lti_config.config_id

# 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),
'keyset_url': get_lms_lti_keyset_link(link_location),
'deployment_id': '1',
'oidc_callback': get_lms_lti_launch_link(),
'token_url': get_lms_lti_access_token_link(lti_config.location),
'token_url': get_lms_lti_access_token_link(link_location),
'deep_linking_launch_url': deep_linking_launch_url,
'deep_linking_content_items':
json.dumps(deep_linking_content_items, indent=4) if deep_linking_content_items else None,
Expand Down
184 changes: 8 additions & 176 deletions lti_consumer/lti_xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,6 @@
from .exceptions import LtiError
from .lti_1p1.consumer import LtiConsumer1p1, parse_result_json, LTI_PARAMETERS
from .lti_1p1.oauth import log_authorization_header
from .lti_1p3.exceptions import (
Lti1p3Exception,
UnsupportedGrantType,
MalformedJwtToken,
MissingRequiredClaim,
NoSuitableKeys,
TokenSignatureExpired,
UnknownClientId,
)
from .lti_1p3.constants import LTI_1P3_CONTEXT_TYPE
from .outcomes import OutcomeService
from .track import track_event
from .utils import _, resolve_custom_parameter_template, external_config_filter_enabled, database_config_enabled
Expand Down Expand Up @@ -1168,187 +1158,29 @@ def lti_launch_handler(self, request, suffix=''): # pylint: disable=unused-argu
template = loader.render_django_template('/templates/html/lti_launch.html', context)
return Response(template, content_type='text/html')

@XBlock.handler
def lti_1p3_launch_callback(self, request, suffix=''): # pylint: disable=unused-argument
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved
"""
XBlock handler for launching the LTI 1.3 tool.

This endpoint is only valid when a LTI 1.3 tool is being used.

Returns:
webob.response: HTML LTI launch form or error page if misconfigured
"""
if self.lti_version != "lti_1p3":
return Response(status=404)

loader = ResourceLoader(__name__)
context = {}

user_role = self.runtime.get_user_role()
lti_consumer = self._get_lti_consumer()

try:
# Pass user data
lti_consumer.set_user_data(
user_id=self.external_user_id,
# Pass django user role to library
role=user_role
)

# Set launch context
# Hardcoded for now, but we need to translate from
# self.launch_target to one of the LTI compliant names,
# either `iframe`, `frame` or `window`
# This is optional though
lti_consumer.set_launch_presentation_claim('iframe')

# Set context claim
# This is optional
context_title = " - ".join([
self.course.display_name_with_default,
self.course.display_org_with_default
])
lti_consumer.set_context_claim(
self.context_id,
context_types=[LTI_1P3_CONTEXT_TYPE.course_offering],
context_title=context_title,
context_label=self.context_id
)

# Retrieve preflight response
preflight_response = dict(request.GET)
lti_message_hint = preflight_response.get('lti_message_hint', '')

# Set LTI Launch URL
launch_url = self.lti_1p3_launch_url
if self.config_type == 'database':
launch_url = lti_consumer.launch_url
context.update({'launch_url': launch_url})

# Modify LTI Launch URL dependind on launch type
# Deep Linking Launch - Configuration flow launched by
# course creators to set up content.
lti_advantage_deep_linking_enabled = lti_consumer.lti_dl_enabled()
if lti_advantage_deep_linking_enabled and 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 not in ['instructor', 'staff']:
raise AssertionError('Deep Linking can only be performed by instructors and staff.')
# Set deep linking launch
context.update({'launch_url': lti_consumer.lti_dl.deep_linking_launch_url})

# Deep Linking ltiResourceLink content presentation
# When content type is `ltiResourceLink`, the tool will be launched with
# different parameters, set by instructors when running the DL configuration flow.
elif lti_advantage_deep_linking_enabled and 'deep_linking_content_launch' in lti_message_hint:
# Retrieve Deep Linking parameters using lti_message_hint parameter.
deep_linking_id = lti_message_hint.split(':')[1]
from lti_consumer.api import get_deep_linking_data # pylint: disable=import-outside-toplevel
dl_params = get_deep_linking_data(deep_linking_id, block=self)

# Modify LTI launch and set ltiResourceLink parameters
lti_consumer.set_dl_content_launch_parameters(
url=dl_params.get('url'),
custom=dl_params.get('custom')
)

# Update context with LTI launch parameters
context.update({
"preflight_response": preflight_response,
"launch_request": lti_consumer.generate_launch_request(
resource_link=str(self.location), # pylint: disable=no-member
preflight_response=preflight_response
)
})

# emit tracking event
event = {
'lti_version': self.lti_version,
'user_roles': user_role,
'launch_url': self.lti_1p3_launch_url,
}
track_event('xblock.launch_request', event)

template = loader.render_mako_template('/templates/html/lti_1p3_launch.html', context)
return Response(template, content_type='text/html')
except (Lti1p3Exception, LtiError, NotImplementedError, TypeError, ValueError) as exc:
log.warning(
"Error preparing LTI 1.3 launch for block %r: %s",
str(self.location), # pylint: disable=no-member
exc,
exc_info=True,
)
template = loader.render_django_template('/templates/html/lti_1p3_launch_error.html', context)
return Response(template, status=400, content_type='text/html')
except AssertionError as exc:
log.warning(
"Permission on LTI block %r denied for user %r: %s",
str(self.location), # pylint: disable=no-member
self.external_user_id,
exc,
exc_info=True
)
template = loader.render_django_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
"""
XBlock handler for creating access tokens for the LTI 1.3 tool.

This endpoint is only valid when a LTI 1.3 tool is being used.

Returns:
webob.response:
django.http.HttpResponse:
Either an access token or error message detailing the failure.
All responses are RFC 6749 compliant.

References:
Sucess: https://tools.ietf.org/html/rfc6749#section-4.4.3
Failure: https://tools.ietf.org/html/rfc6749#section-5.2
"""
if self.lti_version != "lti_1p3":
return Response(status=404)
if request.method != "POST":
return Response(status=405)

lti_consumer = self._get_lti_consumer()
try:
token = lti_consumer.access_token(
dict(urllib.parse.parse_qsl(
request.body.decode('utf-8'),
keep_blank_values=True
))
)
# The returned `token` is compliant with RFC 6749 so we just
# need to return a 200 OK response with the token as Json body
return Response(json_body=token, content_type="application/json")

# Handle errors and return a proper response
except MissingRequiredClaim:
# Missing request attibutes
return Response(
json_body={"error": "invalid_request"},
status=400
)
except (MalformedJwtToken, TokenSignatureExpired):
# Triggered when a invalid grant token is used
return Response(
json_body={"error": "invalid_grant"},
status=400,
)
except (NoSuitableKeys, UnknownClientId):
# Client ID is not registered in the block or
# isn't possible to validate token using available keys.
return Response(
json_body={"error": "invalid_client"},
status=400,
)
except UnsupportedGrantType:
return Response(
json_body={"error": "unsupported_grant_type"},
status=400,
)
# Asserting that the consumer can be created. This makes sure that the LtiConfiguration
# object exists before calling the Django View
assert self._get_lti_consumer()
# Runtime import because this can only be run in the LMS/Studio Django
# environments. Importing the views on the top level will cause RuntimeErorr
from lti_consumer.plugin.views import access_token_endpoint # pylint: disable=import-outside-toplevel
return access_token_endpoint(request, usage_id=str(self.location)) # pylint: disable=no-member

@XBlock.handler
def outcome_service_handler(self, request, suffix=''): # pylint: disable=unused-argument
Expand Down
63 changes: 39 additions & 24 deletions lti_consumer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import uuid
import json

from django.db import models
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
Expand Down Expand Up @@ -401,45 +402,60 @@ def _setup_lti_1p3_ags(self, consumer):
"""
Set up LTI 1.3 Advantage Assigment and Grades Services.
"""

try:
lti_advantage_ags_mode = self.get_lti_advantage_ags_mode()
except NotImplementedError as exc:
log.exception("Error setting up LTI 1.3 Advantage Assignment and Grade Services: %s", exc)
return

if lti_advantage_ags_mode != self.LTI_ADVANTAGE_AGS_DISABLED:
lineitem = None
# 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.
if lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_DECLARATIVE:
# Set grade attributes
if lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_DISABLED:
log.info('LTI Advantage AGS is disabled for %s', self)
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.
if not lineitem and lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_DECLARATIVE:
try:
block = self.block
except ValueError: # There is no location to load the block
block = None

if block:
default_values = {
'resource_id': self.block.location,
'resource_id': self.location,
'score_maximum': self.block.weight,
'label': self.block.display_name,
}

if hasattr(self.block, 'start'):
default_values['start_date_time'] = self.block.start

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

# create LineItem if there is none for current lti configuration
lineitem, _ = LtiAgsLineItem.objects.get_or_create(
lti_configuration=self,
resource_link_id=self.block.location,
defaults=default_values
)
# create LineItem if there is none for current lti configuration
lineitem = LtiAgsLineItem.objects.create(
lti_configuration=self,
resource_link_id=self.location,
**default_values
)

consumer.enable_ags(
lineitems_url=get_lti_ags_lineitems_url(self.id),
lineitem_url=get_lti_ags_lineitems_url(self.id, lineitem.id) if lineitem else None,
allow_programmatic_grade_interaction=(
lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_PROGRAMMATIC
)
consumer.enable_ags(
lineitems_url=get_lti_ags_lineitems_url(self.id),
lineitem_url=get_lti_ags_lineitems_url(self.id, lineitem.id) if lineitem else None,
allow_programmatic_grade_interaction=(
lti_advantage_ags_mode == self.LTI_ADVANTAGE_AGS_PROGRAMMATIC
)
)

def _setup_lti_1p3_deep_linking(self, consumer):
"""
Expand Down Expand Up @@ -471,7 +487,6 @@ def _get_lti_1p3_consumer(self):
Uses the `config_store` variable to determine where to
look for the configuration and instance the class.
"""
# If LTI configuration is stored in the XBlock.
if self.config_store == self.CONFIG_ON_XBLOCK:
consumer = LtiAdvantageConsumer(
iss=get_lms_base(),
Expand Down Expand Up @@ -505,13 +520,13 @@ def _get_lti_1p3_consumer(self):
tool_keyset_url=self.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
# or CONFIG_ON_DB.
raise NotImplementedError

self._setup_lti_1p3_ags(consumer)
self._setup_lti_1p3_deep_linking(consumer)
self._setup_lti_1p3_nrps(consumer)

return consumer

def get_lti_consumer(self):
Expand Down
Loading