Skip to content

Commit 4588b7a

Browse files
Merge pull request #216 from open-craft/felipetrz/BB-5168-upstream-jwk-support
[BB-5168] Add support for JWK Keysets for LTI 1.3
2 parents d3f7d9b + 1375473 commit 4588b7a

9 files changed

Lines changed: 274 additions & 116 deletions

File tree

README.rst

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

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

370+
3.3.0 - 2022-01-20
371+
-------------------
372+
373+
* Added support for specifying LTI 1.3 JWK URLs.
374+
370375
3.2.0 - 2022-01-18
371376
-------------------
372377

lti_consumer/__init__.py

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

7-
__version__ = '3.2.0'
7+
__version__ = '3.3.0'

lti_consumer/lti_1p3/key_handlers.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,15 @@ def _get_keyset(self, kid=None):
6868
keyset = []
6969

7070
if self.keyset_url:
71-
# TODO: Improve support for keyset handling, handle errors.
72-
keyset.extend(load_jwks_from_url(self.keyset_url))
71+
try:
72+
keys = load_jwks_from_url(self.keyset_url)
73+
except Exception as err:
74+
# Broad Exception is required here because jwkest raises
75+
# an Exception object explicitly.
76+
# Beware that many different scenarios are being handled
77+
# as an invalid key when the JWK loading fails.
78+
raise exceptions.NoSuitableKeys() from err
79+
keyset.extend(keys)
7380

7481
if self.public_key and kid:
7582
# Fill in key id of stored key.

lti_consumer/lti_xblock.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,33 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
283283
"prior to doing the launch request."
284284
),
285285
)
286+
287+
lti_1p3_tool_key_mode = String(
288+
display_name=_("Tool Public Key Mode"),
289+
scope=Scope.settings,
290+
values=[
291+
{"display_name": "Public Key", "value": "public_key"},
292+
{"display_name": "Keyset URL", "value": "keyset_url"},
293+
],
294+
default="public_key",
295+
help=_(
296+
"Select how the tool's public key information will be specified."
297+
),
298+
)
299+
lti_1p3_tool_keyset_url = String(
300+
display_name=_("Tool Keyset URL"),
301+
default='',
302+
scope=Scope.settings,
303+
help=_(
304+
"Enter the LTI 1.3 Tool's JWK keysets URL."
305+
"<br />This link should retrieve a JSON file containing"
306+
" public keys and signature algorithm information, so"
307+
" that the LMS can check if the messages and launch"
308+
" requests received have the signature from the tool."
309+
"<br /><b>This is not required when doing LTI 1.3 Launches"
310+
" without LTI Advantage nor Basic Outcomes requests.</b>"
311+
),
312+
)
286313
lti_1p3_tool_public_key = String(
287314
display_name=_("Tool Public Key"),
288315
multiline_editor=True,
@@ -520,7 +547,9 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
520547
editable_field_names = (
521548
'display_name', 'description',
522549
# LTI 1.3 variables
523-
'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', 'lti_1p3_tool_public_key', 'lti_1p3_enable_nrps',
550+
'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url',
551+
'lti_1p3_tool_key_mode', 'lti_1p3_tool_keyset_url', 'lti_1p3_tool_public_key',
552+
'lti_1p3_enable_nrps',
524553
# LTI Advantage variables
525554
'lti_advantage_deep_linking_enabled', 'lti_advantage_deep_linking_launch_url',
526555
'lti_advantage_ags_mode',
@@ -574,6 +603,12 @@ def validate_field_data(self, validation, data):
574603
_("Custom Parameters must be a list")
575604
)))
576605

