Files
meet/src/backend/core/external_api/permissions.py
T
lebaudantoine eceefa0b6f ✨(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-10-08 15:32:00 +02:00

175 lines
5.6 KiB
Python

"""Permission handlers for application-delegated API access."""
import logging
from typing import Dict
from django.conf import settings
from rest_framework import exceptions, permissions
from .. import models
logger = logging.getLogger(__name__)
class BaseScopePermission(permissions.BasePermission):
"""Base class for scope-based permission checking.
Subclasses must define `scope_map` attribute mapping actions to required scopes.
"""
scope_map: Dict[str, str] = {}
def get_required_scope(self, view):
"""Return the scope required by the view's current action.
Returns:
The required scope, or None for an unsupported method so
DRF's router can answer 405.
Raises:
PermissionDenied: If the action is not in scope_map (deny by
default).
"""
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
action = getattr(view, "action", None)
if not action:
# DRF routers return a 405 for unsupported methods
return None
required_scope = self.scope_map.get(action)
if not required_scope:
# Action not in scope_map, deny by default
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
return required_scope
def get_token_scopes(self, request):
"""Extract and normalize the scopes claimed by the token."""
token_scopes = (request.auth or {}).get("scope")
if not token_scopes:
return []
# Ensure scopes is a list (handle both list and space-separated string)
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
return self.strip_scope_prefix(token_scopes)
@staticmethod
def strip_scope_prefix(token_scopes):
"""Strip the OIDC resource server prefix, when configured."""
if settings.OIDC_RS_SCOPES_PREFIX:
return [
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
for scope in token_scopes
]
return token_scopes
def has_permission(self, request, view):
"""Check if the token claims the scope required by this action.
Args:
request: DRF request object with authenticated user
view: ViewSet instance
Returns:
bool: True if permission granted
Raises:
PermissionDenied: If required scope is missing from token
"""
required_scope = self.get_required_scope(view)
if required_scope is None:
return True
token_scopes = self.get_token_scopes(request)
if not token_scopes:
raise exceptions.PermissionDenied("Insufficient permissions.")
if required_scope not in token_scopes:
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
return True
class ApplicationScopePermission(BaseScopePermission):
"""Scope-based permission for application-authenticated endpoints."""
@staticmethod
def strip_scope_prefix(token_scopes):
"""Compare application scopes verbatim."""
return token_scopes
def has_permission(self, request, view):
"""Check the scope claim, then the grant recorded in the database."""
granted = super().has_permission(request, view)
required_scope = self.get_required_scope(view)
if granted and required_scope:
client_id = (request.auth or {}).get("client_id")
if not models.Application.has_active_scope(client_id, required_scope):
logger.warning(
"Application '%s' presented scope '%s' without a matching "
"grant in database",
client_id,
required_scope,
)
raise exceptions.PermissionDenied(
f"Application is not granted the required scope: {required_scope}"
)
return granted
class HasRequiredRoomScope(BaseScopePermission):
"""Permission class for Room-related operations."""
scope_map = {
"list": models.ApplicationScope.ROOMS_LIST,
"retrieve": models.ApplicationScope.ROOMS_RETRIEVE,
"create": models.ApplicationScope.ROOMS_CREATE,
"update": models.ApplicationScope.ROOMS_UPDATE,
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
class HasRequiredUserScope(ApplicationScopePermission):
"""Scope-based permissions for the external user endpoints."""
scope_map = {
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
user = request.user
if request.method in permissions.SAFE_METHODS:
return obj.has_any_role(user)
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)