Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 4f29c5d35d 🐛(backend) fix unescaped dot in regex pattern
The dot before (?P<extension>...) was not escaped and matched any
character instead of a literal period.

Escape it to align with MEDIA_STORAGE_URL_PATTERN, which correctly
uses \. for the file extension separator.
2026-03-13 15:42:44 +01:00
12 changed files with 13 additions and 355 deletions
-15
View File
@@ -12,9 +12,6 @@ on:
branches:
- 'main'
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -23,8 +20,6 @@ env:
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -68,8 +63,6 @@ jobs:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -114,8 +107,6 @@ jobs:
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -160,8 +151,6 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -208,8 +197,6 @@ jobs:
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -255,8 +242,6 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
-1
View File
@@ -28,7 +28,6 @@ and this project adheres to
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
### Fixed
-6
View File
@@ -73,9 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class SessionExchangeAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous requests to the session exchange endpoint."""
scope = "session_exchange"
+5 -3
View File
@@ -488,7 +488,9 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response({"status": "error"}, status=status_code)
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
@decorators.action(
detail=False,
@@ -755,10 +757,10 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
except InvalidFilepathError:
return drf_response.Response(
-61
View File
@@ -1,61 +0,0 @@
"""API endpoint for exchanging a one-time code for a session ID."""
import logging
from django.conf import settings
from django.core.cache import cache
from rest_framework import serializers, status
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from core.api.throttling import SessionExchangeAnonRateThrottle
from .views import EXCHANGE_CODE_PREFIX
logger = logging.getLogger(__name__)
class SessionExchangeSerializer(serializers.Serializer):
"""Validates the exchange code request."""
code = serializers.CharField(max_length=64, min_length=16)
@api_view(["POST"])
@permission_classes([AllowAny])
@throttle_classes([SessionExchangeAnonRateThrottle])
def session_exchange(
request,
): # NOSONAR (S3752) POST-only, AllowAny is intentional: single-use code with 30s TTL and rate limiting
"""Exchange a one-time code for a session ID.
The code was generated during the OIDC callback and stored in cache
with a short TTL. This endpoint retrieves the session ID, deletes the
code (single-use), and returns the session ID to the native app.
"""
serializer = SessionExchangeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
session_key = cache.get(cache_key)
if session_key is None:
logger.warning(
"Session exchange failed: invalid or expired code from %s",
request.META.get("REMOTE_ADDR"),
)
return Response(
{"detail": "Invalid or expired code."},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete immediately — single use
cache.delete(cache_key)
cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "sessionid")
logger.info("Session exchange successful from %s", request.META.get("REMOTE_ADDR"))
return Response({cookie_name: session_key})
-103
View File
@@ -1,103 +0,0 @@
"""Custom OIDC authentication views for native app support.
When a native app (iOS, Android, Desktop) initiates OIDC login, it sets
`returnTo` to a custom URL scheme (e.g. `visio://auth-callback`). After
the OIDC flow completes, Django sets the session cookie and redirects to
that URL. However, native apps cannot read browser cookies — they need
the session ID passed explicitly.
Instead of exposing the session ID directly in the redirect URL, this
module generates a short-lived, single-use exchange code. The native app
then exchanges this code for the session ID via a dedicated API endpoint.
"""
import uuid
from urllib.parse import urlencode, urlparse
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponseRedirect
from lasuite.oidc_login.views import (
OIDCAuthenticationCallbackView as BaseCallbackView,
OIDCAuthenticationRequestView as BaseRequestView,
)
# Cache key prefix and TTL for exchange codes
EXCHANGE_CODE_PREFIX = "auth_exchange:"
EXCHANGE_CODE_TTL = 30 # seconds
class NativeAppRedirect(HttpResponseRedirect):
"""HttpResponseRedirect subclass that allows native app custom URL schemes.
Django's HttpResponseRedirect only allows http, https, and ftp schemes.
Native apps use custom schemes (e.g. visio://) for deep links, which
Django rejects with DisallowedRedirect. This subclass extends
allowed_schemes with the configured native app schemes.
"""
allowed_schemes = HttpResponseRedirect.allowed_schemes + list(
getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
)
class OIDCAuthenticationRequestView(BaseRequestView):
"""Custom authenticate view that preserves native app returnTo in session.
mozilla-django-oidc's get_next_url() rejects custom URL schemes
(e.g. visio://auth-callback) because url_has_allowed_host_and_scheme()
only allows http/https. We intercept the returnTo parameter and store
it directly in the session for whitelisted schemes, bypassing the
safety check (which is not relevant for native app deep links).
"""
def get(self, request):
redirect_field = getattr(settings, "OIDC_REDIRECT_FIELD_NAME", "returnTo")
return_to = request.GET.get(redirect_field, "")
parsed = urlparse(return_to)
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
response = super().get(request)
# Override oidc_login_next AFTER super() which set it to None
# via get_next_url() rejecting the custom scheme.
if parsed.scheme in allowed_schemes:
request.session["oidc_login_next"] = return_to
request.session.save()
return response
class OIDCAuthenticationCallbackView(BaseCallbackView):
"""Callback view that generates an exchange code for native app deep links."""
def login_success(self):
"""After successful login, append exchange code for whitelisted scheme redirects."""
# Temporarily remove native redirect from session to prevent
# super().login_success() from raising DisallowedRedirect when
# it tries HttpResponseRedirect with a custom scheme.
native_redirect = self.request.session.pop("oidc_login_next", None)
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
parsed = urlparse(native_redirect or "")
if native_redirect and parsed.scheme in allowed_schemes:
# Let super() redirect to the default URL (homepage)
super().login_success()
# Generate a short-lived, single-use exchange code
exchange_code = uuid.uuid4().hex
session_key = self.request.session.session_key
cache.set(
f"{EXCHANGE_CODE_PREFIX}{exchange_code}",
session_key,
EXCHANGE_CODE_TTL,
)
separator = "&" if parsed.query else "?"
new_url = (
f"{native_redirect}{separator}{urlencode({'code': exchange_code})}"
)
return NativeAppRedirect(new_url)
return super().login_success()
+1 -1
View File
@@ -14,7 +14,7 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
f"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
)
MEDIA_STORAGE_URL_PATTERN = re.compile(
@@ -1,143 +0,0 @@
"""
Tests for the session exchange API endpoint and OIDC callback view.
"""
import uuid
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from core.authentication.views import EXCHANGE_CODE_PREFIX, EXCHANGE_CODE_TTL
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def _clear_throttle_cache():
"""Clear cache before each test to reset throttle counters."""
cache.clear()
def test_session_exchange_valid_code():
"""A valid exchange code should return the session ID and be consumed."""
code = uuid.uuid4().hex
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
cache.set(cache_key, "test-session-key-123", EXCHANGE_CODE_TTL)
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 200
data = response.json()
assert "test-session-key-123" in data.values()
# Code should be consumed (single-use)
assert cache.get(cache_key) is None
def test_session_exchange_invalid_code():
"""An invalid/unknown code should return 400."""
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": uuid.uuid4().hex},
format="json",
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid or expired code."
def test_session_exchange_expired_code():
"""An expired code should return 400."""
code = uuid.uuid4().hex
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
# Set with 0 TTL to simulate expiration
cache.set(cache_key, "expired-session", 0)
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 400
def test_session_exchange_code_too_short():
"""A code that's too short should be rejected by validation."""
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": "short"},
format="json",
)
assert response.status_code == 400
def test_session_exchange_missing_code():
"""Missing code field should be rejected."""
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{},
format="json",
)
assert response.status_code == 400
def test_session_exchange_replay_attack():
"""Using the same code twice should fail the second time."""
code = uuid.uuid4().hex
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "session-123", EXCHANGE_CODE_TTL)
client = APIClient()
# First use succeeds
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 200
# Second use fails
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 400
def test_session_exchange_returns_correct_cookie_name(settings):
"""The response key should match SESSION_COOKIE_NAME from settings."""
settings.SESSION_COOKIE_NAME = "meet_sessionid"
code = uuid.uuid4().hex
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "my-session", EXCHANGE_CODE_TTL)
client = APIClient()
response = client.post(
"/api/v1.0/auth/session-exchange/",
{"code": code},
format="json",
)
assert response.status_code == 200
assert response.json() == {"meet_sessionid": "my-session"}
def test_session_exchange_get_not_allowed():
"""GET method should not be allowed on the exchange endpoint."""
client = APIClient()
response = client.get("/api/v1.0/auth/session-exchange/")
assert response.status_code == 405
@@ -95,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data."}
assert response.json() == {"detail": "Invalid request data: Error message"}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -112,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified."}
assert response.json() == {"detail": "Invalid bucket specified"}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -77,6 +77,7 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
@@ -90,7 +91,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
)
assert response.status_code == 400
assert response.json() == {"status": "error"}
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
def test_unknown_event_type(client, mock_livekit_config):
@@ -115,6 +116,7 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
-4
View File
@@ -7,7 +7,6 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.authentication.api import session_exchange
from core.external_api import viewsets as external_viewsets
# - Main endpoints
@@ -41,9 +40,6 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
path(
"auth/session-exchange/", session_exchange, name="session_exchange"
),
path("config/", get_frontend_configuration, name="config"),
]
),
+2 -15
View File
@@ -344,11 +344,6 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"session_exchange": values.Value(
default="5/minute",
environ_name="SESSION_EXCHANGE_THROTTLE_RATES",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
@@ -464,16 +459,8 @@ class Base(Configuration):
)
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
# Custom URL schemes allowed for native app OIDC redirects.
# Only these schemes will receive an exchange code in the callback.
NATIVE_APP_REDIRECT_SCHEMES = values.ListValue(
default=["visio"],
environ_name="NATIVE_APP_REDIRECT_SCHEMES",
environ_prefix=None,
)
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
)