openvibers/openvibe.chat Commits

?token= on /ws/chat is deprecated (C-05): still honoured for the bots that use it, counted in /metrics chat_ws_url_token_uses{kind}; bots authenticate the upgrade with an Authorization header (tested) and browsers with their first join message. POST /api/chat/send is the bots' global-chat API, no longer a browser fallback (C-06)

OP OpenVibers committed 24 Sept 2026 · 01081ad94d · 5 files, +37 −8


Changes

ddiff --git a/server/app.js b/server/app.js
iindex 27137ce..404e1d6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -79,6 +79,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
// How sign-ins resolve (auth/network-session.js): here, through Live, or refused.
metrics.registry.gauge({ name: 'chat_auth_resolutions', help: 'Token resolutions since start, by where they were decided', labelNames: ['via'],
collect: () => Object.entries(require('./auth/network-session').stats()).map(([via, n]) => ({ labels: { via }, value: n })) });
++ // Deprecated ?token= on /ws/chat (C-05), by kind, since start: when both stay at 0, the shim goes.
++ metrics.registry.gauge({ name: 'chat_ws_url_token_uses', help: 'WebSocket upgrades that carried the token in the URL (deprecated), by token kind', labelNames: ['kind'],
++ collect: () => Object.entries(require('./auth/auth').urlTokenUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });
/** Exact allow-list only (no subdomain wildcard). */
function isAllowedOrigin(origin) {
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 37010c3..434a59c 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -70,14 +70,18 @@ function extractToken(req) {
/**
* Extract the token for a WebSocket upgrade request
*/
++const urlTokenUses = { jwt: 0, api_token: 0 };
function extractWsToken(req) {
-- // Prefer explicit ?token= query param — clients (e.g. broadcast signaling)
-- // put the freshest localStorage token in the URL, which may be newer than
-- // a stale httpOnly cookie from an earlier session.
++ // DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message
++ // and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and
++ // counted, /metrics chat_ws_url_token_uses) while bots move off it.
try {
const url = new URL(req.url || '/', 'http://localhost');
const queryToken = url.searchParams.get('token');
-- if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') return queryToken;
++ if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') {
++ urlTokenUses[queryToken.startsWith('hbt_') ? 'api_token' : 'jwt']++;
++ return queryToken;
++ }
} catch { /* fall through */ }
// Fall back to cookie / Authorization header
@@@ -178,4 +182,5 @@ module.exports = {
requireAuth,
optionalAuth,
requireAdmin,
++ urlTokenUses: () => ({ ...urlTokenUses }),
};
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex 203f516..e96aecb 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -4,7 +4,7 @@
* Same paths, bodies and responses as Live's /api/chat. Messages are Chat's; authors, streams,
* slots, cosmetics, tags, settings and profile cards are Live's and come through live-context.
*
-- * POST /api/chat/send - Send a chat message (REST fallback)
++ * POST /api/chat/send - Post a message to global chat (bots; API tokens need the chat scope)
* GET /api/chat/:streamId/history - Get chat history for a stream
* GET /api/chat/:streamId/users - Get users in chat
* GET /api/chat/search - Search chat messages
@@@ -224,7 +224,7 @@ function hydrateReplies(messages) {
});
}
--// ── Send Chat Message (REST fallback when WS is down) ────────
++// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
router.post('/send', requireAuth, async (req, res) => {
try {
const text = (req.body.message || '').trim();
ddiff --git a/test/helpers.js b/test/helpers.js
iindex abb793a..4bf1bdd 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -342,12 +342,12 @@ async function boot({ env = {} } = {}) {
const WebSocket = require('ws');
/** Open /ws/chat as a browser would (through nginx: the address arrives in CF-Connecting-IP). */
-- h.ws = ({ ip = '203.0.113.10', token = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
++ h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
const qs = new URLSearchParams();
if (stream) qs.set('stream', stream);
if (token) qs.set('token', token);
const url = `ws://127.0.0.1:${h.port}/ws/chat${qs.toString() ? `?${qs}` : ''}${query}`;
-- const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin } });
++ const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });
const all = [];
const waiters = [];
ws.on('message', (d) => {
ddiff --git a/test/network-session.test.js b/test/network-session.test.js
iindex e60f626..1d9a84d 100644
---- a/test/network-session.test.js
++++ b/test/network-session.test.js
@@@ -100,4 +100,25 @@ t('/metrics counts where sign-ins were decided', async () => {
for (const via of ['local', 'live', 'rejected']) assert.match(m, new RegExp(`chat_auth_resolutions\\{[^}]*via="${via}"[^}]*\\} [1-9]`), via);
});
++t('a token in the WebSocket URL still works (deprecated, C-05) and is counted', async () => {
++ const before = require('../server/auth/auth').urlTokenUses().jwt;
++ const ws = await h.ws({ token: jwt(claimsFor(alice)) });
++ ws.sendJson({ type: 'join' });
++ const auth = await ws.next((m) => m.type === 'auth');
++ assert.strictEqual(auth.core_username, 'alice');
++ ws.close();
++ assert.strictEqual(require('../server/auth/auth').urlTokenUses().jwt, before + 1);
++ assert.match((await h.http('GET', '/metrics')).text, /chat_ws_url_token_uses\{[^}]*kind="jwt"[^}]*\} [1-9]/);
++});
++
++t('bots authenticate the upgrade with an Authorization header, no token in the URL', async () => {
++ const before = require('../server/auth/auth').urlTokenUses();
++ const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
++ ws.sendJson({ type: 'join' });
++ const auth = await ws.next((m) => m.type === 'auth');
++ assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, 'carol']);
++ ws.close();
++ assert.deepStrictEqual(require('../server/auth/auth').urlTokenUses(), before, 'not counted as a URL token');
++});
++
t.run(async () => { if (h && h.close) await h.close(); });