Skip to content

Commit

Permalink
Merge pull request #36077 from openedx/rijuma/remove-edx-token-utils-dep
Browse files Browse the repository at this point in the history
chore: Remove edx-token-utils dependency
  • Loading branch information
rijuma authored Jan 24, 2025
2 parents 2a07080 + dd86710 commit 05c7d26
Show file tree
Hide file tree
Showing 12 changed files with 271 additions and 31 deletions.
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
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 @@ -138,6 +137,7 @@
from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.courses import get_course_by_id
from openedx.core.lib.jwt import unpack_jwt
from openedx.core.lib.mobile_utils import is_request_from_mobile_app
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
from openedx.features.course_experience import course_home_url
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
8 changes: 8 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4311,13 +4311,21 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# Exam Service
EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1'

############## Settings for JWT token handling ##############
TOKEN_SIGNING = {
'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,
}

# NOTE: In order to create both JWT_PRIVATE_SIGNING_JWK and JWT_PUBLIC_SIGNING_JWK_SET,
# in an lms shell run the following command:
# > python manage.py lms generate_jwt_signing_key
# This will output asymmetric JWTs to use here. Read more on this on:
# https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst

COURSE_CATALOG_URL_ROOT = 'http://localhost:8008'
COURSE_CATALOG_API_URL = f'{COURSE_CATALOG_URL_ROOT}/api/v1'

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"
}
]
}''',
}
91 changes: 91 additions & 0 deletions openedx/core/lib/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
129 changes: 129 additions & 0 deletions openedx/core/lib/tests/test_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""
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 openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.lib.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
}


@skip_unless_lms
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


@skip_unless_lms
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)
4 changes: 0 additions & 4 deletions requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@ django==4.2.18
# 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.1
# via
# -r requirements/edx/kernel.in
Expand Down Expand Up @@ -931,7 +928,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
Loading

0 comments on commit 05c7d26

Please sign in to comment.