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 56af5551..42efae8b 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 @@ -102,7 +105,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 @@ -128,7 +131,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 @@ -168,7 +171,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 @@ -315,7 +318,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 @@ -345,7 +348,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 @@ -376,7 +379,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 @@ -396,7 +399,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 @@ -427,7 +430,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 @@ -455,7 +458,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 @@ -484,7 +487,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 @@ -1035,3 +1038,142 @@ 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) + application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION]) + + 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": application.client_id, + "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 6f0dcedb..01afb290 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 @@ -75,7 +78,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 @@ -90,7 +96,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 @@ -107,7 +116,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 @@ -123,7 +135,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 @@ -134,7 +149,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 @@ -148,7 +165,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 @@ -172,7 +189,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 @@ -187,7 +207,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 @@ -206,7 +229,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 @@ -226,7 +249,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 @@ -246,7 +269,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 @@ -263,7 +286,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 @@ -278,7 +304,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 @@ -292,7 +321,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 @@ -304,7 +336,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 @@ -315,7 +347,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 @@ -326,7 +360,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 @@ -354,7 +391,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 @@ -369,7 +406,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 @@ -388,7 +428,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 @@ -408,7 +448,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 @@ -425,7 +465,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 @@ -442,7 +482,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 @@ -468,7 +508,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 @@ -482,7 +522,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 @@ -496,7 +536,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 @@ -510,7 +550,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 @@ -525,7 +568,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 @@ -542,7 +588,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 @@ -556,7 +602,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 @@ -571,10 +620,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 f87a5836..b0e7da97 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 @@ -111,7 +113,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 @@ -129,7 +131,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 @@ -149,7 +151,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 @@ -179,7 +181,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 @@ -199,7 +201,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 @@ -220,10 +222,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), }) }