openvibers/openvibe.chat Commits

Instrument the WS cookie fallback and authenticate joinroom tokens (#10)

OP OpenVibers committed 3 Oct 2026 · 68f4f201c1 · 6 files, +108 −27

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high) · 1 fix round

https://github.com/OpenVibers/OpenVibe.Chat/pull/10

Changes

ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex bc4a32e..fae1c2f 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -27,6 +27,13 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
| `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL), `GET queue`, `POST queue/skip`, `POST queue/clear`, `POST queue/:id/report` (the TTS and sound queue) |
| `= /api/sounds` and `^~ /api/sounds/` | `POST /api/sounds` (upload), `GET channel/:userId`, `GET all/:streamId`, `DELETE :id`, `PATCH command`, `GET file/:filename`, `GET alert/mine`, `POST/DELETE alert/:kind` |
++> **WebSocket credentials.** C-05 shipped in `2dcca25`: a `/ws/chat` upgrade authenticates with the
++> session cookie or an `Authorization` bearer, and Live's legacy `token` cookie is ignored there. The
++> deprecated `?token=` query param is still honoured for Live's bot guide and call client, counted in
++> `chat_ws_url_token_uses`. C-06 — dropping the `ov_token` cookie fallback so upgrades authenticate by
++> bearer only — is gated on `chat_ws_cookie_uses` reading 0 for a full release together with the
++> browser client sending its token in the join message.
++
Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),
`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,
`/ws/call` (until the calls cutover, `docs/calls-cutover.md`), `/ws/broadcast`, `/ws/control`, and
ddiff --git a/server/app.js b/server/app.js
iindex 963e2b5..84c8c87 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -84,6 +84,10 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
// 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 })) });
++ // /ws/chat upgrades that authenticated from the ov_token cookie (the C-06 removal gate), by kind,
++ // since start: the browser's cookie fallback goes once both stay at 0 for a release.
++ metrics.registry.gauge({ name: 'chat_ws_cookie_uses', help: '/ws/chat upgrades whose ov_token cookie resolved to an account, by token kind', labelNames: ['kind'],
++ collect: () => Object.entries(require('./auth/auth').wsCookieUses()).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 e3a39bd..2d28903 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -34,19 +34,21 @@ function apiTokenAllows(req, scopes) {
}
/**
-- * Extract the token from Authorization header or cookie
++ * Extract the token and where it came from (Bearer header, the Network `ov_token` cookie, or Live's
++ * legacy `token` cookie). Internal: extractToken returns the token alone, while extractWsToken also
++ * needs the source so it can count the cookie fallback (C-06).
*/
--function extractToken(req, { legacyCookie = true } = {}) {
++function extractTokenFrom(req, { legacyCookie = true } = {}) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
-- return authHeader.slice(7);
++ return { token: authHeader.slice(7), from: 'header' };
}
// The Network session cookie is shared with the browser. REST still accepts Live's legacy
// 'token' cookie (as Live does); WebSocket upgrades ignore it (legacyCookie: false).
if (req.cookies) {
-- if (req.cookies.ov_token) return req.cookies.ov_token;
-- if (legacyCookie && req.cookies.token) return req.cookies.token;
++ if (req.cookies.ov_token) return { token: req.cookies.ov_token, from: 'ov_token_cookie' };
++ if (legacyCookie && req.cookies.token) return { token: req.cookies.token, from: 'legacy_cookie' };
}
// Raw Node/WebSocket upgrade requests do not go through cookie-parser,
@@@ -62,18 +64,30 @@ function extractToken(req, { legacyCookie = true } = {}) {
if (!key) continue;
try { parsed[key] = decodeURIComponent(value); } catch { parsed[key] = value; }
}
-- if (parsed.ov_token) return parsed.ov_token;
-- if (legacyCookie && parsed.token) return parsed.token;
++ if (parsed.ov_token) return { token: parsed.ov_token, from: 'ov_token_cookie' };
++ if (legacyCookie && parsed.token) return { token: parsed.token, from: 'legacy_cookie' };
}
-- return null;
++ return { token: null, from: null };
++}
++
++/**
++ * Extract the token from Authorization header or cookie
++ */
++function extractToken(req, opts) {
++ return extractTokenFrom(req, opts).token;
}
/**
-- * Extract the token for a WebSocket upgrade request
++ * Extract the token for a WebSocket upgrade request, and where it came from ('url', 'header',
++ * 'ov_token_cookie' or null)
*/
const urlTokenUses = { jwt: 0, api_token: 0 };
--function extractWsToken(req) {
++// /ws/chat upgrades that authenticated from the Network `ov_token` cookie (the C-06 removal gate),
++// by token kind, counted once the token resolved to an account (countWsCookieUse): when both stay
++// at 0 for a release the cookie fallback can be deleted.
++const wsCookieUses = { jwt: 0, api_token: 0 };
++function extractWsTokenFrom(req) {
// 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 Live's bot guide and call client still send it.
@@@ -82,12 +96,21 @@ function extractWsToken(req) {
const queryToken = url.searchParams.get('token');
if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') {
urlTokenUses[queryToken.startsWith('hbt_') ? 'api_token' : 'jwt']++;
-- return queryToken;
++ return { token: queryToken, from: 'url' };
}
} catch { /* fall through */ }
// Live's legacy `token` cookie rides along with ov_token on every browser upgrade: ignored.
-- return extractToken(req, { legacyCookie: false });
++ return extractTokenFrom(req, { legacyCookie: false });
++}
++
++function extractWsToken(req) {
++ return extractWsTokenFrom(req).token;
++}
++
++/** Count a /ws/chat upgrade that the ov_token cookie authenticated (call once the user resolved). */
++function countWsCookieUse(token) {
++ wsCookieUses[token.startsWith('hbt_') ? 'api_token' : 'jwt']++;
}
/**
@@@ -190,9 +213,12 @@ module.exports = {
extractToken,
requestUser,
extractWsToken,
++ extractWsTokenFrom,
++ countWsCookieUse,
authenticateWs,
requireAuth,
optionalAuth,
requireAdmin,
urlTokenUses: () => ({ ...urlTokenUses }),
++ wsCookieUses: () => ({ ...wsCookieUses }),
};
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 22d42d4..8b20cc8 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -22,7 +22,7 @@ const fs = require('fs');
const crypto = require('crypto');
const db = require('../db/database');
const ctx = require('../live-context');
--const { extractWsToken, authenticateWs } = require('../auth/auth');
++const { extractWsTokenFrom, countWsCookieUse, authenticateWs } = require('../auth/auth');
const session = require('../auth/network-session');
const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
@@@ -268,7 +268,7 @@ class ChatServer {
}
const urlParams = new URL(req.url, 'http://localhost').searchParams;
-- const token = extractWsToken(req);
++ const { token, from: tokenFrom } = extractWsTokenFrom(req);
const streamId = parseInt(urlParams.get('stream')) || null;
ws.isAlive = true;
@@@ -289,6 +289,7 @@ class ChatServer {
(async () => {
// Authenticate (optional — anon if no token)
const user = await authenticateWs(token).catch(() => null);
++ if (user && tokenFrom === 'ov_token_cookie') countWsCookieUse(token);
if (!user) await this._resolveUnifiedAnonNum(this.normalizeIp(ip)).catch(() => {});
await ctx.warm({ user, streamId, ip }).catch(() => {});
ws.off('message', onEarlyMessage);
@@@ -379,6 +380,25 @@ class ChatServer {
});
}
++ /**
++ * Re-authenticate a socket from a token in a join message, so an anonymous or guest socket can
++ * become the signed-in member without reconnecting. A token for a different account than the one
++ * the socket already carries is ignored: a socket cannot change identity mid-session.
++ */
++ async _applyJoinToken(client, msg) {
++ if (!msg.token) return;
++ const user = await authenticateWs(msg.token).catch(() => null);
++ if (!user) return;
++ if (client.user && client.user.id !== user.id) {
++ console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
++ return;
++ }
++ client.user = user;
++ await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
++ client.tokenIat = session.tokenIat(msg.token);
++ client.anonId = null; // no longer anonymous
++ }
++
/**
* Handle incoming chat message
*/
@@@ -417,19 +437,7 @@ class ChatServer {
case 'join':
case 'join_stream': {
// (Re-)authenticate if a token is provided
-- if (msg.token) {
-- const user = await authenticateWs(msg.token).catch(() => null);
-- if (user) {
-- if (!client.user || client.user.id === user.id) {
-- client.user = user;
-- await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
-- client.tokenIat = session.tokenIat(msg.token);
-- client.anonId = null; // no longer anonymous
-- } else {
-- console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
-- }
-- }
-- }
++ await this._applyJoinToken(client, msg);
const oldStream = client.streamId;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
@@@ -510,6 +518,9 @@ class ChatServer {
break;
// Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.
case 'join_room': {
++ // A room socket can still arrive anonymous or as a guest: authenticate a token the
++ // same way as join, so room access is resolved with the member's identity.
++ await this._applyJoinToken(client, msg);
const rooms = require('../rooms/rooms');
const room = await rooms.bySlug(msg.room);
const a = room ? await rooms.access(room, client.user) : null;
ddiff --git a/test/network-session.test.js b/test/network-session.test.js
iindex f670b8f..73f8af1 100644
---- a/test/network-session.test.js
++++ b/test/network-session.test.js
@@@ -119,6 +119,25 @@ t('WebSocket upgrades ignore the legacy token cookie', async () => {
ws.close();
});
++t('an upgrade from the ov_token cookie is counted (C-06 gate)', async () => {
++ const before = require('../server/auth/auth').wsCookieUses().jwt;
++ const ws = await h.ws({ cookie: `ov_token=${jwt(claimsFor(alice))}` });
++ ws.sendJson({ type: 'join' });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
++ ws.close();
++ assert.strictEqual(require('../server/auth/auth').wsCookieUses().jwt, before + 1);
++ assert.match((await h.http('GET', '/metrics')).text, /chat_ws_cookie_uses\{[^}]*kind="jwt"[^}]*\} [1-9]/);
++});
++
++t('an ov_token cookie that does not resolve to an account is not counted', async () => {
++ const before = require('../server/auth/auth').wsCookieUses().jwt;
++ const ws = await h.ws({ cookie: `ov_token=${jwt(claimsFor(alice), crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey)}` });
++ ws.sendJson({ type: 'join' });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).authenticated, false);
++ ws.close();
++ assert.strictEqual(require('../server/auth/auth').wsCookieUses().jwt, before);
++});
++
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)) });
ddiff --git a/test/rooms.test.js b/test/rooms.test.js
iindex 776491a..51efb05 100644
---- a/test/rooms.test.js
++++ b/test/rooms.test.js
@@@ -92,6 +92,20 @@ t('moderation: delete and block, owner-only mods, logged; live delivery only to
for (const ws of [annWs, bobWs, outsider]) ws.close();
});
++t('join_room authenticates a token in the message; a different account\'s token is refused', async () => {
++ // secret-club is private: only its members (ann, bob) can open it.
++ const anon = await h.ws({ ip: '203.0.113.31' });
++ anon.sendJson({ type: 'join_room', room: 'secret-club' });
++ assert.strictEqual((await anon.next((m) => m.type === 'room_joined' || m.type === 'room_error')).type, 'room_error', 'anonymous: no access');
++ anon.sendJson({ type: 'join_room', room: 'secret-club', token: bob.token });
++ assert.strictEqual((await anon.next((m) => m.type === 'room_joined')).role, 'member', 'the token makes the socket bob');
++ const catWs = await h.ws({ ip: '203.0.113.32', token: cat.token });
++ catWs.sendJson({ type: 'join_room', room: 'secret-club', token: bob.token });
++ const r = await catWs.next((m) => m.type === 'room_joined' || m.type === 'room_error');
++ assert.deepStrictEqual([r.type, r.code], ['room_error', 'rooms.not_found'], 'a socket cannot switch to another account');
++ for (const ws of [anon, catWs]) ws.close();
++});
++
t('slow mode and site bans', async () => {
let r = await api('PATCH', '/night-owls', ann, { slow_seconds: 30 });
assert.strictEqual(r.body.room.slow_seconds, 30);