Skip to content

Commit 9a69753

Browse files
authored
feat: only add acs scope when proctoring (#373)
* feat: only add acs scope when proctoring * feat: helper function to get token scopes * feat: improved scope helper functions * test: tests for new acs filtering * test: improved test coverage for proctoring scopes * chore: version bump * docs: changelog update * fix: removed duplicate test
1 parent 53e1391 commit 9a69753

5 files changed

Lines changed: 117 additions & 26 deletions

File tree

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ Unreleased
1717
~~~~~~~~~~
1818
* Improved logging for Proctoring LTI 1.3 launch failure.
1919

20+
9.5.1 - 2023-05-19
21+
------------------
22+
* Added gate to ensure the ACS scope is only added when using the LtiProctoringConsumer
23+
* Moved scope validation to a helper function
24+
2025
9.5.0 - 2023-05-08
2126
------------------
2227
* Return HTML message telling user that exam is ready to start on start assessment response to LTI proctoring launch.

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__ = '9.5.0'
7+
__version__ = '9.5.1'

lti_consumer/lti_1p3/constants.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,9 @@
6565

6666
# LTI-NRPS Scopes
6767
'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
68-
69-
# ACS Scope
70-
'https://purl.imsglobal.org/spec/lti-ap/scope/control.all',
7168
]
7269

70+
LTI_1P3_ACS_SCOPE = 'https://purl.imsglobal.org/spec/lti-ap/scope/control.all'
7371

