From 0189ff3dcb90ca45a8f989eb0c5e1a823af68864 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Wed, 5 Aug 2026 11:11:38 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(collaboration)=20add=20admin=20reset-?= =?UTF-8?q?connections=20endpoint=20on=20yhub=200.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /collaboration/reset-connections/v1/{org}/{docid} (optional X-User-Id header) to yhub-server. It distributes yhub recheckAuth: every server re-runs the access check per matching connection and closes only those whose access actually changed (close code 4401), so unaffected clients see no reconnect churn. The endpoint authenticates with the RS256 admin JWT issued by JWTService, verified against the backend JWKS (new jose dependency); the admin token acts as the "system" user and is the only principal granted the reset-connections access purpose. The backend does not trigger it on permission changes yet - that wiring comes separately, now that CollaborationService is gone. yhub is upgraded to 0.4.0 and serves every route under the /collaboration/ prefix (server.apiPrefix): the websocket moves to /collaboration/ws/v1/docs, and the built-in document apis are meant to be publicly exposed alongside it, with reset-connections as the one backend-internal exception. Also harden websocket auth: fail closed when the backend errors (only a genuine 401/403 falls back to the anonymous identity, so a signed-in editor can never hide from a targeted recheck under an anon userid) and tolerate small clock skew when verifying the cached admin token. Signed-off-by: Kevin Jahns --- CHANGELOG.md | 1 + env.d/development/common | 3 +- .../core/config/hooks/useCollaborationUrl.tsx | 2 +- src/yhub-server/README.md | 16 +++ src/yhub-server/package-lock.json | 26 +++-- src/yhub-server/package.json | 3 +- src/yhub-server/server.js | 105 +++++++++++++++++- 7 files changed, 138 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a73f6241..c903f6752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - ✨(backend) add a service generating cached RS256 JWT tokens - ✨(backend) publish the JWT public key on a JWKS endpoint - 🔧(dev) generate the JWT signing key when bootstrapping the dev stack +- ✨(collaboration) add an admin reset-connections endpoint on yhub ### Changed diff --git a/env.d/development/common b/env.d/development/common index 498bcf367..60072b5fd 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -77,11 +77,10 @@ OIDC_RS_ALLOWED_AUDIENCES="" USER_RECONCILIATION_FORM_URL=http://localhost:3000 # Collaboration -# TODO(yhub): no management API yet COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret -COLLABORATION_WS_URL=ws://localhost:3002/ws/docs +COLLABORATION_WS_URL=ws://localhost:3002/collaboration/ws/v1/docs COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token diff --git a/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx b/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx index feee4ab03..d87974c1c 100644 --- a/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx +++ b/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx @@ -12,7 +12,7 @@ export const useCollaborationUrl = (room?: string) => { conf?.COLLABORATION_WS_URL || (typeof window !== 'undefined' ? // TODO(yhub): no prod ingress route yet - `wss://${window.location.host}/ws/docs` + `wss://${window.location.host}/collaboration/ws/v1/docs` : '') ); }; diff --git a/src/yhub-server/README.md b/src/yhub-server/README.md index 229c5b982..248a52d9a 100644 --- a/src/yhub-server/README.md +++ b/src/yhub-server/README.md @@ -11,9 +11,25 @@ It is not a fork of yhub — it is a thin wrapper (`server.js`) that: - plugs in an auth plugin that resolves users and per-document access rights by calling the Docs Django backend (`/api/v1.0/users/me/` and `/api/v1.0/documents/{id}/`), +- serves every route under the `/collaboration/` prefix + (`server.apiPrefix`), including the websocket sync route + `/collaboration/ws/v1/{org}/{docid}`, +- exposes `POST /collaboration/reset-connections/v1/{org}/{docid}` (optional + `X-User-Id` header), for the Django backend to re-check the authorization + of a document's connected clients when permissions change (backend wiring + pending) — authenticated with an RS256 admin JWT issued by Django and + verified against its JWKS (`/api/v1.0/jwks`); the `reset-connections` + purpose is granted only to that admin token, never to regular users, - 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/`, which is backend-internal and should +not be routed through the public ingress. + The `Dockerfile` builds the container image used by the `yhub` service in `compose.yml`. diff --git a/src/yhub-server/package-lock.json b/src/yhub-server/package-lock.json index 050099a8d..9808e2ab1 100644 --- a/src/yhub-server/package-lock.json +++ b/src/yhub-server/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "yhub-server", "dependencies": { - "@y/hub": "0.3.1" + "@y/hub": "0.4.0", + "jose": "6.2.8" }, "engines": { "node": ">=22" @@ -109,15 +110,15 @@ "license": "ISC" }, "node_modules/@y/hub": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@y/hub/-/hub-0.3.1.tgz", - "integrity": "sha512-gauvqZ2XwOi7c/Fu13xbOCV3uR+OCHH5QUMyIPXJfDvdTwILEo0hatQWaLK1NhfOZCcaxTPbpgeu2GdLGs7xhg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@y/hub/-/hub-0.4.0.tgz", + "integrity": "sha512-LejJTSrBt86DI88pMO8rck4tQcHHR1SPLrZrOIfb5Ws/zNd601x5SXX0MOuTwKqoNOweT4esUX0Eh/2ER4sSsA==", "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { "@y-crdt/yn": "^0.1.4", "@y/protocols": "^1.0.6-rc.1", "@y/y": "^14.0.0-rc.24", - "lib0": "^1.0.0-rc.22", + "lib0": "^1.0.0-rc.23", "minio": "^8.0.6", "pino": "^10.3.1", "postgres": "^3.4.3", @@ -323,10 +324,19 @@ ], "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/lib0": { - "version": "1.0.0-rc.22", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.22.tgz", - "integrity": "sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==", + "version": "1.0.0-rc.23", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.23.tgz", + "integrity": "sha512-JPomcbwgKoTIDoXP61DFZV+Yvkw8bCyQhr9QYxps0fmHzsliEw+mhbUP1/nyVmk6ugzapJw1hUpFEMFRA4sRIg==", "license": "MIT", "bin": { "0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js", diff --git a/src/yhub-server/package.json b/src/yhub-server/package.json index 0d5e5d0d9..f4ec5cb51 100644 --- a/src/yhub-server/package.json +++ b/src/yhub-server/package.json @@ -6,7 +6,8 @@ "start": "node server.js" }, "dependencies": { - "@y/hub": "0.3.1" + "@y/hub": "0.4.0", + "jose": "6.2.8" }, "engines": { "node": ">=22" diff --git a/src/yhub-server/server.js b/src/yhub-server/server.js index 8577706a5..5eade43e3 100644 --- a/src/yhub-server/server.js +++ b/src/yhub-server/server.js @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; -import { createAuthPlugin, createYHub } from '@y/hub'; +import { createApiEndpoint, createAuthPlugin, createYHub } from '@y/hub'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; // mirror y-provider's env.ts secret-file support const secret = (name, dflt) => @@ -23,6 +24,13 @@ const ORG = process.env.YHUB_ORG || 'docs'; const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// 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. +const JWKS = createRemoteJWKSet( + new URL(`${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/jwks`), +); + const backendFetch = async (path, { cookie, origin }) => { const res = await fetch(`${COLLABORATION_BACKEND_BASE_URL}${path}`, { headers: { @@ -32,7 +40,9 @@ const backendFetch = async (path, { cookie, origin }) => { }, }); if (!res.ok) { - throw new Error(`Failed to fetch ${path}: ${res.status}`); + const err = new Error(`Failed to fetch ${path}: ${res.status}`); + err.status = res.status; + throw err; } return res.json(); }; @@ -40,9 +50,37 @@ const backendFetch = async (path, { cookie, origin }) => { const auth = createAuthPlugin({ // uws req is only valid synchronously — read headers AND query before first await. async readAuthInfo(req) { + const authorization = req.getHeader('authorization'); const cookie = req.getHeader('cookie'); const origin = req.getHeader('origin'); const gcOff = req.getQuery('gc') === 'false'; + 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 + // upgrade or a credentialed cross-origin fetch, so this never shadows a + // real user session. present-but-invalid fails here (401) instead of + // falling through to the cookie flow, which would mask a + // misconfiguration as an origin error. + const token = authorization.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : authorization; + try { + // clockTolerance absorbs Django's cache-at-exp race (the admin token + // is cached for exactly its lifetime, so it can arrive here moments + // after exp) plus small clock skew — without it a kick would be + // silently dropped as a 401. + const { payload } = await jwtVerify(token, JWKS, { + algorithms: ['RS256'], + clockTolerance: 5, + }); + // admin tokens act as the "system" user (no per-user admin identities yet) + return payload.admin === true + ? { userid: 'system', admin: true } + : null; + } catch { + return null; // bad signature / expired / JWKS unreachable — fail closed + } + } if (gcOff) return null; // full-history connections: not for Docs users if (!origin || !allowedOrigins.includes(origin)) return null; // was 4001 'Origin not allowed' if (!cookie) return null; // was 4001 'No cookies' @@ -52,7 +90,13 @@ const auth = createAuthPlugin({ origin, }); return { userid: String(user.id), cookie, origin }; // MUST be string (yhub server.js:667) - } catch { + } catch (err) { + // Only a genuine "not signed in" falls back to the anonymous identity. + // On backend failure (5xx/network) fail closed: a signed-in editor + // authorized under an anon userid would be invisible to the targeted + // reset-connections recheck (users: []) for the connection's + // whole lifetime. + if (err?.status !== 401 && err?.status !== 403) return null; // anonymous (public docs): stable per-session id — random ids would mint a new // permanent attribution identity per reconnect const anon = createHash('sha256') @@ -62,8 +106,17 @@ const auth = createAuthPlugin({ return { userid: `anon:${anon}`, cookie, origin }; } }, - async getAccessType(authInfo, { org, docid, branch }) { - if (org !== ORG || branch !== 'main' || !UUID4.test(docid)) { + async getAccessType(authInfo, { org, docid, branch }, purpose) { + if (authInfo.admin === true) return 'rw'; // Django's admin token: full access + // Regular users only get access for the default purpose — custom-endpoint + // purposes (reset-connections) are backend-internal. Loose != on purpose: + // ws upgrades and rechecks pass undefined, built-in rest endpoints null. + if ( + org !== ORG || + branch !== 'main' || + !UUID4.test(docid) || + purpose != null + ) { return null; } try { @@ -81,6 +134,43 @@ const auth = createAuthPlugin({ }, }); +// Mimic the old y-provider REST responses (JSON, not yhub's lib0-any +// encoding) so the Django caller keeps its historical contract. +const jsonResponse = (status, body) => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + +const api = [ + // 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 + // 'reset-connections' purpose in getAccessType. uws routes are exact: a + // trailing slash 404s. + createApiEndpoint('reset-connections', { + accessPurpose: 'reset-connections', + post: { + handler: async (req) => { + const userId = req.headers['x-user-id'] || null; + if (req.org !== ORG) { + return jsonResponse(400, { error: 'Unknown org' }); + } + if (!UUID4.test(req.docid)) { + return jsonResponse(400, { error: 'Room name is invalid' }); + } + // in-place recheck: every yhub server re-runs getAccessType per + // matching connection and closes 4401 only when the access changed — + // no reconnect churn for unaffected clients + await req.yhub.recheckAuth(req.room, { + users: userId ? [userId] : null, + }); + return jsonResponse(200, { message: 'Connections reset' }); + }, + }, + }), +]; + await createYHub({ redis: { url: REDIS, @@ -90,7 +180,10 @@ await createYHub({ }, postgres: POSTGRES, persistence: [], // blobs live in yhub's postgres - server: { port: PORT, auth }, + // 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 — blocked upstream: the payload is a DocTable without