(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:
lebaudantoine
2026-04-29 17:15:55 +02:00
parent 012857f8c6
commit 6f38d60a27
4 changed files with 291 additions and 0 deletions
+1
View File
@@ -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)