mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-23 10:05:10 +02:00
✨(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:
@@ -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'"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user