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

chore: Remove edx-token-utils dependency #36077

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 5 additions & 6 deletions .github/workflows/check_python_dependencies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install repo-tools
run: pip install edx-repo-tools[find_dependencies]

- name: Install setuptool
run: pip install setuptools
run: pip install setuptools

- name: Run Python script
run: |
find_python_dependencies \
Expand All @@ -35,6 +35,5 @@ jobs:
--ignore https://github.com/edx/braze-client \
--ignore https://github.com/edx/edx-name-affirmation \
--ignore https://github.com/mitodl/edx-sga \
--ignore https://github.com/edx/token-utils \
--ignore https://github.com/open-craft/xblock-poll

91 changes: 91 additions & 0 deletions lms/djangoapps/courseware/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
JWT Token handling and signing functions.
"""

import json
from time import time

from django.conf import settings
from jwkest import Expired, Invalid, MissingKey, jwk
from jwkest.jws import JWS


def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None):
"""
Produce an encoded JWT (string) indicating some temporary permission for the indicated user.

What permission that is must be encoded in additional_claims.
Arguments:
lms_user_id (int): LMS user ID this token is being generated for
expires_in_seconds (int): Time to token expiry, specified in seconds.
additional_token_claims (dict): Additional claims to include in the token.
now(int): optional now value for testing
"""
now = now or int(time())

payload = {
'lms_user_id': lms_user_id,
'exp': now + expires_in_seconds,
'iat': now,
'iss': settings.TOKEN_SIGNING['JWT_ISSUER'],
'version': settings.TOKEN_SIGNING['JWT_SUPPORTED_VERSION'],
}
payload.update(additional_token_claims)
return _encode_and_sign(payload)


def _encode_and_sign(payload):
"""
Encode and sign the provided payload.

The signing key and algorithm are pulled from settings.
"""
keys = jwk.KEYS()

serialized_keypair = json.loads(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK'])
keys.add(serialized_keypair)
algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM']

data = json.dumps(payload)
jws = JWS(data, alg=algorithm)
return jws.sign_compact(keys=keys)


def unpack_jwt(token, lms_user_id, now=None):
"""
Unpack and verify an encoded JWT.

Validate the user and expiration.

Arguments:
token (string): The token to be unpacked and verified.
lms_user_id (int): LMS user ID this token should match with.
now (int): Optional now value for testing.

Returns a valid, decoded json payload (string).
"""
now = now or int(time())
payload = _unpack_and_verify(token)

if "lms_user_id" not in payload:
raise MissingKey("LMS user id is missing")
if "exp" not in payload:
raise MissingKey("Expiration is missing")
if payload["lms_user_id"] != lms_user_id:
raise Invalid("User does not match")
if payload["exp"] < now:
raise Expired("Token is expired")

return payload


def _unpack_and_verify(token):
"""
Unpack and verify the provided token.

The signing key and algorithm are pulled from settings.
"""
keys = jwk.KEYS()
keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET'])
decoded = JWS().verify_compact(token.encode('utf-8'), keys)
return decoded
126 changes: 126 additions & 0 deletions lms/djangoapps/courseware/tests/test_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""
Tests for token handling
"""
import unittest

from django.conf import settings
from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk
from jwkest.jws import JWS

from lms.djangoapps.courseware.jwt import _encode_and_sign, create_jwt, unpack_jwt


test_user_id = 121
invalid_test_user_id = 120
test_timeout = 60
test_now = 1661432902
test_claims = {"foo": "bar", "baz": "quux", "meaning": 42}
expected_full_token = {
"lms_user_id": test_user_id,
"iat": 1661432902,
"exp": 1661432902 + 60,
"iss": "token-test-issuer", # these lines from test_settings.py
"version": "1.2.0", # these lines from test_settings.py
}


class TestSign(unittest.TestCase):
"""
Tests for JWT creation and signing.
"""

def test_create_jwt(self):
token = create_jwt(test_user_id, test_timeout, {}, test_now)

decoded = _verify_jwt(token)
self.assertEqual(expected_full_token, decoded)

def test_create_jwt_with_claims(self):
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)

expected_token_with_claims = expected_full_token.copy()
expected_token_with_claims.update(test_claims)

decoded = _verify_jwt(token)
self.assertEqual(expected_token_with_claims, decoded)

def test_malformed_token(self):
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)
token = token + "a"

expected_token_with_claims = expected_full_token.copy()
expected_token_with_claims.update(test_claims)

with self.assertRaises(BadSignature):
_verify_jwt(token)


def _verify_jwt(jwt_token):
"""
Helper function which verifies the signature and decodes the token
from string back to claims form
"""
keys = jwk.KEYS()
keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET'])
decoded = JWS().verify_compact(jwt_token.encode('utf-8'), keys)
return decoded


class TestUnpack(unittest.TestCase):
"""
Tests for JWT unpacking.
"""

def test_unpack_jwt(self):
token = create_jwt(test_user_id, test_timeout, {}, test_now)
decoded = unpack_jwt(token, test_user_id, test_now)

self.assertEqual(expected_full_token, decoded)

def test_unpack_jwt_with_claims(self):
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)

expected_token_with_claims = expected_full_token.copy()
expected_token_with_claims.update(test_claims)

decoded = unpack_jwt(token, test_user_id, test_now)

self.assertEqual(expected_token_with_claims, decoded)

def test_malformed_token(self):
token = create_jwt(test_user_id, test_timeout, test_claims, test_now)
token = token + "a"

expected_token_with_claims = expected_full_token.copy()
expected_token_with_claims.update(test_claims)

with self.assertRaises(BadSignature):
unpack_jwt(token, test_user_id, test_now)

def test_unpack_token_with_invalid_user(self):
token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now)