606+
# keyset URL and public key are mutually exclusive
607+
if data.lti_1p3_tool_key_mode == 'keyset_url':
608+
data.lti_1p3_tool_public_key = ''
609+
elif data.lti_1p3_tool_key_mode == 'public_key':
610+
data.lti_1p3_tool_keyset_url = ''
611+
577612
def get_settings(self):
578613
"""
579614
Get the XBlock settings bucket via the SettingsService.

lti_consumer/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ def _get_lti_1p3_consumer(self):
266266
rsa_key_id=self.lti_1p3_private_key_id,
267267
# LTI 1.3 Tool key/keyset url
268268
tool_key=self.block.lti_1p3_tool_public_key,
269-
tool_keyset_url=None,
269+
tool_keyset_url=self.block.lti_1p3_tool_keyset_url,
270270
)
271271

272272
# Check if enabled and setup LTI-AGS

lti_consumer/static/js/xblock_studio_view.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ function LtiConsumerXBlockInitStudio(runtime, element) {
1414
const lti1P3FieldList = [
1515
"lti_1p3_launch_url",
1616
"lti_1p3_oidc_url",
17+
"lti_1p3_tool_key_mode",
18+
"lti_1p3_tool_keyset_url",
1719
"lti_1p3_tool_public_key",
1820
"lti_advantage_ags_mode",
1921
"lti_advantage_deep_linking_enabled",
@@ -72,12 +74,37 @@ function LtiConsumerXBlockInitStudio(runtime, element) {
7274
});
7375
}
7476

77+
/**
78+
* Only display the field appropriate for the selected key mode.
79+
*/
80+
function toggleLtiToolKeyMode() {
81+
const ltiKeyModeField = $(element).find('#xb-field-edit-lti_1p3_tool_key_mode');
82+
83+
// find the field containers
84+
const ltiKeysetUrlField = $(element).find('[data-field-name=lti_1p3_tool_keyset_url]');
85+
const ltiPublicKeyField = $(element).find('[data-field-name=lti_1p3_tool_public_key]');
86+
87+
const selectedKeyMode = ltiKeyModeField.children("option:selected").val();
88+
if (selectedKeyMode === 'public_key') {
89+
ltiKeysetUrlField.hide();
90+
ltiPublicKeyField.show();
91+
} else if (selectedKeyMode === 'keyset_url') {
92+
ltiPublicKeyField.hide();
93+
ltiKeysetUrlField.show();
94+
}
95+
}
96+
7597
// Call once component is instanced to hide fields
7698
toggleLtiFields();
99+
toggleLtiToolKeyMode();
77100

78101
// Bind to onChange method of lti_version selector
79102
$(element).find('#xb-field-edit-lti_version').bind('change', function() {
80103
toggleLtiFields();
81-
});
104+
});
82105

106+
// Bind to onChange method of lti_1p3_tool_key_mode selector
107+
$(element).find('#xb-field-edit-lti_1p3_tool_key_mode').bind('change', function() {
108+
toggleLtiToolKeyMode();
109+
});
83110
}

lti_consumer/tests/unit/test_lti_xblock.py

Lines changed: 92 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import json
66
import logging
7-
import urllib.parse
87
from datetime import timedelta
98
from unittest.mock import Mock, NonCallableMock, PropertyMock, patch
109

@@ -13,14 +12,14 @@
1312
from django.conf import settings as dj_settings
1413
from django.test.testcases import TestCase
1514
from django.utils import timezone
16-
from jwkest.jwk import RSAKey
15+
from jwkest.jwk import RSAKey, KEYS
1716

1817
from lti_consumer.api import get_lti_1p3_launch_info
1918
from lti_consumer.exceptions import LtiError
2019
from lti_consumer.lti_1p3.tests.utils import create_jwt
2120
from lti_consumer.lti_xblock import LtiConsumerXBlock, parse_handler_suffix
2221
from lti_consumer.tests.unit import test_utils
23-
from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_request, make_xblock
22+
from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_jwt_request, make_request, make_xblock
2423
from lti_consumer.utils import resolve_custom_parameter_template
2524

2625
HTML_PROBLEM_PROGRESS = '<div class="problem-progress">'
@@ -452,6 +451,8 @@ def test_lti_1p3_fields_appear(self):
452451
'lti_version',
453452
'lti_1p3_launch_url',
454453
'lti_1p3_oidc_url',
454+
'lti_1p3_tool_key_mode',
455+
'lti_1p3_tool_keyset_url',
455456
'lti_1p3_tool_public_key',
456457
'lti_advantage_deep_linking_enabled',
457458
'lti_advantage_deep_linking_launch_url',
@@ -1484,17 +1485,7 @@ def test_access_token_malformed(self):
14841485
"""
14851486
Test request with invalid JWT.
14861487
"""
1487-
request = make_request(
1488-
urllib.parse.urlencode({
1489-
"grant_type": "client_credentials",
1490-
"client_assertion_type": "something",
1491-
"client_assertion": "invalid-jwt",
1492-
"scope": "",
1493-
}),
1494-
'POST'
1495-
)
1496-
request.content_type = 'application/x-www-form-urlencoded'
1497-
1488+
request = make_jwt_request("invalid-jwt")
14981489
response = self.xblock.lti_1p3_access_token(request)
14991490
self.assertEqual(response.status_code, 400)
15001491
self.assertEqual(response.json_body, {'error': 'invalid_grant'})
@@ -1503,15 +1494,7 @@ def test_access_token_invalid_grant(self):
15031494
"""
15041495
Test request with invalid grant.
15051496
"""
1506-
request = make_request(
1507-
urllib.parse.urlencode({
1508-
"grant_type": "password",
1509-
"client_assertion_type": "something",
1510-
"client_assertion": "invalit-jwt",
1511-
"scope": "",
1512-
}),
1513-
'POST'
1514-
)
1497+
request = make_jwt_request("invalid-jwt", grant_type="password")
15151498
request.content_type = 'application/x-www-form-urlencoded'
15161499

15171500
response = self.xblock.lti_1p3_access_token(request)
@@ -1526,17 +1509,7 @@ def test_access_token_invalid_client(self):
15261509
self.xblock.save()
15271510

15281511
jwt = create_jwt(self.key, {})
1529-
request = make_request(
1530-
urllib.parse.urlencode({
1531-
"grant_type": "client_credentials",
1532-
"client_assertion_type": "something",
1533-
"client_assertion": jwt,
1534-
"scope": "",
1535-
}),
1536-
'POST'
1537-
)
1538-
request.content_type = 'application/x-www-form-urlencoded'
1539-
1512+
request = make_jwt_request(jwt)
15401513
response = self.xblock.lti_1p3_access_token(request)
15411514
self.assertEqual(response.status_code, 400)
15421515
self.assertEqual(response.json_body, {'error': 'invalid_client'})
@@ -1546,17 +1519,7 @@ def test_access_token(self):
15461519
Test request with valid JWT.
15471520
"""
15481521
jwt = create_jwt(self.key, {})
1549-
request = make_request(
1550-
urllib.parse.urlencode({
1551-
"grant_type": "client_credentials",
1552-
"client_assertion_type": "something",
1553-
"client_assertion": jwt,
1554-
"scope": "",
1555-
}),
1556-
'POST'
1557-
)
1558-
request.content_type = 'application/x-www-form-urlencoded'
1559-
1522+
request = make_jwt_request(jwt)
15601523
response = self.xblock.lti_1p3_access_token(request)
15611524
self.assertEqual(response.status_code, 200)
15621525

