diff --git a/CHANGELOG.md b/CHANGELOG.md index b637b12fc..eeb2cecc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ⚡️(backend) add UPPER(email) index for case-insensitive user lookups +- ✨(backend) authenticate external API calls with Menshen exchanged tokens ## [1.35.0] - 2026-10-13 diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 75b82786c..6592bc83c 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -82,9 +82,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): "User info contained an invalid sub claim" ) from err - if len(sub) > 255: - raise SuspiciousOperation("User info contained an invalid sub claim") - try: return User.objects.get(sub=sub) except User.DoesNotExist: diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py index 07dfeb1c1..d8ab45557 100644 --- a/src/backend/core/external_api/authentication.py +++ b/src/backend/core/external_api/authentication.py @@ -4,21 +4,107 @@ # ruff: noqa: PLR0913 import logging +from dataclasses import asdict +from functools import lru_cache from django.conf import settings from django.contrib.auth import get_user_model -from django.core.exceptions import SuspiciousOperation +from django.core.exceptions import SuspiciousOperation, ValidationError +import requests from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend +from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient +from menshen_client.exceptions import ResponseParsingError from rest_framework import authentication, exceptions -from core.models import Application +from core.models import Application, ApplicationScope from core.services import jwt_token +from core.validators import sub_validator User = get_user_model() logger = logging.getLogger(__name__) +def get_token(request, token_type): + """Extract the token from the Authorization header. + + Args: + request: The incoming request + token_type: Expected Authorization scheme (e.g. Bearer), matched + case-insensitively + + Returns: + Token string, or None if the request carries no token of this type + + Raises: + AuthenticationFailed: If the Authorization header is malformed + """ + + auth_header = authentication.get_authorization_header(request).split() + + if not auth_header or auth_header[0].lower() != token_type.lower().encode(): + return None + + if len(auth_header) != 2: + logger.warning("Invalid token header format") + raise exceptions.AuthenticationFailed("Invalid token header.") + + try: + return auth_header[1].decode("utf-8") + except UnicodeError as e: + logger.warning("Token decode error: %s", e) + raise exceptions.AuthenticationFailed("Invalid token encoding.") from e + + +def get_or_create_user_by_sub(sub, email=None): + """Retrieve the user matching a token subject, creating it if allowed. + + Shared by the resource server backends, so that tokens introspected with the + OIDC provider or with Menshen resolve users the same way. + + Args: + sub: Subject identifier from the introspected token + email: Email to store on a newly created user + + Returns: + User instance, or None if unknown and OIDC_CREATE_USER is unset + + Raises: + SuspiciousOperation: If the sub is invalid, the email of a user to create + is already used, or the user account is disabled + """ + + try: + sub_validator(sub) + except ValidationError as err: + raise SuspiciousOperation("User info contained an invalid sub claim") from err + + try: + user = User.objects.get(sub=sub) + except User.DoesNotExist: + if not settings.OIDC_CREATE_USER: + return None + + # Users are never identified by email here. But a second account with the + # same email (e.g. provisionnal account) would break the lookups by email: + # refuse it. + if email and User.objects.filter(email__iexact=email).exists(): + logger.warning("Email already used, not creating user: %s", sub) + raise SuspiciousOperation( + "Email already used by another account." + ) from None + + user = User(sub=sub, email=email) + user.set_unusable_password() + user.save() + + if not user.is_active: + logger.warning("Inactive user attempted authentication: %s", user.pk) + raise SuspiciousOperation("User account is disabled.") + + return user + + class BaseJWTAuthentication(authentication.BaseAuthentication): """Base JWT authentication class.""" @@ -72,25 +158,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication): if not self.is_enabled: return None - auth_header = authentication.get_authorization_header(request).split() + token = get_token(request, self._token_type) - if ( - not auth_header - or auth_header[0].lower() != self._token_type.lower().encode() - ): + if token is None: # Defer to next authentication backend return None - if len(auth_header) != 2: - logger.warning("Invalid token header format") - raise exceptions.AuthenticationFailed("Invalid token header.") - - try: - token = auth_header[1].decode("utf-8") - except UnicodeError as e: - logger.warning("Token decode error: %s", e) - raise exceptions.AuthenticationFailed("Invalid token encoding.") from e - return self.authenticate_credentials(token) def decode_jwt(self, token): @@ -256,6 +329,127 @@ class AddonsJWTAuthentication(BaseJWTAuthentication): ) +# Menshen grants scopes following La Suite's "service:resource:action" convention. +# Map them to the scopes expected by the external API permissions. +MENSHEN_SCOPES_MAPPING = { + "meet:room:create": ApplicationScope.ROOMS_CREATE, +} + + +@lru_cache(1) +def _get_token_exchange_client(client_id, client_secret, server_url): + return TokenExchangeClient( + config=Configuration( + client_id, + client_secret, + server_url, + ) + ) + + +class MenshenAuthentication(authentication.BaseAuthentication): + """Authentication for tokens exchanged through Menshen. + + Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service + exchanges its user's access token for a token targeting Meet, then calls the external + API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen + only reports a token as active when Meet is among its audiences. + """ + + def __init__(self): + """Initialize the Menshen client from Django settings.""" + + super().__init__() + + self._client = None + + if not settings.MENSHEN_ENABLED: + return + + self._client = _get_token_exchange_client( + settings.MENSHEN_CLIENT_ID, + settings.MENSHEN_CLIENT_SECRET, + settings.MENSHEN_SERVER_URL, + ) + + def authenticate(self, request): + """Introspect the bearer token with Menshen. + + Returns: + Tuple of (user, payload) if the token is active, None otherwise + """ + + if self._client is None: + return None + + token = get_token(request, "bearer") + + if token is None: + return None + + try: + response = self._client.introspect(IntrospectionRequest(token=token)) + except (requests.RequestException, ResponseParsingError) as e: + logger.warning("Menshen introspection failed: %s", e) + return None + + # Permission classes expect a dict payload in request.auth + payload = asdict(response) + + if not payload.get("active"): + # Not a Menshen token, or an expired or revoked one: defer to next + # authentication backend + return None + + user = self.get_or_create_user(payload) + + scopes = payload.get("scope") or "" + payload["scope"] = [ + MENSHEN_SCOPES_MAPPING[scope] + for scope in scopes.split() + if scope in MENSHEN_SCOPES_MAPPING + ] + + return (user, payload) + + def get_or_create_user(self, payload): + """Retrieve or create the user from the introspection response. + + Menshen forwards the `sub` and `email` of the subject token, as introspected + with the OIDC provider. + + Args: + payload: Introspection response dict + + Returns: + User instance + + Raises: + AuthenticationFailed: If user not found or inactive + """ + + sub = payload.get("sub") + + if not sub: + logger.warning("Missing 'sub' in Menshen introspection response") + raise exceptions.AuthenticationFailed("Invalid token claims.") + + try: + user = get_or_create_user_by_sub(sub, email=payload.get("email")) + except SuspiciousOperation as e: + raise exceptions.AuthenticationFailed(str(e)) from e + + if user is None: + logger.warning("User not found: %s", sub) + raise exceptions.AuthenticationFailed("User not found.") + + return user + + def authenticate_header(self, request): + """Return authentication scheme for WWW-Authenticate header.""" + return "bearer" + + class ResourceServerBackend(LaSuiteBackend): """OIDC Resource Server backend for user creation and retrieval.""" @@ -285,28 +479,4 @@ class ResourceServerBackend(LaSuiteBackend): logger.debug(message) raise SuspiciousOperation(message) - user = self.get_user(access_token, id_token, payload) - - if user is None and settings.OIDC_CREATE_USER: - user = self.create_user(sub) - - if user is not None and not user.is_active: - logger.warning("Inactive user attempted authentication: %s", user.pk) - raise SuspiciousOperation("User account is disabled.") - - return user - - def create_user(self, sub): - """Create new user from subject claim. - - Args: - sub: Subject identifier from token - - Returns: - Newly created User instance - """ - user = self.UserModel(sub=sub) - user.set_unusable_password() - user.save() - - return user + return get_or_create_user_by_sub(sub) diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index aaa5d2956..d25a9fc9b 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -166,6 +166,7 @@ class RoomViewSet( authentication_classes = [ authentication.ApplicationJWTAuthentication, authentication.AddonsJWTAuthentication, + authentication.MenshenAuthentication, ResourceServerAuthentication, ] permission_classes = [ diff --git a/src/backend/core/tests/test_api_user_access_token_authentication.py b/src/backend/core/tests/test_api_user_access_token_authentication.py index 57cfef4dc..356ee5814 100644 --- a/src/backend/core/tests/test_api_user_access_token_authentication.py +++ b/src/backend/core/tests/test_api_user_access_token_authentication.py @@ -35,6 +35,20 @@ def test_user_access_token_users_me(): assert response.data["email"] == user.email +def test_user_access_token_custom_token_type(settings): + """The Authorization scheme should match the configured token type.""" + settings.USER_ACCESS_TOKEN_TYPE = "Token" + user = UserFactory() + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Token {generate_user_access_token(user)}") + + response = client.get("/api/v1.0/users/me/") + + assert response.status_code == 200 + assert response.data["email"] == user.email + + def test_user_access_token_expired(): """An expired user access token should be rejected.""" user = UserFactory() diff --git a/src/backend/core/tests/test_external_api_authentication.py b/src/backend/core/tests/test_external_api_authentication.py index ad628df5b..af0c6ac31 100644 --- a/src/backend/core/tests/test_external_api_authentication.py +++ b/src/backend/core/tests/test_external_api_authentication.py @@ -67,6 +67,19 @@ def test_resource_server_backend_get_or_create_user_no_creation(settings): assert not User.objects.filter(sub="new-sub").exists() +def test_resource_server_backend_get_or_create_user_invalid_sub(settings): + """A sub longer than 255 characters should be rejected without creating a user.""" + + settings.OIDC_CREATE_USER = True + + with pytest.raises(SuspiciousOperation, match="invalid sub claim"): + ResourceServerBackend().get_or_create_user( + access_token="token", id_token=None, payload=_payload("a" * 256) + ) + + assert not User.objects.exists() + + @responses.activate def test_api_rooms_list_resource_server_inactive_user(settings): """End to end: a valid introspected token for an inactive user should get 401.""" diff --git a/src/backend/core/tests/test_external_api_menshen.py b/src/backend/core/tests/test_external_api_menshen.py new file mode 100644 index 000000000..e743a5899 --- /dev/null +++ b/src/backend/core/tests/test_external_api_menshen.py @@ -0,0 +1,302 @@ +"""Tests for the external API MenshenAuthentication.""" + +from unittest import mock + +import pytest +import responses +from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication +from rest_framework.test import APIClient + +from core.factories import UserFactory +from core.models import RoleChoices, Room, User + +pytestmark = pytest.mark.django_db + +SERVER_URL = "https://menshen.example.com" +INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/" +TOKEN = "menshen-exchanged-token" + + +@pytest.fixture(autouse=True) +def menshen_settings(settings): + """Enable Menshen authentication.""" + settings.MENSHEN_ENABLED = True + settings.MENSHEN_SERVER_URL = SERVER_URL + settings.MENSHEN_CLIENT_ID = "meet" + settings.MENSHEN_CLIENT_SECRET = "meet-secret" + + +def _introspection(sub, scope="meet:room:create", **overrides): + return { + "active": True, + "sub": sub, + "email": "user@example.com", + "scope": scope, + "aud": "meet", + "client_id": "menshen", + **overrides, + } + + +def _create_room(token=TOKEN): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + return client.post("/external-api/v1.0/rooms/", {}, format="json") + + +@responses.activate +def test_menshen_active_token(): + """An active token with a mapped scope should create a room owned by the user.""" + + user = UserFactory() + + # Catch and mock external HTTP requests + responses.add( + responses.POST, + INTROSPECTION_ENDPOINT, + json=_introspection(user.sub), + match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})], + ) + + response = _create_room() + + assert response.status_code == 201 + room = Room.objects.get(id=response.data["id"]) + assert room.get_role(user) == RoleChoices.OWNER + assert responses.calls[0].request.headers["Authorization"].startswith("Basic ") + + +@responses.activate +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_menshen_other_token_type_ignored(mock_rs_authenticate): + """A token sent with another scheme than Bearer should not be introspected.""" + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Token {TOKEN}") + response = client.post("/external-api/v1.0/rooms/", {}, format="json") + + assert response.status_code == 401 + assert len(responses.calls) == 0 + mock_rs_authenticate.assert_called_once() + + +@responses.activate +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_menshen_disabled(mock_rs_authenticate, settings): + """Menshen should not be called when disabled.""" + + settings.MENSHEN_ENABLED = False + + response = _create_room() + + assert response.status_code == 401 + assert len(responses.calls) == 0 + mock_rs_authenticate.assert_called_once() + + +@responses.activate +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_menshen_inactive_token_defers(mock_rs_authenticate): + """An inactive token should defer to the next authentication backend.""" + + user = UserFactory() + + responses.add( + responses.POST, + INTROSPECTION_ENDPOINT, + json=_introspection(user.sub, scope="meet:room:create", active=False), + ) + + response = _create_room() + + assert response.status_code == 401 + mock_rs_authenticate.assert_called_once() + + +@responses.activate +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_menshen_error_defers(mock_rs_authenticate): + """A Menshen error should defer to the next authentication backend.""" + + responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500) + + response = _create_room() + + assert response.status_code == 401 + mock_rs_authenticate.assert_called_once() + + +@responses.activate +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_menshen_unparsable_response_defers(mock_rs_authenticate): + """A response the client can't parse should defer to the next backend.""" + + responses.add( + responses.POST, + INTROSPECTION_ENDPOINT, + json={"active": True, "unexpected": "field"}, + ) + + response = _create_room() + + assert response.status_code == 401 + mock_rs_authenticate.assert_called_once() + + +@responses.activate +def test_menshen_resource_server_token(settings): + """End to end: a token issued by the OIDC provider is unknown to Menshen and + should still authenticate through the resource server.""" + + settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect" + settings.OIDC_OP_URL = "https://oidc.example.com" + + user = UserFactory() + token = "oidc-access-token" + + menshen_introspection = responses.add( + responses.POST, + INTROSPECTION_ENDPOINT, + json={"active": False}, + match=[responses.matchers.urlencoded_params_matcher({"token": token})], + ) + oidc_introspection = responses.add( + responses.POST, + "https://oidc.example.com/introspect", + json={ + "iss": "https://oidc.example.com", + "active": True, + "sub": user.sub, + "scope": "openid lasuite_meet rooms:create", + "client_id": "app", + }, + ) + + response = _create_room(token) + + assert response.status_code == 201 + room = Room.objects.get(id=response.data["id"]) + assert room.get_role(user) == RoleChoices.OWNER + assert menshen_introspection.call_count == 1 + assert oidc_introspection.call_count == 1 + + +@responses.activate +def test_menshen_unmapped_scope(): + """Scopes granted for other services or other Meet actions should not grant access.""" + + user = UserFactory() + + responses.add( + responses.POST, + INTROSPECTION_ENDPOINT, + json=_introspection(user.sub, scope="drive:item:create meet:room:list"), + ) + + response = _create_room() + + assert response.status_code == 403 + assert "insufficient permissions." in str(response.data).lower() + assert not Room.objects.exists() + + +@responses.activate +def test_menshen_missing_sub(): + """An active token without sub should be rejected.""" + + responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None)) + + response = _create_room() + + assert response.status_code == 401 + assert "invalid token claims." in str(response.data).lower() + + +@responses.activate +def test_menshen_invalid_sub(settings): + """An active token with an invalid sub should be rejected without creating a user.""" + + settings.OIDC_CREATE_USER = True + + responses.add( + responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("invalid\nsub") + ) + + response = _create_room() + + assert response.status_code == 401 + assert "invalid sub claim" in str(response.data).lower() + assert not User.objects.exists() + + +@responses.activate +def test_menshen_inactive_user(): + """An active token for an inactive user should be rejected.""" + + user = UserFactory(is_active=False) + + responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub)) + + response = _create_room() + + assert response.status_code == 401 + assert "user account is disabled." in str(response.data).lower() + + +@responses.activate +def test_menshen_unknown_user_created(settings): + """An unknown sub should create a user when OIDC_CREATE_USER is set.""" + + settings.OIDC_CREATE_USER = True + + responses.add( + responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub") + ) + + response = _create_room() + + assert response.status_code == 201 + user = User.objects.get(sub="new-sub") + assert user.email == "user@example.com" + assert user.is_active is True + + +@responses.activate +def test_menshen_unknown_user_not_created(settings): + """An unknown sub should be rejected when OIDC_CREATE_USER is unset.""" + + settings.OIDC_CREATE_USER = False + + responses.add( + responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub") + ) + + response = _create_room() + + assert response.status_code == 401 + assert "user not found." in str(response.data).lower() + assert not User.objects.filter(sub="new-sub").exists() + + +@responses.activate +@pytest.mark.parametrize("existing_sub", [None, "other-sub"]) +def test_menshen_unknown_user_email_already_used(settings, existing_sub): + """An unknown sub should not create a second account with an email already + used, by a provisional user or a user with another sub.""" + + settings.OIDC_CREATE_USER = True + + existing_user = UserFactory(sub=existing_sub, email="User@Example.com") + + responses.add( + responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub") + ) + + response = _create_room() + + assert response.status_code == 401 + assert "email already used by another account." in str(response.data).lower() + assert User.objects.count() == 1 + existing_user.refresh_from_db() + assert existing_user.sub == existing_sub + assert not Room.objects.exists() diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py index 284a1d34b..9960b1b12 100644 --- a/src/backend/core/tests/test_models_users.py +++ b/src/backend/core/tests/test_models_users.py @@ -69,7 +69,7 @@ def test_models_users_sub_validator(sub, is_valid): else: with pytest.raises( ValidationError, - match="Enter a valid sub. This value should be printable ASCII only.", + match="Enter a valid sub. This value should be printable ASCII only, with 255 characters or less.", ): user.full_clean() diff --git a/src/backend/core/validators.py b/src/backend/core/validators.py index 63cf417b6..0f26dcd52 100644 --- a/src/backend/core/validators.py +++ b/src/backend/core/validators.py @@ -17,7 +17,13 @@ def sub_validator(value): ``isprintable()`` is false exactly for those control characters, while space (U+0020) remains allowed. """ + msg = "Enter a valid sub. This value should be printable ASCII only, with 255 characters or less." + + if value is None: + raise ValidationError(_(msg)) + + if len(value) > 255: + raise ValidationError(_(msg)) + if not value.isascii() or not value.isprintable(): - raise ValidationError( - _("Enter a valid sub. This value should be printable ASCII only.") - ) + raise ValidationError(_(msg)) diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index b1cfd4043..399a165e6 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -709,6 +709,20 @@ class Base(Configuration): default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None ) + # Menshen, La Suite's OAuth 2.0 token exchange server + MENSHEN_ENABLED = values.BooleanValue( + False, environ_name="MENSHEN_ENABLED", environ_prefix=None + ) + MENSHEN_SERVER_URL = values.Value( + None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None + ) + MENSHEN_CLIENT_ID = values.Value( + None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None + ) + MENSHEN_CLIENT_SECRET = SecretFileValue( + None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None + ) + # Video conference configuration LIVEKIT_CONFIGURATION = { "api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None), diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 81ec58c88..d7695c21a 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "gunicorn==26.2.0", "jsonschema==4.26.0", "markdown==3.10.3", + "menshen-client==0.2.0", "nested-multipart-parser==1.6.0", "posthog==7.44.0", "psycopg[binary]==3.3.4", diff --git a/src/backend/uv.lock b/src/backend/uv.lock index f3a0d3e69..c4d79570b 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.13" [[package]] @@ -1327,6 +1327,7 @@ dependencies = [ { name = "jsonschema" }, { name = "livekit-api" }, { name = "markdown" }, + { name = "menshen-client" }, { name = "mozilla-django-oidc" }, { name = "nested-multipart-parser" }, { name = "phonenumbers" }, @@ -1392,6 +1393,7 @@ requires-dist = [ { name = "jsonschema", specifier = "==4.26.0" }, { name = "livekit-api", specifier = "==1.2.0" }, { name = "markdown", specifier = "==3.10.3" }, + { name = "menshen-client", specifier = "==0.2.0" }, { name = "mozilla-django-oidc", specifier = "==5.0.2" }, { name = "nested-multipart-parser", specifier = "==1.6.0" }, { name = "phonenumbers", specifier = "==9.0.37" }, @@ -1428,6 +1430,18 @@ dev = [ { name = "types-requests", specifier = "==2.33.0.20260712" }, ] +[[package]] +name = "menshen-client" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/a2/5862b0a39d5f96301e1daff146445f591b1980412c20417162f5cfd6aa34/menshen_client-0.2.0.tar.gz", hash = "sha256:548c4224c38b2c844a76325b5a4b65da520ea9eba23aafb004da0f430a7e4fcf", size = 6326, upload-time = "2026-10-13T10:19:34.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/4d/932efd81c340d12d07b1c33633c6e7a4a2d0c9086ac0da7ffe9a1ce17068/menshen_client-0.2.0-py3-none-any.whl", hash = "sha256:415eaba30184452bc393b528c6088ed6e37cbd00ce60716aef9e2d9bd0697221", size = 7149, upload-time = "2026-10-13T10:19:33.272Z" }, +] + [[package]] name = "mozilla-django-oidc" version = "5.0.2"