mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-31 20:29:52 +02:00
✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user