diff --git a/.github/workflows/meet.yml b/.github/workflows/meet.yml index f17d23ead..8c243eb90 100644 --- a/.github/workflows/meet.yml +++ b/.github/workflows/meet.yml @@ -183,6 +183,10 @@ jobs: AWS_S3_ENDPOINT_URL: http://localhost:9000 AWS_S3_ACCESS_KEY_ID: meet AWS_S3_SECRET_ACCESS_KEY: password + OIDC_RS_CLIENT_ID: meet + OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly + OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect + OIDC_OP_URL: https://oidc.example.com steps: - name: Checkout repository diff --git a/compose.yml b/compose.yml index cd760e82f..cd435e363 100644 --- a/compose.yml +++ b/compose.yml @@ -90,6 +90,9 @@ services: - createwebhook extra_hosts: - "127.0.0.1.nip.io:host-gateway" + networks: + - resource-server + - default celery-dev: user: ${DOCKER_USER:-1000} @@ -145,6 +148,9 @@ services: - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro depends_on: - keycloak + networks: + - resource-server + - default frontend: user: "${DOCKER_USER:-1000}" @@ -298,3 +304,7 @@ services: watch: - action: rebuild path: ./src/summary + +networks: + default: + resource-server: diff --git a/docker/resource-server/compose.yml b/docker/resource-server/compose.yml new file mode 100644 index 000000000..a92e89200 --- /dev/null +++ b/docker/resource-server/compose.yml @@ -0,0 +1,23 @@ +version: '3' + +# You can add any necessary service here that will join the same docker network +# sharing keycloak. Services added to the 'meet_resource-server' network will be +# able to communicate with keycloak and the backend on that network. +services: + # busybox service is only used for testing purposes. It provides curl to test + # connectivity to the backend and keycloak services. Replace this with your + # relevant application services that need to communicate with keycloak. + busybox: + image: alpine:latest + privileged: true + command: sh -c "apk add --no-cache curl && sleep infinity" + stdin_open: true + tty: true + networks: + - default + - meet_resource-server + +networks: + default: {} + meet_resource-server: + external: true diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 32f85678c..91760b49c 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -32,6 +32,8 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/cert OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo +OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect +OIDC_OP_URL=http://localhost:8083/realms/meet OIDC_RP_CLIENT_ID=meet OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly @@ -45,6 +47,9 @@ LOGOUT_REDIRECT_URL=http://localhost:3000 OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000 OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} +OIDC_RS_CLIENT_ID=meet +OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly + # Livekit Token settings LIVEKIT_API_SECRET=secret LIVEKIT_API_KEY=devkey diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py index 9b8efd474..03c4b2537 100644 --- a/src/backend/core/external_api/authentication.py +++ b/src/backend/core/external_api/authentication.py @@ -4,8 +4,10 @@ from django.conf import settings from django.contrib.auth import get_user_model +from django.core.exceptions import SuspiciousOperation -import jwt +import jwt as pyJwt +from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend from rest_framework import authentication, exceptions User = get_user_model() @@ -25,9 +27,11 @@ def authenticate(self, request): Returns: Tuple of (user, payload) if authentication successful, None otherwise """ + auth_header = authentication.get_authorization_header(request).split() if not auth_header or auth_header[0].lower() != b"bearer": + # Defer to next authentication backend return None if len(auth_header) != 2: @@ -45,6 +49,8 @@ def authenticate(self, request): def authenticate_credentials(self, token): """Validate JWT token and return authenticated user. + If token is invalid, defer to next authentication backend. + Args: token: JWT token string @@ -52,29 +58,29 @@ def authenticate_credentials(self, token): Tuple of (user, payload) Raises: - AuthenticationFailed: If token is invalid, expired, or user not found + AuthenticationFailed: If token is expired, or user not found """ # Decode and validate JWT try: - payload = jwt.decode( + payload = pyJwt.decode( token, settings.APPLICATION_JWT_SECRET_KEY, algorithms=[settings.APPLICATION_JWT_ALG], issuer=settings.APPLICATION_JWT_ISSUER, audience=settings.APPLICATION_JWT_AUDIENCE, ) - except jwt.ExpiredSignatureError as e: + except pyJwt.ExpiredSignatureError as e: logger.warning("Token expired") raise exceptions.AuthenticationFailed("Token expired.") from e - except jwt.InvalidIssuerError as e: + except pyJwt.InvalidIssuerError as e: logger.warning("Invalid JWT issuer: %s", e) raise exceptions.AuthenticationFailed("Invalid token.") from e - except jwt.InvalidAudienceError as e: + except pyJwt.InvalidAudienceError as e: logger.warning("Invalid JWT audience: %s", e) raise exceptions.AuthenticationFailed("Invalid token.") from e - except jwt.InvalidTokenError as e: - logger.warning("Invalid JWT token: %s", e) - raise exceptions.AuthenticationFailed("Invalid token.") from e + except pyJwt.InvalidTokenError: + # Invalid JWT token - defer to next authentication backend + return None user_id = payload.get("user_id") client_id = payload.get("client_id") @@ -107,3 +113,55 @@ def authenticate_credentials(self, token): def authenticate_header(self, request): """Return authentication scheme for WWW-Authenticate header.""" return "Bearer" + + +class ResourceServerBackend(LaSuiteBackend): + """OIDC Resource Server backend for user creation and retrieval.""" + + def get_or_create_user(self, access_token, id_token, payload): + """Get or create user from OIDC token claims. + + Despite the LaSuiteBackend's method name suggesting "get_or_create", + its implementation only performs a GET operation. + Create new user from the sub claim. + + Args: + access_token: The access token string + id_token: The ID token string (unused) + payload: Token payload dict (unused) + + Returns: + User instance + + Raises: + SuspiciousOperation: If user info validation fails + """ + + sub = payload.get("sub") + + if sub is None: + message = "User info contained no recognizable user identification" + logger.debug(message) + raise SuspiciousOperation(message) + + user = self.get_user(access_token, id_token, payload) + + if user is None and settings.OIDC_CREATE_USER: + user = self.create_user(sub) + + return user + + def create_user(self, sub): + """Create new user from subject claim. + + Args: + sub: Subject identifier from token + + Returns: + Newly created User instance + """ + user = self.UserModel(sub=sub) + user.set_unusable_password() + user.save() + + return user diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index 732fe0516..ebe3dec7e 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -9,6 +9,7 @@ from django.core.validators import validate_email import jwt +from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from rest_framework import decorators, mixins, viewsets from rest_framework import ( exceptions as drf_exceptions, @@ -149,7 +150,10 @@ class RoomViewSet( - create: Create a new room owned by the user (requires 'rooms:create' scope) """ - authentication_classes = [authentication.ApplicationJWTAuthentication] + authentication_classes = [ + authentication.ApplicationJWTAuthentication, + ResourceServerAuthentication, + ] permission_classes = [ api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope ] diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index cab7a923d..bffc7e9f4 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -10,13 +10,14 @@ import jwt import pytest +import responses from rest_framework.test import APIClient from core.factories import ( RoomFactory, UserFactory, ) -from core.models import ApplicationScope, RoleChoices, Room +from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User pytestmark = pytest.mark.django_db @@ -90,13 +91,29 @@ def test_api_rooms_list_with_expired_token(settings): assert "expired" in str(response.data).lower() -def test_api_rooms_list_with_invalid_token(): - """Listing rooms with invalid token should return 401.""" +@responses.activate +def test_api_rooms_list_with_invalid_token(settings): + """Listing rooms with invalid token should return 400.""" + + settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect" + settings.OIDC_OP_URL = "https://oidc.example.com" + + responses.add( + responses.POST, + "https://oidc.example.com/introspect", + json={ + "iss": "https://oidc.example.com", + "active": False, + }, + ) + client = APIClient() client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123") response = client.get("/external-api/v1.0/rooms/") - assert response.status_code == 401 + # Return 400 instead of 401 because ResourceServerAuthentication raises + # SuspiciousOperation when the introspected user is not active + assert response.status_code == 400 def test_api_rooms_list_missing_scope(settings): @@ -332,3 +349,219 @@ def test_api_rooms_token_missing_client_id(settings): assert response.status_code == 401 assert "Invalid token claims." in str(response.data) + + +@responses.activate +def test_resource_server_creates_user_on_first_authentication(settings): + """New user should be created during first authentication. + + Verifies that the ResourceServerBackend.get_or_create_user() creates a user + in the database when authenticating with a token from an unknown subject (sub). + This tests the user creation workflow during the OIDC introspection process. + """ + + with pytest.raises( + User.DoesNotExist, + match="User matching query does not exist.", + ): + User.objects.get(sub="very-specific-sub") + + 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_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 rooms:list", + "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) == 0 + + db_user = User.objects.get(sub="very-specific-sub") + assert db_user is not None + assert db_user.email is None + + +@responses.activate +def test_resource_server_skips_user_creation_when_auto_creation_disabled(settings): + """Verify that ResourceServerBackend respects the user auto-creation setting. + + This ensures that the OIDC introspection process respects the configuration flag + that controls whether new users should be automatically provisioned during + authentication, preventing unwanted user proliferation when auto-creation is + explicitly disabled. + """ + + settings.OIDC_CREATE_USER = False + + with pytest.raises( + User.DoesNotExist, + match="User matching query does not exist.", + ): + User.objects.get(sub="very-specific-sub") + + 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_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 rooms:list", + "active": True, + }, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer some_token") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 401 + + +@responses.activate +def test_resource_server_authentication_successful(settings): + """Authenticated requests should be processed and user-specific data is returned. + + Verifies that once a user is authenticated via OIDC token introspection, + the API correctly identifies the user and returns only data accessible to that user + (e.g., rooms with appropriate access levels). + """ + + 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_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 rooms:list", + "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. + + Verifies that the ResourceServerBackend validates token scopes during introspection + and returns 403 Forbidden when the token is missing required scopes for the endpoint. + """ + + 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_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", # missing rooms:list scope + "active": True, + }, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer some_token") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 403 diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 17d2a5378..5d331d137 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -10,6 +10,8 @@ https://docs.djangoproject.com/en/3.1/ref/settings/ """ +# pylint: disable=too-many-lines + import json from os import path from socket import gethostbyname, gethostname @@ -404,6 +406,10 @@ class Base(Configuration): default=False, environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION", ) + OIDC_TIMEOUT = values.IntegerValue( + 5, environ_name="OIDC_TIMEOUT", environ_prefix=None + ) + OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None) OIDC_RP_SIGN_ALGO = values.Value( "RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None ) @@ -427,12 +433,16 @@ class Base(Configuration): OIDC_OP_USER_ENDPOINT = values.Value( None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None ) + OIDC_OP_INTROSPECTION_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None + ) OIDC_OP_USER_ENDPOINT_FORMAT = values.Value( "AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None ) OIDC_OP_LOGOUT_ENDPOINT = values.Value( None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None ) + OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None) OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue( {}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None ) @@ -493,6 +503,42 @@ class Base(Configuration): environ_prefix=None, ) + # OIDC Resource Server Backend + OIDC_RS_BACKEND_CLASS = "core.external_api.authentication.ResourceServerBackend" + OIDC_RS_CLIENT_ID = values.Value( + "meet", environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None + ) + OIDC_RS_CLIENT_SECRET = SecretFileValue( + None, + environ_name="OIDC_RS_CLIENT_SECRET", + environ_prefix=None, + ) + OIDC_RS_AUDIENCE_CLAIM = values.Value( + default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None + ) + OIDC_RS_ENCRYPTION_ENCODING = values.Value( + default="A256GCM", + environ_name="OIDC_RS_ENCRYPTION_ENCODING", + environ_prefix=None, + ) + OIDC_RS_ENCRYPTION_ALGO = values.Value( + default="RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None + ) + OIDC_RS_SIGNING_ALGO = values.Value( + default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None + ) + OIDC_RS_SCOPES = values.ListValue( + default=["lasuite_meet"], + environ_name="OIDC_RS_SCOPES", + environ_prefix=None, + ) + OIDC_RS_PRIVATE_KEY_STR = SecretFileValue( + environ_name="OIDC_RS_PRIVATE_KEY_STR", environ_prefix=None + ) + OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value( + default="RSA", environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", environ_prefix=None + ) + # Video conference configuration LIVEKIT_CONFIGURATION = { "api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None), diff --git a/src/backend/meet/urls.py b/src/backend/meet/urls.py index 2bb20d06f..2a310f810 100644 --- a/src/backend/meet/urls.py +++ b/src/backend/meet/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path("", include("core.urls")), + path("", include("lasuite.oidc_resource_server.urls")), ] if settings.DEBUG: diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 347cc9237..5d82040e9 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "django-configurations==2.5.1", "django-cors-headers==4.9.0", "django-countries==8.0.0", - "django-lasuite[all]==0.0.17", + "django-lasuite[all]==0.0.19", "django-parler==2.3", "redis==5.2.1", "django-redis==6.0.0",