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