mirror of
https://github.com/suitenumerique/django-lasuite
synced 2026-04-25 17:15:14 +02:00
⬆️(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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
12
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
|
||||
|
||||
Reference in New Issue
Block a user