mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-08 02:37:53 +02:00
✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
: '')
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Generated
+18
-8
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user