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" },