(backend) publish the JWT public key on a JWKS endpoint

The yhub service will need our public key in order to validate the jwt
token we will used. We choose to expose a jwks endpoint as it is a
standard wat to do this.
This commit is contained in:
Manuel Raynaud
2026-08-13 12:08:20 +02:00
parent 7ea52473dc
commit cb58076edd
10 changed files with 317 additions and 5 deletions
+3
View File
@@ -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 `<dl>` structure in document info card #2379
- 💄(frontend) use the same highlight color for cells and moves #2575
+7
View File
@@ -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.
+5
View File
@@ -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.
+24
View File
@@ -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."""
+58 -2
View File
@@ -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
+166
View File
@@ -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
@@ -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"}})
+12 -1
View File
@@ -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/<version>/jwks".
urlpatterns.append(
path(
f"api/{settings.API_VERSION}/",
f"external_api/{settings.API_VERSION}/",
include([*oidc_resource_server_urls]),
)
)
+1
View File
@@ -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",
+2
View File
@@ -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" },