Compare commits

...

4 Commits

Author SHA1 Message Date
Emmanuel Pelletier 27b0f44b2a 💄(dinum) Apply UI-Kit styles through custom CSS
Use hand-picked tokens from @gouvfr-lasuite/ui-tokens and apply them on
our components.
Depending on the context, we apply light or dark variants.
2026-10-19 13:19:55 +02:00
Emmanuel Pelletier 0b33806d52 💄(css) add [data-page] attr for easier theming through custom CSS
When customizing meet through the custom CSS file
(FRONTEND_CUSTOM_CSS_URL feature), it can be handy to easily know on
what page we are with CSS.
This exposes the current route on the `<html>` tag with a new
`[data-page]` attribute to allow easier targeting through custom CSS.
2026-10-19 13:15:53 +02:00
Emmanuel Pelletier 0fa8e45b3c 💄(css) add classes to components for easier theming through custom CSS
Since Panda generates atomic CSS, it's hard to modify styling through
custom CSS (FRONTEND_CUSTOM_CSS_URL feature).
This adds a few classes on some low-lvl components to allow easy
customization through the custom CSS file.
2026-10-19 13:13:18 +02:00
leo dce67e37f8 ✨(backend) add Menshen token-exchange authentication to external API
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another
service exchanges its user's access token for a token targeting Meet, then
calls the external API on behalf of that user. Tokens are validated by
introspection (RFC 7662). Menshen only reports a token as active when Meet
is among its audiences.

Add Menshen auth to the external API. Only activate it for room creation
scope (other scopes may be added later). If a user doesn't exist for the
provided sub,  create it, unless the provided email is already associated
to a an existing account.

