diff --git a/addons/wiki/static/wikiPageMilkdown.js b/addons/wiki/static/wikiPageMilkdown.js index 6edfab6..a41e42c 100644 --- a/addons/wiki/static/wikiPageMilkdown.js +++ b/addons/wiki/static/wikiPageMilkdown.js @@ -380,8 +380,14 @@ async function createMEditor(editor, vm, template) { return ret; }; + const wsToken = window.contextVars.wiki.metadata.yWebsocketToken; + const wsParams = wsToken ? { token: wsToken } : {}; + if (!wsProvider) { - wsProvider = new yWebsocket.WebsocketProvider(wsUrl, docId, doc, { disableBc: true }); + wsProvider = new yWebsocket.WebsocketProvider(wsUrl, docId, doc, { + disableBc: true, + params: wsParams, + }); } originalContent = template; diff --git a/docker/y-websocket-auth/Dockerfile b/docker/y-websocket-auth/Dockerfile new file mode 100644 index 0000000..ffe9bb5 --- /dev/null +++ b/docker/y-websocket-auth/Dockerfile @@ -0,0 +1,24 @@ +# Authenticated entrypoint for y-websocket. +# RDM-y-websocket itself is not modified; this image adds auth on top of the base image. +# +# Local build example (after building RDM-y-websocket): +# docker build --build-arg Y_WEBSOCKET_BASE_IMAGE=rdm-20260715-2-y-websocket \ +# -t rdm-y-websocket-auth:local docker/y-websocket-auth +# +# Production build example: +# docker build -t rdm-y-websocket-auth:prod docker/y-websocket-auth + +ARG Y_WEBSOCKET_BASE_IMAGE=niicloudoperation/rdm-y-websocket:latest +FROM ${Y_WEBSOCKET_BASE_IMAGE} + +WORKDIR /home/node/app + +COPY auth.cjs server-wrapper.cjs ./ + +USER root +RUN npm install jsonwebtoken@9.0.2 --omit=dev +USER node + +EXPOSE 1234 + +CMD ["node", "./server-wrapper.cjs"] diff --git a/docker/y-websocket-auth/auth.cjs b/docker/y-websocket-auth/auth.cjs new file mode 100644 index 0000000..5181577 --- /dev/null +++ b/docker/y-websocket-auth/auth.cjs @@ -0,0 +1,118 @@ +const url = require('url') +const jwt = require('jsonwebtoken') + +const Y_WEBSOCKET_SECRET = process.env.Y_WEBSOCKET_SECRET || '' +const Y_WEBSOCKET_ALLOWED_ORIGINS = (process.env.Y_WEBSOCKET_ALLOWED_ORIGINS || '') + .split(',') + .map(origin => origin.trim()) + .filter(Boolean) + +function assertYWebsocketSecretConfigured () { + if (!Y_WEBSOCKET_SECRET) { + console.error('Y_WEBSOCKET_SECRET is required; refusing to start without authentication') + process.exit(1) + } +} + +function verifyJwt (token, secret) { + try { + const payload = jwt.verify(token, secret, { + algorithms: ['HS256'] + }) + // Require exp so tokens without expiry are rejected. + if (!payload || typeof payload !== 'object' || !payload.exp) { + return null + } + return payload + } catch (error) { + return null + } +} + +function isOriginAllowed (origin) { + if (Y_WEBSOCKET_ALLOWED_ORIGINS.length === 0) { + return true + } + if (!origin) { + return false + } + return Y_WEBSOCKET_ALLOWED_ORIGINS.includes(origin) +} + +function getDocIdFromRequest (request) { + const parsedUrl = url.parse(request.url || '', true) + const pathname = parsedUrl.pathname || '' + const docId = pathname.replace(/^\/+/, '').split('/')[0] + return docId || null +} + +/** + * Read the token query parameter. + * Accepts only a single non-empty string. + * Multiple values (?token=a&token=b) become an array and are rejected. + */ +function getTokenFromRequest (request) { + const parsedUrl = url.parse(request.url || '', true) + const token = parsedUrl.query.token + + if (token === undefined || token === null || token === '') { + return { token: null } + } + + if (typeof token !== 'string') { + return { + token: null, + error: 'invalid token parameter', + statusCode: 400 + } + } + + return { token } +} + +function authorizeUpgrade (request) { + const origin = request.headers.origin + if (!isOriginAllowed(origin)) { + return { authorized: false, reason: 'origin not allowed', statusCode: 401 } + } + + const docId = getDocIdFromRequest(request) + const tokenResult = getTokenFromRequest(request) + if (tokenResult.error) { + return { + authorized: false, + reason: tokenResult.error, + statusCode: tokenResult.statusCode || 400 + } + } + + const token = tokenResult.token + if (!docId || !token) { + return { authorized: false, reason: 'missing doc id or token', statusCode: 401 } + } + + const payload = verifyJwt(token, Y_WEBSOCKET_SECRET) + if (!payload) { + return { authorized: false, reason: 'invalid token', statusCode: 401 } + } + + if (payload.doc_id !== docId) { + return { authorized: false, reason: 'doc id mismatch', statusCode: 401 } + } + + // Require signed subject (OSF user GUID) for connection traceability. + if (typeof payload.sub !== 'string' || !payload.sub) { + return { authorized: false, reason: 'missing subject', statusCode: 401 } + } + + return { authorized: true, docId, sub: payload.sub } +} + +module.exports = { + assertYWebsocketSecretConfigured, + authorizeUpgrade, + verifyJwt, + isOriginAllowed, + getDocIdFromRequest, + getTokenFromRequest +} diff --git a/docker/y-websocket-auth/server-wrapper.cjs b/docker/y-websocket-auth/server-wrapper.cjs new file mode 100644 index 0000000..42fbea3 --- /dev/null +++ b/docker/y-websocket-auth/server-wrapper.cjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +const WebSocket = require('ws') +const http = require('http') +const number = require('lib0/number') +const wss = new WebSocket.Server({ noServer: true }) +const setupWSConnection = require('./bin/utils.cjs').setupWSConnection +const { + assertYWebsocketSecretConfigured, + authorizeUpgrade +} = require('./auth.cjs') + +assertYWebsocketSecretConfigured() + +const host = process.env.HOST || 'localhost' +const port = number.parseInt(process.env.PORT || '1234') + +const server = http.createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('okay') +}) + +wss.on('connection', setupWSConnection) + +server.on('upgrade', (request, socket, head) => { + const authResult = authorizeUpgrade(request) + if (!authResult.authorized) { + const statusCode = authResult.statusCode || 401 + const statusText = statusCode === 400 ? 'Bad Request' : 'Unauthorized' + socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\n\r\n`) + socket.destroy() + return + } + + console.log( + `authorized connection docId=${authResult.docId} sub=${authResult.sub}` + ) + + wss.handleUpgrade(request, socket, head, /** @param {any} ws */ ws => { + wss.emit('connection', ws, request) + }) +}) + +server.listen(port, host, () => { + console.log(`running at '${host}' on port ${port} with auth enabled`) +})