diff --git a/CHANGELOG.md b/CHANGELOG.md index 529bccf..2526096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Changed + +- ⬆️(oidc) allow use mozilla-django-oidc >5.0.0 with PyJWT + ## [0.0.22] - 2025-12-04 ### Added diff --git a/pyproject.toml b/pyproject.toml index 34a6756..11fdfca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ "django>=5.0", "djangorestframework>=3.15.2", "mozilla-django-oidc>=4.0.1", - "joserfc>=1.4.0", + "PyJWT>=2.8.0", # mozilla-django-oidc>=5.0.0 + "joserfc>=1.4.0", # mozilla-django-oidc<5.0.0 "requests>=2.32.3", "requests-toolbelt>=1.0.0", ] diff --git a/src/lasuite/oidc_login/backends.py b/src/lasuite/oidc_login/backends.py index 8e6437c..ba1e4a1 100644 --- a/src/lasuite/oidc_login/backends.py +++ b/src/lasuite/oidc_login/backends.py @@ -4,6 +4,7 @@ import logging from functools import lru_cache from json import JSONDecodeError +import jwt import requests from cryptography.fernet import Fernet from django.conf import settings @@ -170,6 +171,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): Agent Connect, which follows the OIDC standard. It forces us to override the base method, which deal with 'application/json' response. + Note: Since mozilla-django-oidc >= 5.0.0, we could rely on the original method + if we want to get rid of `self.OIDC_OP_USER_ENDPOINT_FORMAT`. + Returns: dict: User details dictionary obtained from the OpenID Connect user endpoint. @@ -196,7 +200,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): if _expected_format == OIDCUserEndpointFormat.JWT: try: userinfo = self.verify_token(user_response.text) - except UnicodeDecodeError as exc: + except (UnicodeDecodeError, jwt.DecodeError) as exc: + # Since mozilla-django-oidc >= 5.0.0, PyJWT is used, which raises jwt.DecodeError raise SuspiciousOperation("User info response was not valid JWT") from exc else: try: diff --git a/src/lasuite/oidc_login/views.py b/src/lasuite/oidc_login/views.py index 8c8cff9..36f0921 100644 --- a/src/lasuite/oidc_login/views.py +++ b/src/lasuite/oidc_login/views.py @@ -5,6 +5,8 @@ import logging from importlib import import_module from urllib.parse import urlencode +import jwt # mozilla-django-oidc>=5.0.0 +import mozilla_django_oidc from django.conf import settings from django.contrib import auth from django.contrib.auth import get_user_model @@ -17,10 +19,11 @@ from django.utils import crypto, timezone from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt -from joserfc import jws, jwt -from joserfc.errors import DecodeError, JoseError -from joserfc.jwk import KeySet -from joserfc.util import to_bytes +from joserfc import jws # mozilla-django-oidc<5.0.0 +from joserfc import jwt as joserfc_jwt # mozilla-django-oidc<5.0.0 +from joserfc.errors import DecodeError, JoseError # mozilla-django-oidc<5.0.0 +from joserfc.jwk import KeySet # mozilla-django-oidc<5.0.0 +from joserfc.util import to_bytes # mozilla-django-oidc<5.0.0 from mozilla_django_oidc.auth import OIDCAuthenticationBackend from mozilla_django_oidc.utils import absolutify, import_from_settings from mozilla_django_oidc.views import ( @@ -33,6 +36,8 @@ from mozilla_django_oidc.views import ( OIDCLogoutView as MozillaOIDCOIDCLogoutView, ) +MOZILLA_OIDC_V4 = mozilla_django_oidc.__version__.startswith("4.") + User = get_user_model() logger = logging.getLogger(__name__) @@ -301,7 +306,76 @@ class OIDCBackChannelLogoutView(View): """ return request.POST.get("logout_token") - def validate_logout_token(self, logout_token): # noqa: PLR0911 + def _get_logout_token_payload_joserfc(self, logout_token): + """Decode the logout_token using joserfc with complete validation.""" + # Check token type (recommended but not mandatory for compatibility) + logout_token = to_bytes(logout_token) + + try: + obj = jws.extract_compact(logout_token) + header = obj.protected + token_type = header.get("typ") + if token_type and token_type.lower() != self.LOGOUT_TOKEN_TYPE: + logger.warning("Unexpected token type: %s (expected: %s)", token_type, self.LOGOUT_TOKEN_TYPE) + # Don't reject for compatibility with existing implementations + except DecodeError: + logger.error("Unable to decode JWT header") + return None + + backend = OIDCAuthenticationBackend() + + # Retrieve OIDC provider's public key for signature validation + jwks_client = backend.retrieve_matching_jwk(logout_token) + + # Decode token with complete validation + keyset = KeySet.import_key_set({"keys": [jwks_client]}) + decoded_jwt = joserfc_jwt.decode(logout_token, keyset, algorithms=["RS256", "ES256"]) + claims_requests = joserfc_jwt.JWTClaimsRegistry( + now=int(timezone.now().timestamp()), + iss={"value": settings.OIDC_OP_URL, "essential": True}, + aud={"value": backend.OIDC_RP_CLIENT_ID, "essential": True}, + exp={"essential": True}, + iat={"essential": True}, + ) + claims_requests.validate(decoded_jwt.claims) + return decoded_jwt.claims + + def _get_logout_token_payload_pyjwt(self, logout_token): + """Decode the logout_token using PyJWT with complete validation.""" + backend = OIDCAuthenticationBackend() + + # Retrieve OIDC provider's public key for signature validation + jwk_key = backend.retrieve_matching_jwk(logout_token) + + # Decode token with complete validation using PyJWT + # PyJWT automatically validates exp, iat, iss, and aud when provided + decoded = jwt.decode_complete( + logout_token, + jwk_key.key, + algorithms=["RS256", "ES256"], + issuer=settings.OIDC_OP_URL, + audience=backend.OIDC_RP_CLIENT_ID, + options={ + "verify_signature": True, + "verify_exp": True, + "verify_iat": True, + "verify_iss": True, + "verify_aud": True, + "require": ["exp", "iat", "iss", "aud"], + }, + ) + + # Check token type (recommended but not mandatory for compatibility) + token_type = decoded["header"].get("typ") + if not token_type or token_type.lower() != self.LOGOUT_TOKEN_TYPE: + logger.warning( + "Unexpected token type after decoding: %s (expected: %s)", token_type, self.LOGOUT_TOKEN_TYPE + ) + # Don't reject for compatibility with existing implementations + + return decoded["payload"] + + def validate_logout_token(self, logout_token): # noqa: PLR0911, PLR0912 """ Validate and decode the logout_token JWT according to section 2.6 of the spec. @@ -325,37 +399,10 @@ class OIDCBackChannelLogoutView(View): """ try: - # Check token type (recommended but not mandatory for compatibility) - logout_token = to_bytes(logout_token) - - try: - obj = jws.extract_compact(logout_token) - header = obj.protected - token_type = header.get("typ") - if token_type and token_type.lower() != self.LOGOUT_TOKEN_TYPE: - logger.warning("Unexpected token type: %s (expected: %s)", token_type, self.LOGOUT_TOKEN_TYPE) - # Don't reject for compatibility with existing implementations - except DecodeError: - logger.error("Unable to decode JWT header") - return None - - backend = OIDCAuthenticationBackend() - - # Retrieve OIDC provider's public key for signature validation - jwks_client = backend.retrieve_matching_jwk(logout_token) - - # Decode token with complete validation - keyset = KeySet.import_key_set({"keys": [jwks_client]}) - decoded_jwt = jwt.decode(logout_token, keyset, algorithms=["RS256", "ES256"]) - claims_requests = jwt.JWTClaimsRegistry( - now=int(timezone.now().timestamp()), - iss={"value": settings.OIDC_OP_URL, "essential": True}, - aud={"value": backend.OIDC_RP_CLIENT_ID, "essential": True}, - exp={"essential": True}, - iat={"essential": True}, - ) - claims_requests.validate(decoded_jwt.claims) - payload = decoded_jwt.claims + if MOZILLA_OIDC_V4: + payload = self._get_logout_token_payload_joserfc(logout_token) + else: + payload = self._get_logout_token_payload_pyjwt(logout_token) # Validation according to the spec (section 2.6) @@ -390,6 +437,18 @@ class OIDCBackChannelLogoutView(View): except JoseError as e: logger.exception("Invalid JWT token: %s", e) return None + except jwt.DecodeError as e: + logger.exception("Invalid JWT token: %s", e) + return None + except jwt.ExpiredSignatureError as e: + logger.exception("JWT token has expired: %s", e) + return None + except jwt.InvalidIssuerError as e: + logger.exception("Invalid issuer: %s", e) + return None + except jwt.InvalidAudienceError as e: + logger.exception("Invalid audience: %s", e) + return None except Exception as e: logger.exception("Error validating token: %s", e) return None diff --git a/tests/oidc_login/test_views.py b/tests/oidc_login/test_views.py index e7ab54a..ae1233a 100644 --- a/tests/oidc_login/test_views.py +++ b/tests/oidc_login/test_views.py @@ -52,6 +52,7 @@ def test_view_logout_anonymous(settings): def test_view_logout(mocked_oidc_logout_url, settings): """Authenticated users should be redirected to OIDC provider for logout.""" settings.ALLOW_LOGOUT_GET_METHOD = True + settings.LOGOUT_REDIRECT_URL = "/example-logout" user = factories.UserFactory() diff --git a/tests/oidc_resource_server/test_backend.py b/tests/oidc_resource_server/test_backend.py index 04ded23..11f5a23 100644 --- a/tests/oidc_resource_server/test_backend.py +++ b/tests/oidc_resource_server/test_backend.py @@ -194,7 +194,7 @@ def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token): def test_verify_claims_invalid_token_error(resource_server_backend, mock_token): """Test '_verify_claims' method with an invalid token error.""" with patch.object(resource_server_backend._introspection_claims_registry, "validate") as mock_validate: - mock_validate.side_effect = InvalidTokenError + mock_validate.side_effect = InvalidTokenError("Invalid token") expected_message = "Failed to validate token's claims" with patch.object(Logger, "debug") as mock_logger_debug: diff --git a/tox.ini b/tox.ini index 5c1169a..ed0e37e 100644 --- a/tox.ini +++ b/tox.ini @@ -2,15 +2,15 @@ requires = tox>=4.11.4 tox-uv>=0.0.4 -envlist = py{311,312,313,314}-django{42,50,51,52} +envlist = py{311,312,313,314}-django{42,50,51,52}-mozoidc{4,5} isolated_build = True [gh] python = - 3.14 = py314-django{52} - 3.13 = py313-django{42,50,51,52} - 3.12 = py312-django{42,50,51,52} - 3.11 = py311-django{42,50,51,52} + 3.14 = py314-django{52}-mozoidc{4,5} + 3.13 = py313-django{42,50,51,52}-mozoidc{4,5} + 3.12 = py312-django{42,50,51,52}-mozoidc{4,5} + 3.11 = py311-django{42,50,51,52}-mozoidc{4,5} [testenv] runner = uv-venv-runner @@ -21,6 +21,8 @@ deps = django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 django52: Django>=5.2,<5.3 + mozoidc4: mozilla-django-oidc>=4.0.1,<5.0 + mozoidc5: mozilla-django-oidc>=5.0.0,<6.0 junitxml setenv = PYTHONPATH = {toxinidir}:{toxinidir}/tests