This PR does not implement fallback to email to automatically create
user when no sub or an invalid sub is passed and email is provided, when
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True.
2026-10-16 08:33:46 +02:00
21 changed files with 893 additions and 97 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- ✨(backend) add room soft-deletion to the external API
- ✨(all) allow any authenticated user to start a recording
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
## [1.35.0] - 2026-10-13
+193 -32
View File
@@ -1,35 +1,3 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
@@ -69,3 +37,196 @@
font-style: normal;
font-display: swap;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
:root {
--fonts-sans: Marianne, "Mona Sans VF", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans Backtick Fix", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--global-font-body: var(--fonts-sans);
--avatar-cap-height: 0.7;
/* use hand-picked variables from UI-kit and apply them to meet's variables */
--c--globals--colors--brand-050: #EDF0FF;
--c--globals--colors--brand-100: #DAE2FF;
--c--globals--colors--brand-250: #A2B6FF;
--c--globals--colors--brand-550: #3E5DE7;
--c--globals--colors--brand-600: #304DDF;
--c--globals--colors--brand-650: #2845C1;
--c--globals--colors--brand-700: #223E9E;
--c--globals--colors--gray-050: #F0F0F3;
--c--globals--colors--gray-100: #DFE2EA;
--c--globals--colors--gray-150: #CFD5DE;
--c--globals--colors--gray-250: #B2B9C7;
--c--globals--colors--gray-300: #A4ABBC;
--c--globals--colors--gray-550: #626A80;
--c--globals--colors--gray-600: #555E74;
--c--globals--colors--gray-650: #4A5267;
--c--globals--colors--gray-700: #3F4759;
--c--globals--colors--gray-750: #363B4C;
--c--globals--colors--gray-800: #2B303D;
--c--globals--colors--gray-850: #222631;
--c--globals--colors--gray-900: #181B24;
--c--globals--colors--white-050: #F6F8F90D;
--c--globals--colors--white-950: #F6F8F9F2;
--c--globals--colors--black-050: #181B240D;
--c--globals--colors--black-850: #181B24D9;
--c--globals--colors--error-550: #CF202D;
--c--globals--colors--error-650: #9D2227;
--colors-primary-800: var(--c--contextuals--background--semantic--brand--primary);
--colors-primary: var(--c--contextuals--background--semantic--brand--primary);
--colors-primary-action: var(--c--contextuals--background--semantic--brand--primary-hover);
--colors-default-subtle-text: var(--c--contextuals--content--semantic--neutral--secondary);
--colors-default-text: var(--c--contextuals--content--semantic--neutral--primary);
--colors-primary-subtle: var(--c--contextuals--background--semantic--brand--secondary);
--colors-primary-subtle-text: var(--c--contextuals--content--semantic--brand--secondary);
--colors-greyscale-250: var(--c--globals--colors--gray-100);
/* for the room's page background color */
--colors-primary-dark-50: var(--c--globals--colors--gray-900);
/* light theme specific UI-kit semantic values */
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-600);
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-850);
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-100);
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-600);
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--black-050);
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--white-950);
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-050);
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-150);
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-550);
/* try to match the UI-kit's control border color, but keep a 3:1 contrast ratio with white to pass WCAG rules on input borders */
--colors-control-border: #8996A9;
}
/* dark theme matching (room page) */
.Box--variant-dark,
.Tooltip,
#control-bar {
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-100);
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-050);
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-700);
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-100);
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-250);
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--white-050);
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--black-850);
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
--c--contextuals--background--semantic--neutral--secondary: var(--c--globals--colors--gray-700);
--c--contextuals--background--semantic--neutral--secondary-hover: var(--c--globals--colors--gray-750);
--c--contextuals--content--semantic--neutral--on-neutral: var(--c--globals--colors--gray-050);
--c--contextuals--background--surface--primary: var(--c--globals--colors--gray-800);
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-750);
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-700);
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-250);
}
.Button--variant-primary {
background-color: var(--c--contextuals--background--semantic--brand--primary);
color: var(--c--contextuals--content--semantic--brand--on-brand);
}
.Button--variant-primary:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--brand--primary-hover);
}
.Button--variant-secondary {
border-color: #CFD5DE;
}
.Button--variant-secondaryText {
background: transparent;
color: var(--c--contextuals--content--semantic--brand--tertiary);
}
.Button--variant-secondaryText:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--contextual--primary);
}
.Button--variant-primaryDark {
background-color: var(--c--contextuals--background--semantic--neutral--secondary);
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
.Button--variant-primaryDark:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--neutral--secondary-hover);
}
.Button--variant-danger {
background-color: var(--c--contextuals--background--semantic--error--primary);
color: var(--c--contextuals--content--semantic--brand--on-brand);
}
.Button--variant-danger:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--error--primary-hover);
}
.Button--variant-primaryTextDark {
background: transparent;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
.Button--variant-primaryTextDark:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--contextual--primary);
}
.Box--variant-dark.Box--type-popover {
background-color: var(--c--contextuals--background--surface--primary);
}
.Tooltip {
background: var(--c--contextuals--background--semantic--neutral--tertiary);
border-color: var(--c--contextuals--border--semantic--neutral--tertiary);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
/* switch component */
.indicator,
[data-selected="true"] .indicator::before {
background-color: var(--c--contextuals--content--semantic--contextual--primary);
}
.indicator .cross {
color: var(--c--contextuals--content--semantic--contextual--primary);
}
label:not([data-selected="true"]) .indicator::before,
[data-selected="true"] .indicator {
background: var(--colors-primary-800);
}
/* end switch component */
/* prevent impacting the toolbar buttons (primaryDark variant) */
.Button--size-default:is(.Button--variant-primary, .Button--variant-secondary) {
padding-block: 0.4375rem;
}
@@ -82,9 +82,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
+204 -42
View File
@@ -4,21 +4,113 @@
# ruff: noqa: PLR0913
import logging
from dataclasses import asdict
from functools import lru_cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.core.exceptions import SuspiciousOperation, ValidationError
import requests
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
from menshen_client.exceptions import ResponseParsingError
from rest_framework import authentication, exceptions
from core.models import Application
from core.models import Application, ApplicationScope
from core.services import jwt_token
from core.validators import sub_validator
User = get_user_model()
logger = logging.getLogger(__name__)
def get_token(request, token_type):
"""Extract the token from the Authorization header.
Args:
request: The incoming request
token_type: Expected Authorization scheme (e.g. Bearer), matched
case-insensitively
Returns:
Token string, or None if the request carries no token of this type
Raises:
AuthenticationFailed: If the Authorization header is malformed
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != token_type.lower().encode():
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
return auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
def get_or_create_user_by_sub(sub, email=None):
"""Retrieve the user matching a token subject, creating it if allowed.
Shared by the resource server backends, so that tokens introspected with the
OIDC provider or with Menshen resolve users the same way.
Args:
sub: Subject identifier from the introspected token
email: Email to store on a newly created user
Returns:
User instance, or None if unknown and OIDC_CREATE_USER is unset
Raises:
SuspiciousOperation: If the sub is invalid, the email of a user to create
is already used, or the user account is disabled
"""
if sub is not None:
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation("User info contained an invalid sub claim") from err
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if not settings.OIDC_CREATE_USER:
return None
# Users are never identified by email here. But a second account with the
# same email (e.g. provisionnal account) would break the lookups by email:
# refuse it.
if (
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and email
and User.objects.filter(email__iexact=email).exists()
):
logger.warning("Email already used, not creating user: %s", sub)
raise SuspiciousOperation(
"Email already used by another account."
) from None
user = User(sub=sub, email=email)
user.set_unusable_password()
user.save()
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
@@ -72,25 +164,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
if not self.is_enabled:
return None
auth_header = authentication.get_authorization_header(request).split()
token = get_token(request, self._token_type)
if (
not auth_header
or auth_header[0].lower() != self._token_type.lower().encode()
):
if token is None:
# Defer to next authentication backend
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def decode_jwt(self, token):
@@ -256,6 +335,113 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
)
# Menshen grants scopes following La Suite's "service:resource:action" convention.
# Map them to the scopes expected by the external API permissions.
MENSHEN_SCOPES_MAPPING = {
"meet:room:create": ApplicationScope.ROOMS_CREATE,
}
@lru_cache(1)
def _get_token_exchange_client():
return TokenExchangeClient(
config=Configuration(
settings.MENSHEN_CLIENT_ID,
settings.MENSHEN_CLIENT_SECRET,
settings.MENSHEN_SERVER_URL,
)
)
class MenshenAuthentication(authentication.BaseAuthentication):
"""Authentication for tokens exchanged through Menshen.
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
exchanges its user's access token for a token targeting Meet, then calls the external
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
only reports a token as active when Meet is among its audiences.
"""
def authenticate(self, request):
"""Introspect the bearer token with Menshen.
Returns:
Tuple of (user, payload) if the token is active, None otherwise
"""
if not settings.MENSHEN_ENABLED:
return None
token = get_token(request, "bearer")
if token is None:
return None
client = _get_token_exchange_client()
try:
response = client.introspect(IntrospectionRequest(token=token))
except (requests.RequestException, ResponseParsingError) as e:
logger.warning("Menshen introspection failed: %s", e)
return None
# Permission classes expect a dict payload in request.auth
payload = asdict(response)
if not payload.get("active"):
# Not a Menshen token, or an expired or revoked one: defer to next
# authentication backend
return None
user = self.get_or_create_user(payload)
scopes = payload.get("scope") or ""
payload["scope"] = [
MENSHEN_SCOPES_MAPPING[scope]
for scope in scopes.split()
if scope in MENSHEN_SCOPES_MAPPING
]
return (user, payload)
def get_or_create_user(self, payload):
"""Retrieve or create the user from the introspection response.
Menshen forwards the `sub` and `email` of the subject token, as introspected
with the OIDC provider.
Args:
payload: Introspection response dict
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
sub = payload.get("sub")
if not sub:
logger.warning("Missing 'sub' in Menshen introspection response")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = get_or_create_user_by_sub(sub, email=payload.get("email"))
except SuspiciousOperation as e:
raise exceptions.AuthenticationFailed(str(e)) from e
if user is None:
logger.warning("User not found: %s", sub)
raise exceptions.AuthenticationFailed("User not found.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
@@ -285,28 +471,4 @@ class ResourceServerBackend(LaSuiteBackend):
logger.debug(message)
raise SuspiciousOperation(message)
user = self.get_user(access_token, id_token, payload)
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
"""Create new user from subject claim.
Args:
sub: Subject identifier from token
Returns:
Newly created User instance
"""
user = self.UserModel(sub=sub)
user.set_unusable_password()
user.save()
return user
return get_or_create_user_by_sub(sub)
@@ -174,6 +174,7 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
authentication.MenshenAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
@@ -35,6 +35,20 @@ def test_user_access_token_users_me():
assert response.data["email"] == user.email
def test_user_access_token_custom_token_type(settings):
"""The Authorization scheme should match the configured token type."""
settings.USER_ACCESS_TOKEN_TYPE = "Token"
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {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()
@@ -67,6 +67,19 @@ def test_resource_server_backend_get_or_create_user_no_creation(settings):
assert not User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_invalid_sub(settings):
"""A sub longer than 255 characters should be rejected without creating a user."""
settings.OIDC_CREATE_USER = True
with pytest.raises(SuspiciousOperation, match="invalid sub claim"):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("a" * 256)
)
assert not User.objects.exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
@@ -0,0 +1,304 @@
"""Tests for the external API MenshenAuthentication."""
from unittest import mock
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.models import RoleChoices, Room, User
pytestmark = pytest.mark.django_db
SERVER_URL = "https://menshen.example.com"
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
TOKEN = "menshen-exchanged-token"
@pytest.fixture(autouse=True)
def menshen_settings(settings):
"""Enable Menshen authentication."""
settings.MENSHEN_ENABLED = True
settings.MENSHEN_SERVER_URL = SERVER_URL
settings.MENSHEN_CLIENT_ID = "meet"
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
def _introspection(sub, scope="meet:room:create", **overrides):
return {
"active": True,
"sub": sub,
"email": "user@example.com",
"scope": scope,
"aud": "meet",
"client_id": "menshen",
**overrides,
}
def _create_room(token=TOKEN):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
return client.post("/external-api/v1.0/rooms/", {}, format="json")
@responses.activate
def test_menshen_active_token():
"""An active token with a mapped scope should create a room owned by the user."""
user = UserFactory()
# Catch and mock external HTTP requests
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub),
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
)
response = _create_room()
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_other_token_type_ignored(mock_rs_authenticate):
"""A token sent with another scheme than Bearer should not be introspected."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {TOKEN}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_disabled(mock_rs_authenticate, settings):
"""Menshen should not be called when disabled."""
settings.MENSHEN_ENABLED = False
response = _create_room()
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_inactive_token_defers(mock_rs_authenticate):
"""An inactive token should defer to the next authentication backend."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="meet:room:create", active=False),
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_error_defers(mock_rs_authenticate):
"""A Menshen error should defer to the next authentication backend."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
"""A response the client can't parse should defer to the next backend."""
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": True, "unexpected": "field"},
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
def test_menshen_resource_server_token(settings):
"""End to end: a token issued by the OIDC provider is unknown to Menshen and
should still authenticate through the resource server."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory()
token = "oidc-access-token"
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": False},
match=[responses.matchers.urlencoded_params_matcher({"token": token})],
)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:create",
"client_id": "app",
},
)
response = _create_room(token)
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
responses.assert_call_count(INTROSPECTION_ENDPOINT, 1)
responses.assert_call_count("https://oidc.example.com/introspect", 1)
@responses.activate
def test_menshen_unmapped_scope():
"""Scopes granted for other services or other Meet actions should not grant access."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
)
response = _create_room()
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
assert not Room.objects.exists()
@responses.activate
def test_menshen_missing_sub():
"""An active token without sub should be rejected."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
response = _create_room()
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
@responses.activate
def test_menshen_invalid_sub(settings):
"""An active token with an invalid sub should be rejected without creating a user."""
settings.OIDC_CREATE_USER = True
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("invalid\nsub")
)
response = _create_room()
assert response.status_code == 401
assert "invalid sub claim" in str(response.data).lower()
assert not User.objects.exists()
@responses.activate
def test_menshen_inactive_user():
"""An active token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
response = _create_room()
assert response.status_code == 401
assert "user account is disabled." in str(response.data).lower()
@responses.activate
def test_menshen_unknown_user_created(settings):
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 201
user = User.objects.get(sub="new-sub")
assert user.email == "user@example.com"
assert user.is_active is True
@responses.activate
def test_menshen_unknown_user_not_created(settings):
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
def test_menshen_unknown_user_email_already_used(settings, existing_sub):
"""An unknown sub should not create a second account with an email already
used, by a provisional user or a user with another sub, when
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True."""
settings.OIDC_CREATE_USER = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "email already used by another account." in str(response.data).lower()
assert User.objects.count() == 1
existing_user.refresh_from_db()
assert existing_user.sub == existing_sub
assert not Room.objects.exists()
+4 -1
View File
@@ -69,7 +69,10 @@ def test_models_users_sub_validator(sub, is_valid):
else:
with pytest.raises(
ValidationError,
match="Enter a valid sub. This value should be printable ASCII only.",
match=(
"Enter a valid sub. This value should be printable ASCII only, "
"with 255 characters or less."
),
):
user.full_clean()
+12 -3
View File
@@ -17,7 +17,16 @@ def sub_validator(value):
``isprintable()`` is false exactly for those control characters, while
space (U+0020) remains allowed.
"""
msg = (
"Enter a valid sub. This value should be printable ASCII only, "
"with 255 characters or less."
)
if value is None:
raise ValidationError(_(msg))
if len(value) > 255:
raise ValidationError(_(msg))
if not value.isascii() or not value.isprintable():
raise ValidationError(
_("Enter a valid sub. This value should be printable ASCII only.")
)
raise ValidationError(_(msg))
+14
View File
@@ -709,6 +709,20 @@ class Base(Configuration):
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Menshen, La Suite's OAuth 2.0 token exchange server
MENSHEN_ENABLED = values.BooleanValue(
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
)
MENSHEN_SERVER_URL = values.Value(
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
)
MENSHEN_CLIENT_ID = values.Value(
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
)
MENSHEN_CLIENT_SECRET = SecretFileValue(
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
+1
View File
@@ -49,6 +49,7 @@ dependencies = [
"gunicorn==26.2.0",
"jsonschema==4.26.0",
"markdown==3.10.3",
"menshen-client==0.2.0",
"nested-multipart-parser==1.6.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
+15 -1
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.13"
[[package]]
@@ -1327,6 +1327,7 @@ dependencies = [
{ name = "jsonschema" },
{ name = "livekit-api" },
{ name = "markdown" },
{ name = "menshen-client" },
{ name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
@@ -1392,6 +1393,7 @@ requires-dist = [
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" },
{ name = "markdown", specifier = "==3.10.3" },
{ name = "menshen-client", specifier = "==0.2.0" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" },
@@ -1428,6 +1430,18 @@ dev = [
{ name = "types-requests", specifier = "==2.33.0.20260712" },
]
[[package]]
name = "menshen-client"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/a2/5862b0a39d5f96301e1daff146445f591b1980412c20417162f5cfd6aa34/menshen_client-0.2.0.tar.gz", hash = "sha256:548c4224c38b2c844a76325b5a4b65da520ea9eba23aafb004da0f430a7e4fcf", size = 6326, upload-time = "2026-10-13T10:19:34.344Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/4d/932efd81c340d12d07b1c33633c6e7a4a2d0c9086ac0da7ffe9a1ce17068/menshen_client-0.2.0-py3-none-any.whl", hash = "sha256:415eaba30184452bc393b528c6088ed6e37cbd00ce60716aef9e2d9bd0697221", size = 7149, upload-time = "2026-10-13T10:19:33.272Z" },
]
[[package]]
name = "mozilla-django-oidc"
version = "5.0.2"
@@ -18,6 +18,7 @@ import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
import { usePageDataAttribute } from '@/navigation/usePageDataAttribute'
const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
@@ -38,10 +39,12 @@ const Room = () => {
const initialRoomData = history.state?.initialRoomData
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
const isJoinScreen = !hasSubmittedEntry && !skipJoinScreen
const { data } = useConfig()
useRoomPageTitle(roomId)
usePageDataAttribute(isJoinScreen ? 'join' : 'conference')
useEffect(() => {
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
@@ -75,7 +78,7 @@ const Room = () => {
return <ErrorScreen />
}
if (!hasSubmittedEntry && !skipJoinScreen) {
if (isJoinScreen) {
return (
<BaseRoom>
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
+3
View File
@@ -6,6 +6,7 @@ import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
import { usePageDataAttribute } from '@/navigation/usePageDataAttribute'
export type Layout = 'fullpage' | 'centered'
@@ -16,6 +17,8 @@ export type Layout = 'fullpage' | 'centered'
* In a specific page, use the `Screen` component and change its props to change global page layout.
*/
export const Layout = ({ children }: { children: ReactNode }) => {
usePageDataAttribute()
const layoutSnap = useSnapshot(layoutStore)
const showHeader = layoutSnap.showHeader
const showFooter = layoutSnap.showFooter
@@ -0,0 +1,57 @@
import { useEffect } from 'react'
import { matchRoute, useLocation, useRouter } from 'wouter'
import { proxy, useSnapshot } from 'valtio'
import { routes } from '@/routes'
const pageStore = proxy<{ override?: string }>({})
/**
* Apply a [data-page] attribute on the html element, that matches
* current route name, or a custom page name if provided.
*
* Used to allow easier customization of the app with custom CSS.
*
* This is meant to be used once without arguments in a wrapping component
* that renders the whole app. And, on a case by case basis with a `page` argument
* when needed.
*/
export const usePageDataAttribute = (page?: string) => {
const router = useRouter()
const [location] = useLocation()
const { override } = useSnapshot(pageStore)
useEffect(() => {
if (page === undefined) return
pageStore.override = page
return () => {
if (pageStore.override === page) {
pageStore.override = undefined
}
}
}, [page])
const routeName = Object.values(routes).find(
(route) => matchRoute(router.parser, route.path, location)[0]
)?.name
const pageName = page ?? override ?? routeName
useEffect(() => {
const root = document.documentElement
if (pageName) {
root.setAttribute('data-page', pageName)
} else {
root.removeAttribute('data-page')
}
}, [pageName])
useEffect(() => {
if (page !== undefined) return
return () => {
document.documentElement.removeAttribute('data-page')
}
}, [page])
}
+26 -5
View File
@@ -1,6 +1,12 @@
import { ComponentProps, forwardRef } from 'react'
import { cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
const boxDefaultVariants = {
variant: 'light',
size: 'default',
} as const
const box = cva({
base: {
position: 'relative',
@@ -64,10 +70,25 @@ const box = cva({
},
},
},
defaultVariants: {
variant: 'light',
size: 'default',
},
defaultVariants: boxDefaultVariants,
})
export const Box = styled('div', box)
const StyledBox = styled('div', box)
export const Box = forwardRef<HTMLDivElement, ComponentProps<typeof StyledBox>>(
({ className, ...props }, ref) => (
<StyledBox
ref={ref}
className={[
// These are not necessary for the app, but can help people using custom CSS for theming
'Box',
`Box--variant-${props.variant || boxDefaultVariants.variant}`,
`Box--size-${props.size || boxDefaultVariants.size}`,
props.type && `Box--type-${props.type}`,
className,
].filter(Boolean).join(' ')}
{...props}
/>
)
)
Box.displayName = 'Box'
+9 -2
View File
@@ -3,7 +3,7 @@ import {
type ButtonProps as RACButtonsProps,
} from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { buttonRecipe, buttonRecipeDefaultVariants, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode, forwardRef } from 'react'
import { Loader } from './Loader'
@@ -26,7 +26,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
ref={ref}
className={[buttonRecipe(variantProps), className].join(' ')}
className={[
buttonRecipe(variantProps),
className,
// These are not necessary for the app, but can help people using custom CSS for theming
'Button',
`Button--variant-${variantProps.variant || buttonRecipeDefaultVariants.variant}`,
`Button--size-${variantProps.size || buttonRecipeDefaultVariants.size}`,
].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
+9 -2
View File
@@ -3,7 +3,7 @@ import {
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { type ButtonRecipeProps, buttonRecipe, buttonRecipeDefaultVariants } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
export type ToggleButtonProps = RACToggleButtonProps &
@@ -25,7 +25,14 @@ export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
<RACToggleButton
ref={ref}
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
className={[
buttonRecipe(variantProps),
props.className,
// These are not necessary for the app, but can help people using custom CSS for theming
'Button Button--toggle',
`Button--variant-${variantProps.variant || buttonRecipeDefaultVariants.variant}`,
`Button--size-${variantProps.size || buttonRecipeDefaultVariants.size}`,
].join(' ')}
>
<>
{componentProps.children as ReactNode}
@@ -114,10 +114,11 @@ const TooltipArrow = () => {
const Tooltip = ({
children,
className,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
return (
<StyledTooltip {...props}>
<StyledTooltip {...props} className={[className, 'Tooltip'].filter(Boolean).join(' ')}>
<TooltipArrow />
{children}
</StyledTooltip>
+7 -4
View File
@@ -1,5 +1,11 @@
import { type RecipeVariantProps, cva } from '@/styled-system/css'
export const buttonRecipeDefaultVariants = {
size: 'default',
variant: 'primary',
} as const
export const buttonRecipe = cva({
base: {
display: 'flex',
@@ -393,10 +399,7 @@ export const buttonRecipe = cva({
},
},
],
defaultVariants: {
size: 'default',
variant: 'primary',
},
defaultVariants: buttonRecipeDefaultVariants,
})
export type ButtonRecipe = typeof buttonRecipe