Skip to content

Commit 05c7d26

Browse files
authored
Merge pull request #36077 from openedx/rijuma/remove-edx-token-utils-dep
chore: Remove edx-token-utils dependency
2 parents 2a07080 + dd86710 commit 05c7d26

File tree

12 files changed

+271
-31
lines changed

12 files changed

+271
-31
lines changed

.github/workflows/check_python_dependencies.yml

+5-6
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@ jobs:
1414
steps:
1515
- name: Checkout Repository
1616
uses: actions/checkout@v4
17-
17+
1818
- name: Set up Python
1919
uses: actions/setup-python@v5
2020
with:
2121
python-version: ${{ matrix.python-version }}
22-
22+
2323
- name: Install repo-tools
2424
run: pip install edx-repo-tools[find_dependencies]
2525

2626
- name: Install setuptool
27-
run: pip install setuptools
28-
27+
run: pip install setuptools
28+
2929
- name: Run Python script
3030
run: |
3131
find_python_dependencies \
@@ -35,6 +35,5 @@ jobs:
3535
--ignore https://github.com/edx/braze-client \
3636
--ignore https://github.com/edx/edx-name-affirmation \
3737
--ignore https://github.com/mitodl/edx-sga \
38-
--ignore https://github.com/edx/token-utils \
3938
--ignore https://github.com/open-craft/xblock-poll
40-
39+

