♻️(backend) use a dedicated auth scheme for LiveKit token auth

We now use `Authorization: Bearer <token>` to authenticate users
from the token exchange flow (used for iframe embeds).

Until now, the `Bearer` scheme was also reused for the alternative
LiveKit authentication, where a client presents its LiveKit token
issued by the backend to prove room membership on actions open to
any room participant. Sharing the scheme between the two flows is
not viable anymore.

Switch the LiveKit token authentication to a dedicated
`Authorization` scheme, so `Bearer` stays reserved for the iframe /
token-exchange flow.

Follow-up: a broader effort should look into harmonizing and
hardening the backend authentication stack of the app.
This commit is contained in:
lebaudantoine
2026-08-02 18:59:02 +02:00
parent de73870a34
commit cd4dd0ef1e
9 changed files with 583 additions and 60 deletions
+9 -2
View File
@@ -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 <user access token>"): defer, another
# backend may recognize it.
return None
if len(parts) != 2:
raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>"
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
)
token = parts[1]
@@ -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 <token>"
}
mock_livekit_client.room.mute_published_track.assert_not_called()
@@ -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 <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 <token>"
}
mock_livekit_client.room.update_participant.assert_not_called()
@@ -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 <token>"
}
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
@@ -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
@@ -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,
}),
@@ -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,
}),
@@ -0,0 +1,7 @@
const LIVEKIT_AUTH_SCHEME = 'X-LiveKit-Token'
export const getLiveKitAuthHeaders = (token: string) => {
return {
Authorization: `${LIVEKIT_AUTH_SCHEME} ${token}`,
}
}
@@ -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<ApiRoom> => {
return fetchApi(`rooms/${id}/start-subtitle/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: getLiveKitAuthHeaders(token),
})
}