with self.assertRaises(Invalid):
unpack_jwt(token, test_user_id, test_now)

def test_unpack_expired_token(self):
token = create_jwt(test_user_id, test_timeout, {}, test_now)

with self.assertRaises(Expired):
unpack_jwt(token, test_user_id, test_now + test_timeout + 1)

def test_missing_expired_lms_user_id(self):
payload = expected_full_token.copy()
del payload['lms_user_id']
token = _encode_and_sign(payload)

with self.assertRaises(MissingKey):
unpack_jwt(token, test_user_id, test_now)

def test_missing_expired_key(self):
payload = expected_full_token.copy()
del payload['exp']
token = _encode_and_sign(payload)

with self.assertRaises(MissingKey):
unpack_jwt(token, test_user_id, test_now)
8 changes: 4 additions & 4 deletions lms/djangoapps/courseware/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2933,9 +2933,9 @@ def test_render_xblock_with_course_duration_limits_in_mobile_browser(self, mock_
)
@ddt.unpack
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True})
@patch('lms.djangoapps.courseware.views.views.unpack_token_for')
@patch('lms.djangoapps.courseware.views.views.unpack_jwt')
def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token,
expected_response, _mock_token_unpack):
expected_response, _mock_unpack_jwt):
"""
Verify blocks inside an exam that requires token access are gated by
a valid exam access JWT issued for that exam sequence.
Expand Down Expand Up @@ -2968,15 +2968,15 @@ def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token
CourseOverview.load_from_module_store(self.course.id)
self.setup_user(admin=False, enroll=True, login=True)

def _mock_token_unpack_fn(token, user_id):
def _mock_unpack_jwt_fn(token, user_id):
if token == 'valid-jwt-for-exam-sequence':
return {'content_id': str(self.sequence.location)}
elif token == 'valid-jwt-for-incorrect-sequence':
return {'content_id': str(self.other_sequence.location)}
else:
raise Exception('invalid JWT')

_mock_token_unpack.side_effect = _mock_token_unpack_fn
_mock_unpack_jwt.side_effect = _mock_unpack_jwt_fn

# Problem and Vertical response should be gated on access token
for block in [self.problem_block, self.vertical_block]:
Expand Down
4 changes: 2 additions & 2 deletions lms/djangoapps/courseware/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from token_utils.api import unpack_token_for
from web_fragments.fragment import Fragment
from xmodule.course_block import (
COURSE_VISIBILITY_PUBLIC,
Expand Down Expand Up @@ -92,6 +91,7 @@
)
from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect
from lms.djangoapps.courseware.jwt import unpack_jwt
from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student, setup_masquerade
from lms.djangoapps.courseware.model_data import FieldDataCache
from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
Expand Down Expand Up @@ -1535,7 +1535,7 @@ def _check_sequence_exam_access(request, location):
try:
# unpack will validate both expiration and the requesting user matches the
# token user
exam_access_unpacked = unpack_token_for(exam_access_token, request.user.id)
exam_access_unpacked = unpack_jwt(exam_access_token, request.user.id)
except: # pylint: disable=bare-except
log.exception(f"Failed to validate exam access token. user_id={request.user.id} location={location}")
return False
Expand Down
1 change: 1 addition & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4315,6 +4315,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
'JWT_ISSUER': 'http://127.0.0.1:8740',
'JWT_SIGNING_ALGORITHM': 'RS512',
'JWT_SUPPORTED_VERSION': '1.2.0',
'JWT_PRIVATE_SIGNING_JWK': None,
'JWT_PUBLIC_SIGNING_JWK_SET': None,
}

Expand Down
32 changes: 32 additions & 0 deletions lms/envs/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,3 +657,35 @@
# case of new django version these values will override.
if django.VERSION[0] >= 4: # for greater than django 3.2 use with schemes.
CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS_WITH_SCHEME


############## Settings for JWT token handling ##############
TOKEN_SIGNING = {
'JWT_ISSUER': 'token-test-issuer',
'JWT_SIGNING_ALGORITHM': 'RS512',
'JWT_SUPPORTED_VERSION': '1.2.0',
'JWT_PRIVATE_SIGNING_JWK': '''{
"e": "AQAB",
"d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ",
"q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE",
"p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0",
"kid": "token-test-sign", "kty": "RSA"
}''',
'JWT_PUBLIC_SIGNING_JWK_SET': '''{
"keys": [
{
"kid":"token-test-wrong-key",
"e": "AQAB",
"kty": "RSA",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dffgRQLD1qf5D6sprmYfWVokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"
},
{
"kid":"token-test-sign",
"e": "AQAB",
"kty": "RSA",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"
}
]
}''',
}
6 changes: 1 addition & 5 deletions requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@ django==4.2.17
# edx-search
# edx-submissions
# edx-toggles
# edx-token-utils
# edx-when
# edxval
# enmerkar
Expand Down Expand Up @@ -538,8 +537,6 @@ edx-toggles==5.2.0
# edxval
# event-tracking
# ora2
edx-token-utils==0.2.1
# via -r requirements/edx/kernel.in
edx-when==2.5.0
# via
# -r requirements/edx/kernel.in
Expand Down Expand Up @@ -933,7 +930,6 @@ pygments==2.19.1
pyjwkest==1.4.2
# via
# -r requirements/edx/kernel.in
# edx-token-utils
# lti-consumer-xblock
pyjwt[crypto]==2.10.1
# via
Expand Down Expand Up @@ -1213,7 +1209,7 @@ uritemplate==4.1.1
# drf-spectacular
# drf-yasg
# google-api-python-client
urllib3==2.2.3
urllib3==1.26.20
# via
# -c requirements/edx/../common_constraints.txt
# botocore
Expand Down
Loading
Loading