⬆️(oidc) allow use mozilla-django-oidc >5.0.0 with PyJWT

This will allow to update Mozilla OIDC package to latest version.
This commit is contained in:
Quentin BEY
2026-01-13 23:12:41 +01:00
parent be2b294c8a
commit 07dc216393
7 changed files with 116 additions and 44 deletions

View File

@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- ⬆️(oidc) allow use mozilla-django-oidc >5.0.0 with PyJWT
## [0.0.22] - 2025-12-04 ## [0.0.22] - 2025-12-04
### Added ### Added

View File

@@ -31,7 +31,8 @@ dependencies = [
"django>=5.0", "django>=5.0",
"djangorestframework>=3.15.2", "djangorestframework>=3.15.2",
"mozilla-django-oidc>=4.0.1", "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>=2.32.3",
"requests-toolbelt>=1.0.0", "requests-toolbelt>=1.0.0",
] ]

View File

@@ -4,6 +4,7 @@ import logging
from functools import lru_cache from functools import lru_cache
from json import JSONDecodeError from json import JSONDecodeError
import jwt
import requests import requests
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from django.conf import settings 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 Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response. 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: Returns:
dict: User details dictionary obtained from the OpenID Connect user endpoint. dict: User details dictionary obtained from the OpenID Connect user endpoint.
@@ -196,7 +200,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
if _expected_format == OIDCUserEndpointFormat.JWT: if _expected_format == OIDCUserEndpointFormat.JWT:
try: try:
userinfo = self.verify_token(user_response.text) 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 raise SuspiciousOperation("User info response was not valid JWT") from exc
else: else:
try: try:

View File

@@ -5,6 +5,8 @@ import logging
from importlib import import_module from importlib import import_module
from urllib.parse import urlencode from urllib.parse import urlencode
import jwt # mozilla-django-oidc>=5.0.0
import mozilla_django_oidc
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.contrib.auth import get_user_model 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.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from joserfc import jws, jwt from joserfc import jws # mozilla-django-oidc<5.0.0
from joserfc.errors import DecodeError, JoseError from joserfc import jwt as joserfc_jwt # mozilla-django-oidc<5.0.0
from joserfc.jwk import KeySet from joserfc.errors import DecodeError, JoseError # mozilla-django-oidc<5.0.0
from joserfc.util import to_bytes 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.auth import OIDCAuthenticationBackend
from mozilla_django_oidc.utils import absolutify, import_from_settings from mozilla_django_oidc.utils import absolutify, import_from_settings
from mozilla_django_oidc.views import ( from mozilla_django_oidc.views import (
@@ -33,6 +36,8 @@ from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView, OIDCLogoutView as MozillaOIDCOIDCLogoutView,
) )
MOZILLA_OIDC_V4 = mozilla_django_oidc.__version__.startswith("4.")
User = get_user_model() User = get_user_model()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -301,7 +306,76 @@ class OIDCBackChannelLogoutView(View):
""" """
return request.POST.get("logout_token") 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. Validate and decode the logout_token JWT according to section 2.6 of the spec.
@@ -325,37 +399,10 @@ class OIDCBackChannelLogoutView(View):
""" """
try: try:
# Check token type (recommended but not mandatory for compatibility) if MOZILLA_OIDC_V4:
logout_token = to_bytes(logout_token) payload = self._get_logout_token_payload_joserfc(logout_token)
else:
try: payload = self._get_logout_token_payload_pyjwt(logout_token)
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
# Validation according to the spec (section 2.6) # Validation according to the spec (section 2.6)
@@ -390,6 +437,18 @@ class OIDCBackChannelLogoutView(View):
except JoseError as e: except JoseError as e:
logger.exception("Invalid JWT token: %s", e) logger.exception("Invalid JWT token: %s", e)
return None 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: except Exception as e:
logger.exception("Error validating token: %s", e) logger.exception("Error validating token: %s", e)
return None return None

View File

@@ -52,6 +52,7 @@ def test_view_logout_anonymous(settings):
def test_view_logout(mocked_oidc_logout_url, settings): def test_view_logout(mocked_oidc_logout_url, settings):
"""Authenticated users should be redirected to OIDC provider for logout.""" """Authenticated users should be redirected to OIDC provider for logout."""
settings.ALLOW_LOGOUT_GET_METHOD = True settings.ALLOW_LOGOUT_GET_METHOD = True
settings.LOGOUT_REDIRECT_URL = "/example-logout"
user = factories.UserFactory() user = factories.UserFactory()

View File

@@ -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): def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid token error.""" """Test '_verify_claims' method with an invalid token error."""
with patch.object(resource_server_backend._introspection_claims_registry, "validate") as mock_validate: 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" expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug: with patch.object(Logger, "debug") as mock_logger_debug:

12
tox.ini
View File

@@ -2,15 +2,15 @@
requires = requires =
tox>=4.11.4 tox>=4.11.4
tox-uv>=0.0.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 isolated_build = True
[gh] [gh]
python = python =
3.14 = py314-django{52} 3.14 = py314-django{52}-mozoidc{4,5}
3.13 = py313-django{42,50,51,52} 3.13 = py313-django{42,50,51,52}-mozoidc{4,5}
3.12 = py312-django{42,50,51,52} 3.12 = py312-django{42,50,51,52}-mozoidc{4,5}
3.11 = py311-django{42,50,51,52} 3.11 = py311-django{42,50,51,52}-mozoidc{4,5}
[testenv] [testenv]
runner = uv-venv-runner runner = uv-venv-runner
@@ -21,6 +21,8 @@ deps =
django50: Django>=5.0,<5.1 django50: Django>=5.0,<5.1
django51: Django>=5.1,<5.2 django51: Django>=5.1,<5.2
django52: Django>=5.2,<5.3 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 junitxml
setenv = setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/tests PYTHONPATH = {toxinidir}:{toxinidir}/tests