diff --git a/CHANGELOG.md b/CHANGELOG.md index de422ea34..28dfa185a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,15 @@ and this project adheres to ### Added - ✨(backend) add a service generating cached RS256 JWT tokens +- ✨(backend) publish the JWT public key on a JWKS endpoint - ♿️(frontend) restore skip to content link after header redesign #2510 - 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486 - ✨(backend) conditional email notification in server to server api #2554 ### Changed +- 💥(backend) move the resource server JWKS from `/api/{version}/jwks` to + `/external_api/{version}/jwks` - ♿️(frontend) use semantic `
` structure in document info card #2379 - 💄(frontend) use the same highlight color for cells and moves #2575 diff --git a/UPGRADE.md b/UPGRADE.md index a142fb47f..feafc80ff 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -16,6 +16,13 @@ the following command inside your docker container: ## [Unreleased] +- The JWKS of the resource server moved from `/api/{version}/jwks` to + `/external_api/{version}/jwks`, alongside the rest of the resource server + endpoints. `/api/{version}/jwks` now publishes the public key validating the + tokens Docs issues to call external services. If you enabled the resource + server (`OIDC_RESOURCE_SERVER_ENABLED`), update the JWKS URI declared to your + OIDC provider accordingly. + ### [5.0.0] - 2026-04-30 We made several changes around document content management leading to several breaking changes in the API. diff --git a/documentation/resource_server.md b/documentation/resource_server.md index d2d353115..115fe4ac7 100644 --- a/documentation/resource_server.md +++ b/documentation/resource_server.md @@ -20,6 +20,11 @@ OIDC_RS_ALLOWED_AUDIENCES= It implements the resource server using `django-lasuite`, see the [documentation](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-resource-server-backend.md) +When `OIDC_RS_PRIVATE_KEY_STR` is set, the resource server publishes its public +key on `/external_api/{version}/jwks`. This is the URI to declare to your OIDC +provider. Do not confuse it with `/api/{version}/jwks`, which publishes the key +validating the tokens Docs itself issues to call external services. + ## Customise allowed routes Configure the `EXTERNAL_API` setting to control which routes and actions are available in the external API. Set it via the `EXTERNAL_API` environment variable (as JSON) or in Django settings. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d9991bcb..292aa9084 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -64,6 +64,10 @@ from core.services.converter_services import ( from core.services.converter_services import ( ValidationError as YProviderValidationError, ) +from core.services.jwt_services import ( + ConfigurationError as JWTConfigurationError, +) +from core.services.jwt_services import JWTService from core.services.search_indexers import ( get_document_indexer, get_visited_document_ids_of, @@ -3146,6 +3150,26 @@ class ConfigView(drf.views.APIView): return theme_customization +class JWKSView(drf.views.APIView): + """API ViewSet exposing the public key validating the tokens we issue.""" + + authentication_classes = [] + permission_classes = [AllowAny] + + def get(self, request): + """ + GET /api/v1.0/jwks + Return the JSON Web Key Set of the tokens issued by this service. + """ + try: + jwks = JWTService().get_jwks() + except JWTConfigurationError: + logger.exception("Unable to publish the JWKS") + raise drf.exceptions.NotFound("No JWKS available.") from None + + return drf.response.Response(jwks) + + class CommentViewSetMixin: """Comment ViewSet Mixin.""" diff --git a/src/backend/core/services/jwt_services.py b/src/backend/core/services/jwt_services.py index a57fa6acd..5a4ac4eb7 100644 --- a/src/backend/core/services/jwt_services.py +++ b/src/backend/core/services/jwt_services.py @@ -1,5 +1,6 @@ """JWT services.""" +import functools import hashlib import json import logging @@ -10,6 +11,7 @@ from django.core.cache import cache from django.utils import timezone import jwt +from joserfc.jwk import KeySet, RSAKey logger = logging.getLogger(__name__) @@ -29,6 +31,33 @@ class TokenGenerationError(JWTError): """Raised when a token cannot be signed.""" +@functools.cache +def import_private_key(private_key): + """ + Import a PEM encoded RSA private key as a JWK. + + The "kid" is the RFC 7638 thumbprint of the key, so it is stable across + restarts and changes on its own when the key is rotated. It is computed + from the public components only, which lets a consumer of the JWKS match + it against the "kid" advertised in the header of our tokens. + + Parsing a RSA key is expensive, hence the cache. It is keyed on the PEM + itself so that rotating the key in the settings imports the new one. + """ + try: + key = RSAKey.import_key(private_key) + return RSAKey.import_key( + private_key, + parameters={ + "alg": ALGORITHM, + "use": "sig", + "kid": key.thumbprint(), + }, + ) + except (TypeError, ValueError) as err: + raise ConfigurationError("The JWT private key cannot be imported.") from err + + class JWTService: """ Service class issuing RS256 signed JSON Web Tokens. @@ -56,6 +85,26 @@ class JWTService: """Return the token lifetime, in seconds.""" return settings.JWT_TOKEN_LIFETIME + @property + def key(self): + """Return the signing key, as a JWK.""" + return import_private_key(self.private_key) + + @property + def kid(self): + """Return the identifier of the signing key, as advertised in the JWKS.""" + return self.key.kid + + def get_jwks(self): + """ + Return the JSON Web Key Set publishing the public part of our key. + + External services validating our tokens fetch it to get the public key + matching the "kid" of the token they received. It never exposes the + private components of the key. + """ + return KeySet([self.key]).as_dict(private=False) + def get_cache_key(self, claims): """ Build the cache key identifying a token for the given claims. @@ -80,7 +129,9 @@ class JWTService: Sign a new token embedding the given claims. The "iat" and "exp" claims are always set by the service, from the - configured lifetime, and take precedence over the caller's claims. + configured lifetime, and take precedence over the caller's claims. The + header carries the "kid" of the signing key, so that a service + validating the token can pick the matching key in our JWKS. """ issued_at = timezone.now() payload = { @@ -90,7 +141,12 @@ class JWTService: } try: - return jwt.encode(payload, self.private_key, algorithm=self.algorithm) + return jwt.encode( + payload, + self.private_key, + algorithm=self.algorithm, + headers={"kid": self.kid}, + ) except (jwt.PyJWTError, TypeError, ValueError) as err: logger.exception( "Unable to sign a JWT token with algorithm %s", self.algorithm diff --git a/src/backend/core/tests/test_api_jwks.py b/src/backend/core/tests/test_api_jwks.py new file mode 100644 index 000000000..3c76bd1fb --- /dev/null +++ b/src/backend/core/tests/test_api_jwks.py @@ -0,0 +1,166 @@ +""" +Tests for the JWKS endpoint publishing the public key of the tokens we issue. +""" + +from django.urls import resolve + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from rest_framework.test import APIClient + +from core.services.jwt_services import JWTService +from core.tests.utils.urls import reload_urls + +pytestmark = pytest.mark.django_db + +# Private members of a RSA JWK, none of them may ever leak in the JWKS +PRIVATE_JWK_MEMBERS = {"d", "p", "q", "dp", "dq", "qi", "oth"} + + +def generate_private_key(): + """Generate a PEM encoded RSA private key.""" + return ( + rsa.generate_private_key(public_exponent=65537, key_size=2048) + .private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + .decode("utf-8") + ) + + +# Generating RSA keys is expensive, do it once for the whole module +PRIVATE_KEY = generate_private_key() +OTHER_PRIVATE_KEY = generate_private_key() + + +@pytest.fixture(name="jwt_settings") +def jwt_settings_fixture(settings): + """Setup valid settings for the JWT service.""" + settings.JWT_PRIVATE_KEY = PRIVATE_KEY + settings.JWT_TOKEN_LIFETIME = 3600 + return settings + + +@pytest.mark.usefixtures("jwt_settings") +def test_api_jwks_is_public(): + """External services must reach the JWKS without authenticating.""" + response = APIClient().get("/api/v1.0/jwks") + + assert response.status_code == 200 + assert len(response.json()["keys"]) == 1 + + +@pytest.mark.usefixtures("jwt_settings") +def test_api_jwks_publishes_a_signature_key(): + """The published key advertises what it is meant to be used for.""" + key = APIClient().get("/api/v1.0/jwks").json()["keys"][0] + + assert key["kty"] == "RSA" + assert key["alg"] == "RS256" + assert key["use"] == "sig" + assert key["kid"] + + +@pytest.mark.usefixtures("jwt_settings") +def test_api_jwks_never_exposes_the_private_key(): + """🔒 The JWKS exposes the public components of the key, and nothing else.""" + key = APIClient().get("/api/v1.0/jwks").json()["keys"][0] + + assert PRIVATE_JWK_MEMBERS & set(key) == set() + assert set(key) == {"kty", "alg", "use", "kid", "n", "e"} + + +@pytest.mark.usefixtures("jwt_settings") +def test_api_jwks_key_validates_the_tokens_we_issue(): + """ + The whole point of the endpoint: a service fetching the JWKS can validate + a token we issued, the way an external service does. + """ + token = JWTService().get_token({"sub": "user-id", "scope": "read"}) + + jwks = APIClient().get("/api/v1.0/jwks").json() + + # This is what an external service does with the JWKS we serve + key = jwt.PyJWKSet.from_dict(jwks).keys[0] + payload = jwt.decode(token, key, algorithms=["RS256"]) + + assert payload["sub"] == "user-id" + assert payload["scope"] == "read" + + +@pytest.mark.usefixtures("jwt_settings") +def test_api_jwks_key_id_matches_the_token_header(): + """A consumer selects the right key by matching the "kid" of the token.""" + token = JWTService().get_token({"sub": "user-id"}) + + jwks = APIClient().get("/api/v1.0/jwks").json() + + kid = jwt.get_unverified_header(token)["kid"] + assert [key["kid"] for key in jwks["keys"]] == [kid] + + +def test_api_jwks_follows_the_key_rotation(jwt_settings): + """After a rotation, the JWKS validates the tokens signed with the new key.""" + first_jwks = APIClient().get("/api/v1.0/jwks").json() + + jwt_settings.JWT_PRIVATE_KEY = OTHER_PRIVATE_KEY + token = JWTService().get_token({"sub": "user-id"}) + second_jwks = APIClient().get("/api/v1.0/jwks").json() + + assert first_jwks != second_jwks + + key = jwt.PyJWKSet.from_dict(second_jwks).keys[0] + assert jwt.decode(token, key, algorithms=["RS256"])["sub"] == "user-id" + + # The retired key can no longer validate the new tokens + with pytest.raises(jwt.InvalidSignatureError): + jwt.decode( + token, jwt.PyJWKSet.from_dict(first_jwks).keys[0], algorithms=["RS256"] + ) + + +@pytest.mark.parametrize("private_key", [None, ""]) +def test_api_jwks_without_private_key(jwt_settings, private_key): + """Without a configured key there is nothing to publish.""" + jwt_settings.JWT_PRIVATE_KEY = private_key + + assert APIClient().get("/api/v1.0/jwks").status_code == 404 + + +def test_api_jwks_with_an_invalid_private_key(jwt_settings): + """An unusable key is reported as a missing JWKS, not as a server error.""" + jwt_settings.JWT_PRIVATE_KEY = "not-a-pem-key" + + assert APIClient().get("/api/v1.0/jwks").status_code == 404 + + +@pytest.mark.usefixtures("jwt_settings", "resource_server_backend_conf") +def test_api_jwks_does_not_shadow_the_resource_server_jwks(settings): + """ + The resource server publishes its own JWKS, holding its encryption key. + Both must stay reachable, on their own path. + """ + settings.OIDC_RS_PRIVATE_KEY_STR = PRIVATE_KEY + reload_urls() + + assert resolve("/api/v1.0/jwks").url_name == "jwks" + assert resolve("/external_api/v1.0/jwks").url_name == "resource_server_jwks" + + ours = APIClient().get("/api/v1.0/jwks").json()["keys"][0] + theirs = APIClient().get("/external_api/v1.0/jwks").json()["keys"][0] + + assert ours["use"] == "sig" + assert theirs["use"] == "enc" + + +@pytest.mark.parametrize("method", ["post", "put", "patch", "delete"]) +@pytest.mark.usefixtures("jwt_settings") +def test_api_jwks_is_read_only(method): + """The JWKS is only exposed for reading.""" + response = getattr(APIClient(), method)("/api/v1.0/jwks") + + assert response.status_code == 405 diff --git a/src/backend/core/tests/test_services_jwt_services.py b/src/backend/core/tests/test_services_jwt_services.py index 423fc3e99..6e77a2dd5 100644 --- a/src/backend/core/tests/test_services_jwt_services.py +++ b/src/backend/core/tests/test_services_jwt_services.py @@ -150,6 +150,36 @@ def test_get_token_caches_each_set_of_claims_separately(): ) +@pytest.mark.usefixtures("jwt_settings") +def test_get_jwks_exposes_only_the_public_key(): + """🔒 The JWKS must never carry the private components of the key.""" + keys = JWTService().get_jwks()["keys"] + + assert len(keys) == 1 + assert set(keys[0]) == {"kty", "alg", "use", "kid", "n", "e"} + + +@pytest.mark.usefixtures("jwt_settings") +def test_kid_is_stable_and_matches_the_signed_tokens(): + """The "kid" identifies the key across the JWKS and the tokens.""" + service = JWTService() + + assert service.kid == JWTService().kid + assert ( + jwt.get_unverified_header(service.get_token({"sub": "user-id"}))["kid"] + == service.kid + ) + + +def test_kid_changes_when_the_key_is_rotated(jwt_settings): + """A rotated key is a different key, hence a different "kid".""" + kid = JWTService().kid + + jwt_settings.JWT_PRIVATE_KEY = OTHER_PRIVATE_KEY + + assert JWTService().kid != kid + + @pytest.mark.usefixtures("jwt_settings") def test_get_token_ignores_the_claims_ordering(): """Claims given in a different order hit the same cache entry.""" @@ -200,8 +230,15 @@ def test_get_token_without_private_key(jwt_settings, private_key): def test_generate_token_with_an_invalid_private_key(jwt_settings): - """An unusable private key is reported as a token generation error.""" + """An unusable private key is a configuration problem.""" jwt_settings.JWT_PRIVATE_KEY = "not-a-pem-key" - with pytest.raises(TokenGenerationError, match="Unable to sign the JWT token"): + with pytest.raises(ConfigurationError, match="cannot be imported"): JWTService().generate_token({"sub": "user-id"}) + + +@pytest.mark.usefixtures("jwt_settings") +def test_generate_token_with_claims_that_cannot_be_serialized(): + """Claims that cannot be encoded are reported as a generation error.""" + with pytest.raises(TokenGenerationError, match="Unable to sign the JWT token"): + JWTService().generate_token({"sub": {"unserializable"}}) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index e89618650..cdcf8f94c 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -82,6 +82,14 @@ urlpatterns = [ ), ), path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()), + # Public keys validating the tokens we issue to call external services. + # Nested under "api/" because this is the only prefix routed to the backend + # by the ingress, a root "/.well-known/" would be served by the frontend. + path( + f"api/{settings.API_VERSION}/jwks", + viewsets.JWKSView.as_view(), + name="jwks", + ), ] if settings.OIDC_RESOURCE_SERVER_ENABLED: @@ -120,9 +128,12 @@ if settings.OIDC_RESOURCE_SERVER_ENABLED: ) if settings.OIDC_RS_PRIVATE_KEY_STR: + # Served under "external_api/" alongside the rest of the resource + # server, so that it does not collide with the JWKS of the tokens we + # issue, which lives at "api//jwks". urlpatterns.append( path( - f"api/{settings.API_VERSION}/", + f"external_api/{settings.API_VERSION}/", include([*oidc_resource_server_urls]), ) ) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 82cb5ca46..a26d8eff9 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "emoji==2.15.0", "factory_boy==3.3.3", "gunicorn==26.0.0", + "joserfc==1.6.5", "jsonschema==4.26.0", "langfuse==3.11.2", "lxml==6.1.1", diff --git a/src/backend/uv.lock b/src/backend/uv.lock index e6fd18c29..9b66bdf98 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -953,6 +953,7 @@ dependencies = [ { name = "emoji" }, { name = "factory-boy" }, { name = "gunicorn" }, + { name = "joserfc" }, { name = "jsonschema" }, { name = "langfuse" }, { name = "lxml" }, @@ -1029,6 +1030,7 @@ requires-dist = [ { name = "gunicorn", specifier = "==26.0.0" }, { name = "ipdb", marker = "extra == 'dev'", specifier = "==0.13.13" }, { name = "ipython", marker = "extra == 'dev'", specifier = "==9.15.0" }, + { name = "joserfc", specifier = "==1.6.5" }, { name = "jsonschema", specifier = "==4.26.0" }, { name = "langfuse", specifier = "==3.11.2" }, { name = "lxml", specifier = "==6.1.1" },