Compare commits

...

5 Commits

Author SHA1 Message Date
lebaudantoine 90920f9d42 ♻️(all) stop relying on cookies for the lobby flow
The lobby system relied on cookies to identify the participant
across the wait/enter cycle, which does not work in an iframe
context where our cookies are dropped.

Simplify the lobby behavior:

* The POST request that enters the lobby now returns the
  participant id in the response.
* The frontend passes that id back on subsequent requests to keep a
  sticky session while trying to enter the room.

This moves a bit more logic to the frontend but should be a
transparent refactoring, without decreasing the security of the
lobby flow.
2026-08-17 17:35:26 +02:00
lebaudantoine ed3784e1ca 🩹(frontend) unblock virtual background loading under bearer auth
Moving off cookie-based authentication surfaced several hard
issues, especially around loading virtual backgrounds: requests
used to be sent with cookies automatically, which trivially
authenticated those loads. With bearer tokens, those requests need
to be authenticated explicitly.

The situation is made harder by the fact that, when the custom
virtual background was introduced, some of the loading was done as
module-level, blocking imports that are not handled by React and
therefore live outside the normal auth flow.

Ship a functional patch to unblock third parties currently waiting
on this integration. The virtual background loading path should
definitely be refactored and simplified in a follow-up.
2026-08-17 17:31:31 +02:00
lebaudantoine b029688e41 (frontend) support alternative auth via URL fragment
Wire the frontend to the backend's token exchange flow: when the
expected URL fragment is present, gate the app loading on exchanging
that fragment for a proper access token, which is then used to
interact with the API.

