From 012857f8c65c200b6c6c95884123dce9d44d6229 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 29 Apr 2026 16:57:51 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7(backend)=20add=20setting=20to=20to?= =?UTF-8?q?ggle=20application=20token=20exchange=20mechanism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a configuration flag to enable or disable the application token exchange (service account) mechanism. This allows activating alternative authentication backends without requiring full application token configuration. Required to support the upcoming add-ons authentication backend. --- CHANGELOG.md | 1 + src/backend/core/api/feature_flag.py | 1 + .../core/external_api/authentication.py | 20 ++++- src/backend/core/external_api/viewsets.py | 2 + .../core/tests/test_external_api_rooms.py | 77 +++++++++++++++++++ .../core/tests/test_external_api_token.py | 29 +++++++ src/backend/meet/settings.py | 4 + 7 files changed, 133 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbcb78f0..f8f0f57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(backend) introduce add-ons authentication backend - 💬(backend) clarify french transcription audio download link text #1299 - 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha) +- 🔧(backend) add setting to toggle application token exchange mechanism ### Fixed diff --git a/src/backend/core/api/feature_flag.py b/src/backend/core/api/feature_flag.py index 17c9eb02..fe49ff5d 100644 --- a/src/backend/core/api/feature_flag.py +++ b/src/backend/core/api/feature_flag.py @@ -15,6 +15,7 @@ class FeatureFlag: "subtitle": "ROOM_SUBTITLE_ENABLED", "file_upload": "FILE_UPLOAD_ENABLED", "addons": "ADDONS_ENABLED", + "application": "APPLICATION_ENABLED", } @classmethod diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py index eb5eea9d..38dbe932 100644 --- a/src/backend/core/external_api/authentication.py +++ b/src/backend/core/external_api/authentication.py @@ -23,7 +23,14 @@ class BaseJWTAuthentication(authentication.BaseAuthentication): """Base JWT authentication class.""" def __init__( - self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type + self, + secret_key, + algorithm, + issuer, + audience, + expiration_seconds, + token_type, + is_enabled, ): """Initialize the JWT authentication backend with the given token service configuration. @@ -34,10 +41,17 @@ class BaseJWTAuthentication(authentication.BaseAuthentication): audience: Expected token audience identifier expiration_seconds: Token expiration time in seconds token_type: Token type (e.g. Bearer) + is_enabled: Whether this authentication backend is active """ super().__init__() + self.is_enabled = is_enabled + self._token_service = None + + if not self.is_enabled: + return + self._token_service = jwt_token.JwtTokenService( secret_key=secret_key, algorithm=algorithm, @@ -54,6 +68,9 @@ class BaseJWTAuthentication(authentication.BaseAuthentication): Tuple of (user, payload) if authentication successful, None otherwise """ + if not self.is_enabled: + return None + auth_header = authentication.get_authorization_header(request).split() if not auth_header or auth_header[0].lower() != b"bearer": @@ -186,6 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication): audience=settings.APPLICATION_JWT_AUDIENCE, expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS, token_type=settings.APPLICATION_JWT_TOKEN_TYPE, + is_enabled=settings.APPLICATION_ENABLED, ) def validate_payload(self, payload): diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index f714cb16..459eda11 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -20,6 +20,7 @@ from rest_framework import ( ) from core import api, models +from core.api.feature_flag import FeatureFlag from core.services.jwt_token import JwtTokenService from . import authentication, permissions, serializers @@ -36,6 +37,7 @@ class ApplicationViewSet(viewsets.ViewSet): url_path="token", url_name="token", ) + @FeatureFlag.require("application") def generate_jwt_access_token(self, request, *args, **kwargs): """Generate JWT access token for application delegation. diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index abb419f5..c5915043 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -123,6 +123,25 @@ def test_api_rooms_list_with_expired_token(settings): assert "expired" in str(response.data).lower() +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_api_rooms_list_with_application_disabled(mock_rs_authenticate, settings): + """Listing rooms should return 401 when application is disabled.""" + + settings.APPLICATION_ENABLED = False + + user = UserFactory() + + # Generate expired 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 == 401 + mock_rs_authenticate.assert_called_once() + + @responses.activate def test_api_rooms_list_with_invalid_rs_token(settings): """Listing rooms with invalid resource server token should return 400.""" @@ -1106,6 +1125,64 @@ def test_resource_server_authentication_successful(settings): assert expected_ids == results_id +@responses.activate +def test_resource_server_authentication_successful_when_application_disabled(settings): + """Resource server should keep working when the application auth backend is disabled.""" + + settings.APPLICATION_ENABLED = False + + user = UserFactory(sub="very-specific-sub") + other_user = UserFactory() + + RoomFactory(access_level=RoomAccessLevel.PUBLIC) + RoomFactory(access_level=RoomAccessLevel.TRUSTED) + RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + room_user_accesses = RoomFactory( + access_level=RoomAccessLevel.RESTRICTED, users=[user] + ) + RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user]) + + assert ( + settings.OIDC_RS_BACKEND_CLASS + == "core.external_api.authentication.ResourceServerBackend" + ) + + settings.OIDC_RS_CLIENT_ID = "some_client_id" + settings.OIDC_RS_CLIENT_SECRET = "some_client_secret" + settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet" + + settings.OIDC_OP_URL = "https://oidc.example.com" + settings.OIDC_VERIFY_SSL = False + settings.OIDC_TIMEOUT = 5 + settings.OIDC_PROXY = None + settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks" + settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect" + + responses.add( + responses.POST, + "https://oidc.example.com/introspect", + json={ + "iss": "https://oidc.example.com", + "aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID + "sub": "very-specific-sub", + "client_id": "some_service_provider", + "scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve", + "active": True, + }, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer some_token") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 1 + expected_ids = {str(room_user_accesses.id)} + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + @responses.activate def test_resource_server_denies_access_with_insufficient_scopes(settings): """Requests should be denied when the token lacks required scopes. diff --git a/src/backend/core/tests/test_external_api_token.py b/src/backend/core/tests/test_external_api_token.py index 17b63dd8..9a8683ae 100644 --- a/src/backend/core/tests/test_external_api_token.py +++ b/src/backend/core/tests/test_external_api_token.py @@ -19,6 +19,35 @@ from core.models import ApplicationScope, User pytestmark = pytest.mark.django_db +def test_api_applications_generate_token_application_disabled(settings): + """When APPLICATION_ENABLED is False, the endpoint should return 404.""" + settings.APPLICATION_ENABLED = False + + user = UserFactory(email="user@example.com") + application = ApplicationFactory( + is_active=True, + scopes=[ApplicationScope.ROOMS_LIST], + ) + + 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 == 404 + + def test_api_applications_generate_token_success(settings): """Valid credentials should return a JWT token.""" UserFactory(email="User.Family@example.com") diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index f16a6c3f..1f5e980b 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -819,6 +819,9 @@ class Base(Configuration): ) # External Applications + APPLICATION_ENABLED = values.BooleanValue( + False, environ_name="APPLICATION_ENABLED", environ_prefix=None + ) APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue( 40, environ_name="APPLICATION_CLIENT_ID_LENGTH", @@ -1079,6 +1082,7 @@ class Test(Base): "url": "http://127.0.0.1.nip.io:7880", } + APPLICATION_ENABLED = True APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105 APPLICATION_JWT_AUDIENCE = "Test inc."