7472
LTI_DEEP_LINKING_ACCEPTED_TYPES = [
7573
'ltiResourceLink',

lti_consumer/lti_1p3/consumer.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
LTI_BASE_MESSAGE,
1717
LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS,
1818
LTI_1P3_ACCESS_TOKEN_SCOPES,
19+
LTI_1P3_ACS_SCOPE,
1920
LTI_1P3_CONTEXT_TYPE,
2021
LTI_PROCTORING_DATA_KEYS,
2122
)
@@ -31,6 +32,7 @@ class LtiConsumer1p3:
3132
"""
3233
LTI 1.3 Consumer Implementation
3334
"""
35+
3436
def __init__(
3537
self,
3638
iss,
@@ -407,6 +409,19 @@ def get_public_keyset(self):
407409
"""
408410
return self.key_handler.get_public_jwk()
409411

412+
def _check_if_scope_is_valid(self, scope):
413+
"""
414+
Helper function to return the list of valid scopes present in the
415+
request to the access token endpoint.
416+
"""
417+
# Currently there are no scopes, because there is no use for
418+
# these access tokens until a tool needs to access the LMS.
419+
# LTI Advantage extensions make use of this.
420+
# TODO: Make this function should only return the NRPS and AGS scopes if those features are enabled.
421+
if scope in LTI_1P3_ACCESS_TOKEN_SCOPES:
422+
return True
423+
return False
424+
410425
def access_token(self, token_request_data):
411426
"""
412427
Validate request and return JWT access token.
@@ -454,13 +469,14 @@ def access_token(self, token_request_data):
454469
# Check scopes and only return valid and supported ones
455470
valid_scopes = []
456471
requested_scopes = token_request_data['scope'].split(' ')
457-
458472
for scope in requested_scopes:
459-
# Currently there are no scopes, because there is no use for
460-
# these access tokens until a tool needs to access the LMS.
461-
# LTI Advantage extensions make use of this.
462-
if scope in LTI_1P3_ACCESS_TOKEN_SCOPES:
473+
if self._check_if_scope_is_valid(scope):
463474
valid_scopes.append(scope)
475+
else:
476+
log.warning(
477+
f'Scope: {scope} found in the request is not a valid '
478+
f'LTI 1.3 access token or ACS scope'
479+
)
464480

465481
# Scopes are space separated as described in
466482
# https://tools.ietf.org/html/rfc6749
@@ -544,6 +560,7 @@ class LtiAdvantageConsumer(LtiConsumer1p3):
544560
Note: this is a partial implementation with read-only LineItems.
545561
Reference spec: https://www.imsglobal.org/spec/lti-ags/v2p0
546562
"""
563+
547564
def __init__(self, *args, **kwargs):
548565
"""
549566
Override parent class and set up required LTI Advantage variables.
@@ -756,6 +773,7 @@ class LtiProctoringConsumer(LtiConsumer1p3):
756773
resource_link, etc. This information is provided to the consumer through the set_proctoring_data method, which
757774
is called from the consuming context to pass in necessary data.
758775
"""
776+
759777
def __init__(
760778
self,
761779
iss,
@@ -877,6 +895,15 @@ def generate_launch_request(
877895

878896
return super().generate_launch_request(preflight_response)
879897

898+
def _check_if_scope_is_valid(self, scope):
899+
"""
900+
Override of Lti1p3Consumer's _check_if_scope_is_valid
901+
that accounts for the ACS scope for proctoring.
902+
"""
903+
if scope in (LTI_1P3_ACCESS_TOKEN_SCOPES, LTI_1P3_ACS_SCOPE):
904+
return True
905+
return False
906+
880907
def check_and_decode_token(self, token):
881908
"""
882909
Decodes a Tool JWT token and validates OAuth and LTI Proctoring Services specificatin related claims. Returns a

lti_consumer/lti_1p3/tests/test_consumer.py

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,26 @@
3939
RSA_KEY = RSA.generate(2048).export_key('PEM')
4040

4141

42+
def _generate_token_request_data(token, scope):
43+
"""
44+
Helper function to generate requests to the access_token endpoint
45+
"""
46+
return {
47+
# We don't actually care about these 2 first values
48+
"grant_type": "client_credentials",
49+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
50+
"client_assertion": token,
51+
"scope": scope,
52+
}
53+
54+
4255
# Test classes
4356
@ddt.ddt
4457
class TestLti1p3Consumer(TestCase):
4558
"""
4659
Unit tests for LtiConsumer1p3
4760
"""
61+
4862
def setUp(self):
4963
super().setUp()
5064

@@ -542,18 +556,31 @@ def test_access_token_invalid_jwt(self):
542556
"""
543557
Check if access token with invalid request data raises.
544558
"""
545-
request_data = {
546-
"grant_type": "client_credentials",
547-
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
548-
# This should be a valid JWT
549-
"client_assertion": "invalid-jwt",
550-
# Scope can be empty
551-
"scope": "",
552-
}
559+
request_data = _generate_token_request_data("invalid_jwt", "")
553560

554561
with self.assertRaises(exceptions.MalformedJwtToken):
555562
self.lti_consumer.access_token(request_data)
556563

564+
def test_access_token_no_acs(self):
565+
"""
566+
Check that ACS does not work for the access token in the
567+
default LTI 1.3 consumer
568+
"""
569+
# Generate a dummy, but valid JWT
570+
token = self.lti_consumer.key_handler.encode_and_sign(
571+
{
572+
"test": "test"
573+
},
574+
expiration=1000
575+
)
576+
577+
request_data = _generate_token_request_data(token, "https://purl.imsglobal.org/spec/lti-ap/scope/control.all")
578+
579+
response = self.lti_consumer.access_token(request_data)
580+
581+
# Check no ACS scope present in returned token
582+
self.assertEqual(response.get('scope'), '')
583+
557584
def test_access_token(self):
558585
"""
559586
Check if a valid access token is returned.
@@ -570,15 +597,7 @@ def test_access_token(self):
570597
expiration=1000
571598
)
572599

573-
request_data = {
574-
# We don't actually care about these 2 first values
575-
"grant_type": "client_credentials",
576-
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
577-
# This should be a valid JWT
578-
"client_assertion": token,
579-
# Scope can be empty
580-
"scope": "",
581-
}
600+
request_data = _generate_token_request_data(token, "")
582601

583602
response = self.lti_consumer.access_token(request_data)
584603

@@ -656,6 +675,7 @@ class TestLtiAdvantageConsumer(TestCase):
656675
"""
657676
Unit tests for LtiAdvantageConsumer
658677
"""
678+
659679
def setUp(self):
660680
super().setUp()
661681

@@ -899,6 +919,7 @@ class TestLtiProctoringConsumer(TestCase):
899919
"""
900920
Unit tests for LtiProctoringConsumer
901921
"""
922+
902923
def setUp(self):
903924
super().setUp()
904925

@@ -1166,6 +1187,46 @@ def test_invalid_check_and_decode_token(self, claim_key):
11661187
with self.assertRaises(MissingRequiredClaim):
11671188
self.lti_consumer.check_and_decode_token(encoded_token)
11681189

1190+
def test_access_token_no_valid_scopes(self):
1191+
"""
1192+
Ensure that the no scopes are returned in the access token if the request scopes are invalid
1193+
"""
1194+
# Generate a dummy, but valid JWT
1195+
token = self.lti_consumer.key_handler.encode_and_sign(
1196+
{
1197+
"test": "test"
1198+
},
1199+
expiration=1000
1200+
)
1201+
1202+
# This should be a valid JWT w/ the ACS scope
1203+
request_data = _generate_token_request_data(token, "invalid_scope")
1204+
1205+
response = self.lti_consumer.access_token(request_data)
1206+
1207+
# Check that the response has the ACS scope
1208+
self.assertEqual(response.get('scope'), "")
1209+
1210+
def test_access_token(self):
1211+
"""
1212+
Ensure that the ACS scope is added based on the request to the access token endpoint
1213+
"""
1214+
# Generate a dummy, but valid JWT
1215+
token = self.lti_consumer.key_handler.encode_and_sign(
1216+
{
1217+
"test": "test"
1218+
},
1219+
expiration=1000
1220+
)
1221+
1222+
# This should be a valid JWT w/ the ACS scope
1223+
request_data = _generate_token_request_data(token, "https://purl.imsglobal.org/spec/lti-ap/scope/control.all")
1224+
1225+
response = self.lti_consumer.access_token(request_data)
1226+
1227+
# Check that the response has the ACS scope
1228+
self.assertEqual(response.get('scope'), "https://purl.imsglobal.org/spec/lti-ap/scope/control.all")
1229+
11691230
def test_valid_check_and_decode_token(self):
11701231
"""
11711232
Ensures that a valid LtiStartAssessment JWT is validated successfully.

0 commit comments

Comments
 (0)