mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-05 16:37:43 +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
|
||||
- 🚧(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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ class RoomViewSet(
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user