From 4649248b6fb273ba3da1c5d7e63af06cc1207d72 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 2 Oct 2025 23:12:42 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8(backend)=20add=20application=20mo?= =?UTF-8?q?del=20with=20secure=20secret=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need to integrate with external applications. Objective: enable them to securely generate room links with proper ownership attribution. Proposed solution: Following the OAuth2 Machine-to-Machine specification, we expose an endpoint allowing external applications to exchange a client_id and client_secret pair for a JWT. This JWT is valid only within a well-scoped, isolated external API, served through a dedicated viewset. This commit introduces a model to persist application records in the database. The main challenge lies in generating a secure client_secret and ensuring it is properly stored. The restframework-apikey dependency was discarded, as its approach diverges significantly from OAuth2. Instead, inspiration was taken from oauthlib and django-oauth-toolkit. However, their implementations proved either too heavy or not entirely suitable for the intended use case. To avoid pulling in large dependencies for minimal utility, the necessary components were selectively copied, adapted, and improved. A generic SecretField was introduced, designed for reuse and potentially suitable for upstream contribution to Django. Secrets are exposed only once at object creation time in the Django admin. Once the object is saved, the secret is immediately hashed, ensuring it can never be retrieved again. One limitation remains: enforcing client_id and client_secret as read-only during edits. At object creation, marking them read-only excluded them from the Django form, which unintentionally regenerated new values. This area requires further refinement. The design prioritizes configurability while adhering to the principle of least privilege. By default, new applications are created without any assigned scopes, preventing them from performing actions on the API until explicitly configured. If no domain is specified, domain delegation is not applied, allowing tokens to be issued for any email domain. --- src/backend/core/admin.py | 64 ++++ src/backend/core/factories.py | 38 +- src/backend/core/fields.py | 43 +++ .../migrations/0015_application_and_more.py | 52 +++ src/backend/core/models.py | 101 ++++++ .../core/tests/test_models_applications.py | 331 ++++++++++++++++++ src/backend/core/utils.py | 40 +++ src/backend/meet/settings.py | 12 + 8 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/fields.py create mode 100644 src/backend/core/migrations/0015_application_and_more.py create mode 100644 src/backend/core/tests/test_models_applications.py diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index d16e90adb..9c7272e40 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -1,5 +1,6 @@ """Admin classes and registrations for core app.""" +from django import forms from django.contrib import admin from django.contrib.auth import admin as auth_admin from django.utils.translation import gettext_lazy as _ @@ -150,3 +151,66 @@ def get_owner(self, obj): return _("Multiple owners") return str(owners[0].user) + + +class ApplicationDomainInline(admin.TabularInline): + """Inline admin for managing allowed domains per application.""" + + model = models.ApplicationDomain + extra = 0 + + +class ApplicationAdminForm(forms.ModelForm): + """Custom form for Application admin with multi-select scopes.""" + + scopes = forms.MultipleChoiceField( + choices=models.ApplicationScope.choices, + widget=forms.CheckboxSelectMultiple, + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk and self.instance.scopes: + self.fields["scopes"].initial = self.instance.scopes + + +@admin.register(models.Application) +class ApplicationAdmin(admin.ModelAdmin): + """Admin interface for managing applications and their permissions.""" + + form = ApplicationAdminForm + + list_display = ("id", "name", "client_id", "get_scopes_display") + fields = [ + "name", + "id", + "created_at", + "updated_at", + "scopes", + "client_id", + "client_secret", + ] + readonly_fields = ["id", "created_at", "updated_at"] + inlines = [ApplicationDomainInline] + + def get_readonly_fields(self, request, obj=None): + """Make client_id and client_secret readonly after creation.""" + if obj: # Editing existing object + return self.readonly_fields + ["client_id", "client_secret"] + return self.readonly_fields + + def get_fields(self, request, obj=None): + """Hide client_secret after creation.""" + fields = super().get_fields(request, obj) + if obj: + return [f for f in fields if f != "client_secret"] + return fields + + def get_scopes_display(self, obj): + """Display scopes in list view.""" + if obj.scopes: + return ", ".join(obj.scopes) + return _("No scopes") + + get_scopes_display.short_description = _("Scopes") diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 8f019f80a..43f4fdfcf 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -9,7 +9,7 @@ import factory.fuzzy from faker import Faker -from core import models +from core import models, utils fake = Faker() @@ -117,3 +117,39 @@ class Meta: recording = factory.SubFactory(RecordingFactory) team = factory.Sequence(lambda n: f"team{n}") role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values) + + +class ApplicationFactory(factory.django.DjangoModelFactory): + """Create fake applications for testing.""" + + class Meta: + model = models.Application + + name = factory.Faker("company") + active = True + client_id = factory.LazyFunction(utils.generate_client_id) + client_secret = factory.LazyFunction(utils.generate_client_secret) + scopes = [] + + class Params: + """Factory traits for common application configurations.""" + + with_all_scopes = factory.Trait( + scopes=[ + models.ApplicationScope.ROOMS_LIST, + models.ApplicationScope.ROOMS_RETRIEVE, + models.ApplicationScope.ROOMS_CREATE, + models.ApplicationScope.ROOMS_UPDATE, + models.ApplicationScope.ROOMS_DELETE, + ] + ) + + +class ApplicationDomainFactory(factory.django.DjangoModelFactory): + """Create fake application domains for testing.""" + + class Meta: + model = models.ApplicationDomain + + domain = factory.Faker("domain_name") + application = factory.SubFactory(ApplicationFactory) diff --git a/src/backend/core/fields.py b/src/backend/core/fields.py new file mode 100644 index 000000000..2b3d6ddc5 --- /dev/null +++ b/src/backend/core/fields.py @@ -0,0 +1,43 @@ +""" +Core application fields +""" + +from logging import getLogger + +from django.contrib.auth.hashers import identify_hasher, make_password +from django.db import models + +logger = getLogger(__name__) + + +class SecretField(models.CharField): + """CharField that automatically hashes secrets before saving. + + Use for API keys, client secrets, or tokens that should never be stored + in plain text. Already-hashed values are preserved to prevent double-hashing. + + Inspired by: https://github.com/django-oauth-toolkit/django-oauth-toolkit + """ + + def pre_save(self, model_instance, add): + """Hash the secret if not already hashed, otherwise preserve it.""" + + secret = getattr(model_instance, self.attname) + + try: + hasher = identify_hasher(secret) + logger.debug( + "%s: %s is already hashed with %s.", + model_instance, + self.attname, + hasher, + ) + except ValueError: + logger.debug( + "%s: %s is not hashed; hashing it now.", model_instance, self.attname + ) + hashed_secret = make_password(secret) + setattr(model_instance, self.attname, hashed_secret) + return hashed_secret + + return super().pre_save(model_instance, add) diff --git a/src/backend/core/migrations/0015_application_and_more.py b/src/backend/core/migrations/0015_application_and_more.py new file mode 100644 index 000000000..260b18d49 --- /dev/null +++ b/src/backend/core/migrations/0015_application_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.6 on 2025-10-02 20:55 + +import core.utils +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_room_pin_code'), + ] + + operations = [ + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('name', models.CharField(help_text='Descriptive name for this application.', max_length=255, verbose_name='Application name')), + ('active', models.BooleanField(default=True)), + ('client_id', models.CharField(default=core.utils.generate_client_id, max_length=100, unique=True)), + ('client_secret', core.fields.SecretField(blank=True, default=core.utils.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255)), + ('scopes', 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')], max_length=50), blank=True, default=list, size=None)), + ], + options={ + 'verbose_name': 'Application', + 'verbose_name_plural': 'Applications', + 'db_table': 'meet_application', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='ApplicationDomain', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('domain', models.CharField(help_text='Email domain this application can act on behalf of.', max_length=253, validators=[django.core.validators.DomainNameValidator(accept_idna=False, message='Enter a valid domain')], verbose_name='Domain')), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_domains', to='core.application')), + ], + options={ + 'verbose_name': 'Application domain', + 'verbose_name_plural': 'Application domains', + 'db_table': 'meet_application_domain', + 'ordering': ('domain',), + 'unique_together': {('application', 'domain')}, + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index b92b9291c..89ff11cdf 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -11,6 +11,7 @@ from django.conf import settings from django.contrib.auth import models as auth_models from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.postgres.fields import ArrayField from django.core import mail, validators from django.core.exceptions import PermissionDenied, ValidationError from django.db import models @@ -18,8 +19,10 @@ from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ +from lasuite.tools.email import get_domain_from_email from timezone_field import TimeZoneField +from . import fields, utils from .recording.enums import FileExtension logger = getLogger(__name__) @@ -717,3 +720,101 @@ def get_abilities(self, user): Compute and return abilities for a given user on the recording access. """ return self._get_abilities(self.recording, user) + + +class ApplicationScope(models.TextChoices): + """Available permission scopes for application operations.""" + + ROOMS_CREATE = "rooms:create", _("Create rooms") + ROOMS_LIST = "rooms:list", _("List rooms") + ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details") + ROOMS_UPDATE = "rooms:update", _("Update rooms") + ROOMS_DELETE = "rooms:delete", _("Delete rooms") + + +class Application(BaseModel): + """External application for API authentication and authorization. + + Represents a third-party integration or automated system that accesses + the API using OAuth2-style client credentials (client_id/client_secret). + Supports scoped permissions and optional domain restrictions for delegation. + """ + + name = models.CharField( + max_length=255, + verbose_name=_("Application name"), + help_text=_("Descriptive name for this application."), + ) + active = models.BooleanField(default=True) + client_id = models.CharField( + max_length=100, unique=True, default=utils.generate_client_id + ) + client_secret = fields.SecretField( + max_length=255, + blank=True, + default=utils.generate_client_secret, + help_text=_("Hashed on Save. Copy it now if this is a new secret."), + ) + scopes = ArrayField( + models.CharField(max_length=50, choices=ApplicationScope.choices), + default=list, + blank=True, + ) + + class Meta: + db_table = "meet_application" + ordering = ("-created_at",) + verbose_name = _("Application") + verbose_name_plural = _("Applications") + + def __str__(self): + return f"{self.name!s}" + + def can_delegate_email(self, email): + """Check if this application can delegate the given email.""" + + if not self.allowed_domains.exists(): + return True # No domain restrictions + + domain = get_domain_from_email(email) + return self.allowed_domains.filter(domain__iexact=domain).exists() + + +class ApplicationDomain(BaseModel): + """Domain authorized for application delegation.""" + + domain = models.CharField( + max_length=253, # Max domain length per RFC 1035 + validators=[ + validators.DomainNameValidator( + accept_idna=False, + message=_("Enter a valid domain"), + ) + ], + verbose_name=_("Domain"), + help_text=_("Email domain this application can act on behalf of."), + ) + + application = models.ForeignKey( + "Application", + on_delete=models.CASCADE, + related_name="allowed_domains", + ) + + class Meta: + db_table = "meet_application_domain" + ordering = ("domain",) + verbose_name = _("Application domain") + verbose_name_plural = _("Application domains") + unique_together = [("application", "domain")] + + def __str__(self): + """Return string representation of the domain.""" + + return self.domain + + def save(self, *args, **kwargs): + """Save the domain after normalizing to lowercase.""" + + self.domain = self.domain.lower().strip() + super().save(*args, **kwargs) diff --git a/src/backend/core/tests/test_models_applications.py b/src/backend/core/tests/test_models_applications.py new file mode 100644 index 000000000..035738c0c --- /dev/null +++ b/src/backend/core/tests/test_models_applications.py @@ -0,0 +1,331 @@ +""" +Unit tests for the Application and ApplicationDomain models +""" + +# pylint: disable=W0613 + +from unittest import mock + +from django.contrib.auth.hashers import check_password +from django.core.exceptions import ValidationError + +import pytest + +from core.factories import ApplicationDomainFactory, ApplicationFactory +from core.models import Application, ApplicationDomain, ApplicationScope + +pytestmark = pytest.mark.django_db + + +# Application Model Tests + + +def test_models_application_str(): + """The str representation should be the name.""" + application = ApplicationFactory(name="My Integration") + assert str(application) == "My Integration" + + +def test_models_application_name_maxlength(): + """The name field should be at most 255 characters.""" + ApplicationFactory(name="a" * 255) + + with pytest.raises(ValidationError) as excinfo: + ApplicationFactory(name="a" * 256) + + assert "Ensure this value has at most 255 characters (it has 256)." in str( + excinfo.value + ) + + +def test_models_application_active_default(): + """An application should be active by default.""" + application = Application.objects.create(name="Test App") + assert application.active is True + + +def test_models_application_scopes_default(): + """Scopes should default to empty list.""" + application = Application.objects.create(name="Test App") + assert application.scopes == [] + + +def test_models_application_client_id_auto_generated(): + """Client ID should be automatically generated on creation.""" + application = ApplicationFactory() + assert application.client_id is not None + assert len(application.client_id) > 0 + + +def test_models_application_client_id_unique(): + """Client IDs should be unique.""" + app1 = ApplicationFactory() + + with pytest.raises(ValidationError) as excinfo: + ApplicationFactory(client_id=app1.client_id) + + assert "Application with this Client id already exists." in str(excinfo.value) + + +def test_models_application_client_id_length(settings): + """Client ID should match configured length.""" + + app1 = ApplicationFactory() + assert len(app1.client_id) == 40 # default value + + settings.APPLICATION_CLIENT_ID_LENGTH = 20 + + app2 = ApplicationFactory() + assert len(app2.client_id) == 20 + + +def test_models_application_client_secret_auto_generated(): + """Client secret should be automatically generated and hashed on creation.""" + application = ApplicationFactory() + + assert application.client_secret is not None + assert len(application.client_secret) > 0 + + +def test_models_application_client_secret_hashed_on_save(): + """Client secret should be hashed when saved.""" + plain_secret = "my-plain-secret" + + with mock.patch( + "core.models.utils.generate_client_secret", return_value=plain_secret + ): + application = ApplicationFactory(client_secret=plain_secret) + + # Secret should be hashed, not plain + assert application.client_secret != plain_secret + # Should verify with check_password + assert check_password(plain_secret, application.client_secret) is True + + +def test_models_application_client_secret_preserves_existing_hash(): + """Re-saving should not re-hash an already hashed secret.""" + application = ApplicationFactory() + original_hash = application.client_secret + + # Update another field and save + application.name = "Updated Name" + application.save() + + # Hash should remain unchanged + assert application.client_secret == original_hash + + +def test_models_application_updates_preserve_client_id(): + """Application updates should preserve existing client_id.""" + application = ApplicationFactory() + original_client_id = application.client_id + + application.name = "Updated Name" + application.save() + + assert application.client_id == original_client_id + + +def test_models_application_scopes_valid_choices(): + """Only valid scope choices should be accepted.""" + application = ApplicationFactory( + scopes=[ + ApplicationScope.ROOMS_LIST, + ApplicationScope.ROOMS_CREATE, + ApplicationScope.ROOMS_RETRIEVE, + ] + ) + + assert len(application.scopes) == 3 + assert ApplicationScope.ROOMS_LIST in application.scopes + + +def test_models_application_scopes_invalid_choice(): + """Invalid scope choices should raise validation error.""" + with pytest.raises(ValidationError) as excinfo: + ApplicationFactory(scopes=["invalid:scope"]) + + assert "is not a valid choice" in str(excinfo.value) + + +def test_models_application_can_delegate_email_no_restrictions(): + """Application with no domain restrictions can delegate any email.""" + application = ApplicationFactory() + + assert application.can_delegate_email("user@example.com") is True + assert application.can_delegate_email("admin@anotherdomain.org") is True + + +def test_models_application_can_delegate_email_allowed_domain(): + """Application can delegate email from allowed domain.""" + application = ApplicationFactory() + ApplicationDomainFactory(application=application, domain="example.com") + + assert application.can_delegate_email("user@example.com") is True + + +def test_models_application_can_delegate_email_denied_domain(): + """Application cannot delegate email from non-allowed domain.""" + application = ApplicationFactory() + ApplicationDomainFactory(application=application, domain="example.com") + + assert application.can_delegate_email("user@other.com") is False + + +def test_models_application_can_delegate_email_case_insensitive(): + """Domain matching should be case-insensitive.""" + application = ApplicationFactory() + ApplicationDomainFactory(application=application, domain="example.com") + + assert application.can_delegate_email("user@EXAMPLE.COM") is True + assert application.can_delegate_email("user@Example.Com") is True + + +def test_models_application_can_delegate_email_multiple_domains(): + """Application with multiple allowed domains should check all.""" + application = ApplicationFactory() + ApplicationDomainFactory(application=application, domain="example.com") + ApplicationDomainFactory(application=application, domain="other.org") + + assert application.can_delegate_email("user@example.com") is True + assert application.can_delegate_email("admin@other.org") is True + assert application.can_delegate_email("test@denied.com") is False + + +# ApplicationDomain Model Tests + + +def test_models_application_domain_str(): + """The str representation should be the domain.""" + domain = ApplicationDomainFactory(domain="example.com") + assert str(domain) == "example.com" + + +def test_models_application_domain_ordering(): + """Domains should be returned ordered by domain name.""" + application = ApplicationFactory() + ApplicationDomainFactory(application=application, domain="zulu.com") + ApplicationDomainFactory(application=application, domain="alpha.com") + ApplicationDomainFactory(application=application, domain="beta.com") + + domains = ApplicationDomain.objects.all() + assert domains[0].domain == "alpha.com" + assert domains[1].domain == "beta.com" + assert domains[2].domain == "zulu.com" + + +@pytest.mark.parametrize( + "valid_domain", + [ + "example.com", + "sub.example.com", + "deep.sub.example.com", + "example-with-dash.com", + "123.example.com", + ], +) +def test_models_application_domain_valid_domain(valid_domain): + """Valid domain names should be accepted.""" + ApplicationDomainFactory(domain=valid_domain) + + +@pytest.mark.parametrize( + "invalid_domain", + [ + "not a domain", + "example..com", + "-example.com", + "example-.com", + "example.com-", + ], +) +def test_models_application_domain_invalid_domain(invalid_domain): + """Invalid domain names should raise validation error.""" + + with pytest.raises(ValidationError): + ApplicationDomainFactory(domain=invalid_domain) + + +def test_models_application_domain_lowercase_on_save(): + """Domain should be normalized to lowercase on save.""" + domain = ApplicationDomainFactory(domain="EXAMPLE.COM") + + assert domain.domain == "example.com" + + +def test_models_application_domain_strip_whitespace_on_save(): + """Domain should strip whitespace on save.""" + domain = ApplicationDomainFactory(domain=" example.com ") + + assert domain.domain == "example.com" + + +def test_models_application_domain_combined_normalization(): + """Domain should strip and lowercase in one operation.""" + domain = ApplicationDomainFactory(domain=" EXAMPLE.COM ") + + assert domain.domain == "example.com" + + +def test_models_application_domain_unique_together(): + """Same domain cannot be added twice to same application.""" + application = ApplicationFactory() + ApplicationDomainFactory(application=application, domain="example.com") + + with pytest.raises(ValidationError) as excinfo: + ApplicationDomainFactory(application=application, domain="example.com") + + assert "Application domain with this Application and Domain already exists." in str( + excinfo.value + ) + + +def test_models_application_domain_same_domain_different_apps(): + """Same domain can belong to different applications.""" + app1 = ApplicationFactory() + app2 = ApplicationFactory() + + ApplicationDomainFactory(application=app1, domain="example.com") + ApplicationDomainFactory(application=app2, domain="example.com") + + assert app1.allowed_domains.count() == 1 + assert app2.allowed_domains.count() == 1 + + +def test_models_application_domain_cascade_delete(): + """Deleting application should delete its domains.""" + application = ApplicationFactory() + ApplicationDomainFactory(application=application, domain="example.com") + ApplicationDomainFactory(application=application, domain="other.com") + + assert ApplicationDomain.objects.count() == 2 + + application.delete() + + assert ApplicationDomain.objects.count() == 0 + + +def test_models_application_domain_related_name(): + """Domains should be accessible via application.allowed_domains.""" + application = ApplicationFactory() + domain1 = ApplicationDomainFactory(application=application, domain="example.com") + domain2 = ApplicationDomainFactory(application=application, domain="other.com") + + assert list(application.allowed_domains.all()) == [domain1, domain2] + + +def test_models_application_domain_filters_delegation(): + """Adding/removing domains should affect can_delegate_email.""" + application = ApplicationFactory() + + # No restrictions initially + assert application.can_delegate_email("user@example.com") is True + + # Add domain restriction + domain = ApplicationDomainFactory(application=application, domain="example.com") + assert application.can_delegate_email("user@example.com") is True + assert application.can_delegate_email("user@other.com") is False + + # Remove domain restriction + domain.delete() + assert application.can_delegate_email("user@other.com") is True diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 92f16eaa1..8f62bd561 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -8,6 +8,7 @@ import hashlib import json import random +import string from typing import List, Optional from uuid import uuid4 @@ -240,3 +241,42 @@ async def notify_participants(room_name: str, notification_data: dict): raise NotificationError("Failed to notify room participants") from e finally: await lkapi.aclose() + + +ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits + + +def generate_secure_token(length: int = 30, charset: str = ALPHANUMERIC_CHARSET) -> str: + """Generate a cryptographically secure random token. + + Uses SystemRandom for proper entropy, suitable for OAuth tokens + and API credentials that must be non-guessable. + + Inspired by: https://github.com/oauthlib/oauthlib/blob/master/oauthlib/common.py + + Args: + length: Token length in characters (default: 30) + charset: Character set to use for generation + + Returns: + Cryptographically secure random token + """ + return "".join(secrets.choice(charset) for _ in range(length)) + + +def generate_client_id() -> str: + """Generate a unique client ID for application authentication. + + Returns: + Random client ID string + """ + return generate_secure_token(settings.APPLICATION_CLIENT_ID_LENGTH) + + +def generate_client_secret() -> str: + """Generate a secure client secret for application authentication. + + Returns: + Cryptographically secure client secret + """ + return generate_secure_token(settings.APPLICATION_CLIENT_SECRET_LENGTH) diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 51d10e880..31b64ec2e 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -664,6 +664,18 @@ class Base(Configuration): environ_prefix=None, ) + # External Applications + APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue( + 40, + environ_name="APPLICATION_CLIENT_ID_LENGTH", + environ_prefix=None, + ) + APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue( + 128, + environ_name="APPLICATION_CLIENT_SECRET_LENGTH", + environ_prefix=None, + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): From ef49ea65cbe066a6224723aa0b482b9c235b1cb0 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 2 Oct 2025 23:21:41 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8(backend)=20introduce=20an=20exter?= =?UTF-8?q?nal=20API=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare for the introduction of new endpoints reserved for external applications. Configure the required router and update the Helm chart to ensure that the Kubernetes ingress properly routes traffic to these new endpoints. It is important to support independent versioning of both APIs. Base route’s name aligns with PR #195 on lasuite/drive, opened by @lunika --- src/backend/core/external_api/__init__.py | 1 + src/backend/core/urls.py | 11 +++++++++ src/backend/meet/settings.py | 1 + src/helm/meet/Chart.yaml | 2 +- src/helm/meet/templates/ingress.yaml | 28 +++++++++++++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/external_api/__init__.py diff --git a/src/backend/core/external_api/__init__.py b/src/backend/core/external_api/__init__.py new file mode 100644 index 000000000..cc93e5bb3 --- /dev/null +++ b/src/backend/core/external_api/__init__.py @@ -0,0 +1 @@ +"""Meet core external API endpoints""" diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 98969f180..13236d3c3 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -17,6 +17,9 @@ "resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses" ) +# - External API +external_router = DefaultRouter() + urlpatterns = [ path( f"api/{settings.API_VERSION}/", @@ -28,4 +31,12 @@ ] ), ), + path( + f"external-api/{settings.EXTERNAL_API_VERSION}/", + include( + [ + *external_router.urls, + ] + ), + ), ] diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 31b64ec2e..be5525703 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -69,6 +69,7 @@ class Base(Configuration): USE_SWAGGER = False API_VERSION = "v1.0" + EXTERNAL_API_VERSION = "v1.0" DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR") diff --git a/src/helm/meet/Chart.yaml b/src/helm/meet/Chart.yaml index 9b285e656..b15abdbde 100644 --- a/src/helm/meet/Chart.yaml +++ b/src/helm/meet/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v2 type: application name: meet -version: 0.0.12 +version: 0.0.13-beta.1 diff --git a/src/helm/meet/templates/ingress.yaml b/src/helm/meet/templates/ingress.yaml index a64065609..8f15f761f 100644 --- a/src/helm/meet/templates/ingress.yaml +++ b/src/helm/meet/templates/ingress.yaml @@ -74,6 +74,20 @@ spec: serviceName: {{ include "meet.backend.fullname" . }} servicePort: {{ .Values.backend.service.port }} {{- end }} + - path: /external-api/ + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "meet.backend.fullname" . }} + port: + number: {{ .Values.backend.service.port }} + {{- else }} + serviceName: {{ include "meet.backend.fullname" . }} + servicePort: {{ .Values.backend.service.port }} + {{- end }} {{- with .Values.ingress.customBackends }} {{- toYaml . | nindent 10 }} {{- end }} @@ -110,6 +124,20 @@ spec: serviceName: {{ include "meet.backend.fullname" $ }} servicePort: {{ $.Values.backend.service.port }} {{- end }} + - path: /external-api/ + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "meet.backend.fullname" $ }} + port: + number: {{ $.Values.backend.service.port }} + {{- else }} + serviceName: {{ include "meet.backend.fullname" $ }} + servicePort: {{ $.Values.backend.service.port }} + {{- end }} {{- with $.Values.ingress.customBackends }} {{- toYaml . | nindent 10 }} {{- end }} From 37f1d958cd4825e423448fa4032a3a5ad3540492 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 3 Oct 2025 00:28:56 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8(backend)=20add=20delegation=20mec?= =?UTF-8?q?hanism=20to=20external=20app=20/token=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This endpoint does not strictly follow the OAuth2 Machine-to-Machine specification, as we introduce the concept of user delegation (instead of using the term impersonation). Typically, OAuth2 M2M is used only to authenticate a machine in server-to-server exchanges. In our case, we require external applications to act on behalf of a user in order to assign room ownership and access. Since these external applications are not integrated with our authorization server, a workaround was necessary. We treat the delegated user’s email as a form of scope and issue a JWT to the application if it is authorized to request it. Using the term scope for an email may be confusing, but it remains consistent with OAuth2 vocabulary and allows for future extension, such as supporting a proper M2M process without any user delegation. It is important not to confuse the scope in the request body with the scope in the generated JWT. The request scope refers to the delegated email, while the JWT scope defines what actions the external application can perform on our viewset, matching Django’s viewset method naming. The viewset currently contains a significant amount of logic. I did not find a clean way to split it without reducing maintainability, but this can be reconsidered in the future. Error messages are intentionally vague to avoid exposing sensitive information to attackers. --- env.d/development/common.dist | 4 + .../core/external_api/authentication.py | 109 +++++++ src/backend/core/external_api/serializers.py | 18 ++ src/backend/core/external_api/viewsets.py | 131 +++++++++ .../core/tests/test_external_api_token.py | 275 ++++++++++++++++++ src/backend/core/urls.py | 6 + src/backend/meet/settings.py | 28 ++ 7 files changed, 571 insertions(+) create mode 100644 src/backend/core/external_api/authentication.py create mode 100644 src/backend/core/external_api/serializers.py create mode 100644 src/backend/core/external_api/viewsets.py create mode 100644 src/backend/core/tests/test_external_api_token.py diff --git a/env.d/development/common.dist b/env.d/development/common.dist index e52186a56..52f33f123 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -65,3 +65,7 @@ ROOM_TELEPHONY_ENABLED=True FRONTEND_USE_FRENCH_GOV_FOOTER=False FRONTEND_USE_PROCONNECT_BUTTON=False + +# External Applications +APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/ +APPLICATION_JWT_SECRET_KEY=devKey diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py new file mode 100644 index 000000000..9b8efd474 --- /dev/null +++ b/src/backend/core/external_api/authentication.py @@ -0,0 +1,109 @@ +"""Authentication Backends for external application to the Meet core app.""" + +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model + +import jwt +from rest_framework import authentication, exceptions + +User = get_user_model() +logger = logging.getLogger(__name__) + + +class ApplicationJWTAuthentication(authentication.BaseAuthentication): + """JWT authentication for application-delegated API access. + + Validates JWT tokens issued to applications that are acting on behalf + of users. Tokens must include user_id, client_id, and delegation flag. + """ + + def authenticate(self, request): + """Extract and validate JWT from Authorization header. + + Returns: + Tuple of (user, payload) if authentication successful, None otherwise + """ + auth_header = authentication.get_authorization_header(request).split() + + if not auth_header or auth_header[0].lower() != b"bearer": + 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 authenticate_credentials(self, token): + """Validate JWT token and return authenticated user. + + Args: + token: JWT token string + + Returns: + Tuple of (user, payload) + + Raises: + AuthenticationFailed: If token is invalid, expired, or user not found + """ + # Decode and validate JWT + try: + payload = jwt.decode( + token, + settings.APPLICATION_JWT_SECRET_KEY, + algorithms=[settings.APPLICATION_JWT_ALG], + issuer=settings.APPLICATION_JWT_ISSUER, + audience=settings.APPLICATION_JWT_AUDIENCE, + ) + except jwt.ExpiredSignatureError as e: + logger.warning("Token expired") + raise exceptions.AuthenticationFailed("Token expired.") from e + except jwt.InvalidIssuerError as e: + logger.warning("Invalid JWT issuer: %s", e) + raise exceptions.AuthenticationFailed("Invalid token.") from e + except jwt.InvalidAudienceError as e: + logger.warning("Invalid JWT audience: %s", e) + raise exceptions.AuthenticationFailed("Invalid token.") from e + except jwt.InvalidTokenError as e: + logger.warning("Invalid JWT token: %s", e) + raise exceptions.AuthenticationFailed("Invalid token.") from e + + user_id = payload.get("user_id") + client_id = payload.get("client_id") + is_delegated = payload.get("delegated", False) + + if not user_id: + logger.warning("Missing 'user_id' in JWT payload") + raise exceptions.AuthenticationFailed("Invalid token claims.") + + if not client_id: + logger.warning("Missing 'client_id' in JWT payload") + raise exceptions.AuthenticationFailed("Invalid token claims.") + + if not is_delegated: + logger.warning("Token is not marked as delegated") + raise exceptions.AuthenticationFailed("Invalid token type.") + + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist as e: + logger.warning("User not found: %s", user_id) + raise exceptions.AuthenticationFailed("User not found.") from e + + if not user.is_active: + logger.warning("Inactive user attempted authentication: %s", user_id) + raise exceptions.AuthenticationFailed("User account is disabled.") + + return (user, payload) + + def authenticate_header(self, request): + """Return authentication scheme for WWW-Authenticate header.""" + return "Bearer" diff --git a/src/backend/core/external_api/serializers.py b/src/backend/core/external_api/serializers.py new file mode 100644 index 000000000..4d4d30577 --- /dev/null +++ b/src/backend/core/external_api/serializers.py @@ -0,0 +1,18 @@ +"""Serializers for the external API of the Meet core app.""" + +# pylint: disable=abstract-method + +from rest_framework import serializers + +from core.api.serializers import BaseValidationOnlySerializer + +OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" + + +class ApplicationJwtSerializer(BaseValidationOnlySerializer): + """Validate OAuth2 JWT token request data.""" + + client_id = serializers.CharField(write_only=True) + client_secret = serializers.CharField(write_only=True) + grant_type = serializers.ChoiceField(choices=[OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS]) + scope = serializers.CharField(write_only=True) diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py new file mode 100644 index 000000000..a36e4a087 --- /dev/null +++ b/src/backend/core/external_api/viewsets.py @@ -0,0 +1,131 @@ +"""External API endpoints""" + +from datetime import datetime, timedelta, timezone +from logging import getLogger + +from django.conf import settings +from django.contrib.auth.hashers import check_password +from django.core.exceptions import ValidationError +from django.core.validators import validate_email + +import jwt +from rest_framework import decorators, viewsets +from rest_framework import ( + exceptions as drf_exceptions, +) +from rest_framework import ( + response as drf_response, +) +from rest_framework import ( + status as drf_status, +) + +from core import models + +from . import serializers + +logger = getLogger(__name__) + + +class ApplicationViewSet(viewsets.GenericViewSet): + """API endpoints for application authentication and token generation.""" + + @decorators.action( + detail=False, + methods=["post"], + url_path="token", + url_name="token", + ) + def generate_jwt_access_token(self, request, *args, **kwargs): + """Generate JWT access token for application delegation. + + Validates application credentials and generates a JWT token scoped + to a specific user email, allowing the application to act on behalf + of that user. + + Note: The 'scope' parameter accepts an email address to identify the user + being delegated. This design allows applications to obtain user-scoped tokens + for delegation purposes. The scope field is intentionally generic and can be + extended to support other values in the future. + + Reference: https://stackoverflow.com/a/27711422 + """ + serializer = serializers.ApplicationJwtSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + client_id = serializer.validated_data["client_id"] + client_secret = serializer.validated_data["client_secret"] + + try: + application = models.Application.objects.get(client_id=client_id) + except models.Application.DoesNotExist as e: + raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e + + if not application.active: + raise drf_exceptions.AuthenticationFailed("Application is inactive") + + if not check_password(client_secret, application.client_secret): + raise drf_exceptions.AuthenticationFailed("Invalid credentials") + + email = serializer.validated_data["scope"] + try: + validate_email(email) + except ValidationError: + return drf_response.Response( + { + "error": "Scope should be a valid email address.", + }, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + + if not application.can_delegate_email(email): + logger.warning( + "Application %s denied delegation for %s", + application.client_id, + email, + ) + return drf_response.Response( + { + "error": "This application is not authorized for this email domain.", + }, + status=drf_status.HTTP_403_FORBIDDEN, + ) + + try: + user = models.User.objects.get(email=email) + except models.User.DoesNotExist as e: + raise drf_exceptions.NotFound( + { + "error": "User not found.", + } + ) from e + + now = datetime.now(timezone.utc) + scope = " ".join(application.scopes or []) + + payload = { + "iss": settings.APPLICATION_JWT_ISSUER, + "aud": settings.APPLICATION_JWT_AUDIENCE, + "iat": now, + "exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS), + "client_id": client_id, + "scope": scope, + "user_id": str(user.id), + "delegated": True, + } + + token = jwt.encode( + payload, + settings.APPLICATION_JWT_SECRET_KEY, + algorithm=settings.APPLICATION_JWT_ALG, + ) + + return drf_response.Response( + { + "access_token": token, + "token_type": settings.APPLICATION_JWT_TOKEN_TYPE, + "expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS, + "scope": scope, + }, + status=drf_status.HTTP_200_OK, + ) diff --git a/src/backend/core/tests/test_external_api_token.py b/src/backend/core/tests/test_external_api_token.py new file mode 100644 index 000000000..e37ca179e --- /dev/null +++ b/src/backend/core/tests/test_external_api_token.py @@ -0,0 +1,275 @@ +""" +Tests for external API /token endpoint +""" + +# pylint: disable=W0621 + +import jwt +import pytest +from freezegun import freeze_time +from rest_framework.test import APIClient + +from core.factories import ( + ApplicationDomainFactory, + ApplicationFactory, + UserFactory, +) +from core.models import ApplicationScope + +pytestmark = pytest.mark.django_db + + +def test_api_applications_generate_token_success(settings): + """Valid credentials should return a JWT token.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + user = UserFactory(email="user@example.com") + application = ApplicationFactory( + active=True, + scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], + ) + + # Store plain secret before it's hashed + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + assert response.status_code == 200 + assert "access_token" in response.data + + response.data.pop("access_token") + + assert response.data == { + "token_type": "Bearer", + "expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS, + "scope": "rooms:list rooms:create", + } + + +def test_api_applications_generate_token_invalid_client_id(): + """Invalid client_id should return 401.""" + user = UserFactory(email="user@example.com") + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": "invalid-client-id", + "client_secret": "some-secret", + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + assert response.status_code == 401 + assert "Invalid credentials" in str(response.data) + + +def test_api_applications_generate_token_invalid_client_secret(): + """Invalid client_secret should return 401.""" + user = UserFactory(email="user@example.com") + application = ApplicationFactory(active=True) + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": "wrong-secret", + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + assert response.status_code == 401 + assert "Invalid credentials" in str(response.data) + + +def test_api_applications_generate_token_inactive_application(): + """Inactive application should return 401.""" + user = UserFactory(email="user@example.com") + application = ApplicationFactory(active=False) + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + assert response.status_code == 401 + assert "Application is inactive" in str(response.data) + + +def test_api_applications_generate_token_invalid_email_format(): + """Invalid email format should return 400.""" + application = ApplicationFactory(active=True) + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": "not-an-email", + }, + format="json", + ) + + assert response.status_code == 400 + assert "scope should be a valid email address." in str(response.data).lower() + + +def test_api_applications_generate_token_domain_not_authorized(): + """Application without domain authorization should return 403.""" + user = UserFactory(email="user@denied.com") + application = ApplicationFactory(active=True) + ApplicationDomainFactory(application=application, domain="allowed.com") + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + assert response.status_code == 403 + assert "not authorized for this email domain" in str(response.data) + + +def test_api_applications_generate_token_domain_authorized(settings): + """Application with domain authorization should succeed.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + user = UserFactory(email="user@allowed.com") + application = ApplicationFactory( + active=True, + scopes=[ApplicationScope.ROOMS_LIST], + ) + ApplicationDomainFactory(application=application, domain="allowed.com") + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + assert response.status_code == 200 + assert "access_token" in response.data + + +def test_api_applications_generate_token_user_not_found(): + """Non-existent user should return 404.""" + application = ApplicationFactory(active=True) + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": "nonexistent@example.com", + }, + format="json", + ) + + assert response.status_code == 404 + assert "User not found" in str(response.data) + + +@freeze_time("2023-01-15 12:00:00") +def test_api_applications_token_payload_structure(settings): + """Generated token should have correct payload structure.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + user = UserFactory(email="user@example.com") + application = ApplicationFactory( + active=True, + scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], + ) + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + # Decode token to verify payload + token = response.data["access_token"] + payload = jwt.decode( + token, + settings.APPLICATION_JWT_SECRET_KEY, + algorithms=[settings.APPLICATION_JWT_ALG], + issuer=settings.APPLICATION_JWT_ISSUER, + audience=settings.APPLICATION_JWT_AUDIENCE, + ) + + assert payload == { + "iss": settings.APPLICATION_JWT_ISSUER, + "aud": settings.APPLICATION_JWT_AUDIENCE, + "client_id": application.client_id, + "exp": 1673787600, + "iat": 1673784000, + "user_id": str(user.id), + "delegated": True, + "scope": "rooms:list rooms:create", + } diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 13236d3c3..6603e393e 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -7,6 +7,7 @@ from rest_framework.routers import DefaultRouter from core.api import get_frontend_configuration, viewsets +from core.external_api import viewsets as external_viewsets # - Main endpoints router = DefaultRouter() @@ -19,6 +20,11 @@ # - External API external_router = DefaultRouter() +external_router.register( + "application", + external_viewsets.ApplicationViewSet, + basename="external_application", +) urlpatterns = [ path( diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index be5525703..cea40dff1 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -676,6 +676,34 @@ class Base(Configuration): environ_name="APPLICATION_CLIENT_SECRET_LENGTH", environ_prefix=None, ) + APPLICATION_JWT_SECRET_KEY = SecretFileValue( + None, environ_name="APPLICATION_JWT_SECRET_KEY", environ_prefix=None + ) + APPLICATION_JWT_ALG = values.Value( + "HS256", + environ_name="APPLICATION_JWT_ALG", + environ_prefix=None, + ) + APPLICATION_JWT_ISSUER = values.Value( + "lasuite-meet", + environ_name="APPLICATION_JWT_ISSUER", + environ_prefix=None, + ) + APPLICATION_JWT_AUDIENCE = values.Value( + None, + environ_name="APPLICATION_JWT_AUDIENCE", + environ_prefix=None, + ) + APPLICATION_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue( + 3600, + environ_name="APPLICATION_JWT_EXPIRATION_SECONDS", + environ_prefix=None, + ) + APPLICATION_JWT_TOKEN_TYPE = values.Value( + "Bearer", + environ_name="APPLICATION_JWT_TOKEN_TYPE", + environ_prefix=None, + ) # pylint: disable=invalid-name @property From 73273ad773ec57591c16b46ffcef6a549406e56f Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 3 Oct 2025 01:18:43 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8(backend)=20add=20minimal=20scope?= =?UTF-8?q?=20control=20for=20external=20API=20JWTs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce the principle of least privilege by granting viewset permissions only based on the scopes included in the token. JWTs should never be issued without controlling which actions the application is allowed to perform. The first and minimal scope is to allow creating a room link. Additional actions on the viewset will only be considered after this baseline scope is in place. --- src/backend/core/external_api/permissions.py | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/backend/core/external_api/permissions.py diff --git a/src/backend/core/external_api/permissions.py b/src/backend/core/external_api/permissions.py new file mode 100644 index 000000000..ec3d570ae --- /dev/null +++ b/src/backend/core/external_api/permissions.py @@ -0,0 +1,76 @@ +"""Permission handlers for application-delegated API access.""" + +import logging +from typing import Dict + +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 has_permission(self, request, view): + """Check if the JWT token contains the required scope for 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 + """ + # Get the current action (e.g., 'list', 'create') + action = getattr(view, "action", None) + if not action: + raise exceptions.PermissionDenied( + "Insufficient permissions. Unknown action." + ) + + 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}" + ) + + token_payload = request.auth + token_scopes = token_payload.get("scope") + + if not token_scopes: + raise exceptions.PermissionDenied("Insufficient permissions.") + + # Ensure scopes is a list (handle both list and space-separated string) + if isinstance(token_scopes, str): + token_scopes = token_scopes.split() + + if required_scope not in token_scopes: + raise exceptions.PermissionDenied( + f"Insufficient permissions. Required scope: {required_scope}" + ) + + return True + + +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, + } From 66d738630908343d5084e9f3a7aee58ab276d6dd Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 3 Oct 2025 01:43:59 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8(backend)=20draft=20initial=20Room?= =?UTF-8?q?=20viewset=20for=20external=20applications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a security perspective, the list endpoint should be limited to return only rooms created by the external application. Currently, there is a risk of exposing public rooms through this endpoint. I will address this in upcoming commits by updating the room model to track the source of generation. This will also provide useful information for analytics. The API viewset was largely copied and adapted. The serializer was heavily restricted to return a response more appropriate for external applications, providing ready-to-use information for their users (for example, a clickable link). I plan to extend the room information further, potentially aligning it with the Google Meet API format. This first draft serves as a solid foundation. Although scopes for delete and update exist, these methods have not yet been implemented in the viewset. They will be added in future commits. --- env.d/development/common.dist | 1 + src/backend/core/external_api/serializers.py | 55 +++ src/backend/core/external_api/viewsets.py | 69 +++- .../core/tests/test_external_api_rooms.py | 334 ++++++++++++++++++ src/backend/core/urls.py | 6 + src/backend/core/utils.py | 12 + src/backend/meet/settings.py | 5 + 7 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 src/backend/core/tests/test_external_api_rooms.py diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 52f33f123..1b2dc5caf 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -69,3 +69,4 @@ FRONTEND_USE_PROCONNECT_BUTTON=False # External Applications APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/ APPLICATION_JWT_SECRET_KEY=devKey +APPLICATION_BASE_URL=http://localhost:3000 diff --git a/src/backend/core/external_api/serializers.py b/src/backend/core/external_api/serializers.py index 4d4d30577..205691d64 100644 --- a/src/backend/core/external_api/serializers.py +++ b/src/backend/core/external_api/serializers.py @@ -2,8 +2,11 @@ # pylint: disable=abstract-method +from django.conf import settings + from rest_framework import serializers +from core import models, utils from core.api.serializers import BaseValidationOnlySerializer OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" @@ -16,3 +19,55 @@ class ApplicationJwtSerializer(BaseValidationOnlySerializer): client_secret = serializers.CharField(write_only=True) grant_type = serializers.ChoiceField(choices=[OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS]) scope = serializers.CharField(write_only=True) + + +class RoomSerializer(serializers.ModelSerializer): + """External API serializer for room data exposed to applications. + + Provides limited, safe room information for third-party integrations: + - Secure defaults for room creation (trusted access level) + - Computed fields (url, telephony) for external consumption + - Filtered data appropriate for delegation scenarios + - Tracks creation source for auditing + + Intentionally exposes minimal information to external applications, + following the principle of least privilege. + """ + + class Meta: + model = models.Room + fields = ["id", "name", "slug", "pin_code", "access_level"] + read_only_fields = ["id", "name", "slug", "pin_code", "access_level"] + + def to_representation(self, instance): + """Enrich response with application-specific computed fields.""" + output = super().to_representation(instance) + request = self.context.get("request") + pin_code = output.pop("pin_code", None) + + if not request: + return output + + # Add room URL for direct access + if settings.APPLICATION_BASE_URL: + output["url"] = f"{settings.APPLICATION_BASE_URL}/{instance.slug}" + + # Add telephony information if enabled + if settings.ROOM_TELEPHONY_ENABLED: + output["telephony"] = { + "enabled": True, + "phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER, + "pin_code": pin_code, + "default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY, + } + + return output + + def create(self, validated_data): + """Create room with secure defaults for application delegation.""" + + # Set secure defaults + validated_data["name"] = utils.generate_room_slug() + validated_data["access_level"] = models.RoomAccessLevel.TRUSTED + + return super().create(validated_data) diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index a36e4a087..732fe0516 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -9,7 +9,7 @@ from django.core.validators import validate_email import jwt -from rest_framework import decorators, viewsets +from rest_framework import decorators, mixins, viewsets from rest_framework import ( exceptions as drf_exceptions, ) @@ -20,9 +20,9 @@ status as drf_status, ) -from core import models +from core import api, models -from . import serializers +from . import authentication, permissions, serializers logger = getLogger(__name__) @@ -129,3 +129,66 @@ def generate_jwt_access_token(self, request, *args, **kwargs): }, status=drf_status.HTTP_200_OK, ) + + +class RoomViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + """Application-delegated API for room management. + + Provides JWT-authenticated access to room operations for external applications + acting on behalf of users. All operations are scope-based and filtered to the + authenticated user's accessible rooms. + + Supported operations: + - list: List rooms the user has access to (requires 'rooms:list' scope) + - retrieve: Get room details (requires 'rooms:retrieve' scope) + - create: Create a new room owned by the user (requires 'rooms:create' scope) + """ + + authentication_classes = [authentication.ApplicationJWTAuthentication] + permission_classes = [ + api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope + ] + queryset = models.Room.objects.all() + serializer_class = serializers.RoomSerializer + + def list(self, request, *args, **kwargs): + """Limit listed rooms to the ones related to the authenticated user.""" + + user = self.request.user + + if user.is_authenticated: + queryset = ( + self.filter_queryset(self.get_queryset()).filter(users=user).distinct() + ) + else: + queryset = self.get_queryset().none() + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return drf_response.Response(serializer.data) + + def perform_create(self, serializer): + """Set the current user as owner of the newly created room.""" + room = serializer.save() + models.ResourceAccess.objects.create( + resource=room, + user=self.request.user, + role=models.RoleChoices.OWNER, + ) + + # Log for auditing + logger.info( + "Room created via application: room_id=%s, user_id=%s, client_id=%s", + room.id, + self.request.user.id, + getattr(self.request.auth, "client_id", "unknown"), + ) diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py new file mode 100644 index 000000000..cab7a923d --- /dev/null +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -0,0 +1,334 @@ +""" +Tests for external API /room endpoint +""" + +# pylint: disable=W0621 + +from datetime import datetime, timedelta, timezone + +from django.conf import settings + +import jwt +import pytest +from rest_framework.test import APIClient + +from core.factories import ( + RoomFactory, + UserFactory, +) +from core.models import ApplicationScope, RoleChoices, Room + +pytestmark = pytest.mark.django_db + + +def generate_test_token(user, scopes): + """Generate a valid JWT token for testing.""" + now = datetime.now(timezone.utc) + scope_string = " ".join(scopes) + + payload = { + "iss": settings.APPLICATION_JWT_ISSUER, + "aud": settings.APPLICATION_JWT_AUDIENCE, + "iat": now, + "exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS), + "client_id": "test-client-id", + "scope": scope_string, + "user_id": str(user.id), + "delegated": True, + } + + return jwt.encode( + payload, + settings.APPLICATION_JWT_SECRET_KEY, + algorithm=settings.APPLICATION_JWT_ALG, + ) + + +def test_api_rooms_list_requires_authentication(): + """Listing rooms without authentication should return 401.""" + client = APIClient() + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 401 + + +def test_api_rooms_list_with_valid_token(settings): + """Listing rooms with valid token should succeed.""" + + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + # Generate valid token + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == str(room.id) + + +def test_api_rooms_list_with_expired_token(settings): + """Listing rooms with expired token should return 401.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0 + + user = UserFactory() + + # Generate expired token + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 401 + assert "expired" in str(response.data).lower() + + +def test_api_rooms_list_with_invalid_token(): + """Listing rooms with invalid token should return 401.""" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 401 + + +def test_api_rooms_list_missing_scope(settings): + """Listing rooms without required scope should return 403.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + + user = UserFactory() + + # Token without ROOMS_LIST scope + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 403 + assert "Insufficient permissions. Required scope: rooms:list" in str(response.data) + + +def test_api_rooms_list_filters_by_user(settings): + """List should only return rooms accessible to the authenticated user.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + + user1 = UserFactory() + user2 = UserFactory() + + room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)]) + room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)]) + room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)]) + + token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + assert response.data["count"] == 2 + returned_ids = [r["id"] for r in response.data["results"]] + assert str(room1.id) in returned_ids + assert str(room3.id) in returned_ids + assert str(room2.id) not in returned_ids + + +def test_api_rooms_retrieve_requires_scope(settings): + """Retrieving a room requires ROOMS_RETRIEVE scope.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + # Token without ROOMS_RETRIEVE scope + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 403 + assert "Insufficient permissions. Required scope: rooms:retrieve" in str( + response.data + ) + + +def test_api_rooms_retrieve_success(settings): + """Retrieving a room with correct scope should succeed.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + settings.APPLICATION_BASE_URL = "http://your-application.com" + settings.ROOM_TELEPHONY_ENABLED = True + settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100" + settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "US" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 200 + + assert response.data == { + "id": str(room.id), + "name": room.name, + "slug": room.slug, + "access_level": str(room.access_level), + "url": f"http://your-application.com/{room.slug}", + "telephony": { + "enabled": True, + "phone_number": "+1-555-0100", + "pin_code": room.pin_code, + "default_country": "US", + }, + } + + +def test_api_rooms_create_requires_scope(settings): + """Creating a room requires ROOMS_CREATE scope.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + user = UserFactory() + + # Token without ROOMS_CREATE scope + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post("/external-api/v1.0/rooms/", {}, format="json") + + assert response.status_code == 403 + assert "Insufficient permissions. Required scope: rooms:create" in str( + response.data + ) + + +def test_api_rooms_create_success(settings): + """Creating a room with correct scope should succeed.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + + user = UserFactory() + + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post("/external-api/v1.0/rooms/", {}, format="json") + + assert response.status_code == 201 + assert "id" in response.data + assert "slug" in response.data + + # Verify room was created with user as owner + room = Room.objects.get(id=response.data["id"]) + assert room.get_role(user) == RoleChoices.OWNER + assert room.access_level == "trusted" + + +def test_api_rooms_response_no_url(settings): + """Response should not include url field when APPLICATION_BASE_URL is None.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + settings.APPLICATION_BASE_URL = None + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 200 + assert "url" not in response.data + assert response.data["id"] == str(room.id) + + +def test_api_rooms_response_no_telephony(settings): + """Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + settings.ROOM_TELEPHONY_ENABLED = False + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 200 + assert "telephony" not in response.data + assert response.data["id"] == str(room.id) + + +def test_api_rooms_token_without_delegated_flag(settings): + """Token without delegated flag should be rejected.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + user = UserFactory() + + # Generate token without delegated flag + now = datetime.now(timezone.utc) + payload = { + "iss": settings.APPLICATION_JWT_ISSUER, + "aud": settings.APPLICATION_JWT_AUDIENCE, + "iat": now, + "exp": now + timedelta(hours=1), + "client_id": "test-client", + "scope": "rooms:list", + "user_id": str(user.id), + "delegated": False, # Not delegated + } + token = jwt.encode( + payload, + settings.APPLICATION_JWT_SECRET_KEY, + algorithm=settings.APPLICATION_JWT_ALG, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 401 + assert "Invalid token type." in str(response.data) + + +def test_api_rooms_token_missing_client_id(settings): + """Token without client_id should be rejected.""" + settings.APPLICATION_JWT_SECRET_KEY = "devKey" + user = UserFactory() + + now = datetime.now(timezone.utc) + payload = { + "iss": settings.APPLICATION_JWT_ISSUER, + "aud": settings.APPLICATION_JWT_AUDIENCE, + "iat": now, + "exp": now + timedelta(hours=1), + "scope": "rooms:list", + "user_id": str(user.id), + "delegated": True, + # Missing client_id + } + token = jwt.encode( + payload, + settings.APPLICATION_JWT_SECRET_KEY, + algorithm=settings.APPLICATION_JWT_ALG, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 401 + assert "Invalid token claims." in str(response.data) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 6603e393e..716e67765 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -26,6 +26,12 @@ basename="external_application", ) +external_router.register( + "rooms", + external_viewsets.RoomViewSet, + basename="external_room", +) + urlpatterns = [ path( f"api/{settings.API_VERSION}/", diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 8f62bd561..19919448c 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -8,6 +8,7 @@ import hashlib import json import random +import secrets import string from typing import List, Optional from uuid import uuid4 @@ -280,3 +281,14 @@ def generate_client_secret() -> str: Cryptographically secure client secret """ return generate_secure_token(settings.APPLICATION_CLIENT_SECRET_LENGTH) + + +def generate_room_slug(): + """Generate a random room slug in the format 'xxx-xxxx-xxx'.""" + + sizes = [3, 4, 3] + parts = [ + "".join(secrets.choice(string.ascii_lowercase) for _ in range(size)) + for size in sizes + ] + return "-".join(parts) diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index cea40dff1..00a002827 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -704,6 +704,11 @@ class Base(Configuration): environ_name="APPLICATION_JWT_TOKEN_TYPE", environ_prefix=None, ) + APPLICATION_BASE_URL = values.Value( + None, + environ_name="APPLICATION_BASE_URL", + environ_prefix=None, + ) # pylint: disable=invalid-name @property From e20a0c2aed3da86b70f2a468868310a3778e28f8 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 3 Oct 2025 02:20:06 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=93=9D(backend)=20add=20Swagger=20doc?= =?UTF-8?q?umentation=20for=20external=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the external API using a simple Swagger file that can be opened in any Swagger editor. The content was mostly generated with the help of an LLM and has been human- reviewed. Corrections or enhancements to the documentation are welcome. Currently, my professional email address is included as a contact. A support email will be added later once available. The documentation will also be expanded as additional endpoints are added. --- docs/openapi.yaml | 475 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 docs/openapi.yaml diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 000000000..d13f8458e --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,475 @@ +openapi: 3.0.3 +info: + title: Meet External API + version: 1.0.0 + description: | + External API for room management with application-delegated authentication. + + #### Authentication Flow + + 1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/token`. + 2. Use the JWT token in the `Authorization: Bearer ` header for all subsequent requests. + 3. Tokens are scoped and allow applications to act on behalf of specific users. + + #### Scopes + + * `rooms:list` – List rooms accessible to the delegated user. + * `rooms:retrieve` – Retrieve details of a specific room. + * `rooms:create` – Create new rooms. + * `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room. + * `rooms:delete` – **Coming soon** Delete rooms generated by the application. + + #### Upcoming Features + + * **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system. + * **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically. + * **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed. + + contact: + name: API Support + email: antoine.lebaud@mail.numerique.gouv.fr + +servers: + - url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0 + description: Sandbox server + +tags: + - name: Authentication + description: Application authentication and token generation + - name: Rooms + description: Room management operations + +paths: + /applications/token: + post: + tags: + - Authentication + summary: Generate JWT token + description: | + Exchange application credentials for a scoped JWT token that allows the application + to act on behalf of a specific user. + + The application must be authorized for the user's email domain. + The returned token expires after a configured duration and must be refreshed by calling this endpoint again. + operationId: generateToken + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + examples: + tokenRequest: + summary: Request token for user delegation + value: + client_id: "550e8400-e29b-41d4-a716-446655440000" + client_secret: "1234567890abcdefghijklmnopqrstuvwxyz" + grant_type: "client_credentials" + scope: "user@example.com" + responses: + '200': + description: Token generated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + examples: + tokenResponse: + summary: Successful token generation + value: + access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + token_type: "Bearer" + expires_in: 3600 + scope: "rooms:list rooms:retrieve rooms:create" + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + examples: + invalidCredentials: + summary: Invalid credentials + value: + error: "Invalid credentials" + inactiveApplication: + summary: Application is inactive + value: + error: "Application is inactive" + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + examples: + userNotFound: + summary: User not found + value: + error: "User not found." + '403': + description: Access denied - cannot delegate user + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + examples: + delegationDenied: + summary: Domain not authorized + value: + error: "This application is not authorized for this email domain." + + /rooms: + get: + tags: + - Rooms + summary: List rooms + description: | + Returns a list of rooms accessible to the authenticated user. + Only rooms where the delegated user has access will be returned. + operationId: listRooms + security: + - BearerAuth: [rooms:list] + parameters: + - name: page + in: query + description: Page number for pagination + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + description: Number of items per page + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: List of accessible rooms + content: + application/json: + schema: + type: object + properties: + count: + type: integer + description: Total number of rooms + next: + type: string + nullable: true + description: URL to next page + previous: + type: string + nullable: true + description: URL to previous page + results: + type: array + items: + $ref: '#/components/schemas/Room' + examples: + roomList: + summary: Paginated room list + value: + count: 2 + next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2" + previous: null + results: + - id: "7c9e6679-7425-40de-944b-e07fc1f90ae7" + slug: "aae-erez-aaz" + access_level: "trusted" + url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz" + telephony: + enabled: true + pin_code: "123456" + phone_number: "+1-555-0100" + default_country: "US" + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + + post: + tags: + - Rooms + summary: Create a room + description: | + Creates a new room with secure defaults for external API usage. + + **Restrictions:** + - Rooms are always created with `trusted` access (no public rooms via API) + - Room access_level can be updated from the webapp interface. + + **Defaults:** + - Delegated user is set as owner + - Room slug auto-generated for uniqueness + - Telephony PIN auto-generated when enabled + - Creation tracked with application client_id for auditing + operationId: createRoom + security: + - BearerAuth: [rooms:create] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/RoomCreate' + examples: + emptyBody: + summary: No parameters (default) + value: {} + responses: + '201': + description: Room created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Room' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + + /rooms/{id}: + get: + tags: + - Rooms + summary: Retrieve a room + description: Get detailed information about a specific room by its ID + operationId: retrieveRoom + security: + - BearerAuth: [rooms:retrieve] + parameters: + - name: id + in: path + required: true + description: Room UUID + schema: + type: string + format: uuid + responses: + '200': + description: Room details + content: + application/json: + schema: + $ref: '#/components/schemas/Room' + examples: + room: + summary: Room details + value: + id: "7c9e6679-7425-40de-944b-e07fc1f90ae7" + slug: "aae-erez-aaz" + access_level: "trusted" + url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz" + telephony: + enabled: true + pin_code: "123456" + phone_number: "+1-555-0100" + default_country: "US" + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/RoomNotFoundError' + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + JWT token obtained from the `/applications/token` endpoint. + Include in requests as: `Authorization: Bearer ` + + schemas: + TokenRequest: + type: object + required: + - client_id + - client_secret + - grant_type + - scope + properties: + client_id: + type: string + description: Application client identifier + example: "550e8400-e29b-41d4-a716-446655440000" + client_secret: + type: string + format: password + writeOnly: true + description: Application secret key + example: "1234567890abcdefghijklmnopqrstuvwxyz" + grant_type: + type: string + enum: + - client_credentials + description: OAuth2 grant type (must be 'client_credentials') + example: "client_credentials" + scope: + type: string + format: email + description: | + Email address of the user to delegate. + The application will act on behalf of this user. + Note: This parameter is named 'scope' to align with OAuth2 conventions, + but accepts an email address to identify the user. This design allows + for future extensibility. + example: "user@example.com" + + TokenResponse: + type: object + properties: + access_token: + type: string + description: JWT access token + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature" + token_type: + type: string + description: Token type (always 'Bearer') + example: "Bearer" + expires_in: + type: integer + description: Token lifetime in seconds + example: 3600 + scope: + type: string + description: Space-separated list of granted permission scopes + example: "rooms:list rooms:retrieve rooms:create" + + RoomCreate: + type: object + description: Empty object - all room properties are auto-generated + properties: {} + + Room: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + description: Unique room identifier + example: "7c9e6679-7425-40de-944b-e07fc1f90ae7" + slug: + type: string + readOnly: true + description: URL-friendly room identifier (auto-generated) + example: "aze-eere-zer" + access_level: + type: string + readOnly: true + description: Room access level (always 'trusted' for API-created rooms) + example: "trusted" + url: + type: string + format: uri + readOnly: true + description: Full URL to access the room + example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer" + telephony: + type: object + readOnly: true + description: Telephony dial-in information (if enabled) + properties: + enabled: + type: boolean + description: Whether telephony is available + example: true + pin_code: + type: string + description: PIN code for dial-in access + example: "123456" + phone_number: + type: string + description: Phone number to dial + example: "+1-555-0100" + default_country: + type: string + description: Default country code + example: "US" + + OAuthError: + type: object + description: OAuth2-compliant error response + properties: + error: + type: string + description: Human-readable error description + example: "Invalid credentials" + + Error: + type: object + properties: + detail: + type: string + description: Error message + example: "Invalid token." + + ValidationError: + type: object + properties: + field_name: + type: array + items: + type: string + description: List of validation errors for this field + example: ["This field is required."] + + responses: + UnauthorizedError: + description: Authentication required or token invalid/expired + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + invalidToken: + summary: Invalid or expired token + value: + error: "Invalid token." + tokenExpired: + summary: Token has expired + value: + error: "Token expired." + + ForbiddenError: + description: Insufficient permissions for this operation + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + insufficientScope: + summary: Missing required scope + value: + detail: "Insufficient permissions. Required scope: 'rooms:xxxx'" + + RoomNotFoundError: + description: Room not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + roomNotFound: + summary: Room does not exist + value: + detail: "Not found." + + BadRequestError: + description: Invalid request data + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + examples: + validationError: + summary: Field validation failed + value: + scope: ["Invalid email address."] From bb4a81870918054b24fa12fb1c39db9c06dd1c90 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 6 Oct 2025 19:23:48 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=A7(backend)=20add=20Django=20sett?= =?UTF-8?q?ing=20to=20disable=20external=20API=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce ENABLE_EXTERNAL_API setting (defaults to False) to allow administrators to disable external API endpoints, preventing unintended exposure for self-hosted instances where such endpoints aren't needed or desired. --- env.d/development/common.dist | 1 + src/backend/core/urls.py | 20 ++++++++++++-------- src/backend/meet/settings.py | 4 ++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 1b2dc5caf..32f85678c 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -67,6 +67,7 @@ FRONTEND_USE_FRENCH_GOV_FOOTER=False FRONTEND_USE_PROCONNECT_BUTTON=False # External Applications +EXTERNAL_API_ENABLED=True APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/ APPLICATION_JWT_SECRET_KEY=devKey APPLICATION_BASE_URL=http://localhost:3000 diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 716e67765..d8493e5aa 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -43,12 +43,16 @@ ] ), ), - path( - f"external-api/{settings.EXTERNAL_API_VERSION}/", - include( - [ - *external_router.urls, - ] - ), - ), ] + +if settings.EXTERNAL_API_ENABLED: + urlpatterns.append( + path( + f"external-api/{settings.EXTERNAL_API_VERSION}/", + include( + [ + *external_router.urls, + ] + ), + ) + ) diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 00a002827..f835d2099 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -70,6 +70,9 @@ class Base(Configuration): API_VERSION = "v1.0" EXTERNAL_API_VERSION = "v1.0" + EXTERNAL_API_ENABLED = values.BooleanValue( + False, environ_name="EXTERNAL_API_ENABLED", environ_prefix=None + ) DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR") @@ -828,6 +831,7 @@ class Test(Base): "django.contrib.auth.hashers.MD5PasswordHasher", ] USE_SWAGGER = True + EXTERNAL_API_ENABLED = True CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)