diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 91d0b1a..d1e2911 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -33,3 +33,32 @@ LOGOUT_REDIRECT_URL=http://localhost:3000 OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"] OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} +OIDC_RS_PRIVATE_KEY_STR="-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+ +U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb +ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE +20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx +qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7 +uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM +vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV +aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX +fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+ +DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q +GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY +cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj +FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz +/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH +EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4 +VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I +vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN +eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1 +bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC +LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j +aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf +Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K +s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ +OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ +IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv +MbyqKyC6DAzv4jEEhHaN7oY= +-----END PRIVATE KEY----- +" diff --git a/src/backend/core/resource_server/backend.py b/src/backend/core/resource_server/backend.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/core/resource_server/urls.py b/src/backend/core/resource_server/urls.py new file mode 100644 index 0000000..41c79e6 --- /dev/null +++ b/src/backend/core/resource_server/urls.py @@ -0,0 +1,9 @@ +"""Resource Server URL Configuration""" + +from django.urls import path + +from .views import JWKSView + +urlpatterns = [ + path("jwks", JWKSView.as_view(), name="resource_server_jwks"), +] diff --git a/src/backend/core/resource_server/utils.py b/src/backend/core/resource_server/utils.py new file mode 100644 index 0000000..cbd514b --- /dev/null +++ b/src/backend/core/resource_server/utils.py @@ -0,0 +1,48 @@ +"""Resource Server utils functions""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from joserfc.jwk import JWKRegistry + + +def import_private_key_from_settings(): + """Import the private key used by the resource server when interacting with the OIDC provider. + + This private key is crucial; its public components are exposed in the JWK endpoints, + while its private component is used for decrypting the introspection token retrieved + from the OIDC provider. + + By default, we recommend using RSAKey for asymmetric encryption, + known for its strong security features. + + Note: + - The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured. + - The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized + based on the chosen key type. + + Raises: + ImproperlyConfigured: If the private key setting is missing, empty, or incorrect. + + Returns: + joserfc.jwk.JWK: The imported private key as a JWK object. + """ + + private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None) + if not private_key_str: + raise ImproperlyConfigured( + "OIDC_RS_PRIVATE_KEY_STR setting is missing or empty." + ) + + private_key_pem = private_key_str.encode() + + try: + private_key = JWKRegistry.import_key( + private_key_pem, + key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE, + parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"}, + ) + except ValueError as err: + raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err + + return private_key diff --git a/src/backend/core/resource_server/views.py b/src/backend/core/resource_server/views.py new file mode 100644 index 0000000..244afd9 --- /dev/null +++ b/src/backend/core/resource_server/views.py @@ -0,0 +1,40 @@ +"""Resource Server views""" + +from django.core.exceptions import ImproperlyConfigured + +from joserfc.jwk import KeySet +from rest_framework.response import Response +from rest_framework.views import APIView + +from . import utils + + +class JWKSView(APIView): + """ + API endpoint for retrieving a JSON Web Keys Set (JWKS). + + Returns: + Response: JSON response containing the JWKS data. + """ + + authentication_classes = [] # disable authentication + permission_classes = [] # disable permission + + def get(self, request): + """Handle GET requests to retrieve JSON Web Keys Set (JWKS). + + Returns: + Response: JSON response containing the JWKS data. + """ + + try: + private_key = utils.import_private_key_from_settings() + except (ImproperlyConfigured, ValueError) as err: + return Response({"error": str(err)}, status=500) + + try: + jwk = KeySet([private_key]).as_dict(private=False) + except (TypeError, ValueError, AttributeError): + return Response({"error": "Could not load key"}, status=500) + + return Response(jwk) diff --git a/src/backend/core/tests/resource_server/__init__.py b/src/backend/core/tests/resource_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/core/tests/resource_server/test_utils.py b/src/backend/core/tests/resource_server/test_utils.py new file mode 100644 index 0000000..c251072 --- /dev/null +++ b/src/backend/core/tests/resource_server/test_utils.py @@ -0,0 +1,88 @@ +""" +Test for the Resource Server (RS) utils functions. +""" + +from django.core.exceptions import ImproperlyConfigured +from django.test.utils import override_settings + +import pytest +from joserfc.jwk import ECKey, RSAKey + +from core.resource_server.utils import import_private_key_from_settings + +PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+ +U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb +ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE +20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx +qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7 +uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM +vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV +aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX +fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+ +DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q +GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY +cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj +FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz +/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH +EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4 +VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I +vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN +eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1 +bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC +LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j +aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf +Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K +s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ +OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ +IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv +MbyqKyC6DAzv4jEEhHaN7oY= +-----END PRIVATE KEY----- +""" + + +@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED) +@pytest.mark.parametrize("mocked_private_key", [None, ""]) +def test_import_private_key_from_settings_missing_or_empty_key( + settings, mocked_private_key +): + """Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty.""" + settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key + + with pytest.raises( + ImproperlyConfigured, + match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.", + ): + import_private_key_from_settings() + + +@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"]) +@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED) +@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA") +@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256") +def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key): + """Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value.""" + settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key + + with pytest.raises( + ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong." + ): + import_private_key_from_settings() + + +@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED) +@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA") +@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256") +def test_import_private_key_from_settings_success_rsa_key(): + """Should import private key string as an RSA key.""" + private_key = import_private_key_from_settings() + assert isinstance(private_key, RSAKey) + + +@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED) +@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC") +@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256") +def test_import_private_key_from_settings_success_ec_key(): + """Should import private key string as an EC key.""" + private_key = import_private_key_from_settings() + assert isinstance(private_key, ECKey) diff --git a/src/backend/core/tests/resource_server/test_views.py b/src/backend/core/tests/resource_server/test_views.py new file mode 100644 index 0000000..505bab2 --- /dev/null +++ b/src/backend/core/tests/resource_server/test_views.py @@ -0,0 +1,70 @@ +""" +Tests for the Resource Server (RS) Views. +""" + +from unittest import mock + +from django.core.exceptions import ImproperlyConfigured +from django.urls import reverse + +import pytest +from joserfc.jwk import RSAKey +from rest_framework.test import APIClient + +pytestmark = pytest.mark.django_db + + +@mock.patch("core.resource_server.utils.import_private_key_from_settings") +def test_view_jwks_valid_public_key(mock_import_private_key_from_settings): + """JWKs endpoint should return a set of valid Json Web Key""" + + mocked_key = RSAKey.generate_key(2048) + mock_import_private_key_from_settings.return_value = mocked_key + + url = reverse("resource_server_jwks") + response = APIClient().get(url) + + mock_import_private_key_from_settings.assert_called_once() + + assert response.status_code == 200 + assert response["Content-Type"] == "application/json" + + jwks = response.json() + assert jwks == {"keys": [mocked_key.as_dict(private=False)]} + + # Security checks to make sure no details from the private key are exposed + private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"] + assert all( + private_detail not in jwks["keys"][0].keys() + for private_detail in private_details + ) + + +@mock.patch("core.resource_server.utils.import_private_key_from_settings") +def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings): + """JWKS endpoint should return a proper exception when loading keys fails.""" + + mock_import_private_key_from_settings.return_value = "wrong_key" + + url = reverse("resource_server_jwks") + response = APIClient().get(url) + + mock_import_private_key_from_settings.assert_called_once() + + assert response.status_code == 500 + assert response.json() == {"error": "Could not load key"} + + +@mock.patch("core.resource_server.utils.import_private_key_from_settings") +def test_view_jwks_missing_private_key(mock_import_private_key_from_settings): + """JWKS endpoint should return a proper exception when private key is missing.""" + + mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.") + + url = reverse("resource_server_jwks") + response = APIClient().get(url) + + mock_import_private_key_from_settings.assert_called_once() + + assert response.status_code == 500 + assert response.json() == {"error": "foo."} diff --git a/src/backend/people/api_urls.py b/src/backend/people/api_urls.py index 4d0e7e1..059b662 100644 --- a/src/backend/people/api_urls.py +++ b/src/backend/people/api_urls.py @@ -7,6 +7,7 @@ from rest_framework.routers import DefaultRouter from core.api import viewsets from core.authentication.urls import urlpatterns as oidc_urls +from core.resource_server.urls import urlpatterns as resource_server_urls # - Main endpoints router = DefaultRouter() @@ -36,6 +37,7 @@ urlpatterns = [ [ *router.urls, *oidc_urls, + *resource_server_urls, re_path( r"^teams/(?P[0-9a-z-]*)/", include(team_related_router.urls), diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index 9d8a266..92846be 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -358,6 +358,26 @@ class Base(Configuration): ALLOW_LOGOUT_GET_METHOD = values.BooleanValue( default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None ) + OIDC_RS_PRIVATE_KEY_STR = values.Value( + default=None, + 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, + ) + OIDC_RS_ENCRYPTION_ALGO = values.Value( + default="RSA-OAEP", + environ_name="OIDC_RS_ENCRYPTION_ALGO", + environ_prefix=None, + ) + OIDC_RS_ENCRYPTION_ENCODING = values.Value( + default="A256GCM", + environ_name="OIDC_RS_ENCRYPTION_ENCODING", + environ_prefix=None, + ) USER_OIDC_FIELDS_TO_NAME = values.ListValue( default=["first_name", "last_name"],