mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-05 17:27:48 +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:
@@ -21,6 +21,7 @@ and this project adheres to
|
||||
- ✨(collaboration) add a get-ydoc endpoint on yhub
|
||||
- ✨(backend) duplicate a document through the collaboration server
|
||||
- ✨(backend) serve `documents/{id}/formatted-content/` from yhub
|
||||
- ✨(collaboration) notify the backend when the worker persists new content
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -69,11 +69,16 @@ data/media:
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
# RSA key signing the JWT tokens the backend issues. Generated locally, never
|
||||
# committed: "data/" is gitignored. Regenerate it by deleting the file.
|
||||
# RSA keys signing the JWT tokens the services issue: one for the backend, one
|
||||
# for the collaboration server. Generated locally, never committed: "data/" is
|
||||
# gitignored. Regenerate one by deleting the file. Both are listed, so a stack
|
||||
# set up before the collaboration server had a key of its own gets it too.
|
||||
data/jwt/private.pem:
|
||||
@bin/generate-jwt-private-key.sh
|
||||
|
||||
data/jwt/yhub-private.pem:
|
||||
@bin/generate-jwt-private-key.sh
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-local-files: ## create env.local files in env.d/development
|
||||
@@ -87,7 +92,7 @@ create-env-local-files:
|
||||
|
||||
generate-secret-keys:
|
||||
generate-secret-keys: ## generate the secret keys needed by the dev stack
|
||||
generate-secret-keys: data/jwt/private.pem
|
||||
generate-secret-keys: data/jwt/private.pem data/jwt/yhub-private.pem
|
||||
@bin/generate-oidc-store-refresh-token-key.sh
|
||||
.PHONY: generate-secret-keys
|
||||
|
||||
|
||||
+22
@@ -31,6 +31,28 @@ the following command inside your docker container:
|
||||
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.
|
||||
- ⚠️ The collaboration server now calls the backend on its own, to declare that
|
||||
a document was edited, and signs those calls: **it needs an RSA private key
|
||||
of its own**, which it had not before. Generate one and give it to the
|
||||
collaboration server in `YHUB_JWT_PRIVATE_KEY`, or in a file
|
||||
`YHUB_JWT_PRIVATE_KEY_FILE` points at:
|
||||
|
||||
```bash
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out yhub-private.pem
|
||||
```
|
||||
|
||||
There is nothing to configure on the backend side: it reads the public half
|
||||
from the JWKS the collaboration server publishes on `/collaboration/jwks/v1`,
|
||||
which it fetches over `YHUB_API_BASE_URL` — so the two only need to reach
|
||||
each other, and this key can be rolled without the backend being touched.
|
||||
Do not share the backend key (`JWT_PRIVATE_KEY`) with it: each service signs
|
||||
with a key of its own.
|
||||
|
||||
Without this key the collaboration server keeps serving documents, and warns
|
||||
at startup that it will not notify the backend: the `updated_at` of a document
|
||||
then stops following the edits made in the editor, and the lists ordered by it
|
||||
drift out of date. In a development environment,
|
||||
`make generate-secret-keys` creates the key in `data/jwt/`.
|
||||
|
||||
### [5.0.0] - 2026-04-30
|
||||
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Generate the RSA private key signing the JWT tokens issued by the backend.
|
||||
# Generate the RSA keys signing the JWT tokens exchanged between the services.
|
||||
#
|
||||
# Development only. The key is generated locally and never committed: it lands
|
||||
# in "data/", which is gitignored. The dev stack mounts it in the backend
|
||||
# containers, where JWT_PRIVATE_KEY_FILE points at it.
|
||||
# Two directions, hence two keys:
|
||||
# - "private.pem" signs the tokens the backend issues to call the converter and
|
||||
# the collaboration server.
|
||||
# - "yhub-private.pem" signs the calls the collaboration server makes to the
|
||||
# backend.
|
||||
#
|
||||
# Idempotent: an existing key is kept. Delete the file to roll the key.
|
||||
# Only the private halves exist as files: each service publishes the public half
|
||||
# of its own key on its JWKS endpoint, where the other one reads it.
|
||||
#
|
||||
# Development only. The keys are generated locally and never committed: they
|
||||
# land in "data/", which is gitignored. The dev stack mounts them in the
|
||||
# containers, where the *_FILE settings point at them.
|
||||
#
|
||||
# Idempotent: existing keys are kept. Delete a file to roll it.
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
KEY_PATH="${REPO_DIR}/data/jwt/private.pem"
|
||||
KEY_DIR="${REPO_DIR}/data/jwt"
|
||||
|
||||
if [ -f "${KEY_PATH}" ]; then
|
||||
exit 0
|
||||
mkdir -p "${KEY_DIR}"
|
||||
|
||||
if [ ! -f "${KEY_DIR}/private.pem" ]; then
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||
-out "${KEY_DIR}/private.pem" 2>/dev/null
|
||||
chmod 600 "${KEY_DIR}/private.pem"
|
||||
echo "✓ backend JWT private key generated in ${KEY_DIR}/private.pem"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${KEY_PATH}")"
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${KEY_PATH}" 2>/dev/null
|
||||
chmod 600 "${KEY_PATH}"
|
||||
echo "✓ JWT private key generated in ${KEY_PATH}"
|
||||
if [ ! -f "${KEY_DIR}/yhub-private.pem" ]; then
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||
-out "${KEY_DIR}/yhub-private.pem" 2>/dev/null
|
||||
chmod 600 "${KEY_DIR}/yhub-private.pem"
|
||||
echo "✓ collaboration JWT private key generated in ${KEY_DIR}/yhub-private.pem"
|
||||
fi
|
||||
|
||||
@@ -246,9 +246,13 @@ services:
|
||||
# seed rooms from the legacy Django/S3 document store on first access —
|
||||
# S3 endpoint/credentials come from env.d/development/common
|
||||
SOFT_MIGRATION: "true"
|
||||
# signs the calls made to the backend, which holds the public half
|
||||
YHUB_JWT_PRIVATE_KEY_FILE: /data/jwt/yhub-private.pem
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
volumes:
|
||||
- ./data/jwt:/data/jwt:ro
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3002:3002"
|
||||
|
||||
@@ -934,6 +934,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)
|
||||
|
||||
@@ -39,14 +39,30 @@ It is not a fork of yhub — it is a thin wrapper:
|
||||
- exposes `POST /collaboration/migrate/v1/{org}/{docid}`, which replays a
|
||||
document's **full** legacy version history out of the S3 media bucket (see
|
||||
"Full migration" below) — admin JWT only, like `reset-connections`,
|
||||
- exposes `GET /collaboration/get-ydoc/v1/{org}/{docid}`, the read counterpart
|
||||
of `create-ydoc`: the current state of a document as a raw binary update
|
||||
(204 when it has no content), which the Django backend reads to export or
|
||||
duplicate a document. Guarded by standard document read access,
|
||||
- notifies the Django backend on
|
||||
`POST /api/v1.0/documents/{id}/content-updated/` whenever the worker
|
||||
persists new content for a document, so that lists ordered by `updated_at`
|
||||
follow the edits made here. Signed with an RS256 JWT of our own
|
||||
(`YHUB_JWT_PRIVATE_KEY`, `aud: "docs-backend"`, one minute), best effort: a
|
||||
notification the backend refuses or never receives is logged and dropped,
|
||||
- publishes the public half of that key on `GET /collaboration/jwks/v1`, where
|
||||
the backend reads it. The exact mirror of the JWKS the backend publishes for
|
||||
its own tokens: neither side is configured with a copy of the key of the
|
||||
other, so either can roll its key on its own. Served unauthenticated, as any
|
||||
JWKS is,
|
||||
- mirrors the environment conventions used elsewhere in this repository
|
||||
(`*_FILE` secret indirection, `COLLABORATION_SERVER_ORIGIN` allowlist, …).
|
||||
|
||||
Public exposure: route the whole `/collaboration/` prefix to this server —
|
||||
the websocket and the built-in document APIs (`ydoc`, `rollback`, `prune`,
|
||||
`changeset`, `activity`) are all guarded by the same cookie-based document
|
||||
authorization and are meant to be reachable by browsers. The one exception
|
||||
is `/collaboration/reset-connections/` and `/collaboration/migrate/`, which are
|
||||
authorization and are meant to be reachable by browsers, as is
|
||||
`/collaboration/jwks/v1`, which carries public keys and nothing else. The one
|
||||
exception is `/collaboration/reset-connections/` and `/collaboration/migrate/`, which are
|
||||
backend-internal and should not be routed through the public ingress.
|
||||
|
||||
## Container image
|
||||
|
||||
+165
-12
@@ -1,12 +1,22 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, createPublicKey, randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
apiError,
|
||||
createApiEndpoint,
|
||||
createAuthPlugin,
|
||||
createYHub,
|
||||
logger,
|
||||
} from '@y/hub';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import {
|
||||
calculateJwkThumbprint,
|
||||
createRemoteJWKSet,
|
||||
exportJWK,
|
||||
importPKCS8,
|
||||
jwtVerify,
|
||||
SignJWT,
|
||||
} from 'jose';
|
||||
import { Client as S3Client } from 'minio';
|
||||
|
||||
import { secret } from './env.js';
|
||||
// legacy Django/S3 document store — see migration.js and README.md
|
||||
@@ -29,6 +39,13 @@ const allowedOrigins = (
|
||||
).split(',');
|
||||
const Y_PROVIDER_API_KEY = secret('Y_PROVIDER_API_KEY', 'yprovider-api-key');
|
||||
const ORG = process.env.YHUB_ORG || 'docs';
|
||||
// Segment every route is mounted under (`server.apiPrefix` below), matching the
|
||||
// URL scheme Docs already routes to the collaboration server. Hardcoded like
|
||||
// the audiences: the backend builds its urls with the same prefix.
|
||||
const API_PREFIX = 'collaboration';
|
||||
// Path the JWKS endpoint declared in `api` is mounted at. readAuthInfo reads
|
||||
// the raw request, without any route context, hence the duplication.
|
||||
const JWKS_PATH = `/${API_PREFIX}/jwks/v1`;
|
||||
// Requiring this audience stops a valid admin JWT that Django issued for
|
||||
// another service (today: the y-converter token in converter_services.py,
|
||||
// which is handed to the converter process) from being replayed against yhub.
|
||||
@@ -51,6 +68,77 @@ const EMPTY_YDOC = new Uint8Array([0, 0]);
|
||||
// websocket path.
|
||||
const MAX_CREATE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const touchLog = logger.child({ module: 'updated-at-notifier' });
|
||||
|
||||
const BACKEND_NOTIFY_TIMEOUT_MS = 5000;
|
||||
// Audience of the tokens the backend accepts from us. It must match the one
|
||||
// its CollaborationServerAuthentication requires, a token minted for anything
|
||||
// else is refused there.
|
||||
const BACKEND_AUDIENCE = 'docs-backend';
|
||||
const BACKEND_TOKEN_LIFETIME_S = 60;
|
||||
// Renew this long before expiry so a token never dies in flight.
|
||||
const BACKEND_TOKEN_MARGIN_MS = 10000;
|
||||
|
||||
// We sign the calls we make to the backend, the mirror of the admin JWT it
|
||||
// signs to call us: no long-lived shared secret, only our private key here and
|
||||
// its public half published on the JWKS endpoint below.
|
||||
const YHUB_JWT_PRIVATE_KEY = secret('YHUB_JWT_PRIVATE_KEY', '');
|
||||
const backendSigningKey = YHUB_JWT_PRIVATE_KEY
|
||||
? await importPKCS8(YHUB_JWT_PRIVATE_KEY, 'RS256')
|
||||
: null;
|
||||
|
||||
if (backendSigningKey == null) {
|
||||
// not fatal, documents keep being served — only their `updated_at` freezes
|
||||
touchLog.warn(
|
||||
'YHUB_JWT_PRIVATE_KEY is empty, the backend will not be notified of content updates',
|
||||
);
|
||||
}
|
||||
|
||||
// The public half of the signing key, as published on the JWKS endpoint. Its
|
||||
// "kid" is the RFC 7638 thumbprint of the key: computed from the public
|
||||
// components only, it is stable across restarts and changes on its own when
|
||||
// the key is rolled. Every token we sign carries it, which is how the backend
|
||||
// picks the matching key — and how it knows to fetch the set again when it
|
||||
// does not know the key yet, so rolling this key needs no change on its side.
|
||||
const backendPublicJwk =
|
||||
backendSigningKey == null
|
||||
? null
|
||||
: await (async () => {
|
||||
// derived from the PEM rather than exported from `backendSigningKey`:
|
||||
// exporting a private key as a JWK would carry its private components
|
||||
const jwk = await exportJWK(createPublicKey(YHUB_JWT_PRIVATE_KEY));
|
||||
return {
|
||||
...jwk,
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
kid: await calculateJwkThumbprint(jwk),
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* @type {{ token: string, expiresAt: number } | null}
|
||||
*/
|
||||
let backendToken = null;
|
||||
|
||||
// The token carries no per-document claim, so one is reused until it is about
|
||||
// to expire rather than signing on every notification.
|
||||
const getBackendToken = async () => {
|
||||
const now = Date.now();
|
||||
if (backendToken != null && backendToken.expiresAt - BACKEND_TOKEN_MARGIN_MS > now) {
|
||||
return backendToken.token;
|
||||
}
|
||||
const token = await new SignJWT({})
|
||||
// the "kid" names the key in our JWKS the backend must verify it with
|
||||
.setProtectedHeader({ alg: 'RS256', kid: backendPublicJwk.kid })
|
||||
.setIssuer('yhub')
|
||||
.setAudience(BACKEND_AUDIENCE)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${BACKEND_TOKEN_LIFETIME_S}s`)
|
||||
.sign(backendSigningKey);
|
||||
backendToken = { token, expiresAt: now + BACKEND_TOKEN_LIFETIME_S * 1000 };
|
||||
return token;
|
||||
};
|
||||
|
||||
// Public keys verifying the RS256 admin tokens Django issues (JWTService).
|
||||
// Lazily fetched on first use; jose caches the keys and refetches on unknown
|
||||
// "kid", so Django can rotate the signing key without a yhub restart.
|
||||
@@ -110,10 +198,19 @@ const seedFromLegacyStore = async (room) => {
|
||||
const auth = createAuthPlugin({
|
||||
// uws req is only valid synchronously — read headers AND query before first await.
|
||||
async readAuthInfo(req) {
|
||||
const url = req.getUrl();
|
||||
const authorization = req.getHeader('authorization');
|
||||
const cookie = req.getHeader('cookie');
|
||||
const origin = req.getHeader('origin');
|
||||
const gcOff = req.getQuery('gc') === 'false';
|
||||
// The JWKS holds public keys and nothing else, and the backend must be
|
||||
// able to fetch it before it can authenticate anything we send it — so it
|
||||
// is served to anyone, as the backend serves its own. This identity is
|
||||
// granted the 'jwks' purpose and nothing else (getGlobalAccessType), and
|
||||
// the check is on the exact path of that one route.
|
||||
if (url === JWKS_PATH) {
|
||||
return { userid: 'anonymous' };
|
||||
}
|
||||
if (authorization !== '') {
|
||||
// backend-to-server call: RS256 JWT signed by Django, verified against
|
||||
// its JWKS. A browser cannot attach an Authorization header to a ws
|
||||
@@ -183,6 +280,10 @@ const auth = createAuthPlugin({
|
||||
return { userid: `anon:${anon}`, cookie, origin };
|
||||
}
|
||||
},
|
||||
// Authorizes the global-scoped endpoints, of which the JWKS is the only one.
|
||||
async getGlobalAccessType(authInfo, purpose) {
|
||||
return purpose === 'jwks' ? 'r' : null;
|
||||
},
|
||||
async getAccessType(authInfo, { org, docid, branch }, purpose) {
|
||||
if (authInfo.admin === true) {
|
||||
// Django's admin token: full access. It still goes through the legacy
|
||||
@@ -254,6 +355,24 @@ const jsonResponse = (status, body) =>
|
||||
});
|
||||
|
||||
const api = [
|
||||
// GET /collaboration/jwks/v1 — the public keys verifying the tokens we sign
|
||||
// to call the backend, in the JSON Web Key Set format (RFC 7517). Global
|
||||
// scope: it is about this server, not about a document, so the route carries
|
||||
// no org and no docid. The counterpart of the backend's own /api/v1.0/jwks,
|
||||
// which we read above to verify its tokens: neither side stores a copy of
|
||||
// the other's key, so either can be rolled without the other being changed.
|
||||
createApiEndpoint('jwks', {
|
||||
scope: 'global',
|
||||
accessPurpose: 'jwks',
|
||||
get: {
|
||||
// an empty set when no key is configured: honest, and the backend
|
||||
// refuses our (equally absent) tokens rather than trusting anything
|
||||
handler: () =>
|
||||
jsonResponse(200, {
|
||||
keys: backendPublicJwk == null ? [] : [backendPublicJwk],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// POST /collaboration/reset-connections/v1/{org}/{docid} — replaces
|
||||
// y-provider's /collaboration/api/reset-connections/?room=. Doc-scoped, so
|
||||
// the room comes from the path; access is gated to the admin token via the
|
||||
@@ -475,8 +594,46 @@ const api = [
|
||||
}),
|
||||
];
|
||||
|
||||
// referenced by getAccessType above — safe: auth callbacks only fire once the
|
||||
// server is up, i.e. after this assignment
|
||||
// Django orders the document lists by `updated_at` and no edit goes through it
|
||||
// anymore, so it is told here that a document moved on.
|
||||
const touchDocument = async (docid) => {
|
||||
if (backendSigningKey == null) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/documents/${docid}/content-updated/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${await getBackendToken()}` },
|
||||
signal: AbortSignal.timeout(BACKEND_NOTIFY_TIMEOUT_MS),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
touchLog.warn({ docid, status: res.status }, 'backend refused the notification');
|
||||
}
|
||||
} catch (err) {
|
||||
// best effort: a lost notification only leaves `updated_at` behind until
|
||||
// the document is edited again, it must never fail a compaction
|
||||
touchLog.warn({ err, docid }, 'could not notify the backend');
|
||||
}
|
||||
};
|
||||
|
||||
// `docUpdate` is the worker event for "this compaction found new content": the
|
||||
// task returns before it when it has nothing to persist, so the awareness-only
|
||||
// traffic of someone merely opening a document never reaches it. Since yhub
|
||||
// 0.5.0 it is handed the room of the task alongside the merged document.
|
||||
const workerEvents = {
|
||||
docUpdate: ({ room }) => {
|
||||
// Django knows the documents of this org, on the main branch, by their uuid
|
||||
if (room.org !== ORG || room.branch !== 'main' || !UUID4.test(room.docid)) {
|
||||
return;
|
||||
}
|
||||
// deliberately not awaited: a slow backend must not hold the worker
|
||||
touchDocument(room.docid);
|
||||
},
|
||||
};
|
||||
|
||||
// the instance is referenced by the soft-migration helpers above — safe: auth
|
||||
// callbacks only fire once the server is up, i.e. after this assignment
|
||||
const yhub = await createYHub({
|
||||
redis: {
|
||||
url: REDIS,
|
||||
@@ -486,12 +643,8 @@ const yhub = await createYHub({
|
||||
},
|
||||
postgres: POSTGRES,
|
||||
persistence: [], // blobs live in yhub's postgres
|
||||
// apiPrefix mounts every route — built-ins, reset-connections, and the
|
||||
// websocket (/collaboration/ws/v1/{org}/{docid}) — under /collaboration/,
|
||||
// matching the URL scheme Docs already routes to the collaboration server.
|
||||
server: { port: PORT, auth, api, apiPrefix: 'collaboration' },
|
||||
worker: { taskConcurrency: 5 },
|
||||
// TODO(yhub): worker.events.docUpdate could push snapshots to Django and replace the
|
||||
// client useSaveDoc PATCH flow. No longer blocked upstream — yhub 0.5.0 adds `room`
|
||||
// to the event payload, which was the missing piece.
|
||||
// apiPrefix mounts every route — built-ins, our custom endpoints, and the
|
||||
// websocket (/collaboration/ws/v1/{org}/{docid}) — under /collaboration/.
|
||||
server: { port: PORT, auth, api, apiPrefix: API_PREFIX },
|
||||
worker: { taskConcurrency: 5, events: workerEvents },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user