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."