@@ -1633,3 +1596,87 @@ def test_lti_custom_param_templates_not_configured(self, mock_import_module, moc
16331596
self.assertEqual(resolved_value, custom_parameter_template_value)
16341597
assert mock_log.error.called
16351598
mock_import_module.asser_not_called()
1599+
1600+
1601+
class TestLti1p3AccessTokenJWK(TestCase):
1602+
"""
1603+
Unit tests for LtiConsumerXBlock Access Token endpoint when using a
1604+
LTI 1.3 setup with JWK authentication.
1605+
"""
1606+
def setUp(self):
1607+
super().setUp()
1608+
self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, {
1609+
'lti_version': 'lti_1p3',
1610+
'lti_1p3_launch_url': 'http://tool.example/launch',
1611+
'lti_1p3_oidc_url': 'http://tool.example/oidc',
1612+
'lti_1p3_tool_keyset_url': "http://tool.example/keyset",
1613+
})
1614+
self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
1615+
self.xblock.save()
1616+
1617+
self.key = RSAKey(key=RSA.generate(2048), kid="1")
1618+
1619+
jwt = create_jwt(self.key, {})
1620+
self.request = make_jwt_request(jwt)
1621+
1622+
def make_keyset(self, keys):
1623+
"""
1624+
Builds a keyset object with the given keys.
1625+
"""
1626+
jwks = KEYS()
1627+
jwks._keys = keys # pylint: disable=protected-access
1628+
return jwks
1629+
1630+
@patch("lti_consumer.lti_1p3.key_handlers.load_jwks_from_url")
1631+
def test_access_token_using_keyset_url(self, load_jwks_from_url):
1632+
"""
1633+
Test request using the provider's keyset URL instead of a public key.
1634+
"""
1635+
load_jwks_from_url.return_value = self.make_keyset([self.key])
1636+
response = self.xblock.lti_1p3_access_token(self.request)
1637+
load_jwks_from_url.assert_called_once_with("http://tool.example/keyset")
1638+
self.assertEqual(response.status_code, 200)
1639+
1640+
@patch("lti_consumer.lti_1p3.key_handlers.load_jwks_from_url")
1641+
def test_access_token_using_keyset_url_with_empty_keys(self, load_jwks_from_url):
1642+
"""
1643+
Test request where the provider's keyset URL returns an empty list of keys.
1644+
"""
1645+
load_jwks_from_url.return_value = self.make_keyset([])
1646+
response = self.xblock.lti_1p3_access_token(self.request)
1647+
self.assertEqual(response.status_code, 400)
1648+
self.assertEqual(response.json_body, {"error": "invalid_client"})
1649+
1650+
@patch("lti_consumer.lti_1p3.key_handlers.load_jwks_from_url")
1651+
def test_access_token_using_keyset_url_with_wrong_keys(self, load_jwks_from_url):
1652+
"""
1653+
Test request where the provider's keyset URL returns wrong keys.
1654+
"""
1655+
key = RSAKey(key=RSA.generate(2048), kid="2")
1656+
load_jwks_from_url.return_value = self.make_keyset([key])
1657+
response = self.xblock.lti_1p3_access_token(self.request)
1658+
self.assertEqual(response.status_code, 400)
1659+
self.assertEqual(response.json_body, {"error": "invalid_client"})
1660+
1661+
@patch("jwkest.jwk.request")
1662+
def test_access_token_using_keyset_url_that_fails(self, request):
1663+
"""
1664+
Test request where the provider's keyset URL request fails.
1665+
"""
1666+
request.side_effect = Exception("request fails")
1667+
response = self.xblock.lti_1p3_access_token(self.request)
1668+
self.assertEqual(response.status_code, 400)
1669+
self.assertEqual(response.json_body, {'error': 'invalid_client'})
1670+
1671+
@patch("jwkest.jwk.request")
1672+
def test_access_token_using_keyset_url_with_invalid_contents(self, request):
1673+
"""
1674+
Test request where the provider's keyset URL doesn't return valid JSON.
1675+
"""
1676+
response_mock = Mock()
1677+
response_mock.status_code = 200
1678+
response_mock.text = b'this is not a valid json'
1679+
request.return_value = response_mock
1680+
response = self.xblock.lti_1p3_access_token(self.request)
1681+
self.assertEqual(response.status_code, 400)
1682+
self.assertEqual(response.json_body, {'error': 'invalid_client'})

lti_consumer/tests/unit/test_utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
from unittest.mock import Mock
6+
import urllib
67
from webob import Request
78
from workbench.runtime import WorkbenchRuntime
89
from xblock.fields import ScopeIds
@@ -53,6 +54,21 @@ def make_request(body, method='POST'):
5354
return request
5455

5556

57+
def make_jwt_request(token, **overrides):
58+
"""
59+
Builds a Request with a JWT body.
60+
"""
61+
body = {
62+
"grant_type": "client_credentials",
63+
"client_assertion_type": "something",
64+
"client_assertion": token,
65+
"scope": "",
66+
}
67+
request = make_request(urllib.parse.urlencode({**body, **overrides}), 'POST')
68+
request.content_type = 'application/x-www-form-urlencoded'
69+
return request
70+
71+
5672
def dummy_processor(_xblock):
5773
"""
5874
A dummy LTI parameter processor.

0 commit comments

Comments
 (0)