diff --git a/env.d/development/common.dist b/env.d/development/common.dist index e52186a5..52f33f12 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 00000000..9b8efd47 --- /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 00000000..4d4d3057 --- /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 00000000..a36e4a08 --- /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 00000000..e37ca179 --- /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 13236d3c..6603e393 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls from rest_framework.routers import DefaultRouter from core.api import get_frontend_configuration, viewsets +from core.external_api import viewsets as external_viewsets # - Main endpoints router = DefaultRouter() @@ -19,6 +20,11 @@ router.register( # - 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 be552570..cea40dff 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