diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b383131..e7304f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - 🔧(summary) add setting to control Sentry traces sampling rate - ✨(frontend) let signed-out visitors start a meeting - ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration +- ✨(backend) authenticate external API calls with Menshen exchanged tokens ### Changed diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py index 445eb02f..0952b860 100644 --- a/src/backend/core/external_api/authentication.py +++ b/src/backend/core/external_api/authentication.py @@ -4,21 +4,51 @@ # ruff: noqa: PLR0913 import logging +from dataclasses import asdict from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import SuspiciousOperation +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 User = get_user_model() logger = logging.getLogger(__name__) +def get_bearer_token(request): + """Extract the bearer token from the Authorization header. + + Returns: + Token string, or None if the request carries no bearer token + + 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() != b"bearer": + 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 + + class BaseJWTAuthentication(authentication.BaseAuthentication): """Base JWT authentication class.""" @@ -71,22 +101,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication): if not self.is_enabled: return None - auth_header = authentication.get_authorization_header(request).split() + token = get_bearer_token(request) - if not auth_header or auth_header[0].lower() != b"bearer": + 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): @@ -252,6 +272,139 @@ 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, +} + + +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 = TokenExchangeClient( + config=Configuration( + client_id=settings.MENSHEN_CLIENT_ID, + client_secret=settings.MENSHEN_CLIENT_SECRET, + server_root_url=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_bearer_token(request) + + if token is None: + return None + + payload = self.introspect(token) + + 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_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 introspect(self, token): + """Submit the token to Menshen's introspection endpoint. + + Errors are reported as an inactive token, so a Menshen outage doesn't + prevent the next authentication backends from running. + + Args: + token: Bearer token string + + Returns: + Introspection response dict + """ + + try: + response = self._client.introspect(IntrospectionRequest(token=token)) + except (requests.RequestException, ResponseParsingError) as e: + logger.warning("Menshen introspection failed: %s", e) + return {"active": False} + + # Permission classes expect a dict payload in request.auth + return asdict(response) + + def get_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 = User.objects.get(sub=sub) + except User.DoesNotExist as e: + if not settings.OIDC_CREATE_USER: + logger.warning("User not found: %s", sub) + raise exceptions.AuthenticationFailed("User not found.") from e + + user = User(sub=sub, email=payload.get("email")) + user.set_unusable_password() + user.save() + + if not user.is_active: + logger.warning("Inactive user attempted authentication: %s", user.pk) + raise exceptions.AuthenticationFailed("User account is disabled.") + + 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.""" diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index d61d095f..7061fd8c 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_external_api_menshen.py b/src/backend/core/tests/test_external_api_menshen.py new file mode 100644 index 00000000..711bbe51 --- /dev/null +++ b/src/backend/core/tests/test_external_api_menshen.py @@ -0,0 +1,203 @@ +"""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(): + 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_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.""" + + responses.add(responses.POST, INTROSPECTION_ENDPOINT, json={"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_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_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() diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index f39a633b..c4762678 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -702,6 +702,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 8abb2ed9..e644472c 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.1.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 33948939..405ceab2 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -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.1.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.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/d5/c2be400c190efe26b70e5c60dd9d9ab279ece3b51de457abc184a238c727/menshen_client-0.1.0.tar.gz", hash = "sha256:21fe48788e860c7f2e103787ce0827981e9059146cf7bdb9ac1a8778acb9ff77", size = 6219, upload-time = "2026-09-21T21:04:01.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/bf/11d15da4852b8344eb9f30861bd15a8f1d931ff4232d9b7de18fce09fd3c/menshen_client-0.1.0-py3-none-any.whl", hash = "sha256:d0dfb351812bb93620a4796e0f0ce1a40e8ec859dc8de2a1f50ae7f306d4d29a", size = 7040, upload-time = "2026-09-21T21:04:00.433Z" }, +] + [[package]] name = "mozilla-django-oidc" version = "5.0.2"