🔧(backend) add setting to toggle application token exchange mechanism

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.
This commit is contained in:
lebaudantoine
2026-04-29 16:57:51 +02:00
parent e5a804f748
commit 012857f8c6
7 changed files with 133 additions and 1 deletions
+1
View File
@@ -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
+1
View File
@@ -15,6 +15,7 @@ class FeatureFlag:
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
}
@classmethod
@@ -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):
@@ -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.
@@ -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.
@@ -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")
+4
View File
@@ -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."