(collaboration) notify the backend when the worker persists new content

notify the backend when the worker persists new content for
a document, so the lists ordered by `updated_at` follow the edits made on the
collaboration server. The backend serves it on
`POST /api/v1.0/documents/{id}/content-updated/`, authenticated with a short
lived RS256 JWT the collaboration server signs (`aud: "docs-backend"`) and
the backend verifies against the JWKS the collaboration server publishes on
`/collaboration/jwks/v1` — the mirror of the admin token the backend signs to
call it, so no long lived secret is shared and either side can roll its key
on its own
This commit is contained in:
Manuel Raynaud
2026-09-21 14:46:11 +02:00
parent 6f77d73e0e
commit b2be3bd2cb
15 changed files with 939 additions and 30 deletions
+31
View File
@@ -940,6 +940,37 @@ class DocumentViewSet(
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(
authentication_classes=[authentication.CollaborationServerAuthentication],
detail=True,
methods=["post"],
permission_classes=[],
url_path="content-updated",
)
def content_updated(self, request, *args, **kwargs):
"""
Record that the collaboration server saved a new content for a document.
The content of a document does not go through Django anymore, so nothing
would refresh its "updated_at" as it is edited and the lists ordered by
it would freeze. The collaboration server calls this once it persisted
the changes of a document, at most once per debounce window.
The update is written without going through the model, saving it would
trigger a re-indexing of a content Django did not see change.
"""
try:
document_id = uuid.UUID(kwargs["pk"])
except ValueError as err:
raise Http404 from err
if not models.Document.objects.filter(pk=document_id).update(
updated_at=timezone.now()
):
raise Http404
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
@drf.decorators.action(detail=True, methods=["post"])
@transaction.atomic
def move(self, request, *args, **kwargs):
@@ -2,9 +2,13 @@
from django.conf import settings
import jwt
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from core.services.jwt_services import JWTError
from core.services.yhub_services import YHubError, YHubService
class ServerToServerAuthentication(BaseAuthentication):
"""
@@ -50,3 +54,64 @@ class ServerToServerAuthentication(BaseAuthentication):
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Create document server to server'"
class CollaborationServerAuthentication(BaseAuthentication):
"""
Authenticate the collaboration server on the JWT it signs.
The mirror of the token the backend signs to call it: the collaboration
server holds the private key and publishes the public half on its JWKS,
which is where we read it, and the tokens it mints live for a few minutes.
Nothing long-lived is shared between them, and either side can roll its key
without the other being reconfigured.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
ALGORITHM = "RS256"
# The collaboration server mints its tokens for us, and only us: a token it
# signed for another service is refused here.
AUDIENCE = "docs-backend"
def authenticate(self, request):
"""
Authenticate the request on the signature, the audience and the expiry
of the token it carries. The key validating the signature is the one
the collaboration server publishes for the "kid" the token names.
Returns:
None: If authentication is successful, no user acts behind a call
of the collaboration server.
Raises:
AuthenticationFailed: If the Authorization header is missing,
malformed, or carries a token we cannot validate.
"""
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
raise AuthenticationFailed("Authorization header is missing.")
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
try:
signing_key = YHubService().jwks.get_signing_key(token)
jwt.decode(
token,
signing_key.key,
algorithms=[self.ALGORITHM],
audience=self.AUDIENCE,
)
# a collaboration server we cannot reach, or one that publishes nothing
# we can verify a token with, authenticates nobody either
except (jwt.PyJWTError, JWTError, YHubError) as err:
raise AuthenticationFailed("Invalid collaboration server token.") from err
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Collaboration server'"
+108
View File
@@ -12,12 +12,24 @@ from django.core.cache import cache
from django.utils import timezone
import jwt
import requests
from joserfc.jwk import KeySet, RSAKey
logger = logging.getLogger(__name__)
ALGORITHM = "RS256"
CACHE_KEY_PREFIX = "jwt_token"
JWKS_CACHE_KEY_PREFIX = "jwks"
# How long a fetched JWKS is served from the cache before being fetched again.
JWKS_CACHE_TIMEOUT = 300
# Minimum delay, in seconds, between two fetches of the same JWKS. A token
# names the key that signed it and anybody can name one that does not exist, so
# the refresh a rotation needs is rate limited: an unknown key costs at most one
# fetch per window, not one per request.
JWKS_REFRESH_COOLDOWN = 30
# Timeout, in seconds, of the fetch of a JWKS. Short: it happens while
# authenticating a request.
JWKS_FETCH_TIMEOUT = 10
class Audiences(StrEnum):
@@ -39,6 +51,10 @@ class TokenGenerationError(JWTError):
"""Raised when a token cannot be signed."""
class JWKSError(JWTError):
"""Raised when the keys validating the tokens of a service cannot be used."""
@functools.cache
def import_private_key(private_key):
"""
@@ -66,6 +82,98 @@ def import_private_key(private_key):
raise ConfigurationError("The JWT private key cannot be imported.") from err
@functools.cache
def import_jwks(jwks):
"""
Import a JSON Web Key Set, as published by the service issuing the tokens.
Importing keys is expensive, hence the cache. It is keyed on the document
itself, so a service publishing a new key gets it imported instead of the
previous set being served forever.
"""
try:
return jwt.PyJWKSet.from_json(jwks)
# a JWKS is fetched from another service: anything malformed in it, from
# the JSON to the key material, must surface as a JWKS error
except (jwt.PyJWTError, AttributeError, TypeError, ValueError) as err:
raise JWKSError("The JWKS cannot be imported.") from err
class JWKSClient:
"""
Client of the JSON Web Key Set a service publishes to let us verify the
tokens it signs.
Fetching and importing the keys on every token would be wasteful, so the
document is cached — in the Django cache, hence shared by our processes —
and its import memoized. The service can still roll its key without
anything changing here: a token signed by a key we do not know refreshes
the set.
"""
def __init__(self, url, timeout=JWKS_FETCH_TIMEOUT):
"""Bind the client to the url a service publishes its keys at."""
self.url = url
self.timeout = timeout
@property
def cache_key(self):
"""Build the cache key holding the document published at our url."""
digest = hashlib.sha256(self.url.encode("utf-8")).hexdigest()
return f"{JWKS_CACHE_KEY_PREFIX}:{digest}"
def fetch(self):
"""Fetch the published document and cache it, as published."""
try:
response = requests.get(self.url, timeout=self.timeout)
response.raise_for_status()
except requests.RequestException as err:
logger.exception("Unable to fetch the JWKS at %s", self.url)
raise JWKSError(f"Unable to fetch the JWKS at {self.url}") from err
cache.set(self.cache_key, response.text, JWKS_CACHE_TIMEOUT)
return response.text
def get_keys(self, refresh=False):
"""Return the published keys, from the cache unless a refresh is asked."""
jwks = None if refresh else cache.get(self.cache_key)
if jwks is None:
jwks = self.fetch()
return import_jwks(jwks)
def get_signing_key(self, token):
"""
Return the key a token was signed with, among the published ones.
The header of the token names it, which is what makes a rotation
transparent: a key we do not know yet is looked for again in a freshly
fetched set. That name is not authenticated though, so the refresh is
rate limited, and a token naming a key nobody published is refused.
"""
try:
kid = jwt.get_unverified_header(token)["kid"]
except (jwt.PyJWTError, KeyError) as err:
raise JWKSError("The token does not name the key that signed it.") from err
try:
return self.get_keys()[kid]
except KeyError:
pass
# `add` only succeeds for the first caller of the cooldown window,
# whichever process it runs in
if not cache.add(f"{self.cache_key}:refresh", True, JWKS_REFRESH_COOLDOWN):
raise JWKSError(f'The JWKS at {self.url} has no key "{kid}".')
logger.info('Unknown key "%s", refreshing the JWKS at %s', kid, self.url)
try:
return self.get_keys(refresh=True)[kid]
except KeyError as err:
raise JWKSError(f'The JWKS at {self.url} has no key "{kid}".') from err
class JWTService:
"""
Service class issuing RS256 signed JSON Web Tokens.
+21 -1
View File
@@ -13,6 +13,10 @@ update), `rollback`, `prune`, `changeset` and `activity`, all at `v1`. yhub also
accepts a `branch` query parameter, but our auth plugin only ever grants access
to the `main` branch, so this service never sends it.
A few routes are about the server itself rather than about a document, and
carry no room: `/{prefix}/jwks/{version}` publishes the public keys validating
the tokens yhub signs to call us back.
This service only owns the transport for now, the endpoints are added as we
need them.
"""
@@ -23,7 +27,7 @@ from django.conf import settings
import requests
from core.services.jwt_services import Audiences, JWTService
from core.services.jwt_services import Audiences, JWKSClient, JWTService
logger = logging.getLogger(__name__)
@@ -134,6 +138,22 @@ class YHubService:
)
return f"Bearer {token}"
@property
def jwks_url(self):
"""Return the url yhub publishes its public keys at."""
return f"{self.base_url}/{self.api_prefix}/jwks/{self.api_version}"
@property
def jwks(self):
"""
Return the client of the keys validating the tokens yhub signs.
The mirror of the JWKS we publish for the tokens we sign to call it:
neither side holds a copy of the key of the other, so either can roll
its own without the other being reconfigured.
"""
return JWKSClient(self.jwks_url)
def build_url(self, endpoint, document):
"""Build the url of a document scoped endpoint of the yhub API."""
return (
@@ -0,0 +1,273 @@
"""
Tests for Documents API endpoint in impress's core app: content updated
"""
from datetime import datetime, timedelta
from datetime import timezone as tz
from uuid import uuid4
import jwt
import pytest
import responses
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories
from core.authentication import CollaborationServerAuthentication
from core.models import Document
from core.tests.utils.jwt_helper import build_jwks, generate_key_pair, key_id
pytestmark = pytest.mark.django_db
# Generating an RSA key is expensive, do it once for the whole module
PRIVATE_KEY, PUBLIC_KEY = generate_key_pair()
JWKS_URL = "http://yhub:3002/collaboration/jwks/v1"
@pytest.fixture(name="yhub_jwks", autouse=True)
def yhub_jwks_fixture(settings):
"""
Publish the collaboration server keys where the backend reads them.
It only ever holds the public half of that key, and not even in its
configuration: it fetches it from the collaboration server itself.
"""
settings.YHUB_API_BASE_URL = "http://yhub:3002"
with responses.RequestsMock(assert_all_requests_are_fired=False) as mock:
mock.get(JWKS_URL, json=build_jwks(PUBLIC_KEY))
yield mock
def collaboration_token(private_key=PRIVATE_KEY, public_key=PUBLIC_KEY, **claims):
"""Sign a token the way the collaboration server does."""
issued_at = datetime.now(tz=tz.utc)
return jwt.encode(
{
"iss": "yhub",
"aud": CollaborationServerAuthentication.AUDIENCE,
"iat": issued_at,
"exp": issued_at + timedelta(seconds=60),
**claims,
},
private_key,
algorithm="RS256",
headers={"kid": key_id(public_key)},
)
def test_api_documents_content_updated_anonymous():
"""Anonymous users should not be allowed to declare a content update."""
document = factories.DocumentFactory()
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/content-updated/")
assert response.status_code == 401
def test_api_documents_content_updated_authenticated():
"""A logged-in user is not the collaboration server, their session is no credential."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
response = client.post(f"/api/v1.0/documents/{document.id!s}/content-updated/")
assert response.status_code == 401
def test_api_documents_content_updated_token_signed_by_another_key():
"""🔒 A token signed by another key should not be allowed, published "kid" or not."""
document = factories.DocumentFactory()
other_private_key, _other_public_key = generate_key_pair()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
# the key that signs it is not the one it names
HTTP_AUTHORIZATION=f"Bearer {collaboration_token(other_private_key)}",
)
assert response.status_code == 401
def test_api_documents_content_updated_token_naming_an_unpublished_key():
"""A token naming a key the collaboration server does not publish is refused."""
document = factories.DocumentFactory()
token = jwt.encode(
{
"aud": CollaborationServerAuthentication.AUDIENCE,
"exp": datetime.now(tz=tz.utc) + timedelta(seconds=60),
},
PRIVATE_KEY,
algorithm="RS256",
headers={"kid": "a-key-nobody-published"},
)
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == 401
def test_api_documents_content_updated_token_naming_no_key():
"""A token that does not name the key it was signed with is refused."""
document = factories.DocumentFactory()
token = jwt.encode(
{
"aud": CollaborationServerAuthentication.AUDIENCE,
"exp": datetime.now(tz=tz.utc) + timedelta(seconds=60),
},
PRIVATE_KEY,
algorithm="RS256",
)
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == 401
def test_api_documents_content_updated_token_for_another_audience():
"""A token the collaboration server minted for another service should be refused."""
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token(aud='somewhere-else')}",
)
assert response.status_code == 401
def test_api_documents_content_updated_expired_token():
"""An expired token should be refused, they are short-lived on purpose."""
document = factories.DocumentFactory()
expired = datetime.now(tz=tz.utc) - timedelta(seconds=60)
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token(exp=expired)}",
)
assert response.status_code == 401
def test_api_documents_content_updated_collaboration_server_not_configured(settings):
"""Without a collaboration server to read the keys from, nothing is authenticated."""
settings.YHUB_API_BASE_URL = None
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token()}",
)
assert response.status_code == 401
def test_api_documents_content_updated_jwks_unavailable(yhub_jwks):
"""A collaboration server that publishes no key authenticates nobody."""
yhub_jwks.reset()
yhub_jwks.get(JWKS_URL, status=500)
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token()}",
)
assert response.status_code == 401
def test_api_documents_content_updated_rolled_key(yhub_jwks):
"""
A key rolled on the collaboration server should be picked up on its own.
This is what publishing a JWKS buys over a key pinned in our settings:
the tokens signed with the new key name a key we do not know, and looking
it up fetches the set again.
"""
document = factories.DocumentFactory()
url = f"/api/v1.0/documents/{document.id!s}/content-updated/"
# a first call caches the keys published so far
response = APIClient().post(
url, HTTP_AUTHORIZATION=f"Bearer {collaboration_token()}"
)
assert response.status_code == 204
new_private_key, new_public_key = generate_key_pair()
yhub_jwks.reset()
yhub_jwks.get(JWKS_URL, json=build_jwks(new_public_key))
response = APIClient().post(
url,
HTTP_AUTHORIZATION=(
f"Bearer {collaboration_token(new_private_key, new_public_key)}"
),
)
assert response.status_code == 204
def test_api_documents_content_updated():
"""The collaboration server should be able to refresh the date of a document."""
with freeze_time("2026-08-01 12:00:00"):
# no content: writing one to S3 under a frozen clock breaks its signature
document = factories.DocumentFactory(title="my document", content="")
with freeze_time("2026-08-06 12:00:00"):
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token()}",
)
assert response.status_code == 204
document.refresh_from_db()
assert document.updated_at == datetime(2026, 8, 6, 12, 0, 0, tzinfo=tz.utc)
# the document itself is left alone
assert document.created_at == datetime(2026, 8, 1, 12, 0, 0, tzinfo=tz.utc)
assert document.title == "my document"
def test_api_documents_content_updated_restricted_document():
"""
The collaboration server acts for whoever is editing, the access of a
document is not its business.
"""
document = factories.DocumentFactory(link_reach="restricted")
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token()}",
)
assert response.status_code == 204
def test_api_documents_content_updated_unknown_document():
"""A document deleted in the meantime should answer a 404."""
response = APIClient().post(
f"/api/v1.0/documents/{uuid4()!s}/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token()}",
)
assert response.status_code == 404
assert not Document.objects.exists()
def test_api_documents_content_updated_invalid_document_id():
"""A room name that is no document id should answer a 404, not a 500."""
response = APIClient().post(
"/api/v1.0/documents/not-an-uuid/content-updated/",
HTTP_AUTHORIZATION=f"Bearer {collaboration_token()}",
)
assert response.status_code == 404
@@ -0,0 +1,165 @@
"""
This module contains tests for the JWKSClient class in the
core.services.jwt_services module.
"""
from datetime import datetime, timedelta, timezone
import jwt
import pytest
import responses
from core.services.jwt_services import JWKSClient, JWKSError
from core.tests.utils.jwt_helper import build_jwks, generate_key_pair, key_id
# Generating RSA keys is expensive, do it once for the whole module
PRIVATE_KEY, PUBLIC_KEY = generate_key_pair()
OTHER_PRIVATE_KEY, OTHER_PUBLIC_KEY = generate_key_pair()
JWKS_URL = "http://service.example.com/jwks"
def signed_token(private_key=PRIVATE_KEY, public_key=PUBLIC_KEY, headers=None):
"""
Sign a token the way a service publishing a JWKS does.
Unless the caller wants something else in there, the header names the key
the token can be validated with.
"""
return jwt.encode(
{"exp": datetime.now(tz=timezone.utc) + timedelta(seconds=60)},
private_key,
algorithm="RS256",
headers={"kid": key_id(public_key)} if headers is None else headers,
)
@pytest.fixture(name="jwks")
def jwks_fixture():
"""Serve the JWKS of the key this module signs its tokens with."""
with responses.RequestsMock(assert_all_requests_are_fired=False) as mock:
mock.get(JWKS_URL, json=build_jwks(PUBLIC_KEY))
yield mock
@pytest.mark.usefixtures("jwks")
def test_get_signing_key_returns_the_published_key():
"""The key a token names should validate its signature."""
token = signed_token()
key = JWKSClient(JWKS_URL).get_signing_key(token)
assert jwt.decode(token, key.key, algorithms=["RS256"])
def test_the_document_is_fetched_once(jwks):
"""Validating tokens should not call the publisher every time."""
client = JWKSClient(JWKS_URL)
client.get_signing_key(signed_token())
client.get_signing_key(signed_token())
# the document is cached for the url, not for the client instance
JWKSClient(JWKS_URL).get_signing_key(signed_token())
assert len(jwks.calls) == 1
def test_an_unknown_key_is_looked_for_in_a_fresh_document(jwks):
"""A key published after the document was cached should still be found."""
client = JWKSClient(JWKS_URL)
client.get_signing_key(signed_token())
# the service rolls its key and publishes the new one
jwks.reset()
jwks.get(JWKS_URL, json=build_jwks(OTHER_PUBLIC_KEY))
token = signed_token(OTHER_PRIVATE_KEY, OTHER_PUBLIC_KEY)
key = client.get_signing_key(token)
assert jwt.decode(token, key.key, algorithms=["RS256"])
assert len(jwks.calls) == 1 # the fetch of the refreshed document
@pytest.mark.usefixtures("jwks")
def test_a_key_nobody_published_is_refused():
"""A token can name any key, only a published one validates it."""
with pytest.raises(JWKSError, match="has no key"):
JWKSClient(JWKS_URL).get_signing_key(
signed_token(headers={"kid": "a-key-nobody-published"})
)
def test_an_unknown_key_only_refreshes_once_per_cooldown(jwks):
"""
🔒 A forged "kid" must not turn every request into a call to the publisher.
Nothing authenticates the key a token names, so without a cooldown an
unauthenticated caller would have us fetch the document as often as it asks.
"""
client = JWKSClient(JWKS_URL)
for _ in range(5):
with pytest.raises(JWKSError):
client.get_signing_key(
signed_token(headers={"kid": "a-key-nobody-published"})
)
# the first call fills the cache, the second is the refresh of the window
assert len(jwks.calls) == 2
@pytest.mark.usefixtures("jwks")
def test_a_token_naming_no_key_is_refused():
"""A token that does not name its key cannot be matched to one."""
with pytest.raises(JWKSError, match="does not name"):
JWKSClient(JWKS_URL).get_signing_key(signed_token(headers={}))
@pytest.mark.usefixtures("jwks")
def test_a_token_that_is_not_a_token_is_refused():
"""A header we cannot even read is reported like any unusable token."""
with pytest.raises(JWKSError, match="does not name"):
JWKSClient(JWKS_URL).get_signing_key("not-a-token")
@responses.activate
def test_an_unreachable_publisher_is_reported():
"""A service we cannot fetch the keys from validates no token."""
responses.get(JWKS_URL, status=500)
with pytest.raises(JWKSError, match="Unable to fetch"):
JWKSClient(JWKS_URL).get_signing_key(signed_token())
@responses.activate
def test_a_malformed_document_is_reported():
"""A published document we cannot import validates no token either."""
responses.get(JWKS_URL, body="<html>not a jwks</html>")
with pytest.raises(JWKSError, match="cannot be imported"):
JWKSClient(JWKS_URL).get_signing_key(signed_token())
@responses.activate
def test_a_document_without_any_usable_key_is_reported():
"""A publisher answering an empty set is not a publisher we can use."""
responses.get(JWKS_URL, json={"keys": []})
with pytest.raises(JWKSError, match="cannot be imported"):
JWKSClient(JWKS_URL).get_signing_key(signed_token())
@responses.activate
def test_the_documents_of_two_services_do_not_share_a_cache_entry():
"""Two publishers are two documents, whatever each of them holds."""
other_url = "http://other-service.example.com/jwks"
responses.get(JWKS_URL, json=build_jwks(PUBLIC_KEY))
responses.get(other_url, json=build_jwks(OTHER_PUBLIC_KEY))
JWKSClient(JWKS_URL).get_signing_key(signed_token())
key = JWKSClient(other_url).get_signing_key(
signed_token(OTHER_PRIVATE_KEY, OTHER_PUBLIC_KEY)
)
assert key.key_id == key_id(OTHER_PUBLIC_KEY)
assert len(responses.calls) == 2
@@ -60,6 +60,14 @@ def test_build_url():
assert url == f"http://yhub:3002/collaboration/ydoc/v1/docs/{DOCUMENT.id!s}"
def test_jwks_url():
"""The keys validating what yhub signs should be read from yhub itself."""
service = YHubService()
assert service.jwks_url == "http://yhub:3002/collaboration/jwks/v1"
assert service.jwks.url == service.jwks_url
def test_auth_header():
"""The auth header should carry an admin JWT signed with the configured key."""
scheme, token = YHubService().auth_header.split(" ")
@@ -2,6 +2,7 @@
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc.jwk import KeySet, RSAKey
def generate_key_pair():
@@ -21,3 +22,24 @@ def generate_key_pair():
.decode("utf-8")
)
return private_pem, public_pem
def key_id(public_pem):
"""
Return the "kid" naming a key, the way every service here names its own.
It is the RFC 7638 thumbprint of the key, computed from its public
components: the signer stamps it in the header of its tokens and publishes
it in its JWKS, which is how the two are matched.
"""
return RSAKey.import_key(public_pem).thumbprint()
def build_jwks(public_pem):
"""Build the JWKS a service publishes for a PEM encoded RSA public key."""
key = RSAKey.import_key(
public_pem,
parameters={"alg": "RS256", "use": "sig", "kid": key_id(public_pem)},
)
return KeySet([key]).as_dict(private=False)