diff --git a/src/backend/core/authentication/livekit.py b/src/backend/core/authentication/livekit.py index f9ab3b8e..48eda3c0 100644 --- a/src/backend/core/authentication/livekit.py +++ b/src/backend/core/authentication/livekit.py @@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions UserModel = get_user_model() +LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token" + class LiveKitTokenAuthentication(authentication.BaseAuthentication): """Authenticate using LiveKit token and load the associated Django user.""" @@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication): return None # No authentication attempted parts = auth_header.split() - if len(parts) != 2 or parts[0].lower() != "bearer": + if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower(): + # Not our scheme (e.g. "Bearer "): defer, another + # backend may recognize it. + return None + + if len(parts) != 2: raise exceptions.AuthenticationFailed( - "Authorization header must be: Bearer " + f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} " ) token = parts[1] diff --git a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py index 5c00bd00..bf10d4fb 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py +++ b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py @@ -5,13 +5,16 @@ Test rooms API endpoints in the Meet core app: participants management. # pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines import random +from datetime import datetime, timedelta, timezone from unittest import mock from uuid import uuid4 +from django.conf import settings as django_settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import SuspiciousOperation from django.urls import reverse +import jwt import pytest from livekit.api import TwirpError, UpdateParticipantRequest from livekit.protocol.models import ParticipantInfo @@ -87,7 +90,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client): url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -113,7 +116,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden( url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -153,7 +156,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin( url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -300,7 +303,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client): url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -330,7 +333,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client) url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -361,7 +364,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin( url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -381,7 +384,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -412,7 +415,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant( url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -440,7 +443,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found( url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -469,7 +472,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden( url, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -1020,3 +1023,141 @@ def test_remove_participant_not_found(mock_livekit_client): assert response.data == {"error": "Participant not found"} mock_livekit_client.aclose.assert_called_once() + + +def generate_user_access_token(user): + """Generate a valid user access JWT signed with the token secret.""" + now = datetime.now(timezone.utc) + + payload = { + "iss": django_settings.USER_ACCESS_TOKEN_ISSUER, + "aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE, + "iat": now, + "exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL), + "user_id": str(user.id), + "token_type": "user_access", + "client_id": "test-app", + "scope": "user:access", + } + + return jwt.encode( + payload, + django_settings.USER_ACCESS_TOKEN_SECRET_KEY, + algorithm=django_settings.USER_ACCESS_TOKEN_ALG, + ) + + +def test_mute_participant_bearer_scheme_defers_to_next_authentication( + mock_livekit_client, +): + """Should defer a "Bearer" header to the next authentication backend. + + The LiveKit backend only claims the "X-LiveKit-Token" scheme. Any other + scheme must be left untouched so the backends declared after it get a + chance to authenticate the request. + """ + client = APIClient() + room = RoomFactory() + user = UserFactory() + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}") + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.get_participant.assert_not_called() + mock_livekit_client.room.mute_published_track.assert_called_once() + + +def test_mute_participant_bearer_scheme_defers_role_permissions_still_apply( + mock_livekit_client, +): + """Should still enforce room privileges once another backend authenticated.""" + client = APIClient() + room = RoomFactory(configuration={"everyone_can_mute": False}) + user = UserFactory() # no UserResourceAccess for this room + client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}") + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_unknown_scheme_defers_and_stays_anonymous( + mock_livekit_client, +): + """Should leave the request unauthenticated when no backend claims the scheme.""" + client = APIClient() + room = RoomFactory() + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_livekit_scheme_is_case_insensitive(mock_livekit_client): + """Should claim the LiveKit scheme whatever its casing, and not defer it.""" + client = APIClient() + room = RoomFactory() + + token = utils.generate_token(str(room.id), AnonymousUser()) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"x-livekit-token {token}", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.get_participant.assert_called_once() + mock_livekit_client.room.mute_published_track.assert_called_once() + + +def test_mute_participant_livekit_scheme_malformed_header_is_rejected( + mock_livekit_client, +): + """Should reject a malformed header once the LiveKit scheme is claimed.""" + client = APIClient() + room = RoomFactory() + + token = utils.generate_token(str(room.id), AnonymousUser()) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == { + "detail": "Authorization header must be: X-LiveKit-Token " + } + mock_livekit_client.room.mute_published_track.assert_not_called() diff --git a/src/backend/core/tests/rooms/test_api_rooms_rename_toggle.py b/src/backend/core/tests/rooms/test_api_rooms_rename_toggle.py index 72511926..f4c196af 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_rename_toggle.py +++ b/src/backend/core/tests/rooms/test_api_rooms_rename_toggle.py @@ -4,12 +4,15 @@ Test rooms API endpoints: toggle hand and rename participant. # pylint: disable=redefined-outer-name,unused-argument,protected-access +from datetime import datetime, timedelta, timezone from unittest import mock from uuid import uuid4 +from django.conf import settings as django_settings from django.contrib.auth.models import AnonymousUser from django.urls import reverse +import jwt import pytest from freezegun import freeze_time from livekit.api import TwirpError @@ -17,7 +20,7 @@ from rest_framework import status from rest_framework.test import APIClient from core import utils -from core.factories import RoomFactory, UserFactory +from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory pytestmark = pytest.mark.django_db @@ -69,7 +72,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) response = client.post( - url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -84,7 +90,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) response = client.post( - url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"raised": False}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -101,7 +110,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) response = client.post( - url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -117,7 +129,10 @@ def test_toggle_hand_identity_derived_from_token( client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) client.post( - url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) call_kwargs = mock_livekit_client.room.update_participant.call_args @@ -128,7 +143,9 @@ def test_toggle_hand_missing_raised_field(room, token): """Test toggle hand with missing raised field returns 400.""" client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) - response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}" + ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert "raised" in response.data @@ -142,7 +159,7 @@ def test_toggle_hand_invalid_raised_field(room, token): url, {"raised": "not-a-boolean"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -166,7 +183,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user): client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id}) response = client.post( - url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}" + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -181,7 +201,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) response = client.post( - url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR @@ -200,7 +223,7 @@ def test_toggle_hand_raise_success_anonymous( url, {"raised": True}, format="json", - HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}", ) assert response.status_code == status.HTTP_200_OK @@ -220,7 +243,7 @@ def test_toggle_hand_lower_success_anonymous( url, {"raised": False}, format="json", - HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}", ) assert response.status_code == status.HTTP_200_OK @@ -240,7 +263,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous( url, {"raised": True}, format="json", - HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}", ) call_kwargs = mock_livekit_client.room.update_participant.call_args @@ -257,7 +280,10 @@ def test_rename_participant_success(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) response = client.post( - url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_200_OK @@ -272,7 +298,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) client.post( - url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"name": "Jane Doe"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) call_kwargs = mock_livekit_client.room.update_participant.call_args @@ -286,7 +315,10 @@ def test_rename_participant_uses_identity_from_token( client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) client.post( - url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) call_kwargs = mock_livekit_client.room.update_participant.call_args @@ -298,7 +330,7 @@ def test_rename_participant_empty_name(room, token): client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) response = client.post( - url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}" ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -309,7 +341,9 @@ def test_rename_participant_missing_name(room, token): """Test rename with missing name field returns 400.""" client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) - response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}" + ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert "name" in response.data @@ -320,7 +354,10 @@ def test_rename_participant_name_too_long(room, token): client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) response = client.post( - url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"name": "a" * 256}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -348,7 +385,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user): url, {"name": "John Doe"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {wrong_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -363,7 +400,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) response = client.post( - url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR @@ -382,7 +422,7 @@ def test_rename_participant_success_anonymous( url, {"name": "Guest User"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}", ) assert response.status_code == status.HTTP_200_OK @@ -402,7 +442,7 @@ def test_rename_participant_uses_identity_from_token_anonymous( url, {"name": "Guest User"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}", ) call_kwargs = mock_livekit_client.room.update_participant.call_args @@ -419,7 +459,7 @@ def test_rename_participant_sets_correct_name_anonymous( url, {"name": "Guest User"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}", ) call_kwargs = mock_livekit_client.room.update_participant.call_args @@ -436,7 +476,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t url, {"name": "Guest User"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -462,7 +502,7 @@ def test_toggle_hand_expired_token(room, expired_token): url, {"raised": True}, format="json", - HTTP_AUTHORIZATION=f"Bearer {expired_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -476,7 +516,7 @@ def test_rename_participant_expired_token(room, expired_token): url, {"name": "John Doe"}, format="json", - HTTP_AUTHORIZATION=f"Bearer {expired_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -490,7 +530,7 @@ def test_toggle_hand_malformed_token(room): url, {"raised": True}, format="json", - HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt", + HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -504,7 +544,10 @@ def test_toggle_hand_room_not_found(user): client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id}) response = client.post( - url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -519,7 +562,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) response = client.post( - url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -536,7 +582,7 @@ def test_rename_participant_malformed_token(room): url, {"name": "John Doe"}, format="json", - HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt", + HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt", ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -550,7 +596,10 @@ def test_rename_participant_room_not_found(user): client = APIClient() url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id}) response = client.post( - url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -565,10 +614,205 @@ def test_rename_participant_not_found(mock_livekit_client, room, token): client = APIClient() url = reverse("rooms-rename", kwargs={"pk": room.id}) response = client.post( - url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}", ) assert response.status_code == status.HTTP_404_NOT_FOUND assert response.data == {"error": "Participant not found"} mock_livekit_client.aclose.assert_called_once() + + +@pytest.fixture +def user_access_token(user): + """Generate a valid user access JWT, sent with the "Bearer" scheme.""" + now = datetime.now(timezone.utc) + + payload = { + "iss": django_settings.USER_ACCESS_TOKEN_ISSUER, + "aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE, + "iat": now, + "exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL), + "user_id": str(user.id), + "token_type": "user_access", + "client_id": "test-app", + "scope": "user:access", + } + + return jwt.encode( + payload, + django_settings.USER_ACCESS_TOKEN_SECRET_KEY, + algorithm=django_settings.USER_ACCESS_TOKEN_ALG, + ) + + +def test_toggle_hand_bearer_scheme_defers_to_next_authentication( + mock_livekit_client, room, user, user_access_token +): + """Test toggle hand defers a "Bearer" header instead of failing on it.""" + UserResourceAccessFactory(resource=room, user=user, role="owner") + + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post( + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {user_access_token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == {"detail": "Authentication credentials were not provided."} + + mock_livekit_client.room.update_participant.assert_not_called() + + +def test_rename_participant_bearer_scheme_defers_to_next_authentication( + mock_livekit_client, room, user, user_access_token +): + """Test rename defers a "Bearer" header instead of failing on it.""" + UserResourceAccessFactory(resource=room, user=user, role="owner") + + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post( + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {user_access_token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == {"detail": "Authentication credentials were not provided."} + + mock_livekit_client.room.update_participant.assert_not_called() + + +def test_toggle_hand_unknown_scheme_defers(mock_livekit_client, room): + """Test toggle hand defers a scheme no backend recognizes.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post( + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == {"detail": "Authentication credentials were not provided."} + + mock_livekit_client.room.update_participant.assert_not_called() + + +def test_rename_participant_unknown_scheme_defers(mock_livekit_client, room): + """Test rename defers a scheme no backend recognizes.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post( + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == {"detail": "Authentication credentials were not provided."} + + mock_livekit_client.room.update_participant.assert_not_called() + + +def test_toggle_hand_session_authentication_is_not_accepted( + mock_livekit_client, room, user +): + """Test toggle hand is not granted by a session, whatever the user's room role.""" + UserResourceAccessFactory(resource=room, user=user, role="owner") + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"raised": True}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.update_participant.assert_not_called() + + +def test_rename_participant_session_authentication_is_not_accepted( + mock_livekit_client, room, user +): + """Test rename is not granted by a session, whatever the user's room role.""" + UserResourceAccessFactory(resource=room, user=user, role="owner") + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post(url, {"name": "John Doe"}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.update_participant.assert_not_called() + + +def test_rename_participant_livekit_scheme_is_case_insensitive( + mock_livekit_client, room, token +): + """Test rename claims the LiveKit scheme whatever its casing.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post( + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"x-livekit-token {token}", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.update_participant.assert_called_once() + + +def test_toggle_hand_livekit_scheme_malformed_header_is_rejected( + mock_livekit_client, room, token +): + """Test toggle hand rejects a malformed header once the LiveKit scheme is claimed.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post( + url, + {"raised": True}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == { + "detail": "Authorization header must be: X-LiveKit-Token " + } + + mock_livekit_client.room.update_participant.assert_not_called() + + +def test_rename_participant_livekit_scheme_malformed_header_is_rejected( + mock_livekit_client, room, token +): + """Test rename rejects a malformed header once the LiveKit scheme is claimed.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post( + url, + {"name": "John Doe"}, + format="json", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == { + "detail": "Authorization header must be: X-LiveKit-Token " + } + + mock_livekit_client.room.update_participant.assert_not_called() diff --git a/src/backend/core/tests/rooms/test_api_rooms_subtitle.py b/src/backend/core/tests/rooms/test_api_rooms_subtitle.py index 6c4923d8..03cf6cb9 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_subtitle.py +++ b/src/backend/core/tests/rooms/test_api_rooms_subtitle.py @@ -4,10 +4,12 @@ Test rooms API endpoints in the Meet core app: start subtitle. # pylint: disable=W0621 import uuid +from datetime import datetime, timedelta, timezone from unittest import mock from django.conf import settings +import jwt import pytest from livekit.api import AccessToken, TwirpError, VideoGrants from rest_framework.test import APIClient @@ -110,7 +112,7 @@ def test_start_subtitle_invalid_token(): response = client.post( f"/api/v1.0/rooms/{room.id}/start-subtitle/", {}, - HTTP_AUTHORIZATION="Bearer invalid-token", + HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token", ) assert response.status_code == 403 @@ -128,7 +130,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token): response = client.post( f"/api/v1.0/rooms/{room.id}/start-subtitle/", {}, - HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}", ) assert response.status_code == 404 @@ -148,7 +150,7 @@ def test_start_subtitle_valid_token( response = client.post( f"/api/v1.0/rooms/{room.id}/start-subtitle/", {}, - HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}", ) assert response.status_code == 200 @@ -178,7 +180,7 @@ def test_start_subtitle_twirp_error( response = client.post( f"/api/v1.0/rooms/{room.id}/start-subtitle/", {}, - HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}", ) assert response.status_code == 500 @@ -198,7 +200,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token): response = client.post( f"/api/v1.0/rooms/{room.id}/start-subtitle/", {}, - HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}", ) assert response.status_code == 403 @@ -219,10 +221,132 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token): response = client.post( f"/api/v1.0/rooms/{room.id}/start-subtitle/", {}, - HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", + HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}", ) assert response.status_code == 403 assert response.json() == { "detail": "Invalid LiveKit token: Signature verification failed" } + + +def generate_user_access_token(user): + """Generate a valid user access JWT, sent with the "Bearer" scheme.""" + + now = datetime.now(timezone.utc) + + payload = { + "iss": settings.USER_ACCESS_TOKEN_ISSUER, + "aud": settings.USER_ACCESS_TOKEN_AUDIENCE, + "iat": now, + "exp": now + timedelta(seconds=settings.USER_ACCESS_TOKEN_TTL), + "user_id": str(user.id), + "token_type": "user_access", + "client_id": "test-app", + "scope": "user:access", + } + + return jwt.encode( + payload, + settings.USER_ACCESS_TOKEN_SECRET_KEY, + algorithm=settings.USER_ACCESS_TOKEN_ALG, + ) + + +def test_start_subtitle_bearer_scheme_defers_to_next_authentication( + settings, mock_livekit_client +): + """Test that a "Bearer" header is deferred instead of failing on the LiveKit backend. + + The action declares LiveKitTokenAuthentication as its only backend, so a + scheme it does not own must be left to the next one. None follows, so the + request ends up unauthenticated: the body reports missing credentials + rather than an invalid LiveKit token. + """ + + settings.ROOM_SUBTITLE_ENABLED = True + + room = RoomFactory() + user = UserFactory() + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-subtitle/", + {}, + HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called() + + +def test_start_subtitle_unknown_scheme_defers(settings, mock_livekit_client): + """Test that a scheme no backend recognizes is deferred, not rejected.""" + + settings.ROOM_SUBTITLE_ENABLED = True + + room = RoomFactory() + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-subtitle/", + {}, + HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called() + + +def test_start_subtitle_scheme_is_case_insensitive( + settings, mock_livekit_client, mock_livekit_token, mock_room_id +): + """Test that the LiveKit scheme is claimed whatever its casing.""" + + settings.ROOM_SUBTITLE_ENABLED = True + + room = RoomFactory(id=mock_room_id) + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-subtitle/", + {}, + HTTP_AUTHORIZATION=f"x-livekit-token {mock_livekit_token}", + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success"} + + mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once() + + +def test_start_subtitle_malformed_header_is_rejected( + settings, mock_livekit_client, mock_livekit_token +): + """Test that a malformed header is rejected once the LiveKit scheme is claimed.""" + + settings.ROOM_SUBTITLE_ENABLED = True + + room = RoomFactory() + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-subtitle/", + {}, + HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token} extra-part", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Authorization header must be: X-LiveKit-Token " + } + + mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called() diff --git a/src/frontend/src/features/rooms/api/muteParticipant.ts b/src/frontend/src/features/rooms/api/muteParticipant.ts index 02f88bb5..2f1bd1ce 100644 --- a/src/frontend/src/features/rooms/api/muteParticipant.ts +++ b/src/frontend/src/features/rooms/api/muteParticipant.ts @@ -10,6 +10,7 @@ import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner' import { useCallback } from 'react' import { reportError } from '@/features/analytics/telemetry' +import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders' export const useMuteParticipant = () => { const apiRoomData = useRoomData() @@ -40,7 +41,7 @@ export const useMuteParticipant = () => { } const headers = !isAdminOrOwner - ? { Authorization: `Bearer ${apiRoomData.livekit.token}` } + ? getLiveKitAuthHeaders(apiRoomData.livekit.token) : undefined let response diff --git a/src/frontend/src/features/rooms/api/renameParticipant.ts b/src/frontend/src/features/rooms/api/renameParticipant.ts index f26600d7..ed32f908 100644 --- a/src/frontend/src/features/rooms/api/renameParticipant.ts +++ b/src/frontend/src/features/rooms/api/renameParticipant.ts @@ -1,5 +1,6 @@ import { fetchApi } from '@/api/fetchApi' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders' export const useRenameParticipant = () => { const data = useRoomData() @@ -15,11 +16,10 @@ export const useRenameParticipant = () => { throw new Error('LiveKit token is not available') } + const headers = getLiveKitAuthHeaders(token) return fetchApi(`rooms/${data.id}/rename/`, { method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify({ name, }), diff --git a/src/frontend/src/features/rooms/api/updateRaiseHand.ts b/src/frontend/src/features/rooms/api/updateRaiseHand.ts index 97accb18..1f6041f1 100644 --- a/src/frontend/src/features/rooms/api/updateRaiseHand.ts +++ b/src/frontend/src/features/rooms/api/updateRaiseHand.ts @@ -1,5 +1,6 @@ import { fetchApi } from '@/api/fetchApi' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders' export const useRaiseHand = () => { const data = useRoomData() @@ -15,11 +16,10 @@ export const useRaiseHand = () => { throw new Error('LiveKit token is not available') } + const headers = getLiveKitAuthHeaders(token) return fetchApi(`rooms/${data.id}/toggle-hand/`, { method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify({ raised, }), diff --git a/src/frontend/src/features/rooms/utils/getLiveKitAuthHeaders.ts b/src/frontend/src/features/rooms/utils/getLiveKitAuthHeaders.ts new file mode 100644 index 00000000..44ec3e60 --- /dev/null +++ b/src/frontend/src/features/rooms/utils/getLiveKitAuthHeaders.ts @@ -0,0 +1,7 @@ +const LIVEKIT_AUTH_SCHEME = 'X-LiveKit-Token' + +export const getLiveKitAuthHeaders = (token: string) => { + return { + Authorization: `${LIVEKIT_AUTH_SCHEME} ${token}`, + } +} diff --git a/src/frontend/src/features/subtitle/api/startSubtitle.ts b/src/frontend/src/features/subtitle/api/startSubtitle.ts index 429f26c9..6c8e5591 100644 --- a/src/frontend/src/features/subtitle/api/startSubtitle.ts +++ b/src/frontend/src/features/subtitle/api/startSubtitle.ts @@ -2,6 +2,7 @@ import { useMutation, type UseMutationOptions } from '@tanstack/react-query' import { fetchApi } from '@/api/fetchApi' import type { ApiError } from '@/api/ApiError' import type { ApiRoom } from '@/features/rooms/api/ApiRoom' +import { getLiveKitAuthHeaders } from '@/features/rooms/utils/getLiveKitAuthHeaders' export interface StartSubtitleParams { id: string @@ -14,9 +15,7 @@ const startSubtitle = ({ }: StartSubtitleParams): Promise => { return fetchApi(`rooms/${id}/start-subtitle/`, { method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, + headers: getLiveKitAuthHeaders(token), }) }