Skip to content

Commit 0cdbd4e

Browse files
authored
feat: add JWT signed authentication for OAuth application API (#318)
* feat(auth): add JWT signed authentication for OAuth application API * feat(support-api): allow AnonymousUser access only via JWT-signed OAuth authentication
1 parent fd092b3 commit 0cdbd4e

File tree

8 files changed

+77
-6
lines changed

8 files changed

+77
-6
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ Please do not update the unreleased notes.
1111

1212
<!-- Content should be placed here -->
1313

14+
## [v11.3.0](https://github.com/eduNEXT/eox-core/compare/v11.2.0...v11.3.0) - (2025-03-02)
15+
16+
### Added
17+
- Added signed JWT authentication for the OAuth application API to support secure authentication when creating tenant OAuth clients from Control Center.
18+
1419
## [v11.2.0](https://github.com/eduNEXT/eox-core/compare/v11.1.0...v11.2.0) - (2025-01-20)
1520

1621
### Added

eox_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""
22
Init for main eox-core app
33
"""
4-
__version__ = '11.2.0'
4+
__version__ = '11.3.0'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Custom authenticators for the Support V1 API.
3+
"""
4+
import time
5+
6+
import jwt
7+
from django.conf import settings
8+
from django.contrib.auth.models import AnonymousUser
9+
from jwt import ExpiredSignatureError, InvalidTokenError
10+
from rest_framework import authentication
11+
from rest_framework.authentication import get_authorization_header
12+
13+
14+
class JWTsignedOauthAppAuthentication(authentication.BaseAuthentication):
15+
"""
16+
Authentication class to verify JWTs signed by trusted services.
17+
Allows authentication for the OauthApplicationAPIView in Open edX.
18+
"""
19+
20+
def authenticate(self, request):
21+
"""
22+
Extracts the JWT token from the Authorization header and verifies it.
23+
If authentication fails, it does NOT raise an exception to allow
24+
other authentication classes to attempt authentication.
25+
"""
26+
auth = get_authorization_header(request).split()
27+
28+
if not auth or auth[0].lower() != b'bearer':
29+
return None
30+
31+
if len(auth) != 2:
32+
return None
33+
34+
token = auth[1]
35+
return self.authenticate_token(token)
36+
37+
def authenticate_token(self, token):
38+
"""
39+
Attempts to authenticate the JWT token. If verification fails,
40+
returns None instead of raising an exception, allowing other authentication
41+
classes to handle authentication.
42+
"""
43+
try:
44+
decoded_payload = jwt.decode(
45+
token,
46+
settings.EOX_CORE_JWT_SIGNED_OAUTH_APP_PUBLIC_KEY,
47+
algorithms=["RS256"]
48+
)
49+
50+
if decoded_payload["exp"] < time.time():
51+
return None
52+
53+
return (AnonymousUser(), None)
54+
55+
except (ExpiredSignatureError, InvalidTokenError):
56+
return None

eox_core/api/support/v1/permissions.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
"""
44
Custom Support API permissions module
55
"""
6+
from django.contrib.auth.models import AnonymousUser
67
from rest_framework import exceptions, permissions
78

9+
from eox_core.api.support.v1.authentication import JWTsignedOauthAppAuthentication
10+
811

912
class EoxCoreSupportAPIPermission(permissions.BasePermission):
1013
"""
@@ -14,9 +17,15 @@ class EoxCoreSupportAPIPermission(permissions.BasePermission):
1417

1518
def has_permission(self, request, view):
1619
"""
17-
For now, to grant access only checks if the requesting user:
18-
1) is_staff
20+
Grants access if:
21+
1) The user was authenticated via the signed JWT token (AnonymousUser but authenticated).
22+
2) The user is authenticated and is a staff user.
1923
"""
24+
if isinstance(request.user, AnonymousUser):
25+
auth_class_used = getattr(request.successful_authenticator, "__class__", None)
26+
if auth_class_used == JWTsignedOauthAppAuthentication:
27+
return True
28+
2029
if request.user.is_staff:
2130
return True
2231

eox_core/api/support/v1/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from rest_framework.response import Response
2121
from rest_framework.views import APIView
2222

23+
from eox_core.api.support.v1.authentication import JWTsignedOauthAppAuthentication
2324
from eox_core.api.support.v1.permissions import EoxCoreSupportAPIPermission
2425
from eox_core.api.support.v1.serializers import (
2526
OauthApplicationSerializer,
@@ -154,7 +155,7 @@ class OauthApplicationAPIView(UserQueryMixin, APIView):
154155
django_oauth_toolkit Application object.
155156
"""
156157

157-
authentication_classes = (BearerAuthentication, SessionAuthentication, JwtAuthentication)
158+
authentication_classes = (JWTsignedOauthAppAuthentication, BearerAuthentication, SessionAuthentication, JwtAuthentication)
158159
permission_classes = (EoxCoreSupportAPIPermission,)
159160
renderer_classes = (JSONRenderer, BrowsableAPIRenderer)
160161

eox_core/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def plugin_settings(settings):
5050
settings.EOX_CORE_ASYNC_TASKS = []
5151
settings.EOX_CORE_THIRD_PARTY_AUTH_BACKEND = 'eox_core.edxapp_wrapper.backends.third_party_auth_l_v1'
5252
settings.EOX_CORE_LANG_PREF_BACKEND = 'eox_core.edxapp_wrapper.backends.lang_pref_middleware_p_v1'
53+
settings.EOX_CORE_JWT_SIGNED_OAUTH_APP_PUBLIC_KEY = ''
5354

5455
if settings.EOX_CORE_USER_ENABLE_MULTI_TENANCY:
5556
settings.EOX_CORE_USER_ORIGIN_SITE_SOURCES = [

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 11.2.0
2+
current_version = 11.3.0
33
commit = False
44
tag = False
55

setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from setuptools import setup
1010

11-
1211
with open("README.rst", "r") as fh:
1312
README = fh.read()
1413

0 commit comments

Comments
 (0)