lms/djangoapps/courseware/tests/test_views.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -2933,9 +2933,9 @@ def test_render_xblock_with_course_duration_limits_in_mobile_browser(self, mock_
29332933
)
29342934
@ddt.unpack
29352935
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True})
2936-
@patch('lms.djangoapps.courseware.views.views.unpack_token_for')
2936+
@patch('lms.djangoapps.courseware.views.views.unpack_jwt')
29372937
def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token,
2938-
expected_response, _mock_token_unpack):
2938+
expected_response, _mock_unpack_jwt):
29392939
"""
29402940
Verify blocks inside an exam that requires token access are gated by
29412941
a valid exam access JWT issued for that exam sequence.
@@ -2968,15 +2968,15 @@ def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token
29682968
CourseOverview.load_from_module_store(self.course.id)
29692969
self.setup_user(admin=False, enroll=True, login=True)
29702970

2971-
def _mock_token_unpack_fn(token, user_id):
2971+
def _mock_unpack_jwt_fn(token, user_id):
29722972
if token == 'valid-jwt-for-exam-sequence':
29732973
return {'content_id': str(self.sequence.location)}
29742974
elif token == 'valid-jwt-for-incorrect-sequence':
29752975
return {'content_id': str(self.other_sequence.location)}
29762976
else:
29772977
raise Exception('invalid JWT')
29782978

2979-
_mock_token_unpack.side_effect = _mock_token_unpack_fn
2979+
_mock_unpack_jwt.side_effect = _mock_unpack_jwt_fn
29802980

29812981
# Problem and Vertical response should be gated on access token
29822982
for block in [self.problem_block, self.vertical_block]:

lms/djangoapps/courseware/views/views.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
from rest_framework.decorators import api_view, throttle_classes
4747
from rest_framework.response import Response
4848
from rest_framework.throttling import UserRateThrottle
49-
from token_utils.api import unpack_token_for
5049
from web_fragments.fragment import Fragment
5150
from xmodule.course_block import (
5251
COURSE_VISIBILITY_PUBLIC,
@@ -138,6 +137,7 @@
138137
from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
139138
from openedx.core.djangolib.markup import HTML, Text
140139
from openedx.core.lib.courses import get_course_by_id
140+
from openedx.core.lib.jwt import unpack_jwt
141141
from openedx.core.lib.mobile_utils import is_request_from_mobile_app
142142
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
143143
from openedx.features.course_experience import course_home_url
@@ -1535,7 +1535,7 @@ def _check_sequence_exam_access(request, location):
15351535
try:
15361536
# unpack will validate both expiration and the requesting user matches the
15371537
# token user
1538-
exam_access_unpacked = unpack_token_for(exam_access_token, request.user.id)
1538+
exam_access_unpacked = unpack_jwt(exam_access_token, request.user.id)
15391539
except: # pylint: disable=bare-except
15401540
log.exception(f"Failed to validate exam access token. user_id={request.user.id} location={location}")
15411541
return False

lms/envs/common.py

+8
Original file line numberDiff line numberDiff line change
@@ -4311,13 +4311,21 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
43114311
# Exam Service
43124312
EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1'
43134313

4314+
############## Settings for JWT token handling ##############
43144315
TOKEN_SIGNING = {
43154316
'JWT_ISSUER': 'http://127.0.0.1:8740',
43164317
'JWT_SIGNING_ALGORITHM': 'RS512',
43174318
'JWT_SUPPORTED_VERSION': '1.2.0',
4319+
'JWT_PRIVATE_SIGNING_JWK': None,
43184320
'JWT_PUBLIC_SIGNING_JWK_SET': None,
43194321
}
43204322

4323+
# NOTE: In order to create both JWT_PRIVATE_SIGNING_JWK and JWT_PUBLIC_SIGNING_JWK_SET,
4324+
# in an lms shell run the following command:
4325+
# > python manage.py lms generate_jwt_signing_key
4326+
# This will output asymmetric JWTs to use here. Read more on this on:
4327+
# https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst
4328+
43214329
COURSE_CATALOG_URL_ROOT = 'http://localhost:8008'
43224330
COURSE_CATALOG_API_URL = f'{COURSE_CATALOG_URL_ROOT}/api/v1'
43234331

lms/envs/test.py

+32
Original file line numberDiff line numberDiff line change
@@ -657,3 +657,35 @@
657657
# case of new django version these values will override.
658658
if django.VERSION[0] >= 4: # for greater than django 3.2 use with schemes.
659659
CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS_WITH_SCHEME
660+
661+
662+
############## Settings for JWT token handling ##############
663+
TOKEN_SIGNING = {
664+
'JWT_ISSUER': 'token-test-issuer',
665+
'JWT_SIGNING_ALGORITHM': 'RS512',
666+
'JWT_SUPPORTED_VERSION': '1.2.0',
667+
'JWT_PRIVATE_SIGNING_JWK': '''{
668+
"e": "AQAB",
669+
"d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ",
670+
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ",
671+
"q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE",
672+
"p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0",
673+
"kid": "token-test-sign", "kty": "RSA"
674+
}''',
675+
'JWT_PUBLIC_SIGNING_JWK_SET': '''{
676+
"keys": [
677+
{
678+
"kid":"token-test-wrong-key",
679+
"e": "AQAB",
680+
"kty": "RSA",
681+
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dffgRQLD1qf5D6sprmYfWVokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"
682+
},
683+
{
684+
"kid":"token-test-sign",
685+
"e": "AQAB",
686+
"kty": "RSA",
687+
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"
688+
}
689+
]
690+
}''',
691+
}

openedx/core/lib/jwt.py

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""
2+
JWT Token handling and signing functions.
3+
"""
4+
5+
import json
6+
from time import time
7+
8+
from django.conf import settings
9+
from jwkest import Expired, Invalid, MissingKey, jwk
10+
from jwkest.jws import JWS
11+
12+
13+
def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None):
14+
"""
15+
Produce an encoded JWT (string) indicating some temporary permission for the indicated user.
16+
17+
What permission that is must be encoded in additional_claims.
18+
Arguments:
19+
lms_user_id (int): LMS user ID this token is being generated for
20+
expires_in_seconds (int): Time to token expiry, specified in seconds.
21+
additional_token_claims (dict): Additional claims to include in the token.
22+
now(int): optional now value for testing
23+
"""
24+
now = now or int(time())
25+
26+
payload = {
27+
'lms_user_id': lms_user_id,
28+
'exp': now + expires_in_seconds,
29+
'iat': now,
30+
'iss': settings.TOKEN_SIGNING['JWT_ISSUER'],
31+
'version': settings.TOKEN_SIGNING['JWT_SUPPORTED_VERSION'],
32+
}
33+
payload.update(additional_token_claims)
34+
return _encode_and_sign(payload)
35+
36+
37+
def _encode_and_sign(payload):
38+
"""
39+
Encode and sign the provided payload.
40+
41+
The signing key and algorithm are pulled from settings.
42+
"""
43+
keys = jwk.KEYS()
44+
45+
serialized_keypair = json.loads(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK'])
46+
keys.add(serialized_keypair)
47+
algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM']
48+
49+
data = json.dumps(payload)
50+
jws = JWS(data, alg=algorithm)
51+
return jws.sign_compact(keys=keys)
52+
53+
54+
def unpack_jwt(token, lms_user_id, now=None):
55+
"""
56+
Unpack and verify an encoded JWT.
57+
58+
Validate the user and expiration.
59+
60+
Arguments:
61+
token (string): The token to be unpacked and verified.
62+
lms_user_id (int): LMS user ID this token should match with.
63+
now (int): Optional now value for testing.
64+
65+
Returns a valid, decoded json payload (string).
66+
"""
67+
now = now or int(time())
68+
payload = _unpack_and_verify(token)
69+
70+
if "lms_user_id" not in payload:
71+
raise MissingKey("LMS user id is missing")
72+
if "exp" not in payload:
73+
raise MissingKey("Expiration is missing")
74+
if payload["lms_user_id"] != lms_user_id:
75+
raise Invalid("User does not match")
76+
if payload["exp"] < now:
77+
raise Expired("Token is expired")
78+
79+
return payload
80+
81+
82+
def _unpack_and_verify(token):
83+
"""
84+
Unpack and verify the provided token.
85+
86+
The signing key and algorithm are pulled from settings.
87+
"""
88+
keys = jwk.KEYS()
89+
keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET'])
90+
decoded = JWS().verify_compact(token.encode('utf-8'), keys)
91+
return decoded

openedx/core/lib/tests/test_jwt.py

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Tests for token handling
3+
"""
4+
import unittest
5+
6+
from django.conf import settings
7+
from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk
8+
from jwkest.jws import JWS
9+
10+
from openedx.core.djangolib.testing.utils import skip_unless_lms
11+
from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt
12+
13+
14+
test_user_id = 121
15+
invalid_test_user_id = 120
16+
test_timeout = 60
17+
test_now = 1661432902
18+
test_claims = {"foo": "bar", "baz": "quux", "meaning": 42}
19+
expected_full_token = {
20+
"lms_user_id": test_user_id,
21+
"iat": 1661432902,
22+
"exp": 1661432902 + 60,
23+
"iss": "token-test-issuer", # these lines from test_settings.py
24+
"version": "1.2.0", # these lines from test_settings.py
25+
}
26+
27+
28+
@skip_unless_lms
29+
class TestSign(unittest.TestCase):
30+
"""
31+
Tests for JWT creation and signing.
32+
"""
33+
34+
def test_create_jwt(self):
35+
token = create_jwt(test_user_id, test_timeout, {}, test_now)
36+
37+
decoded = _verify_jwt(token)
38+
self.assertEqual(expected_full_token, decoded)
39+
40+
def test_create_jwt_with_claims(self):
41+
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)
42+
43+
expected_token_with_claims = expected_full_token.copy()
44+
expected_token_with_claims.update(test_claims)
45+
46+
decoded = _verify_jwt(token)
47+
self.assertEqual(expected_token_with_claims, decoded)
48+
49+
def test_malformed_token(self):
50+
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)
51+
token = token + "a"
52+
53+
expected_token_with_claims = expected_full_token.copy()
54+
expected_token_with_claims.update(test_claims)
55+
56+
with self.assertRaises(BadSignature):
57+
_verify_jwt(token)
58+
59+
60+
def _verify_jwt(jwt_token):
61+
"""
62+
Helper function which verifies the signature and decodes the token
63+
from string back to claims form
64+
"""
65+
keys = jwk.KEYS()
66+
keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET'])
67+
decoded = JWS().verify_compact(jwt_token.encode('utf-8'), keys)
68+
return decoded
69+
70+
71+
@skip_unless_lms
72+
class TestUnpack(unittest.TestCase):
73+
"""
74+
Tests for JWT unpacking.
75+
"""
76+
77+
def test_unpack_jwt(self):
78+
token = create_jwt(test_user_id, test_timeout, {}, test_now)
79+
decoded = unpack_jwt(token, test_user_id, test_now)
80+
81+
self.assertEqual(expected_full_token, decoded)
82+
83+
def test_unpack_jwt_with_claims(self):
84+
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)
85+
86+
expected_token_with_claims = expected_full_token.copy()
87+
expected_token_with_claims.update(test_claims)
88+
89+
decoded = unpack_jwt(token, test_user_id, test_now)
90+
91+
self.assertEqual(expected_token_with_claims, decoded)
92+
93+
def test_malformed_token(self):
94+
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)
95+
token = token + "a"
96+
97+
expected_token_with_claims = expected_full_token.copy()
98+
expected_token_with_claims.update(test_claims)
99+
100+
with self.assertRaises(BadSignature):
101+
unpack_jwt(token, test_user_id, test_now)
102+
103+
def test_unpack_token_with_invalid_user(self):
104+
token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now)
105+
106+
with self.assertRaises(Invalid):
107+
unpack_jwt(token, test_user_id, test_now)
108+
109+
def test_unpack_expired_token(self):
110+
token = create_jwt(test_user_id, test_timeout, {}, test_now)
111+
112+
with self.assertRaises(Expired):
113+
unpack_jwt(token, test_user_id, test_now + test_timeout + 1)
114+
115+
def test_missing_expired_lms_user_id(self):
116+
payload = expected_full_token.copy()
117+
del payload['lms_user_id']
118+
token = _encode_and_sign(payload)
119+
120+
with self.assertRaises(MissingKey):
121+
unpack_jwt(token, test_user_id, test_now)
122+
123+
def test_missing_expired_key(self):
124+
payload = expected_full_token.copy()
125+
del payload['exp']
126+
token = _encode_and_sign(payload)
127+
128+
with self.assertRaises(MissingKey):
129+
unpack_jwt(token, test_user_id, test_now)

requirements/edx/base.txt

-4
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ django==4.2.18
221221
# edx-search
222222
# edx-submissions
223223
# edx-toggles
224-
# edx-token-utils
225224
# edx-when
226225
# edxval
227226
# enmerkar
@@ -538,8 +537,6 @@ edx-toggles==5.2.0
538537
# edxval
539538
# event-tracking
540539
# ora2
541-
edx-token-utils==0.2.1
542-
# via -r requirements/edx/kernel.in
543540
edx-when==2.5.1
544541
# via
545542
# -r requirements/edx/kernel.in
@@ -931,7 +928,6 @@ pygments==2.19.1
931928
pyjwkest==1.4.2
932929
# via
933930
# -r requirements/edx/kernel.in
934-
# edx-token-utils
935931
# lti-consumer-xblock
936932
pyjwt[crypto]==2.10.1
937933
# via

0 commit comments

Comments
 (0)