⬆️(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]
### Changed
- ⬆️(oidc) allow use mozilla-django-oidc >5.0.0 with PyJWT
## [0.0.22] - 2025-12-04
### Added

View File

@@ -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",
]

View File

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

View File

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

View File

@@ -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()

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):
"""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:

12
tox.ini
View File

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