(collaboration) add admin reset-connections endpoint on yhub 0.4.0

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 <kevin.jahns@protonmail.com>
This commit is contained in:
Kevin Jahns
2026-09-04 15:34:05 +02:00
committed by Anthony LC
parent cdffb78ee6
commit 0189ff3dcb
7 changed files with 138 additions and 18 deletions
+1
View File
@@ -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
+1 -2
View File
@@ -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
@@ -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`
: '')
);
};
+16
View File
@@ -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`.
+18 -8
View File
@@ -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",
+2 -1
View File
@@ -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"
+99 -6
View File
@@ -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: [<uuid>]) 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