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."] diff --git a/env.d/development/common.dist b/env.d/development/common.dist index e52186a56..32f85678c 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -65,3 +65,9 @@ ROOM_TELEPHONY_ENABLED=True 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/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/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/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/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, + } diff --git a/src/backend/core/external_api/serializers.py b/src/backend/core/external_api/serializers.py new file mode 100644 index 000000000..205691d64 --- /dev/null +++ b/src/backend/core/external_api/serializers.py @@ -0,0 +1,73 @@ +"""Serializers for the external API of the Meet core app.""" + +# 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" + + +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) + + +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 new file mode 100644 index 000000000..732fe0516 --- /dev/null +++ b/src/backend/core/external_api/viewsets.py @@ -0,0 +1,194 @@ +"""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, mixins, 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 api, models + +from . import authentication, permissions, 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, + ) + + +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/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_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/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/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/urls.py b/src/backend/core/urls.py index 98969f180..d8493e5aa 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() @@ -17,6 +18,20 @@ "resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses" ) +# - External API +external_router = DefaultRouter() +external_router.register( + "application", + external_viewsets.ApplicationViewSet, + basename="external_application", +) + +external_router.register( + "rooms", + external_viewsets.RoomViewSet, + basename="external_room", +) + urlpatterns = [ path( f"api/{settings.API_VERSION}/", @@ -29,3 +44,15 @@ ), ), ] + +if settings.EXTERNAL_API_ENABLED: + urlpatterns.append( + path( + f"external-api/{settings.EXTERNAL_API_VERSION}/", + include( + [ + *external_router.urls, + ] + ), + ) + ) diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 92f16eaa1..19919448c 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -8,6 +8,8 @@ import hashlib import json import random +import secrets +import string from typing import List, Optional from uuid import uuid4 @@ -240,3 +242,53 @@ 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) + + +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 51d10e880..f835d2099 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -69,6 +69,10 @@ class Base(Configuration): USE_SWAGGER = False 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") @@ -664,6 +668,51 @@ 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, + ) + 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, + ) + APPLICATION_BASE_URL = values.Value( + None, + environ_name="APPLICATION_BASE_URL", + environ_prefix=None, + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): @@ -782,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) 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 }}