When no such fragment is present, the code path is a no-op and
should have minimal impact on load performance.
2026-08-17 17:27:23 +02:00
lebaudantoine cd4dd0ef1e ♻️(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.
2026-08-17 17:24:06 +02:00
lebaudantoine de73870a34 (backend) introduce a token exchange endpoint for iframe embeds
Some integrators render our videoconference inside an iframe, where
our cookie-based authentication does not work: our cookies are
SameSite=Lax/Strict, so the iframe drops them.

We looked at what Jitsi offers: a shared secret used to sign JWTs
that authenticate users coming from external services. Since we
already expose an external API where third parties authenticate as
a given user, it was simpler for us to add an exchange mechanism on
top of that.

Flow:

* Through the external API, mint a short-lived, single-use exchange
  code for a user.
* The third party hands that code to the frontend as a URL fragment.
* The frontend exchanges the code for a longer-lived JWT that can be
  used to query the regular API viewsets.

Known limitations and follow-ups:

* At some point it would be nice to shorten the JWT lifetime and
  add a refresh mechanism. This will be handled in a follow-up PR
  when actually needed.
* CSP rules to control which origins are allowed to embed the app
  in an iframe still need to be added.
* This alternative authentication cannot easily be scoped to a
  subset of endpoints without adding a lot of complexity, so it is
  accepted globally on the API for now.
2026-08-17 17:14:11 +02:00
50 changed files with 2598 additions and 426 deletions
+1
View File
@@ -18,6 +18,7 @@ class FeatureFlag:
"application": "APPLICATION_ENABLED", "application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED", "roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED", "connection_test": "CONNECTION_TEST_ENABLED",
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
} }
@classmethod @classmethod
+22
View File
@@ -292,6 +292,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data.""" """Validate request entry data."""
username = serializers.CharField(required=True) username = serializers.CharField(required=True)
participant_id = serializers.UUIDField(required=False, allow_null=True)
def validate_participant_id(self, value):
"""The id is a bearer credential: never trusted, only looked up."""
return str(value) if value else None
class ParticipantEntrySerializer(BaseValidationOnlySerializer): class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -599,3 +604,20 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
# useless bad requests # useless bad requests
type = serializers.CharField(required=False, allow_null=True, allow_blank=True) type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
status = serializers.CharField(required=False, allow_null=True, allow_blank=True) status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class TransitCodeSerializer(BaseValidationOnlySerializer):
"""Validate the single-use transit code sent to the exchange endpoint."""
code = serializers.CharField(trim_whitespace=True)
def validate_code(self, value):
"""Reject codes whose length cannot match a generated one."""
# Calculates urlsafe_b64encode length without padding
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
if len(value) != expected_length:
raise serializers.ValidationError("Invalid transit code format.")
return value
+27 -11
View File
@@ -1,11 +1,11 @@
"""Throttling modules for the API.""" """Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from sentry_sdk import capture_message from sentry_sdk import capture_message
from . import serializers
def sentry_monitoring_throttle_failure(message): def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues.""" """Log when a failure occurs to detect rate limiting issues."""
@@ -42,13 +42,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
def get_cache_key(self, request, view): def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key. """Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet, Only throttle requests carrying a participant identifier. The
return None to skip throttling — the cookie will be set on the first identifier is returned by the first request-entry response and
response, and throttling will apply from the second request onward. echoed back by the client from the second request onward, which is
when throttling starts applying.
Keying on the cookie rather than the IP address prevents penalising Keying on the identifier rather than the IP address prevents
multiple users behind the same NAT/proxy, and is consistent with how penalising multiple users behind the same NAT/proxy, and is
LobbyService identifies participants. consistent with how the lobby identifies participants.
Note: as per DRF documentation, application-level throttling is not a Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists security measure against brute-force or DoS attacks. This throttle exists
@@ -58,10 +59,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
if request.user and request.user.is_authenticated: if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests. return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME) serializer = serializers.RequestEntrySerializer(data=request.data)
if not serializer.is_valid():
return None
if participant_id is None: participant_id = serializer.validated_data.get("participant_id")
return None # No throttling for cookieless requests
if not participant_id:
return None # No throttling for unidentified requests
return self.cache_format % { return self.cache_format % {
"scope": self.scope, "scope": self.scope,
@@ -97,3 +102,14 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens.""" """Throttle anonymous users requesting connection test tokens."""
scope = "connection_test" scope = "connection_test"
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous transit code exchange attempts.
Abuse mitigation only, not a security boundary: DRF throttling is
best-effort. The security of the exchange rests on the codes'
entropy and single use.
"""
scope = "exchange_access_token"
+72 -5
View File
@@ -75,6 +75,7 @@ from core.recording.worker.mediator import (
WorkerServiceMediator, WorkerServiceMediator,
) )
from core.services.invitation import InvitationService from core.services.invitation import InvitationService
from core.services.jwt_token import JwtTokenService
from core.services.livekit_events import ( from core.services.livekit_events import (
LiveKitEventsService, LiveKitEventsService,
LiveKitWebhookError, LiveKitWebhookError,
@@ -99,6 +100,7 @@ from core.services.room_roles import (
RoomRoleService, RoomRoleService,
) )
from core.services.subtitle import SubtitleException, SubtitleService from core.services.subtitle import SubtitleException, SubtitleService
from core.services.transit_code import TransitCodeService
from core.tasks.connection_test import delete_connection_test_room from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion from core.tasks.file import process_file_deletion
from core.utils import generate_token from core.utils import generate_token
@@ -237,6 +239,74 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data self.serializer_class(request.user, context=context).data
) )
@decorators.action(
detail=False,
methods=["post"],
url_path="exchange-access-token",
permission_classes=[],
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
)
@FeatureFlag.require("user_access_token")
def exchange_access_token(self, request):
"""Exchange a single-use transit code for a user access token.
The endpoint is unauthenticated: the transit code itself, an opaque
random string obtained through the external API and delivered to
the embedded frontend via a URL fragment, is the credential. Each
code can be exchanged exactly once (consuming it deletes it from
the cache); replaying a consumed code is denied and logged.
The issued JWT authenticates the user the code was minted for on
the whole core API, exactly like a session cookie would (similar
to lib-jitsi-meet's token authentication), and never appears in
any URL. Role-based permissions apply unchanged.
"""
serializer = serializers.TransitCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
if code_data is None:
logger.warning("Invalid, expired or already used transit code")
raise drf_exceptions.PermissionDenied(
"Invalid, expired or already used transit code."
)
# Re-check the user at exchange time so that a deactivation after
# the transit code was minted is taken into account.
try:
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
except models.User.DoesNotExist as excpt:
raise drf_exceptions.PermissionDenied(
"This account can no longer access the application."
) from excpt
token_service = JwtTokenService(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
)
data = token_service.generate_jwt(
user,
"user:access",
{
"client_id": code_data.get("client_id", "unknown"),
},
)
# Log for auditing
logger.info(
"User access token issued from transit code: user_id=%s, client_id=%s",
user.id,
code_data.get("client_id", "unknown"),
)
return drf_response.Response(data)
class RoomViewSet( class RoomViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
@@ -523,13 +593,10 @@ class RoomViewSet(
participant, livekit = lobby_service.request_entry( participant, livekit = lobby_service.request_entry(
room=room, room=room,
request=request, user=request.user,
**serializer.validated_data, **serializer.validated_data,
) )
response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) return drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
return response
@decorators.action( @decorators.action(
detail=True, detail=True,
+9 -2
View File
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
UserModel = get_user_model() UserModel = get_user_model()
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
class LiveKitTokenAuthentication(authentication.BaseAuthentication): class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user.""" """Authenticate using LiveKit token and load the associated Django user."""
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
return None # No authentication attempted return None # No authentication attempted
parts = auth_header.split() 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( raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>" f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
) )
token = parts[1] token = parts[1]
@@ -0,0 +1,68 @@
"""User access JWT authentication for the Meet core API.
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
session cookies are blocked) to authenticate requests on the core API with
a JWT, obtained by exchanging a single-use transit code (see
core.services.transit_code and the users exchange-access-token endpoint)
and passed as a Bearer header. The JWT itself never appears in any URL.
Similar to lib-jitsi-meet's token authentication, the token is bound to a
user, not to a resource: once authenticated, the request is treated
exactly like a session-authenticated one, and the existing role-based
permissions apply unchanged.
"""
import logging
from django.conf import settings
from rest_framework import exceptions
from core.external_api.authentication import BaseJWTAuthentication
logger = logging.getLogger(__name__)
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
class UserAccessJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for user access tokens.
Validates user access tokens issued by the users exchange-access-token
endpoint and authenticates the user they were issued for. A bearer
token that does not verify against the user access token secret is
deferred to the next authentication backend; a token that does verify
but carries wrong claims is rejected.
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
backend is entirely inert: `BaseJWTAuthentication.authenticate`
returns None before reading the Authorization header, deferring every
request to the next authentication backend.
"""
def __init__(self):
"""Initialize the backend with user access token settings."""
super().__init__(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
)
def validate_payload(self, payload):
"""Validate the token type and the issuance-audit claim.
Raises:
AuthenticationFailed: If the token verified against the user
access token secret but does not carry the expected claims.
"""
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
logger.warning("Wrong 'token_type' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token type.")
if not payload.get("client_id"):
logger.warning("Missing 'client_id' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -86,6 +86,14 @@ class HasRequiredRoomScope(BaseScopePermission):
} }
class HasRequiredUserScope(BaseScopePermission):
"""Scope-based permissions for the external user endpoints."""
scope_map = {
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
}
class RoomPermissions(permissions.BasePermission): class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint.""" """Permissions applying to the room API endpoint."""
+60
View File
@@ -22,6 +22,7 @@ from rest_framework import (
from core import analytics, api, models from core import analytics, api, models
from core.api.feature_flag import FeatureFlag from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService from core.services.jwt_token import JwtTokenService
from core.services.transit_code import TransitCodeService
from ..services.provisional_user_service import ( from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError, ProvisionalUserCreationDisabledError,
@@ -218,3 +219,62 @@ class RoomViewSet(
"$set": {"email": self.request.user.email}, "$set": {"email": self.request.user.email},
}, },
) )
class UserViewSet(viewsets.GenericViewSet):
"""Application-delegated API for user operations.
Provides JWT-authenticated access to user operations for external
applications acting on behalf of users. All operations are
scope-based. Meant to grow with the other user actions exposed to
third parties.
Supported operations:
- transit-code: Mint a single-use transit code for the delegated user
(requires 'users:session' scope)
"""
authentication_classes = [
authentication.ApplicationJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
]
@decorators.action(
detail=False,
methods=["post"],
url_path="transit-code",
url_name="transit-code",
)
@FeatureFlag.require("user_access_token")
def generate_transit_code(self, request):
"""Mint a transit code for the delegated user.
Returns a short-lived, single-use opaque code to pass to an embedded
frontend (e.g. via a URL fragment when cookies are unavailable). The
frontend exchanges it once on
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
equivalent to session-cookie authentication and never exposed in a URL.
"""
auth_method = type(request.successful_authenticator).__name__
client_id = (request.auth or {}).get("client_id", "unknown")
code = TransitCodeService().create_code(request.user, client_id=client_id)
# Log for auditing
logger.info(
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
request.user.id,
client_id,
auth_method,
)
return drf_response.Response(
{
"transit_code": code,
"expires_in": settings.TRANSIT_CODE_TTL,
},
status=drf_status.HTTP_200_OK,
)
@@ -0,0 +1,19 @@
# Generated by Django 5.2.14 on 2026-07-31 18:27
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_recording_external_process_id_alter_recording_status'),
]
operations = [
migrations.AlterField(
model_name='application',
name='scopes',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
),
]
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0021_recording_external_process_id_alter_recording_status'), ('core', '0022_alter_application_scopes'),
] ]
operations = [ operations = [
+1
View File
@@ -795,6 +795,7 @@ class ApplicationScope(models.TextChoices):
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details") ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms") ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms") ROOMS_DELETE = "rooms:delete", _("Delete rooms")
USERS_SESSION = "users:session", _("Create user session tokens")
class Application(BaseModel): class Application(BaseModel):
+44 -61
View File
@@ -86,23 +86,6 @@ class LobbyService:
"""Generate cache key for participant(s) data.""" """Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
@staticmethod
def prepare_response(response, participant_id):
"""Set participant cookie if needed."""
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
response.set_cookie(
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
samesite="Lax",
)
@staticmethod @staticmethod
def can_bypass_lobby(room, user, role) -> bool: def can_bypass_lobby(room, user, role) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly. """Determines if a user can bypass the waiting lobby and join a room directly.
@@ -133,8 +116,9 @@ class LobbyService:
def request_entry( def request_entry(
self, self,
room: models.Room, room: models.Room,
request, user,
username: str, username: str,
participant_id: Optional[uuid.UUID] = None,
) -> Tuple[LobbyParticipant, Optional[Dict]]: ) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant. """Request entry to a room for a participant.
@@ -149,51 +133,48 @@ class LobbyService:
5. If denied, do nothing. 5. If denied, do nothing.
""" """
participant_id = self._get_or_create_participant_id(request) participant = None
if participant_id:
participant = self._get_participant(room.id, participant_id) participant = self._get_participant(room.id, participant_id)
room_id = str(room.id) is_new_participant = participant is None
user_role = room.get_role(request.user) if is_new_participant:
participant = self._create_participant(room.id, username)
if self.can_bypass_lobby(room=room, user=request.user, role=user_role): room_id = str(room.id)
if participant is None: user_role = room.get_role(user)
participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED, if self.can_bypass_lobby(room=room, user=user, role=user_role):
username=username, participant = self.handle_participant_entry(room_id, participant.id, True)
id=participant_id,
color=utils.generate_color(participant_id),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=user,
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
participant_id=participant_id, participant_id=participant.id,
role=user_role, role=user_role,
) )
return participant, livekit_config return participant, livekit_config
livekit_config = None livekit_config = None
if participant is None: if is_new_participant:
participant = self.enter(room.id, participant_id, username) self._notify_entry_request(room_id)
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id) self.refresh_waiting_status(room.id, participant.id)
elif participant.status == LobbyParticipantStatus.ACCEPTED: elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room # wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=user,
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
participant_id=participant_id, participant_id=participant.id,
role=user_role, role=user_role,
) )
@@ -210,27 +191,36 @@ class LobbyService:
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
) )
def enter( def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
self, room_id: UUID, participant_id: str, username: str """Create and persist a new waiting participant.
) -> LobbyParticipant:
"""Add participant to waiting lobby.
Create a new participant entry in waiting status and notify room Participant identifiers are minted here, server-side, exclusively.
participants of the new entry request.
""" """
participant_id = str(uuid.uuid4())
color = utils.generate_color(participant_id)
participant = LobbyParticipant( participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING, status=LobbyParticipantStatus.WAITING,
username=username, username=username,
id=participant_id, id=participant_id,
color=color, color=utils.generate_color(participant_id),
)
self._save_participant(room_id, participant)
return participant
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
"""Persist a participant in the room's lobby."""
cache.set(
self._get_cache_key(room_id, participant.id),
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
@staticmethod
def _notify_entry_request(room_id: str):
"""Notify room participants of a new entry request."""
try: try:
utils.notify_participants( utils.notify_participants(
room_name=str(room_id), room_name=room_id,
notification_data={ notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE, "type": settings.LOBBY_NOTIFICATION_TYPE,
}, },
@@ -239,15 +229,6 @@ class LobbyService:
# If room not created yet, there is no participants to notify # If room not created yet, there is no participants to notify
logger.exception("Failed to notify room participants") logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
cache_key,
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
return participant
def _get_participant( def _get_participant(
self, room_id: UUID, participant_id: str self, room_id: UUID, participant_id: str
) -> Optional[LobbyParticipant]: ) -> Optional[LobbyParticipant]:
@@ -294,7 +275,7 @@ class LobbyService:
room_id: UUID, room_id: UUID,
participant_id: str, participant_id: str,
allow_entry: bool, allow_entry: bool,
) -> None: ) -> LobbyParticipant:
"""Handle decision on participant entry. """Handle decision on participant entry.
Updates participant status based on allow_entry: Updates participant status based on allow_entry:
@@ -312,7 +293,7 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT, "timeout": settings.LOBBY_DENIED_TIMEOUT,
} }
self._update_participant_status(room_id, participant_id, **decision) return self._update_participant_status(room_id, participant_id, **decision)
def _update_participant_status( def _update_participant_status(
self, self,
@@ -320,7 +301,7 @@ class LobbyService:
participant_id: str, participant_id: str,
status: LobbyParticipantStatus, status: LobbyParticipantStatus,
timeout: int, timeout: int,
) -> None: ) -> LobbyParticipant:
"""Update participant status with appropriate timeout.""" """Update participant status with appropriate timeout."""
cache_key = self._get_cache_key(room_id, participant_id) cache_key = self._get_cache_key(room_id, participant_id)
@@ -342,6 +323,8 @@ class LobbyService:
participant.status = status participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
return participant
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.""" """Clear all participant entries from the cache for a specific room."""
+74
View File
@@ -0,0 +1,74 @@
"""Service handling the lifecycle of transit codes.
A transit code is an opaque, cryptographically random, single-use code
handed to an embedded frontend (through a URL fragment) so it can obtain a
user access token on the core API without a session cookie. The code
carries no information by itself: everything it references (user, client)
is stored server-side in the cache, and consumed atomically on exchange.
"""
import hashlib
import secrets
from django.conf import settings
from django.core.cache import cache
class TransitCodeService:
"""Create and consume single-use transit codes."""
@staticmethod
def _cache_key(code):
"""Build the cache key for a code.
The code is hashed so that a dump of the cache never reveals
directly usable codes.
"""
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
def create_code(self, user, client_id="unknown"):
"""Generate a transit code for a user, and store it.
The code expires after TRANSIT_CODE_TTL seconds.
Returns:
str: The opaque code to hand to the client.
"""
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
# entropy: unguessable and safe to transit through a URL fragment.
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
cache.set(
self._cache_key(code),
{
"user_id": str(user.id),
"client_id": client_id,
},
timeout=settings.TRANSIT_CODE_TTL,
)
return code
def consume_code(self, code):
"""Consume a transit code, enforcing single use.
The code is deleted from the cache upon consumption. `cache.delete`
returns whether a key was actually deleted, so if two requests race
on the same code, only one of them wins.
Returns:
dict | None: The data stored at creation time ('user_id',
'client_id'), or None if the code is unknown, expired or
already consumed.
"""
if not code:
return None
key = self._cache_key(code)
data = cache.get(key)
if data is None or not cache.delete(key):
return None
return data
@@ -2,10 +2,13 @@
Test rooms API endpoints in the Meet core app: create. Test rooms API endpoints in the Meet core app: create.
""" """
from datetime import datetime, timedelta, timezone
# pylint: disable=redefined-outer-name,unused-argument # pylint: disable=redefined-outer-name,unused-argument
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
import jwt
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -312,3 +315,38 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
assert response.status_code == 201 assert response.status_code == 201
room = Room.objects.get() room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
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_api_rooms_create_authenticated_with_user_access_token():
"""A user access token should create a room exactly like a session would."""
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
assert response.status_code == 201
room = Room.objects.get()
assert room.accesses.filter(role="owner", user=user).exists()
@@ -2,8 +2,12 @@
Test rooms API endpoints in the Meet core app: list. Test rooms API endpoints in the Meet core app: list.
""" """
from datetime import datetime, timedelta, timezone
from unittest import mock from unittest import mock
from django.conf import settings as django_settings
import jwt
import pytest import pytest
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -156,3 +160,40 @@ def test_api_rooms_list_pagination_page_size():
assert len(content["results"]) == 3 assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3" assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None assert content["previous"] is None
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_api_rooms_list_authenticated_with_user_access_token():
"""A user access token should list rooms exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
RoomFactory() # another user's room, not listed
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
@@ -14,9 +14,6 @@ from rest_framework.test import APIClient
from ... import utils from ... import utils
from ...factories import RoomFactory, UserFactory from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel from ...models import RoomAccessLevel
from ...services.lobby import (
LobbyService,
)
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -29,7 +26,6 @@ def test_request_entry_anonymous(settings):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -47,11 +43,10 @@ def test_request_entry_anonymous(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was properly set # The participant identifier is returned in the response body; no
cookie = response.cookies.get("mocked-cookie") # cookie is involved anymore
assert cookie is not None assert not response.cookies
participant_id = response.json()["id"]
participant_id = cookie.value
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
@@ -78,7 +73,6 @@ def test_request_entry_authenticated_user(settings):
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -96,11 +90,10 @@ def test_request_entry_authenticated_user(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was properly set # The participant identifier is returned in the response body; no
cookie = response.cookies.get("mocked-cookie") # cookie is involved anymore
assert cookie is not None assert not response.cookies
participant_id = response.json()["id"]
participant_id = cookie.value
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
@@ -127,7 +120,6 @@ def test_request_entry_with_existing_participants(settings):
client = APIClient() client = APIClient()
# Configure test settings for cookies and cache # Configure test settings for cookies and cache
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add two participants already waiting in the lobby # Add two participants already waiting in the lobby
@@ -168,11 +160,10 @@ def test_request_entry_with_existing_participants(settings):
# Verify successful response # Verify successful response
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was properly set for the new participant # The participant identifier is returned in the response body; no
cookie = response.cookies.get("mocked-cookie") # cookie is involved anymore
assert cookie is not None assert not response.cookies
participant_id = response.json()["id"]
participant_id = cookie.value
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
@@ -197,7 +188,6 @@ def test_request_entry_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -206,9 +196,7 @@ def test_request_entry_public_room(settings):
with ( with (
mock.patch.object(utils, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch.object( mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
), ),
@@ -221,11 +209,6 @@ def test_request_entry_public_room(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
"id": "123", "id": "123",
@@ -235,9 +218,14 @@ def test_request_entry_public_room(settings):
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
# Verify lobby cache is still empty after the request # The accepted participant is persisted, out of the waiting list
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys assert len(lobby_keys) == 1
ttl = cache.ttl(lobby_keys[0])
assert ttl is not None
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
assert cache.get(lobby_keys[0])["status"] == "accepted"
def test_request_entry_authenticated_user_public_room(settings): def test_request_entry_authenticated_user_public_room(settings):
@@ -247,7 +235,6 @@ def test_request_entry_authenticated_user_public_room(settings):
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -256,9 +243,8 @@ def test_request_entry_authenticated_user_public_room(settings):
with ( with (
mock.patch.object(utils, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch(
LobbyService, "core.services.lobby.uuid.uuid4",
"_get_or_create_participant_id",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def", return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
), ),
mock.patch.object( mock.patch.object(
@@ -273,11 +259,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
@@ -287,9 +268,13 @@ def test_request_entry_authenticated_user_public_room(settings):
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
# Verify lobby cache is still empty after the request # The accepted participant is persisted, out of the waiting list
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys assert len(lobby_keys) == 1
assert cache.get(lobby_keys[0])["status"] == "accepted"
ttl = cache.ttl(lobby_keys[0])
assert ttl is not None
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
def test_request_entry_waiting_participant_public_room(settings): def test_request_entry_waiting_participant_public_room(settings):
@@ -297,7 +282,6 @@ def test_request_entry_waiting_participant_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add a waiting participant to the room's lobby cache # Add a waiting participant to the room's lobby cache
@@ -311,9 +295,7 @@ def test_request_entry_waiting_participant_public_room(settings):
}, },
) )
# Simulate a browser with existing participant cookie # Simulate a returning participant echoing its identifier
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
with ( with (
mock.patch.object(utils, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
@@ -322,16 +304,14 @@ def test_request_entry_waiting_participant_public_room(settings):
): ):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "user1"}, {
"username": "user1",
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
},
) )
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
@@ -345,6 +325,11 @@ def test_request_entry_waiting_participant_public_room(settings):
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1 assert len(lobby_keys) == 1
ttl = cache.ttl(lobby_keys[0])
assert ttl is not None
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
assert cache.get(lobby_keys[0])["status"] == "accepted"
def test_request_entry_invalid_data(): def test_request_entry_invalid_data():
"""Should return 400 for invalid request data.""" """Should return 400 for invalid request data."""
@@ -637,15 +622,14 @@ def test_list_waiting_participants_empty(settings):
@mock.patch.object( @mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
) )
def test_request_entry_throttling_anonymous_without_cookie( def test_request_entry_throttling_anonymous_unidentified(
mock_notify_participants, mock_generate_livekit_config, settings mock_notify_participants, mock_generate_livekit_config, settings
): ):
"""Anonymous users without a cookie should not be throttled.""" """Requests without a participant identifier should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute" settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post( response = client.post(
@@ -654,9 +638,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
@@ -670,34 +651,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
@mock.patch.object( @mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
) )
def test_request_entry_throttling_anonymous_with_cookie( def test_request_entry_throttling_anonymous_identified(
mock_notify_participants, mock_generate_livekit_config, settings mock_notify_participants, mock_generate_livekit_config, settings
): ):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit.""" """Identified requests should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute" settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4()) participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"}, {"username": "test_user", "participant_id": participant_id},
) )
assert response.status_code == 200 assert response.status_code == 200
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"}, {"username": "test_user", "participant_id": participant_id},
) )
assert response.status_code == 200 assert response.status_code == 200
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"}, {"username": "test_user", "participant_id": participant_id},
) )
assert response.status_code == 429 assert response.status_code == 429
@@ -716,7 +695,6 @@ def test_request_entry_throttling_authenticated_user(
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute" settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post( response = client.post(
@@ -737,3 +715,124 @@ def test_request_entry_throttling_authenticated_user(
) )
assert response.status_code == 429 assert response.status_code == 429
def test_request_entry_with_participant_id(settings):
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
participant_id = response.json()["id"]
# Echoing the identifier must be recognized as the same
# participant: no duplicate in the lobby
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] == participant_id
assert response.json()["status"] == "waiting"
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
def test_request_entry_unknown_participant_id_not_seeded(settings):
"""An identifier unknown to the room's lobby must not be honored."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
forged_id = str(uuid.uuid4())
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": forged_id},
)
assert response.status_code == 200
assert response.json()["id"] != forged_id
# Nothing was stored under the forged identifier
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
def test_request_entry_participant_id_bound_to_room(settings):
"""An identifier minted for one room must not be honored in another."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
participant_id = response.json()["id"]
response = client.post(
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] != participant_id
def test_request_entry_legacy_cookie_ignored():
"""The retired cookie channel must not be honored anymore."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
legacy_participant_id = str(uuid.uuid4())
client.cookies["lobbyParticipantId"] = legacy_participant_id
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
returned_id = response.json()["id"]
assert returned_id != legacy_participant_id
uuid.UUID(returned_id)
def test_request_entry_malformed_participant_id(settings):
"""A non-UUID identifier is rejected by the serializer with a 400."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": "../../../evil-key"},
)
assert response.status_code == 400
assert "participant_id" in response.json()
@@ -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 # pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
import random import random
from datetime import datetime, timedelta, timezone
from unittest import mock from unittest import mock
from uuid import uuid4 from uuid import uuid4
from django.conf import settings as django_settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.urls import reverse from django.urls import reverse
import jwt
import pytest import pytest
from livekit.api import TwirpError, UpdateParticipantRequest from livekit.api import TwirpError, UpdateParticipantRequest
from livekit.protocol.models import ParticipantInfo from livekit.protocol.models import ParticipantInfo
@@ -20,7 +23,11 @@ from rest_framework.test import APIClient
from core import utils from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService from core.services.lobby import (
LobbyParticipant,
LobbyParticipantStatus,
LobbyService,
)
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -87,7 +94,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -113,7 +120,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -153,7 +160,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -300,7 +307,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -330,7 +337,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -361,7 +368,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -381,7 +388,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -412,7 +419,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -440,7 +447,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -469,7 +476,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -849,7 +856,15 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
participant_identity = str(uuid4()) participant_identity = str(uuid4())
# Create participant in lobby cache first # Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe") LobbyService()._save_participant(
room.id,
LobbyParticipant(
id=participant_identity,
username="John doe",
status=LobbyParticipantStatus.WAITING,
color="#123456",
),
)
# Accept participant # Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True) LobbyService().handle_participant_entry(room.id, participant_identity, True)
@@ -1020,3 +1035,141 @@ def test_remove_participant_not_found(mock_livekit_client):
assert response.data == {"error": "Participant not found"} assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once() 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 # pylint: disable=redefined-outer-name,unused-argument,protected-access
from datetime import datetime, timedelta, timezone
from unittest import mock from unittest import mock
from uuid import uuid4 from uuid import uuid4
from django.conf import settings as django_settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.urls import reverse from django.urls import reverse
import jwt
import pytest import pytest
from freezegun import freeze_time from freezegun import freeze_time
from livekit.api import TwirpError from livekit.api import TwirpError
@@ -17,7 +20,7 @@ from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import utils from core import utils
from core.factories import RoomFactory, UserFactory from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -69,7 +72,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( 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 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() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( 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 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() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( 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 assert response.status_code == status.HTTP_200_OK
@@ -117,7 +129,10 @@ def test_toggle_hand_identity_derived_from_token(
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post( 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 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.""" """Test toggle hand with missing raised field returns 400."""
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) 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 response.status_code == status.HTTP_400_BAD_REQUEST
assert "raised" in response.data assert "raised" in response.data
@@ -142,7 +159,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
url, url,
{"raised": "not-a-boolean"}, {"raised": "not-a-boolean"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_400_BAD_REQUEST 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() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
response = client.post( 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 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() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( 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 assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -200,7 +223,7 @@ def test_toggle_hand_raise_success_anonymous(
url, url,
{"raised": True}, {"raised": True},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -220,7 +243,7 @@ def test_toggle_hand_lower_success_anonymous(
url, url,
{"raised": False}, {"raised": False},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -240,7 +263,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
url, url,
{"raised": True}, {"raised": True},
format="json", 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 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() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( 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 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() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post( 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 call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -286,7 +315,10 @@ def test_rename_participant_uses_identity_from_token(
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post( 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 call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -298,7 +330,7 @@ def test_rename_participant_empty_name(room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( 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 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.""" """Test rename with missing name field returns 400."""
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) 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 response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data assert "name" in response.data
@@ -320,7 +354,10 @@ def test_rename_participant_name_too_long(room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( 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 assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -348,7 +385,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
url, url,
{"name": "John Doe"}, {"name": "John Doe"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {wrong_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN 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() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( 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 assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -382,7 +422,7 @@ def test_rename_participant_success_anonymous(
url, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -402,7 +442,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
url, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", 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 call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -419,7 +459,7 @@ def test_rename_participant_sets_correct_name_anonymous(
url, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", 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 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, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -462,7 +502,7 @@ def test_toggle_hand_expired_token(room, expired_token):
url, url,
{"raised": True}, {"raised": True},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -476,7 +516,7 @@ def test_rename_participant_expired_token(room, expired_token):
url, url,
{"name": "John Doe"}, {"name": "John Doe"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -490,7 +530,7 @@ def test_toggle_hand_malformed_token(room):
url, url,
{"raised": True}, {"raised": True},
format="json", 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 assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -504,7 +544,10 @@ def test_toggle_hand_room_not_found(user):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id}) url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
response = client.post( 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 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() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( 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 assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -536,7 +582,7 @@ def test_rename_participant_malformed_token(room):
url, url,
{"name": "John Doe"}, {"name": "John Doe"},
format="json", 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 assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -550,7 +596,10 @@ def test_rename_participant_room_not_found(user):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id}) url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
response = client.post( 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.status_code == status.HTTP_404_NOT_FOUND
@@ -565,10 +614,205 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( 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.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"} assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once() 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()
@@ -3,11 +3,14 @@ Test rooms API endpoints in the Meet core app: retrieve.
""" """
import random import random
from datetime import datetime, timedelta, timezone
from unittest import mock from unittest import mock
from django.conf import settings as django_settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings from django.test.utils import override_settings
import jwt
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -507,3 +510,40 @@ def test_api_rooms_retrieve_administrators(
role=str(user_access.role), role=str(user_access.role),
participant_id=None, participant_id=None,
) )
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_api_rooms_retrieve_authenticated_with_user_access_token():
"""A user access token should retrieve a room exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.data["id"] == str(room.id)
assert response.data["pin_code"] == room.pin_code
assert "accesses" in response.data
@@ -4,10 +4,12 @@ Test rooms API endpoints in the Meet core app: start subtitle.
# pylint: disable=W0621 # pylint: disable=W0621
import uuid import uuid
from datetime import datetime, timedelta, timezone
from unittest import mock from unittest import mock
from django.conf import settings from django.conf import settings
import jwt
import pytest import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants from livekit.api import AccessToken, TwirpError, VideoGrants
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -110,7 +112,7 @@ def test_start_subtitle_invalid_token():
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", 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 assert response.status_code == 403
@@ -128,7 +130,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", 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 assert response.status_code == 404
@@ -148,7 +150,7 @@ def test_start_subtitle_valid_token(
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", 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 assert response.status_code == 200
@@ -178,7 +180,7 @@ def test_start_subtitle_twirp_error(
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", 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 assert response.status_code == 500
@@ -198,7 +200,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", 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.status_code == 403
@@ -219,10 +221,132 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", 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.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed" "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()
@@ -3,8 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
""" """
import random import random
from datetime import datetime, timedelta, timezone
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings as django_settings
import jwt
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -437,3 +441,45 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"configuration": {"can_publish_sources": ["camera"]}, "configuration": {"can_publish_sources": ["camera"]},
}, },
) )
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_api_rooms_update_authenticated_with_user_access_token():
"""Role-based permissions apply unchanged with a user access token."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
# A simple member cannot update the room
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 403
# An administrator can
room.accesses.filter(user=user).update(role="administrator")
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "new name"
+123 -190
View File
@@ -3,15 +3,13 @@ Test lobby service.
""" """
# pylint: disable=W0621,W0613, W0212, R0913 # pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913, PLR0917
import uuid import uuid
from unittest import mock from unittest import mock
from django.conf import settings from django.conf import settings as django_settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpResponse
import pytest import pytest
@@ -131,63 +129,10 @@ def test_get_cache_key(lobby_service, participant_id):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = lobby_service._get_cache_key(room.id, participant_id) cache_key = lobby_service._get_cache_key(room.id, participant_id)
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}" expected_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
assert cache_key == expected_key assert cache_key == expected_key
def test_get_or_create_participant_id_from_cookie(lobby_service):
"""Test extracting participant ID from cookie."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "existing-id"
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
request = mock.Mock()
request.COOKIES = {}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "generated-id"
mock_uuid4.assert_called_once()
def test_prepare_response_existing_cookie(lobby_service, participant_id):
"""Test response preparation with existing cookie."""
response = HttpResponse()
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
lobby_service.prepare_response(response, participant_id)
# Verify cookie wasn't set again
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie.value == "existing-cookie"
assert cookie.value != participant_id
def test_prepare_response_new_cookie(lobby_service, participant_id):
"""Test response preparation with new cookie."""
response = HttpResponse()
lobby_service.prepare_response(response, participant_id)
# Verify cookie was set
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie is not None
assert cookie.value == participant_id
assert cookie["httponly"] is True
assert cookie["secure"] is True
assert cookie["samesite"] == "Lax"
# It's a session cookies (no max_age specified):
assert not cookie["max-age"]
def test_can_bypass_lobby_public_room(lobby_service): def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth and role.""" """Should return True for public rooms regardless of user auth and role."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -251,92 +196,97 @@ def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
@mock.patch("core.utils.generate_livekit_config") @mock.patch("core.utils.generate_livekit_config")
def test_request_entry_public_room( def test_request_entry_public_room(
mock_generate_config, lobby_service, participant_id, username mock_generate_config, lobby_service, participant_id, username, settings
): ):
"""Test requesting entry to a public room.""" """Test requesting entry to a public room."""
request = mock.Mock() settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
request.user = AnonymousUser()
user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
mocked_participant = LobbyParticipant( cache.set(
status=LobbyParticipantStatus.UNKNOWN, f"mocked-cache-prefix_{room.id}_{participant_id}",
username=username, {
id=participant_id, "id": participant_id,
color="#123456", "username": username,
"status": "waiting",
"color": "#123456",
},
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"} mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"} assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with( mock_generate_config.assert_called_once_with(
room_id=str(room.id), room_id=str(room.id),
user=request.user, user=user,
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id=participant_id,
role=None, role=None,
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config") @mock.patch("core.utils.generate_livekit_config")
def test_request_entry_trusted_room( def test_request_entry_trusted_room(
mock_generate_config, lobby_service, participant_id, username mock_generate_config, lobby_service, participant_id, username, settings
): ):
"""Test requesting entry to a trusted room when the user is authenticated.""" """Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock() settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
request.user = UserFactory()
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
mocked_participant = LobbyParticipant( cache.set(
status=LobbyParticipantStatus.UNKNOWN, f"mocked-cache-prefix_{room.id}_{participant_id}",
username=username, {
id=participant_id, "id": participant_id,
color="#123456", "username": username,
"status": "waiting",
"color": "#123456",
},
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"} mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"} assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with( mock_generate_config.assert_called_once_with(
room_id=str(room.id), room_id=str(room.id),
user=request.user, user=user,
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id=participant_id,
role=None, role=None,
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
@mock.patch("core.services.lobby.LobbyService.enter") @mock.patch("core.services.lobby.LobbyService._create_participant")
def test_request_entry_new_participant( def test_request_entry_new_participant(
mock_enter, lobby_service, participant_id, username mock_create, mock_notify, lobby_service, participant_id, username
): ):
"""Test requesting entry for a new participant.""" """A new participant gets a server-minted identifier - any provided
request = mock.Mock() one is unknown to the lobby and therefore discarded - and the room is
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} notified of the entry request."""
request.user = AnonymousUser()
user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=None) lobby_service._get_participant = mock.Mock(return_value=None)
participant_data = LobbyParticipant( participant_data = LobbyParticipant(
@@ -345,14 +295,20 @@ def test_request_entry_new_participant(
id=participant_id, id=participant_id,
color="#123456", color="#123456",
) )
mock_enter.return_value = participant_data mock_create.return_value = participant_data
participant, livekit_config = lobby_service.request_entry(room, request, username) forged_id = str(uuid.uuid4())
participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=forged_id
)
assert participant == participant_data assert participant == participant_data
assert livekit_config is None assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username) # The provided identifier was looked up, found unknown, and replaced
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) # by a freshly minted participant
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
mock_create.assert_called_once_with(room.id, username)
mock_notify.assert_called_once_with(str(room.id))
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status") @mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
@@ -360,9 +316,7 @@ def test_request_entry_waiting_participant(
mock_refresh, lobby_service, participant_id, username mock_refresh, lobby_service, participant_id, username
): ):
"""Test requesting entry for a waiting participant.""" """Test requesting entry for a waiting participant."""
request = mock.Mock() user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -372,10 +326,11 @@ def test_request_entry_waiting_participant(
id=participant_id, id=participant_id,
color="#123456", color="#123456",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.WAITING assert participant.status == LobbyParticipantStatus.WAITING
assert livekit_config is None assert livekit_config is None
@@ -385,80 +340,83 @@ def test_request_entry_waiting_participant(
@mock.patch("core.utils.generate_livekit_config") @mock.patch("core.utils.generate_livekit_config")
def test_request_entry_accepted_participant( def test_request_entry_accepted_participant(
mock_generate_config, lobby_service, participant_id, username mock_generate_config, lobby_service, participant_id, username, settings
): ):
"""Test requesting entry for an accepted participant.""" """Test requesting entry for an accepted participant."""
request = mock.Mock() settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
request.user = AnonymousUser() user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mocked_participant = LobbyParticipant( cache.set(
status=LobbyParticipantStatus.ACCEPTED, f"mocked-cache-prefix_{room.id}_{participant_id}",
username=username, {
id=participant_id, "id": participant_id,
color="#123456", "username": username,
"status": "accepted",
"color": "#123456",
},
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"} mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"} assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with( mock_generate_config.assert_called_once_with(
room_id=str(room.id), room_id=str(room.id),
user=request.user, user=user,
username=username, username=username,
color="#123456", color="#123456",
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None, role=None,
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config") @mock.patch("core.utils.generate_livekit_config")
def test_request_entry_participant_with_role( def test_request_entry_participant_with_role(
mock_generate_config, lobby_service, participant_id, username mock_generate_config, lobby_service, participant_id, username, settings
): ):
"""Test requesting entry for a participant with a role on the room.""" """Test requesting entry for a participant with a role on the room."""
request = mock.Mock() settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
request.user = UserFactory()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
UserResourceAccessFactory(resource=room, user=request.user, role="administrator") UserResourceAccessFactory(resource=room, user=user, role="administrator")
mocked_participant = LobbyParticipant( cache.set(
status=LobbyParticipantStatus.ACCEPTED, f"mocked-cache-prefix_{room.id}_{participant_id}",
username=username, {
id=participant_id, "id": participant_id,
color="#123456", "username": username,
"status": "accepted",
"color": "#123456",
},
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"} mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"} assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with( mock_generate_config.assert_called_once_with(
room_id=str(room.id), room_id=str(room.id),
user=request.user, user=user,
username=username, username=username,
color="#123456", color="#123456",
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role="administrator", role="administrator",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@@ -468,77 +426,50 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id) lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with( mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT "mocked_cache_key", django_settings.LOBBY_WAITING_TIMEOUT
) )
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") def test_create_participant(
def test_enter_success(
mock_notify,
mock_generate_color, mock_generate_color,
mock_cache, mock_cache,
lobby_service, lobby_service,
participant_id,
username, username,
): ):
"""Test successful participant entry.""" """A created participant is waiting, colored, and persisted."""
mock_generate_color.return_value = "#123456" mock_generate_color.return_value = "#123456"
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username) participant = lobby_service._create_participant(room.id, username)
mock_generate_color.assert_called_once_with(participant_id) # The identifier is minted server-side
uuid.UUID(participant.id)
mock_generate_color.assert_called_once_with(participant.id)
assert participant.status == LobbyParticipantStatus.WAITING assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456" assert participant.color == "#123456"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id) lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
mock_cache.set.assert_called_once_with( mock_cache.set.assert_called_once_with(
"mocked_cache_key", "mocked_cache_key",
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=django_settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
) )
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error( def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
mock_notify, """A notification error must not break the entry request flow."""
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
mock_notify.side_effect = NotificationError("Error notifying") mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) lobby_service._notify_entry_request("room-id")
participant = lobby_service.enter(room.id, participant_id, username)
mock_generate_color.assert_called_once_with(participant_id) mock_notify.assert_called_once_with(
assert participant.status == LobbyParticipantStatus.WAITING room_name="room-id", notification_data={"type": "participantWaiting"}
assert participant.username == username
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
@@ -584,7 +515,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
assert result == [] assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_not_called() mock_cache.get_many.assert_not_called()
@@ -593,7 +524,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict): def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data.""" """Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key] mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict} mock_cache.get_many.return_value = {cache_key: participant_dict}
@@ -602,7 +533,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert len(result) == 1 assert len(result) == 1
assert result[0]["status"] == "waiting" assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username" assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key]) mock_cache.get_many.assert_called_once_with([cache_key])
@@ -611,8 +542,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
def test_list_waiting_participants_multiple(mock_cache, lobby_service): def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"""Test listing multiple waiting participants with valid data.""" """Test listing multiple waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2" cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = { participant1 = {
"status": "waiting", "status": "waiting",
@@ -645,7 +576,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
# Verify all participants have waiting status # Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result) assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -654,7 +585,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service): def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data.""" """Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key] mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}} mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
@@ -668,8 +599,8 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service): def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
"""Test listing waiting participants with one valid and one corrupted entry.""" """Test listing waiting participants with one valid and one corrupted entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2" cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
valid_participant = { valid_participant = {
"status": "waiting", "status": "waiting",
@@ -698,7 +629,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
mock_cache.delete.assert_called_once_with(cache_key1) mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried # Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -707,8 +638,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service): def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"""Test listing only waiting participants (not accepted/denied).""" """Test listing only waiting participants (not accepted/denied)."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2" cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = { participant1 = {
"status": "waiting", "status": "waiting",
@@ -746,7 +677,7 @@ def test_handle_participant_entry_allow(mock_update, lobby_service, participant_
room.id, room.id,
participant_id, participant_id,
status=LobbyParticipantStatus.ACCEPTED, status=LobbyParticipantStatus.ACCEPTED,
timeout=settings.LOBBY_ACCEPTED_TIMEOUT, timeout=django_settings.LOBBY_ACCEPTED_TIMEOUT,
) )
@@ -760,7 +691,7 @@ def test_handle_participant_entry_deny(mock_update, lobby_service, participant_i
room.id, room.id,
participant_id, participant_id,
status=LobbyParticipantStatus.DENIED, status=LobbyParticipantStatus.DENIED,
timeout=settings.LOBBY_DENIED_TIMEOUT, timeout=django_settings.LOBBY_DENIED_TIMEOUT,
) )
@@ -901,14 +832,16 @@ def test_clear_participant_cache(lobby_service):
room_id = uuid.uuid4() room_id = uuid.uuid4()
participant_id = "test-participant-id" participant_id = "test-participant-id"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
participant_data = { participant_data = {
"status": "waiting", "status": "waiting",
"username": "test-username", "username": "test-username",
"id": participant_id, "id": participant_id,
"color": "#123456", "color": "#123456",
} }
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT) cache.set(
cache_key, participant_data, timeout=django_settings.LOBBY_WAITING_TIMEOUT
)
assert cache.get(cache_key) is not None assert cache.get(cache_key) is not None
lobby_service.clear_participant_cache(room_id, participant_id) lobby_service.clear_participant_cache(room_id, participant_id)
@@ -920,7 +853,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
room_id = uuid.uuid4() room_id = uuid.uuid4()
participant_id = "nonexistent-participant" participant_id = "nonexistent-participant"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
assert cache.get(cache_key) is None assert cache.get(cache_key) is None
lobby_service.clear_participant_cache(room_id, participant_id) lobby_service.clear_participant_cache(room_id, participant_id)
@@ -0,0 +1,58 @@
"""
Unit tests for the TransitCodeService.
"""
from unittest.mock import patch
import pytest
from core.factories import UserFactory
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def test_create_code_returns_unique_opaque_codes():
"""Each created code should be a distinct high-entropy string."""
user = UserFactory()
service = TransitCodeService()
codes = {service.create_code(user) for _ in range(5)}
assert len(codes) == 5
for code in codes:
assert len(code) >= 43
def test_consume_code_returns_stored_data_once():
"""Consuming a code should return its data exactly once."""
user = UserFactory()
service = TransitCodeService()
code = service.create_code(user, client_id="my-app")
assert service.consume_code(code) == {
"user_id": str(user.id),
"client_id": "my-app",
}
# Single use: a second consumption fails
assert service.consume_code(code) is None
def test_consume_code_unknown_or_empty():
"""Unknown or empty codes should not be consumable."""
service = TransitCodeService()
assert service.consume_code("unknown-code") is None
assert service.consume_code("") is None
assert service.consume_code(None) is None
@patch("core.services.transit_code.cache.delete", return_value=False)
def test_consume_code_returns_none_when_delete_loses_the_race(mock_delete):
"""If the code was already deleted by a concurrent request, consumption fails."""
user = UserFactory()
service = TransitCodeService()
code = service.create_code(user, client_id="my-app")
assert service.consume_code(code) is None
mock_delete.assert_called_once()
@@ -0,0 +1,200 @@
"""
Tests for user access JWT authentication on the core API.
The token authenticates the user on the whole API, exactly like a session
cookie would (similar to lib-jitsi-meet's token authentication): the
existing role-based permissions apply unchanged. Room endpoint coverage
with a user access token lives in the room test files.
"""
from datetime import datetime, timedelta, timezone
from django.conf import settings as django_settings
import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoleChoices
pytestmark = pytest.mark.django_db
def generate_user_access_token(user, **overrides):
"""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",
}
payload.update(overrides)
payload = {key: value for key, value in payload.items() if value is not None}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_user_access_token_users_me():
"""A user access token should authenticate the user on /users/me/."""
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.data["email"] == user.email
def test_user_access_token_expired():
"""An expired user access token should be rejected."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = generate_user_access_token(
user,
iat=now - timedelta(hours=3),
exp=now - timedelta(hours=1),
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "token expired" in str(response.data).lower()
def test_user_access_token_invalid_signature():
"""A token signed with the wrong key should defer and end unauthenticated."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = jwt.encode(
{
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=600),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
},
"wrong-secret-key-padded-for-minimum-len!",
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# UserAccessJWTAuthentication defers, session auth finds no session
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_wrong_token_type():
"""A verified token with the wrong 'token_type' claim should be rejected."""
user = UserFactory()
token = generate_user_access_token(user, token_type="addons")
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "invalid token type" in str(response.data).lower()
def test_user_access_token_missing_client_id_claim():
"""A token without the issuance-audit claim should be rejected."""
user = UserFactory()
token = generate_user_access_token(user, client_id=None)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "invalid token claims" in str(response.data).lower()
def test_user_access_token_inactive_user():
"""A user access token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_feature_disabled(settings):
"""When the feature is disabled, user access tokens should be ignored."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_does_not_break_session_authentication():
"""A session-authenticated user should keep full access to the API."""
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
def test_user_access_token_application_jwt_not_accepted_on_core_api():
"""An application-delegation JWT must not authenticate on the core API."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = jwt.encode(
{
"iss": django_settings.APPLICATION_JWT_ISSUER,
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=600),
"user_id": str(user.id),
"client_id": "some-client",
"delegated": True,
"scope": "rooms:retrieve",
},
django_settings.APPLICATION_JWT_SECRET_KEY,
algorithm=django_settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# The user token backend must defer (wrong signature) and the request
# must end up unauthenticated.
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
@@ -0,0 +1,171 @@
"""
Test users API endpoints in the Meet core app: exchange transit code.
"""
# pylint: disable=W0621
import secrets
import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def decode_user_access_token(token, settings):
"""Decode a user access token with the token secret."""
return jwt.decode(
token,
settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
)
def generate_unknown_code(settings):
"""Generate a well-formed code that was never stored."""
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
@pytest.fixture
def client():
"""Return an anonymous API client with a random source IP.
A fresh IP per test isolates the anonymous throttle history, both
between the tests of this module and between test runs.
"""
# `secrets` rather than `random`: the global random module is seeded
# deterministically by the factories, its sequence repeats across runs.
remote_addr = (
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
f".{secrets.randbelow(254) + 1}"
)
return APIClient(REMOTE_ADDR=remote_addr)
def test_exchange_access_token_missing_code(client):
"""The exchange endpoint should validate its input."""
response = client.post("/api/v1.0/users/exchange-access-token/")
assert response.status_code == 400
assert "code" in response.data
def test_exchange_access_token_get_method(client):
"""The exchange endpoint should not accept GET."""
response = client.get("/api/v1.0/users/exchange-access-token/")
assert response.status_code == 405
def test_exchange_access_token_malformed_code(client):
"""A code whose length cannot match a generated one should be a 400."""
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": "not-a-valid-code"},
)
assert response.status_code == 400
assert "invalid transit code format" in str(response.data).lower()
def test_exchange_access_token_unknown_code(client, settings):
"""A well-formed but unknown code should be denied."""
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 403
assert "invalid, expired or already used" in str(response.data).lower()
def test_exchange_access_token_success(client, settings):
"""A valid transit code should be exchangeable for an access token."""
user = UserFactory()
code = TransitCodeService().create_code(user, client_id="my-app")
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
assert response.data["scope"] == "user:access"
payload = decode_user_access_token(response.data["access_token"], settings)
assert payload["user_id"] == str(user.id)
assert payload["client_id"] == "my-app"
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
def test_exchange_access_token_single_use(client):
"""A transit code should be exchangeable exactly once."""
user = UserFactory()
code = TransitCodeService().create_code(user)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
# Replaying the same code must be denied
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
assert "invalid, expired or already used" in str(response.data).lower()
def test_exchange_access_token_inactive_user(client):
"""A code minted for a now-inactive user should be denied."""
user = UserFactory()
code = TransitCodeService().create_code(user)
user.is_active = False
user.save()
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
assert "no longer access" in str(response.data).lower()
def test_exchange_access_token_feature_disabled(client, settings):
"""The exchange endpoint should return 404 when the feature is disabled."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
code = TransitCodeService().create_code(user)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 404
def test_exchange_access_token_throttled(client, settings):
"""Anonymous exchange attempts should be rate limited."""
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
initial_rate = throttle_rates["exchange_access_token"]
# The rates dict is mutated in place: restore it explicitly, the
# `settings` fixture only rolls back attribute assignments.
throttle_rates["exchange_access_token"] = "2/minute"
try:
for _ in range(2):
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 403
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 429
finally:
throttle_rates["exchange_access_token"] = initial_rate
@@ -0,0 +1,217 @@
"""
Tests for external API /users endpoints (transit codes)
"""
# pylint: disable=W0621
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings as django_settings
import jwt
import pytest
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import ApplicationFactory, UserFactory
from core.models import ApplicationScope
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def generate_addons_test_token(user, scopes):
"""Generate a valid JWT token signed with the addons secret for testing."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.ADDONS_TOKEN_ISSUER,
"aud": django_settings.ADDONS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.ADDONS_TOKEN_TTL),
"scope": " ".join(scopes),
"user_id": str(user.id),
}
return jwt.encode(
payload,
django_settings.ADDONS_TOKEN_SECRET_KEY,
algorithm=django_settings.ADDONS_TOKEN_ALG,
)
def generate_test_token(user, scopes):
"""Generate a valid application JWT token for testing."""
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": django_settings.APPLICATION_JWT_ISSUER,
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": str(application.client_id),
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
}
return jwt.encode(
payload,
django_settings.APPLICATION_JWT_SECRET_KEY,
algorithm=django_settings.APPLICATION_JWT_ALG,
)
def test_api_users_transit_code_requires_authentication():
"""Minting a transit code without authentication should return 401."""
client = APIClient()
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
def test_api_users_transit_code_missing_scope():
"""A token without the 'users:session' scope should be rejected."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 403
assert "users:session" in str(response.data)
def test_api_users_transit_code_success(settings):
"""A delegated user with the scope should be able to mint a transit code."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 200
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
code = response.data["transit_code"]
# Opaque, high-entropy random string
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
# The code is stored server-side and references the delegated user
code_data = TransitCodeService().consume_code(code)
assert code_data == {
"user_id": str(user.id),
"client_id": mock.ANY,
}
def test_api_users_transit_code_with_rs_token_get_forbidden():
"""A resource-server-authenticated user should not be able to mint a code with a GET."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
user = UserFactory()
with mock.patch.object(
ResourceServerAuthentication,
"authenticate",
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
):
response = client.get("/external-api/v1.0/users/transit-code/")
assert response.status_code == 405
def test_api_users_transit_code_with_rs_token():
"""A resource-server-authenticated user should be able to mint a code."""
user = UserFactory()
with mock.patch.object(
ResourceServerAuthentication,
"authenticate",
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
) as mock_rs_authenticate:
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
response = client.post("/external-api/v1.0/users/transit-code/")
mock_rs_authenticate.assert_called_once()
assert response.status_code == 200
code_data = TransitCodeService().consume_code(response.data["transit_code"])
assert code_data == {
"user_id": str(user.id),
"client_id": "rs-client",
}
def test_api_users_transit_code_with_rs_token_missing_scope():
"""A resource server token without the scope should be rejected."""
user = UserFactory()
with mock.patch.object(
ResourceServerAuthentication,
"authenticate",
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 403
assert "users:session" in str(response.data)
def test_api_users_transit_code_feature_disabled(settings):
"""Minting a transit code should return 404 when the feature is disabled."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 404
def test_api_users_transit_code_inactive_user():
"""An inactive user should not be able to mint a transit code."""
user = UserFactory(is_active=False)
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
def test_api_users_transit_code_rejects_addons_token():
"""An addons token must not be able to mint a transit code.
The token carries the 'users:session' scope and is signed with the addons
secret, so only the missing backend stands between it and a transit code.
"""
user = UserFactory()
token = generate_addons_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
with mock.patch.object(
ResourceServerAuthentication, "authenticate", return_value=None
):
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
+5
View File
@@ -48,6 +48,11 @@ external_router.register(
external_viewsets.RoomViewSet, external_viewsets.RoomViewSet,
basename="external_room", basename="external_room",
) )
external_router.register(
"users",
external_viewsets.UserViewSet,
basename="external_user",
)
urlpatterns = [ urlpatterns = [
path( path(
+64 -5
View File
@@ -324,6 +324,7 @@ class Base(Configuration):
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( "DEFAULT_AUTHENTICATION_CLASSES": (
"core.authentication.user_token.UserAccessJWTAuthentication",
"core.authentication.backends.SessionAuthenticationWith401", "core.authentication.backends.SessionAuthenticationWith401",
), ),
"DEFAULT_PARSER_CLASSES": [ "DEFAULT_PARSER_CLASSES": [
@@ -344,6 +345,11 @@ class Base(Configuration):
environ_name="REQUEST_ENTRY_THROTTLE_RATES", environ_name="REQUEST_ENTRY_THROTTLE_RATES",
environ_prefix=None, environ_prefix=None,
), ),
"exchange_access_token": values.Value(
default="30/minute",
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
environ_prefix=None,
),
"creation_callback": values.Value( "creation_callback": values.Value(
default="600/minute", default="600/minute",
environ_name="CREATION_CALLBACK_THROTTLE_RATES", environ_name="CREATION_CALLBACK_THROTTLE_RATES",
@@ -875,11 +881,6 @@ class Base(Configuration):
environ_name="LOBBY_NOTIFICATION_TYPE", environ_name="LOBBY_NOTIFICATION_TYPE",
environ_prefix=None, environ_prefix=None,
) )
LOBBY_COOKIE_NAME = values.Value(
"lobbyParticipantId",
environ_name="LOBBY_COOKIE_NAME",
environ_prefix=None,
)
# Calendar integrations # Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue( ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
@@ -1002,6 +1003,61 @@ class Base(Configuration):
environ_name="APPLICATION_BASE_URL", environ_name="APPLICATION_BASE_URL",
environ_prefix=None, environ_prefix=None,
) )
# User access tokens (embedded frontend / iframe support)
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
)
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
)
USER_ACCESS_TOKEN_ALG = values.Value(
"HS256",
environ_name="USER_ACCESS_TOKEN_ALG",
environ_prefix=None,
)
USER_ACCESS_TOKEN_ISSUER = values.Value(
"lasuite-meet",
environ_name="USER_ACCESS_TOKEN_ISSUER",
environ_prefix=None,
)
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
None,
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
environ_prefix=None,
)
# Lifetime of the user access token obtained through the exchange
# endpoint. It never transits through a URL, so it can cover a full
# meeting (default: 2 hours).
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
7200,
environ_name="USER_ACCESS_TOKEN_TTL",
environ_prefix=None,
)
# Lifetime of the single-use transit code handed to the frontend
# through a URL fragment. Kept very short by design: it must only
# survive the redirect and the exchange call.
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
60,
environ_name="TRANSIT_CODE_TTL",
environ_prefix=None,
)
TRANSIT_CODE_CACHE_PREFIX = values.Value(
"transit-code",
environ_name="TRANSIT_CODE_CACHE_PREFIX",
environ_prefix=None,
)
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
48,
environ_name="TRANSIT_CODE_NBYTES",
environ_prefix=None,
)
USER_ACCESS_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="USER_ACCESS_TOKEN_TYPE",
environ_prefix=None,
)
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when # Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public. # EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue( EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
@@ -1298,6 +1354,9 @@ class Test(Base):
ADDONS_ENABLED = True ADDONS_ENABLED = True
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105 ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105 ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
USER_ACCESS_TOKEN_ENABLED = True
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
CONNECTION_TEST_ENABLED = True CONNECTION_TEST_ENABLED = True
+8 -1
View File
@@ -12,6 +12,7 @@ import { routes } from './routes'
import './i18n/init' import './i18n/init'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization' import { AppInitialization } from '@/components/AppInitialization'
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext' import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts' import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
@@ -24,13 +25,18 @@ function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TransitCodeGate>
{!isSDKContext && <AppInitialization />} {!isSDKContext && <AppInitialization />}
<Suspense fallback={null}> <Suspense fallback={null}>
<I18nProvider locale={i18n.language}> <I18nProvider locale={i18n.language}>
<Layout> <Layout>
<Switch> <Switch>
{Object.entries(routes).map(([, route], i) => ( {Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} /> <Route
key={i}
path={route.path}
component={route.Component}
/>
))} ))}
<Route component={NotFoundScreen} /> <Route component={NotFoundScreen} />
</Switch> </Switch>
@@ -41,6 +47,7 @@ function App() {
/> />
</I18nProvider> </I18nProvider>
</Suspense> </Suspense>
</TransitCodeGate>
</QueryClientProvider> </QueryClientProvider>
) )
} }
+6
View File
@@ -1,17 +1,23 @@
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import { apiUrl } from './apiUrl' import { apiUrl } from './apiUrl'
import { getAccessToken } from '@/stores/accessToken'
export const fetchApi = async <T = Record<string, unknown>>( export const fetchApi = async <T = Record<string, unknown>>(
url: string, url: string,
options?: RequestInit options?: RequestInit
): Promise<T> => { ): Promise<T> => {
const csrfToken = getCsrfToken() const csrfToken = getCsrfToken()
// Embedded (iframe) mode: the user access token obtained through the
// transit code exchange authenticates requests in place of the session
// cookie, which is blocked in third-party contexts.
const accessToken = getAccessToken()
const response = await fetch(apiUrl(url), { const response = await fetch(apiUrl(url), {
credentials: 'include', credentials: 'include',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(!!csrfToken && { 'X-CSRFToken': csrfToken }), ...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
...options?.headers, ...options?.headers,
}, },
}) })
@@ -0,0 +1,64 @@
import { fetchApi } from '@/api/fetchApi'
import { setAccessToken } from '@/stores/accessToken'
import { consumeTransitCodeFromFragment } from '../utils/transitCode'
type ApiAccessToken = {
access_token: string
token_type: string
expires_in: number
scope: string
}
/**
* Exchange a single-use transit code for a user access token.
*
* The endpoint is unauthenticated: the code itself is the credential.
*/
export const exchangeAccessToken = (code: string): Promise<ApiAccessToken> => {
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
method: 'POST',
body: JSON.stringify({ code }),
})
}
const runInitialization = async (): Promise<void> => {
const code = consumeTransitCodeFromFragment()
if (!code) {
return
}
try {
const { access_token } = await exchangeAccessToken(code)
setAccessToken(access_token)
} catch (error) {
console.warn('Transit code exchange failed:', error)
}
}
let initialization: Promise<void> | null = null
/**
* Bootstrap the embedded (iframe) authentication, if applicable.
*
* When, and only when, a transit code is present in the URL fragment,
* exchange it for a user access token and keep it in the in-memory
* accessToken store: fetchApi then sends it as a Bearer header on every
* api call, authenticating the user exactly like a session cookie would.
*
* Must complete before anything fires an authenticated query, which the
* TransitCodeGate component guarantees by gating the app tree on it.
*
* Memoized: the fragment is consumed and the code exchanged exactly once,
* however many times this is called (StrictMode double-invoked effects,
* among others). Subsequent calls await the same promise.
*
* A failed exchange (expired or already used code) is not fatal: the app
* starts unauthenticated, falling back to the regular session flow.
*/
export const initializeAccessTokenFromFragment = (): Promise<void> => {
if (!initialization) {
initialization = runInitialization()
}
return initialization
}
@@ -2,6 +2,7 @@ import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { type ApiUser } from './ApiUser' import { type ApiUser } from './ApiUser'
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin' import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
import { getAccessToken } from '@/stores/accessToken'
/** /**
* fetch the logged-in user from the api. * fetch the logged-in user from the api.
@@ -25,7 +26,13 @@ export const fetchUser = (
if (error instanceof ApiError && error.statusCode === 401) { if (error instanceof ApiError && error.statusCode === 401) {
// make sure to not resolve the promise while trying to silent login // make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended // so that consumers of fetchUser don't think the work already ended
if (opts.attemptSilent && canAttemptSilentLogin()) { // Never attempt a silent login in embedded (token) mode: an OIDC
// redirect inside the iframe would break the embed.
if (
opts.attemptSilent &&
!getAccessToken() &&
canAttemptSilentLogin()
) {
attemptSilentLogin(30) attemptSilentLogin(30)
} else { } else {
resolve(false) resolve(false)
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { LoadingScreen } from '@/components/LoadingScreen'
import { useHash } from '@/hooks/useHash'
import { initializeAccessTokenFromFragment } from '../api/exchangeAccessToken'
import { hasTransitCodeInFragment } from '../utils/transitCode'
/**
* Gates the app tree on the embedded (iframe) authentication bootstrap.
*
* Without a transit code in the URL fragment the overwhelmingly common
* case the component early returns children synchronously: no state,
* no effect, no extra render, no loading screen.
*
* When a transit code is present, children are not mounted until it has
* been exchanged for a user access token, so that every authenticated
* query already carries the Authorization header. A loading screen is
* displayed in the meantime, as UserAware does.
*/
export const TransitCodeGate = ({
children,
}: {
children: React.ReactNode
}) => {
const hash = useHash()
// Latch the decision on the initial hash: the bootstrap scrubs the
// fragment as soon as it starts, and the gate must not flip back to the
// fast path while the exchange is still in flight.
const [needsExchange] = useState(() => hasTransitCodeInFragment(hash))
if (!needsExchange) {
return children
}
return <TransitCodeExchange>{children}</TransitCodeExchange>
}
/**
* Only ever mounted when a transit code is present: runs the memoized
* bootstrap (safe against StrictMode double-invoked effects) and holds
* children back until it settles.
*/
const TransitCodeExchange = ({ children }: { children: React.ReactNode }) => {
const [isReady, setIsReady] = useState(false)
useEffect(() => {
let isMounted = true
initializeAccessTokenFromFragment().finally(() => {
if (isMounted) {
setIsReady(true)
}
})
return () => {
isMounted = false
}
}, [])
console.log('$$ isReady', isReady)
return isReady ? (
children
) : (
<LoadingScreen header={false} footer={false} delay={1000} />
)
}
@@ -0,0 +1,46 @@
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code'
/**
* Whether a URL fragment carries a transit code. Pure check, does not
* consume anything.
*/
export const hasTransitCodeInFragment = (hash: string): boolean => {
if (!hash) {
return false
}
return new URLSearchParams(hash.replace(/^#/, '')).has(
TRANSIT_CODE_FRAGMENT_PARAM
)
}
/**
* Extract the transit code from the URL fragment, if any.
*
* The fragment is scrubbed from the address bar immediately, before any
* network call, so the code never lingers in the browser history. Any
* other fragment content is preserved.
*/
export const consumeTransitCodeFromFragment = (): string | null => {
if (typeof window === 'undefined' || !window.location.hash) {
return null
}
const params = new URLSearchParams(window.location.hash.substring(1))
const code = params.get(TRANSIT_CODE_FRAGMENT_PARAM)
if (!code) {
return null
}
params.delete(TRANSIT_CODE_FRAGMENT_PARAM)
const remaining = params.toString()
window.history.replaceState(
null,
'',
window.location.pathname +
window.location.search +
(remaining ? `#${remaining}` : '')
)
return code
}
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { accessTokenStore } from '@/stores/accessToken'
import { resolveMediaUrl } from '../utils/resolveMediaUrl'
/**
* Reactive companion of resolveMediaUrl for browser-native consumers
* (CSS url(), img src attributes): resolves a list of /media/ URLs and
* returns a stable lookup, identity in regular mode.
*
* Object URLs come from the shared session-lifetime cache and are never
* revoked here: they may be used concurrently by the background
* processors.
*/
export const useResolvedMediaUrls = (
urls: (string | null | undefined)[]
): ((url: string) => string) => {
const [resolved, setResolved] = useState<Record<string, string>>({})
const { accessToken } = useSnapshot(accessTokenStore)
// Stable dependency for the effect, insensitive to array identity
const urlsKey = urls.filter(Boolean).sort().join('\n')
useEffect(() => {
if (!accessToken || !urlsKey) {
return
}
let isMounted = true
const resolveAll = async () => {
const entries = await Promise.all(
urlsKey.split('\n').map(async (url) => {
try {
return [url, await resolveMediaUrl(url)] as const
} catch (error) {
console.warn(error)
return [url, url] as const
}
})
)
if (isMounted) {
setResolved(Object.fromEntries(entries))
}
}
resolveAll()
return () => {
isMounted = false
}
}, [accessToken, urlsKey])
// Stable identity so that consumers can safely list the resolver in
// their memo dependencies: it only changes when resolutions land.
return useCallback((url: string) => resolved[url] ?? url, [resolved])
}
@@ -0,0 +1,47 @@
import { getAccessToken } from '@/stores/accessToken'
// Session-lifetime cache: object URLs are shared between every consumer
// of a given media (background processors, thumbnails) and are therefore
// never revoked - their number is bounded by the user's custom
// backgrounds, and they die with the page like the access token does.
const objectUrlCache = new Map<string, string>()
/**
* Resolve an authenticated /media/ URL for the embedded (token) mode.
*
* Media files are served behind an nginx auth_request subrequest that
* authenticates the original request. In regular mode the session cookie
* rides along browser-native loads (img.src, CSS url()) and the URL is
* returned unchanged, without any fetch. In embedded mode the
* third-party cookie is blocked and native loads cannot carry the
* Authorization header, so the media is fetched here with the Bearer
* header - which the media-auth endpoint accepts, as it sits behind the
* default authentication stack - and exposed as a blob object URL.
*/
export const resolveMediaUrl = async (url: string): Promise<string> => {
const accessToken = getAccessToken()
if (!accessToken) {
return url
}
const cached = objectUrlCache.get(url)
if (cached) {
return cached
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
throw new Error(
`Failed to resolve media url ${url}: HTTP ${response.status}`
)
}
const objectUrl = URL.createObjectURL(await response.blob())
objectUrlCache.set(url, objectUrl)
return objectUrl
}
@@ -10,6 +10,7 @@ import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react' import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
export const useMuteParticipant = () => { export const useMuteParticipant = () => {
const apiRoomData = useRoomData() const apiRoomData = useRoomData()
@@ -40,7 +41,7 @@ export const useMuteParticipant = () => {
} }
const headers = !isAdminOrOwner const headers = !isAdminOrOwner
? { Authorization: `Bearer ${apiRoomData.livekit.token}` } ? getLiveKitAuthHeaders(apiRoomData.livekit.token)
: undefined : undefined
let response let response
@@ -1,5 +1,6 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
export const useRenameParticipant = () => { export const useRenameParticipant = () => {
const data = useRoomData() const data = useRoomData()
@@ -15,11 +16,10 @@ export const useRenameParticipant = () => {
throw new Error('LiveKit token is not available') throw new Error('LiveKit token is not available')
} }
const headers = getLiveKitAuthHeaders(token)
return fetchApi(`rooms/${data.id}/rename/`, { return fetchApi(`rooms/${data.id}/rename/`, {
method: 'POST', method: 'POST',
headers: { headers,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: JSON.stringify({
name, name,
}), }),
@@ -1,5 +1,6 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom' import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
import { getLobbyParticipantId } from '@/stores/lobby'
export interface RequestEntryParams { export interface RequestEntryParams {
roomId: string roomId: string
@@ -15,6 +16,7 @@ export enum ApiLobbyStatus {
} }
export interface ApiRequestEntry { export interface ApiRequestEntry {
id?: string
status: ApiLobbyStatus status: ApiLobbyStatus
livekit?: ApiLiveKit livekit?: ApiLiveKit
} }
@@ -23,10 +25,12 @@ export const requestEntry = async ({
roomId, roomId,
username = '', username = '',
}: RequestEntryParams) => { }: RequestEntryParams) => {
const participantId = getLobbyParticipantId(roomId)
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, { return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
username, username,
...(participantId && { participant_id: participantId }),
}), }),
}) })
} }
@@ -1,5 +1,6 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
export const useRaiseHand = () => { export const useRaiseHand = () => {
const data = useRoomData() const data = useRoomData()
@@ -15,11 +16,10 @@ export const useRaiseHand = () => {
throw new Error('LiveKit token is not available') throw new Error('LiveKit token is not available')
} }
const headers = getLiveKitAuthHeaders(token)
return fetchApi(`rooms/${data.id}/toggle-hand/`, { return fetchApi(`rooms/${data.id}/toggle-hand/`, {
method: 'POST', method: 'POST',
headers: { headers,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: JSON.stringify({
raised, raised,
}), }),
@@ -6,6 +6,7 @@ import {
ApiLobbyStatus, ApiLobbyStatus,
type ApiRequestEntry, type ApiRequestEntry,
} from '../api/requestEntry' } from '../api/requestEntry'
import { setLobbyParticipantId } from '@/stores/lobby'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 1000
@@ -43,6 +44,11 @@ export const useLobby = ({
roomId, roomId,
username, username,
}) })
if (response.id) {
setLobbyParticipantId(roomId, response.id)
}
if (response.status === ApiLobbyStatus.ACCEPTED) { if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout() clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED) setStatus(ApiLobbyStatus.ACCEPTED)
@@ -1,4 +1,5 @@
import type { ProcessorOptions, Track } from 'livekit-client' import type { ProcessorOptions, Track } from 'livekit-client'
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
import { import {
FilesetResolver, FilesetResolver,
ImageSegmenter, ImageSegmenter,
@@ -85,7 +86,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.sourceSettings = this.source!.getSettings() this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement this.videoElement = opts.element as HTMLVideoElement
this._initVirtualBackgroundImage() await this._initVirtualBackgroundImage()
this._createMainCanvas() this._createMainCanvas()
this._createMaskCanvas() this._createMaskCanvas()
@@ -103,7 +104,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
captureEvent('firefox-blurring-init', {}) captureEvent('firefox-blurring-init', {})
} }
_initVirtualBackgroundImage() { async _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') { if (this.options.type !== 'virtual') {
throw new Error( throw new Error(
'Virtual background is only supported for virtual background' 'Virtual background is only supported for virtual background'
@@ -115,15 +116,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.virtualBackgroundImage && this.virtualBackgroundImage &&
this.virtualBackgroundImage.src !== this.options.imagePath this.virtualBackgroundImage.src !== this.options.imagePath
if (this.options.imagePath || needsUpdate) { if (this.options.imagePath || needsUpdate) {
// Embedded (token) mode: img.src cannot carry the Authorization
// header, resolve the media to a blob object URL first. Identity
// in regular mode.
const imagePath = await resolveMediaUrl(this.options.imagePath!)
this.virtualBackgroundImage = document.createElement('img') this.virtualBackgroundImage = document.createElement('img')
this.virtualBackgroundImage.crossOrigin = 'anonymous' this.virtualBackgroundImage.crossOrigin = 'anonymous'
this.virtualBackgroundImage.src = this.options.imagePath! this.virtualBackgroundImage.src = imagePath
} }
} }
async update(opts: ProcessorConfig): Promise<void> { async update(opts: ProcessorConfig): Promise<void> {
this.options = opts this.options = opts
this._initVirtualBackgroundImage() await this._initVirtualBackgroundImage()
} }
_initWorker() { _initWorker() {
@@ -1,4 +1,5 @@
import type { ProcessorOptions, Track } from 'livekit-client' import type { ProcessorOptions, Track } from 'livekit-client'
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
import { import {
ProcessorWrapper, ProcessorWrapper,
BackgroundProcessor, BackgroundProcessor,
@@ -47,7 +48,16 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
} }
async init(opts: ProcessorOptions<Track.Kind>) { async init(opts: ProcessorOptions<Track.Kind>) {
return this.processor.init(opts) await this.processor.init(opts)
// Embedded (token) mode: the constructor passed the raw imagePath,
// whose native load cannot carry the Authorization header. Swap it
// for a resolved blob object URL. No-op in regular mode.
if (this.opts.type === 'virtual') {
const imagePath = await resolveMediaUrl(this.opts.imagePath)
if (imagePath !== this.opts.imagePath) {
await this.processor.updateTransformerOptions({ imagePath })
}
}
} }
async restart(opts: ProcessorOptions<Track.Kind>) { async restart(opts: ProcessorOptions<Track.Kind>) {
@@ -59,6 +69,9 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
} }
async update(opts: ProcessorConfig): Promise<void> { async update(opts: ProcessorConfig): Promise<void> {
if (opts.type === 'virtual') {
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
}
this.opts = opts this.opts = opts
const newProcessorType = const newProcessorType =
@@ -8,6 +8,7 @@ import {
ProcessorType, ProcessorType,
} from '../blur' } from '../blur'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives' import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip' import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { HStack, styled } from '@/styled-system/jsx' import { HStack, styled } from '@/styled-system/jsx'
@@ -280,6 +281,14 @@ export const EffectsConfiguration = ({
filesQ.data.count >= appConfig.background_image.max_count_by_user) ?? filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
false false
// Thumbnails are browser-native loads (CSS url()) which cannot carry
// the Authorization header in embedded (token) mode: resolve them. The
// processor configs keep the stable raw URLs - they are persisted in
// the user choices - and the processors resolve them internally.
const resolveMediaUrl = useResolvedMediaUrls(
(filesQ.data?.results ?? []).map((file) => file.url)
)
const getHandleSelectChangeFile = useCallback( const getHandleSelectChangeFile = useCallback(
(file: ApiFileItem) => { (file: ApiFileItem) => {
return async () => { return async () => {
@@ -757,7 +766,7 @@ export const EffectsConfiguration = ({
bgSize: 'cover', bgSize: 'cover',
})} })}
style={{ style={{
backgroundImage: `url(${option.file.url!})`, backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
}} }}
data-attr={`toggle-virtual-${option.file.id}`} data-attr={`toggle-virtual-${option.file.id}`}
/> />
@@ -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 { fetchApi } from '@/api/fetchApi'
import type { ApiError } from '@/api/ApiError' import type { ApiError } from '@/api/ApiError'
import type { ApiRoom } from '@/features/rooms/api/ApiRoom' import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getLiveKitAuthHeaders } from '@/features/rooms/utils/getLiveKitAuthHeaders'
export interface StartSubtitleParams { export interface StartSubtitleParams {
id: string id: string
@@ -14,9 +15,7 @@ const startSubtitle = ({
}: StartSubtitleParams): Promise<ApiRoom> => { }: StartSubtitleParams): Promise<ApiRoom> => {
return fetchApi(`rooms/${id}/start-subtitle/`, { return fetchApi(`rooms/${id}/start-subtitle/`, {
method: 'POST', method: 'POST',
headers: { headers: getLiveKitAuthHeaders(token),
Authorization: `Bearer ${token}`,
},
}) })
} }
+10
View File
@@ -0,0 +1,10 @@
import { useLocationProperty } from 'wouter/use-browser-location'
const hashSelector = () =>
typeof window !== 'undefined' ? window.location.hash : ''
/**
* Reactive window.location.hash, subscribed to wouter's navigation
* events (the same low-level primitive wouter builds useSearch upon).
*/
export const useHash = (): string => useLocationProperty(hashSelector, () => '')
+32
View File
@@ -0,0 +1,32 @@
import { proxy } from 'valtio'
type State = {
accessToken: string | null
}
/**
* User access token for the embedded (iframe) mode.
*
* When Meet is rendered inside an iframe, third-party session cookies are
* blocked: the host application passes a single-use transit code in the
* URL fragment, exchanged at startup for a user access token (see
* features/auth/api/exchangeAccessToken) that authenticates every api
* call exactly like a session cookie would.
*
* The token deliberately lives in this in-memory store only: unlike other
* stores, it is never persisted (no subscribe/localStorage) and never
* appears in a URL. It is lost on reload, in which case the host page is
* expected to mint a fresh transit code.
*
* A non-null token also tells the app it is running in embedded mode:
* components can react to it with useSnapshot(accessTokenStore).
*/
export const accessTokenStore = proxy<State>({
accessToken: null,
})
export const setAccessToken = (accessToken: string | null) => {
accessTokenStore.accessToken = accessToken
}
export const getAccessToken = () => accessTokenStore.accessToken
+23
View File
@@ -0,0 +1,23 @@
import { proxy } from 'valtio'
type State = {
participantIds: Record<string, string | undefined>
}
export const layoutStore = proxy<State>({
participantIds: {},
})
export const setLobbyParticipantId = (
roomId: string,
participantId: string
) => {
layoutStore.participantIds[roomId] = participantId
}
export const clearParticipantId = (roomId: string) => {
delete layoutStore.participantIds[roomId]
}
export const getLobbyParticipantId = (roomId: string) =>
layoutStore.participantIds[roomId]
+11
View File
@@ -1,4 +1,6 @@
import { proxy, subscribe } from 'valtio' import { proxy, subscribe } from 'valtio'
import { initializeAccessTokenFromFragment } from '@/features/auth/api/exchangeAccessToken'
import { getAccessToken } from '@/stores/accessToken'
import { import {
ProcessorConfig, ProcessorConfig,
ProcessorType, ProcessorType,
@@ -48,10 +50,19 @@ if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
// we restore clear the processor config to avoid displaying a black screen. // we restore clear the processor config to avoid displaying a black screen.
userChoicesStore.processorConfig = undefined userChoicesStore.processorConfig = undefined
} else if (userChoicesStore.processorConfig.fileId) { } else if (userChoicesStore.processorConfig.fileId) {
// Embedded (token) mode: this module loads before the transit code
// exchange has settled - wait for it, and carry the Bearer header,
// otherwise the check below would wrongly clear the config.
await initializeAccessTokenFromFragment()
const accessToken = getAccessToken()
// Checking if the image is still available / accessible // Checking if the image is still available / accessible
await fetch(userChoicesStore.processorConfig.imagePath, { await fetch(userChoicesStore.processorConfig.imagePath, {
// We bypass the cache to ensure we have access // We bypass the cache to ensure we have access
cache: 'reload', cache: 'reload',
...(accessToken && {
headers: { Authorization: `Bearer ${accessToken}` },
}),
}) })
.then((response) => { .then((response) => {
// if we cannot fetch the image (likely a 401 from the backend because // if we cannot fetch the image (likely a 401 from the backend because