diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f0f57c..90f3b49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to - 💬(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 +- ✨(backend) support add-ons authentication in external viewset ### Fixed diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py index 38dbe932..57cf9aa9 100644 --- a/src/backend/core/external_api/authentication.py +++ b/src/backend/core/external_api/authentication.py @@ -232,6 +232,26 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication): raise exceptions.AuthenticationFailed("Invalid token type.") +class AddonsJWTAuthentication(BaseJWTAuthentication): + """JWT authentication for addons API access. + + Validates JWT tokens issued to addons. + """ + + def __init__(self): + """Initialize authentication backend with addons JWT settings from Django settings.""" + + super().__init__( + secret_key=settings.ADDONS_TOKEN_SECRET_KEY, + algorithm=settings.ADDONS_TOKEN_ALG, + issuer=settings.ADDONS_TOKEN_ISSUER, + audience=settings.ADDONS_TOKEN_AUDIENCE, + expiration_seconds=settings.ADDONS_TOKEN_TTL, + token_type=settings.ADDONS_TOKEN_TYPE, + is_enabled=settings.ADDONS_ENABLED, + ) + + class ResourceServerBackend(LaSuiteBackend): """OIDC Resource Server backend for user creation and retrieval.""" diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index 459eda11..a96fd283 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -175,6 +175,7 @@ class RoomViewSet( authentication_classes = [ authentication.ApplicationJWTAuthentication, + authentication.AddonsJWTAuthentication, ResourceServerAuthentication, ] permission_classes = [ diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index c5915043..901411cc 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -22,6 +22,28 @@ from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, Us pytestmark = pytest.mark.django_db +def generate_addons_test_token(user, scopes, **overrides): + """Generate a valid JWT token signed with the addons secret for testing.""" + now = datetime.now(timezone.utc) + scope_string = " ".join(scopes) + + payload = { + "iss": settings.ADDONS_TOKEN_ISSUER, + "aud": settings.ADDONS_TOKEN_AUDIENCE, + "iat": now, + "exp": now + timedelta(seconds=settings.ADDONS_TOKEN_TTL), + "scope": scope_string, + "user_id": str(user.id), + } + payload.update(overrides) + + return jwt.encode( + payload, + settings.ADDONS_TOKEN_SECRET_KEY, + algorithm=settings.ADDONS_TOKEN_ALG, + ) + + def generate_test_token(user, scopes): """Generate a valid JWT token for testing.""" now = datetime.now(timezone.utc) @@ -950,6 +972,11 @@ def test_api_rooms_token_inactive_application(settings): assert "application is disabled." in str(response.data).lower() +# ============================== +# Resource Server +# ============================== + + @responses.activate def test_resource_server_creates_user_on_first_authentication(settings): """New user should be created during first authentication. @@ -1224,3 +1251,245 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings): response = client.get("/external-api/v1.0/rooms/") assert response.status_code == 403 + + +# ============================== +# Addons +# ============================== + + +def test_api_rooms_list_with_valid_addons_token(): + """Listing rooms with a valid addons token should succeed.""" + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_addons_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_retrieve_with_valid_addons_token(): + """Retrieving a room with a valid addons token should succeed.""" + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_addons_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) + + +def test_api_rooms_create_with_valid_addons_token(): + """Creating a room with a valid addons token should succeed.""" + user = UserFactory() + + token = generate_addons_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 + room = Room.objects.get(id=response.data["id"]) + assert room.get_role(user) == RoleChoices.OWNER + + +def test_api_rooms_addons_token_inactive_user(): + """Addons token for an inactive user should return 401.""" + user = UserFactory(is_active=False) + RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_addons_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 + assert "user account is disabled" in str(response.data).lower() + + +def test_api_rooms_addons_token_expired(settings): + """Listing rooms with an expired addons token should return 401.""" + settings.ADDONS_TOKEN_TTL = 0 + + user = UserFactory() + token = generate_addons_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 + assert "expired" in str(response.data).lower() + + +def test_api_rooms_addons_token_missing_user_id(settings): + """Addons token without user_id should be rejected.""" + + # Re-encode without user_id + now = datetime.now(timezone.utc) + payload = { + "iss": settings.ADDONS_TOKEN_ISSUER, + "aud": settings.ADDONS_TOKEN_AUDIENCE, + "iat": now, + "exp": now + timedelta(hours=1), + "scope": "rooms:list", + # no user_id + } + token = jwt.encode( + payload, + settings.ADDONS_TOKEN_SECRET_KEY, + algorithm=settings.ADDONS_TOKEN_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).lower() + + +def test_api_rooms_addons_token_invalid_audience(settings): + """Addons token with an invalid audience should be rejected.""" + user = UserFactory() + + now = datetime.now(timezone.utc) + payload = { + "iss": settings.ADDONS_TOKEN_ISSUER, + "aud": "invalid-audience", + "iat": now, + "exp": now + timedelta(hours=1), + "user_id": str(user.id), + "scope": "rooms:list", + } + token = jwt.encode( + payload, + settings.ADDONS_TOKEN_SECRET_KEY, + algorithm=settings.ADDONS_TOKEN_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." in str(response.data).lower() + + +def test_api_rooms_addons_token_unknown_user(settings): + """Addons token for an unknown user should be rejected.""" + now = datetime.now(timezone.utc) + payload = { + "iss": settings.ADDONS_TOKEN_ISSUER, + "aud": settings.ADDONS_TOKEN_AUDIENCE, + "iat": now, + "exp": now + timedelta(hours=1), + "user_id": str(uuid.uuid4()), + "scope": "rooms:list", + } + token = jwt.encode( + payload, + settings.ADDONS_TOKEN_SECRET_KEY, + algorithm=settings.ADDONS_TOKEN_ALG, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 401 + assert "user not found." in str(response.data).lower() + + +def test_api_rooms_addons_token_missing_scope(): + """Addons token without required scope should return 403.""" + user = UserFactory() + + token = generate_addons_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).lower() + ) + + +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_api_rooms_addons_token_invalid_signature(mock_rs_authenticate, settings): + """Addons token signed with a wrong key should defer to the next authentication.""" + user = UserFactory() + + now = datetime.now(timezone.utc) + payload = { + "iss": settings.ADDONS_TOKEN_ISSUER, + "aud": settings.ADDONS_TOKEN_AUDIENCE, + "iat": now, + "exp": now + timedelta(hours=1), + "user_id": str(user.id), + "scope": "rooms:list", + } + token = jwt.encode( + payload, + "invalid-private-key-padded-to-32b!", + algorithm=settings.ADDONS_TOKEN_ALG, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + mock_rs_authenticate.assert_called() + assert response.status_code == 401 + + +@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None) +def test_api_rooms_addons_disabled_defers_to_next_backend( + mock_rs_authenticate, settings +): + """When ADDONS_ENABLED is False, a valid addons token should defer to the next backend.""" + settings.ADDONS_ENABLED = False + + user = UserFactory() + token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + mock_rs_authenticate.assert_called() + assert response.status_code == 401 + + +def test_api_rooms_addons_disabled_does_not_break_application_auth(settings): + """Disabling addons auth should not affect ApplicationJWTAuthentication.""" + settings.ADDONS_ENABLED = False + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + # Use the existing application token helper — that backend should still work + 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)