mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-04 14:45:40 +00:00
✨(backend) support add-ons authentication in external viewset
Integrate the add-ons authentication backend into the externalviewset. This allows third-party integrations (e.g. calendar add-ins) to obtain a JWT for a user and use it to call the API (e.g. create a room) via a Bearer authorization header.
This commit is contained in:
@@ -15,6 +15,7 @@ and this project adheres to
|
|||||||
- 💬(backend) clarify french transcription audio download link text #1299
|
- 💬(backend) clarify french transcription audio download link text #1299
|
||||||
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
|
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
|
||||||
- 🔧(backend) add setting to toggle application token exchange mechanism
|
- 🔧(backend) add setting to toggle application token exchange mechanism
|
||||||
|
- ✨(backend) support add-ons authentication in external viewset
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,26 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
|||||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
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):
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ class RoomViewSet(
|
|||||||
|
|
||||||
authentication_classes = [
|
authentication_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
|
authentication.AddonsJWTAuthentication,
|
||||||
ResourceServerAuthentication,
|
ResourceServerAuthentication,
|
||||||
]
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
|
|||||||
@@ -22,6 +22,28 @@ from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, Us
|
|||||||
pytestmark = pytest.mark.django_db
|
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):
|
def generate_test_token(user, scopes):
|
||||||
"""Generate a valid JWT token for testing."""
|
"""Generate a valid JWT token for testing."""
|
||||||
now = datetime.now(timezone.utc)
|
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()
|
assert "application is disabled." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Resource Server
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_resource_server_creates_user_on_first_authentication(settings):
|
def test_resource_server_creates_user_on_first_authentication(settings):
|
||||||
"""New user should be created during first authentication.
|
"""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/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 403
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user