openvibers/openvibe.chat Commits

Count cookie-reliant sockets and have clients present their token

OP OpenVibers committed 3 Oct 2026 · cfdbc511e2 · 13 files, +335 −44

Asked: T3 leftovers (lane B next) — OpenVibe.Chat: J7 part 1, make the C-06 cookie-removal gate reachable (dossier /home/workstation/openvibe/agents/ds/prep/T3.md, "T3 step 3"). ONE pull request from current origin/main (git fetch first; main is 6d810be: #10 added the chat_ws_cookie_uses{via} counter in e…
Harness job chat-t3-leftovers-lane-b-next-184601 (plan T3).

Changes

ddiff --git a/README.md b/README.md
iindex b7893c3..e7ead3c 100644
---- a/README.md
++++ b/README.md
@@@ -527,6 +527,11 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
Live; WebSocket upgrades use the session cookie or Authorization bearer (Live's legacy `token`
cookie is ignored there); `?token=` on `/ws/chat` is deprecated (C-05), counted in
`chat_ws_url_token_uses`, and stays until Live's bot guide and call client stop sending it.
++ The browser clients present the token in the first message of every socket (`join`,
++ `join_room`, the call's `auth-update`); J7 removes the cookie branch from `extractWsToken` once
++ `chat_ws_cookie_reliant{via="jwt"|"api_token"}` (cookie-authenticated sockets whose first
++ handled auth message lacked a valid token, or call sockets silent past the token grace period)
++ stays 0 for one full release after that ships (`chat_ws_cookie_uses` only counts the cookie seen).
Services use client-credentials tokens for audience
`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,
`chat.presence.read`). Staff gates ask the contracts staff map.
ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex fae1c2f..93aec4b 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -31,8 +31,13 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
> 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.
++> bearer only — is gated on `chat_ws_cookie_reliant`: the browser clients now send their token in the
++> first message of every socket (`join`, `join_room`, the call's `auth-update`), and a socket the
++> cookie signed in whose first handled auth message has no valid token counts there, once; call
++> sockets silent past the token grace period count too. J7 removes the cookie
++> branch from `extractWsToken` once `chat_ws_cookie_reliant` stays 0 for one full release after this
++> ships. `chat_ws_cookie_uses` is only the raw "cookie seen" series: browsers send the cookie on every
++> same-origin upgrade, so it never reads 0.
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/*`,
@@@ -294,4 +299,3 @@ written on PostgreSQL after the switch to Chat's own tables (rooms, calls, the a
state) are not in the file — export them from PostgreSQL first if the rollback comes after real traffic.
Keep the SQLite file read-only for the rollback window; do not delete it until the PostgreSQL deploy has
served a full release.
--
ddiff --git a/public/web/call.js b/public/web/call.js
iindex fcb88bc..7033799 100644
---- a/public/web/call.js
++++ b/public/web/call.js
@@@ -21,6 +21,11 @@
function status(text) { if (statusEl) statusEl.textContent = text || ''; }
function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
++ // The session token (the ov_token cookie is JS-readable), presented in the socket's first message.
++ function sessionToken() {
++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
++ return m ? decodeURIComponent(m[1]) : null;
++ }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function render() {
@@@ -173,6 +178,7 @@
Promise.all([ice, mic]).then(function () {
if (mine !== attempt) return;
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(cfg.call.channel));
++ ws.onopen = function () { var t = sessionToken(); if (t) send({ type: 'auth-update', token: t }); };
ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };
var sock = ws;
ws.onclose = function () { if (sock === ws && mine === attempt) teardown(joined ? 'The call was disconnected. Join again to reconnect.' : 'Could not reach the call.'); };
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex db76500..199b974 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -11,6 +11,11 @@
var latest = Number(cfg.latest) || 0;
var LIVE = 'https://openvibe.live';
++ // The session token (the ov_token cookie is JS-readable), presented in each socket's first message.
++ function sessionToken() {
++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
++ return m ? decodeURIComponent(m[1]) : undefined;
++ }
function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
@@@ -88,7 +93,7 @@
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
ws.onopen = function () {
delay = 1000;
-- ws.send(JSON.stringify({ type: 'join' }));
++ ws.send(JSON.stringify({ type: 'join', token: sessionToken() }));
if (live) live.hidden = false;
if (opened) catchUp();
opened = true;
@@@ -197,7 +202,7 @@
}
function connect() {
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
-- ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room })); if (opened) catchUpRoom(); opened = true; };
++ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room, token: sessionToken() })); if (opened) catchUpRoom(); opened = true; };
ws.onmessage = function (ev) {
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.type === 'room_joined') { if (live) live.hidden = false; }
ddiff --git a/server/app.js b/server/app.js
iindex 84c8c87..9ef5577 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -84,10 +84,15 @@ 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.
++ // /ws/chat upgrades that authenticated from the ov_token cookie, by kind, since start: the raw
++ // "cookie seen" series. Browsers send the cookie on every same-origin upgrade, so it never reads 0.
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 })) });
++ // The C-06 removal gate (J7): /ws/chat and /ws/call sockets the cookie authenticated whose first
++ // accepted auth message did not present a valid token, by via, since start. J7 removes the cookie branch
++ // from extractWsToken once both stay at 0 for one full release.
++ metrics.registry.gauge({ name: 'chat_ws_cookie_reliant', help: 'WebSockets that relied on the ov_token cookie, by token kind', labelNames: ['via'],
++ collect: () => Object.entries(require('./auth/auth').wsCookieReliant()).map(([via, n]) => ({ labels: { via }, 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 2d28903..b175268 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -83,10 +83,14 @@ function extractToken(req, opts) {
* 'ov_token_cookie' or null)
*/
const urlTokenUses = { jwt: 0, api_token: 0 };
--// /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.
++// /ws/chat upgrades authenticated from the Network `ov_token` cookie. This is the raw
++// "cookie seen" series; browsers send the cookie on every same-origin upgrade, so it cannot gate J7.
const wsCookieUses = { jwt: 0, api_token: 0 };
++// /ws/chat and /ws/call sockets that the ov_token cookie authenticated and that did not present the
++// token themselves in an accepted auth message (wsCookieReliance), by token kind: J7 removes the cookie
++// branch from extractWsToken once both stay at 0 for one full release.
++const wsCookieReliant = { jwt: 0, api_token: 0 };
++const tokenKind = (token) => (token.startsWith('hbt_') ? 'api_token' : 'jwt');
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
@@@ -110,7 +114,22 @@ function extractWsToken(req) {
/** 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']++;
++ wsCookieUses[tokenKind(token)]++;
++}
++
++/**
++ * Track one socket whose upgrade the ov_token cookie authenticated (call once the user resolved).
++ * Returns settle(accepted), called for the first auth message this endpoint handles, or with no
++ * argument after a bounded grace period for a silent call socket. A rejected, missing, or mismatched
++ * token leaves the socket dependent on its cookie. Later calls do nothing.
++ */
++function wsCookieReliance(token) {
++ let settled = false;
++ return (accepted = false) => {
++ if (settled) return;
++ settled = true;
++ if (!accepted) wsCookieReliant[tokenKind(token)]++;
++ };
}
/**
@@@ -215,10 +234,12 @@ module.exports = {
extractWsToken,
extractWsTokenFrom,
countWsCookieUse,
++ wsCookieReliance,
authenticateWs,
requireAuth,
optionalAuth,
requireAdmin,
urlTokenUses: () => ({ ...urlTokenUses }),
wsCookieUses: () => ({ ...wsCookieUses }),
++ wsCookieReliant: () => ({ ...wsCookieReliant }),
};
ddiff --git a/server/calls/call-server.js b/server/calls/call-server.js
iindex f15301b..7a81030 100644
---- a/server/calls/call-server.js
++++ b/server/calls/call-server.js
@@@ -42,7 +42,7 @@
const WebSocket = require('ws');
const config = require('../config');
const ctx = require('../live-context');
--const { extractWsToken, authenticateWs } = require('../auth/auth');
++const { extractWsTokenFrom, wsCookieReliance, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const chatServer = require('../chat/chat-server');
const lifecycle = require('./lifecycle');
@@@ -53,6 +53,7 @@ const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userI
const WS_HEARTBEAT_MS = 30000;
const MAX_PARTICIPANTS = 8;
const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never one host filling it
++const FIRST_TOKEN_WAIT_MS = 1000; // how long a refused anonymous socket waits for its auth-update
const KICK_COOLDOWN_MS = 60 * 1000;
const PUBLIC_CHANNEL_ID = 'public';
const MODES = ['mic', 'mic+cam', 'cam+mic'];
@@@ -298,7 +299,7 @@ class CallServer {
async _handleConnection(ws, req) {
const url = new URL(req.url, 'http://localhost');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
-- const token = extractWsToken(req);
++ const { token, from: tokenFrom } = extractWsTokenFrom(req);
// CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js), as on /ws/chat.
const ip = chatServer.getClientIp(req);
@@@ -330,11 +331,12 @@ class CallServer {
const user = await authenticateWs(token).catch(() => null);
if (!user) await chatServer._resolveUnifiedAnonNum(chatServer.normalizeIp(ip)).catch(() => {});
await ctx.warm({ user, streamId: first && first.streamId ? first.streamId : null, ip }).catch(() => {});
-- ws.off('message', onEarlyMessage);
ws.off('close', onEarlyClose);
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
-- await this._admit(ws, { resolvedId, ip, user, stream, early });
++ // Messages keep buffering until _admit attaches the socket's handler: none falls in between.
++ await this._admit(ws, { resolvedId, ip, user, stream, early, stopBuffering: () => ws.off('message', onEarlyMessage),
++ cookieReliance: user && tokenFrom === 'ov_token_cookie' ? wsCookieReliance(token) : null });
})().catch((err) => {
console.warn('[Call] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
@@@ -342,7 +344,7 @@ class CallServer {
}
/** The rest of Live's _handleConnection, once the socket's identity is known. */
-- async _admit(ws, { resolvedId, ip, user, stream, early }) {
++ async _admit(ws, { resolvedId, ip, user, stream, early, stopBuffering, cookieReliance = null }) {
// The channel may have gone while the socket signed in.
const channel = this.channels.get(resolvedId);
if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
@@@ -353,16 +355,24 @@ class CallServer {
if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }
}
-- if (channel.private && !await this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
--
-- // A call room: the room's roles decide who is in and who talks.
-- let roomAccess = null;
-- if (channel.roomSlug) {
-- const room = await this._roomOf(resolvedId);
-- roomAccess = room ? await rooms.access(room, user) : null;
-- if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
-- if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }
++ // Admission counts the token the client presents in its first message (auth-update) too: once
++ // J7 drops the cookie fallback a browser's upgrade is anonymous, and a private call or a
++ // members-only room must still let it in. A socket refused while anonymous waits briefly for
++ // that message.
++ user = await this._presentedUser(early, user);
++ let admission = await this._admission(channel, resolvedId, user);
++ if (admission.error && !user) {
++ // Admission itself may have awaited Live while auth-update arrived in the buffer.
++ user = await this._presentedUser(early, user);
++ if (!user) {
++ await this._firstAuthMessage(ws, FIRST_TOKEN_WAIT_MS);
++ if (ws.readyState !== WebSocket.OPEN) return;
++ user = await this._presentedUser(early, user);
++ }
++ if (user) admission = await this._admission(channel, resolvedId, user);
}
++ if (admission.error) { ws.send(JSON.stringify({ type: 'error', message: admission.error })); ws.close(); return; }
++ const roomAccess = admission.roomAccess;
const listenOnly = !!(roomAccess && !roomAccess.talk);
const peerId = this._generatePeerId();
@@@ -449,21 +459,76 @@ class CallServer {
// One message at a time per socket, in arrival order (auth-update resolves a token first).
let queue = Promise.resolve();
++ const relianceTimer = cookieReliance ? setTimeout(() => {
++ // Give an auth-update already queued for processing a chance to resolve first.
++ queue.finally(() => cookieReliance());
++ }, FIRST_TOKEN_WAIT_MS) : null;
++ if (relianceTimer) ws.once('close', () => { clearTimeout(relianceTimer); queue.finally(() => cookieReliance()); });
const onMessage = (data) => {
// Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in
// a burst; the old 50/s silently dropped some of them and the join stalled.
const now = Date.now();
if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }
if (++clientInfo._msgCount > 200) return;
-- queue = queue.then(async () => await this._handleMessage(ws, JSON.parse(data), resolvedId, peerId))
++ let msg;
++ try { msg = JSON.parse(data); } catch (err) { console.warn('[Call] Message error for peer', peerId, ':', err.message); return; }
++ queue = queue.then(async () => {
++ const accepted = await this._handleMessage(ws, msg, resolvedId, peerId);
++ if (cookieReliance && msg.type === 'auth-update') cookieReliance(accepted);
++ })
.catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });
};
++ stopBuffering();
ws.on('message', onMessage);
ws.on('close', async () => await this._handleDisconnect(ws, resolvedId, peerId));
ws.on('error', async () => await this._handleDisconnect(ws, resolvedId, peerId));
for (const data of early) onMessage(data);
}
++ /** Whether `user` may enter `channel`: { error } or { roomAccess } (null outside call rooms). */
++ async _admission(channel, channelId, user) {
++ if (channel.private && !await this._canSeePrivate(channel, user)) return { error: 'This is a private call' };
++ // A call room: the room's roles decide who is in and who talks.
++ if (!channel.roomSlug) return { roomAccess: null };
++ const room = await this._roomOf(channelId);
++ const roomAccess = room ? await rooms.access(room, user) : null;
++ if (!roomAccess || !roomAccess.read) return { error: 'Voice channel not found' };
++ if (!roomAccess.join) return { error: 'You cannot join this call' };
++ return { roomAccess };
++ }
++
++ /**
++ * The account of the token in the socket's first message when that is an auth-update carrying
++ * one, else `user`. As auth-update: an invalid token changes nothing, and it never replaces the
++ * upgrade's account with another.
++ */
++ async _presentedUser(early, user) {
++ let first = null;
++ for (const data of early) {
++ let msg;
++ try { msg = JSON.parse(data); } catch { continue; }
++ if (msg?.type === 'auth-update') { first = msg; break; }
++ }
++ if (!first || typeof first.token !== 'string' || !first.token.trim()) return user;
++ const next = await authenticateWs(first.token).catch(() => null);
++ if (!next || (user && user.id !== next.id)) return user;
++ await ctx.ensureDecor([next.id]).catch(() => {});
++ return next;
++ }
++
++ /** Resolves on the next auth-update (buffered by then), close, or timeout. */
++ _firstAuthMessage(ws, ms) {
++ return new Promise((resolve) => {
++ const done = () => { clearTimeout(timer); ws.off('message', onMessage); ws.off('close', done); resolve(); };
++ const onMessage = (data) => {
++ try { if (JSON.parse(data)?.type === 'auth-update') done(); } catch { /* wait */ }
++ };
++ const timer = setTimeout(done, ms);
++ ws.on('message', onMessage);
++ ws.on('close', done);
++ });
++ }
++
async _canModerate(user, channelId) {
if (!user) return false;
if (permissions.can(user, 'staff.moderation.calls')) return true;
@@@ -514,6 +579,7 @@ class CallServer {
case 'auth-update': {
const c = room.get(peerId); if (!c) break;
let user = c.user || null;
++ let acceptedToken = false;
if (typeof msg.token === 'string' && msg.token.trim()) {
const nextUser = await authenticateWs(msg.token).catch(() => null);
if (!nextUser) {
@@@ -522,6 +588,7 @@ class CallServer {
console.warn(`[Call] auth-update identity mismatch for peer ${peerId}: keeping existing user ${user.username}, ignoring ${nextUser.username}`);
} else {
user = nextUser;
++ acceptedToken = true;
}
if (user) await ctx.ensureDecor([user.id]).catch(() => {});
// The socket may have left while the token was resolved.
@@@ -550,7 +617,7 @@ class CallServer {
}));
const u = JSON.stringify({ type: 'peer-updated', ...pInfo });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(u); }
-- break;
++ return acceptedToken;
}
case 'force-mute': {
const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 8b20cc8..36801e0 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 { extractWsTokenFrom, countWsCookieUse, authenticateWs } = require('../auth/auth');
++const { extractWsTokenFrom, countWsCookieUse, wsCookieReliance, authenticateWs } = require('../auth/auth');
const session = require('../auth/network-session');
const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
@@@ -296,7 +296,8 @@ class ChatServer {
ws.off('close', onEarlyClose);
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
-- this._registerConnection(ws, req, { ip, streamId, user, early, tokenIat: user ? session.tokenIat(token) : null });
++ this._registerConnection(ws, req, { ip, streamId, user, early, tokenIat: user ? session.tokenIat(token) : null,
++ cookieReliance: user && tokenFrom === 'ov_token_cookie' ? wsCookieReliance(token) : null });
})().catch((err) => {
console.warn('[Chat] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
@@@ -304,7 +305,7 @@ class ChatServer {
}
/** The rest of Live's handleConnection, once the socket's identity is known. */
-- _registerConnection(ws, req, { ip, streamId, user, early, tokenIat = null }) {
++ _registerConnection(ws, req, { ip, streamId, user, early, tokenIat = null, cookieReliance = null }) {
const perIp = this._ipSockets.get(ip) || 0;
if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {
ws.close(4029, 'Too many connections');
@@@ -359,7 +360,7 @@ class ChatServer {
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
return;
}
-- chain = chain.then(async () => await this.handleMessage(ws, msg)).catch((err) => {
++ chain = chain.then(async () => await this.handleMessage(ws, msg, cookieReliance)).catch((err) => {
console.warn('[Chat] message handling failed:', err.message);
});
};
@@@ -386,23 +387,24 @@ class ChatServer {
* the socket already carries is ignored: a socket cannot change identity mid-session.
*/
async _applyJoinToken(client, msg) {
-- if (!msg.token) return;
++ if (!msg.token) return false;
const user = await authenticateWs(msg.token).catch(() => null);
-- if (!user) return;
++ if (!user) return false;
if (client.user && client.user.id !== user.id) {
console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
-- return;
++ return false;
}
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
++ return true;
}
/**
* Handle incoming chat message
*/
-- async handleMessage(ws, msg) {
++ async handleMessage(ws, msg, cookieReliance = null) {
const client = this.clients.get(ws);
if (!client) return;
@@@ -437,7 +439,8 @@ class ChatServer {
case 'join':
case 'join_stream': {
// (Re-)authenticate if a token is provided
-- await this._applyJoinToken(client, msg);
++ const acceptedToken = await this._applyJoinToken(client, msg);
++ if (cookieReliance) cookieReliance(acceptedToken);
const oldStream = client.streamId;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
@@@ -520,7 +523,8 @@ class ChatServer {
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 acceptedToken = await this._applyJoinToken(client, msg);
++ if (cookieReliance) cookieReliance(acceptedToken);
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/server/chat/routes.js b/server/chat/routes.js
iindex 041fb18..ca8ff24 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -276,6 +276,8 @@ async function hydrateReplies(messages) {
}
// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
++// The browser posts over /ws/chat and presents its token in the join; J7 removes the cookie branch
++// from extractWsToken once chat_ws_cookie_reliant{via} stays 0 for one full release after that shipped.
// Every line reaches every global chat socket: a bot posts at most one every 3 s (20 a minute, 300
// an hour), refusals included; the word and spam filters below still decide each line.
router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {
ddiff --git a/test/calls.test.js b/test/calls.test.js
iindex bdd196b..36d3a61 100644
---- a/test/calls.test.js
++++ b/test/calls.test.js
@@@ -18,11 +18,11 @@ let h, config, callServer, lifecycle;
let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;
/** Open /ws/call with a bearer (through nginx: the address in CF-Connecting-IP). */
--function callWs({ channelId, token = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {
++function callWs({ channelId, token = null, cookie = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams();
if (channelId != null) qs.set('channelId', channelId);
-- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}) } });
++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}) } });
const all = [];
const waiters = [];
ws.on('message', (d) => {
@@@ -182,21 +182,74 @@ t('signalling: offer / answer / ice-candidate relayed between two signed-in sock
await bye(a2, b);
});
--t('auth-update: an anonymous socket signs in (sent before its welcome, handled after, in order)', async () => {
++t('auth-update: sent before its welcome, the token admits the socket as that account (then handled, in order)', async () => {
const other = await join({ channelId: 'public', token: bob.token, ip: '198.51.100.2' });
const s = await callWs({ channelId: 'public', ip: '198.51.100.4', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: cat.token }) });
const w = await s.next((m) => m.type === 'welcome');
-- assert.ok(w.participants.find((x) => x.peerId === w.peerId).anonId, 'joined as anon');
++ assert.deepStrictEqual([w.participants.find((x) => x.peerId === w.peerId).username, w.participants.find((x) => x.peerId === w.peerId).anonId], ['cat', null], 'joined as cat');
const self = await s.next((m) => m.type === 'self-updated');
assert.deepStrictEqual([self.participant.username, self.participant.userId, self.participant.anonId, self.canModerate], ['cat', cat.id, null, false]);
-- const upd = await other.next((m) => m.type === 'peer-updated');
-- assert.deepStrictEqual([upd.peerId, upd.username], [w.peerId, 'cat']);
++ const joined = await other.next((m) => m.type === 'peer-joined');
++ assert.deepStrictEqual([joined.peerId, joined.username], [w.peerId, 'cat']);
s.sendJson({ type: 'auth-update', token: dan.token });
const again = await s.next((m) => m.type === 'self-updated');
assert.strictEqual(again.participant.username, 'cat', 'a different account cannot take over the socket');
await bye(s, other);
});
++t('a cookie call socket is cookie-reliant unless its first message is auth-update with the token (the J7 gate)', async () => {
++ const auth = require('../server/auth/auth');
++ const before = auth.wsCookieReliant();
++ // The page's client: auth-update with the token first. Not counted.
++ const s = await callWs({ channelId: 'public', cookie: `ov_token=${cat.token}`, ip: '198.51.100.5', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: cat.token }) });
++ assert.strictEqual((await s.next((m) => m.type === 'self-updated')).participant.username, 'cat');
++ assert.deepStrictEqual(auth.wsCookieReliant(), before);
++ // A client that never presents its token: its first auth-update counts it, once.
++ const old = await callWs({ channelId: 'public', cookie: `ov_token=${dan.token}`, ip: '198.51.100.6' });
++ await old.next((m) => m.type === 'welcome');
++ old.sendJson({ type: 'mute', muted: true });
++ old.sendJson({ type: 'auth-update' });
++ old.sendJson({ type: 'mute', muted: false });
++ await old.next((m) => m.type === 'self-updated');
++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });
++ // A slow admission: the auth-update that arrives while the socket is being admitted still counts.
++ const canModerate = callServer._canModerate;
++ callServer._canModerate = async (...a) => { await h.sleep(300); return canModerate.apply(callServer, a); };
++ let slow;
++ try {
++ slow = await callWs({ channelId: 'public', cookie: `ov_token=${eve.token}`, ip: '198.51.100.7', onOpen: (ws) => setTimeout(() => ws.sendJson({ type: 'auth-update', token: eve.token }), 100) });
++ await slow.next((m) => m.type === 'welcome');
++ assert.strictEqual((await slow.next((m) => m.type === 'self-updated')).participant.username, 'eve');
++ } finally { callServer._canModerate = canModerate; }
++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });
++ await bye(s, old, slow);
++});
++
++t('call reliance waits for auth-update and counts silent or rejected-token sockets', async () => {
++ const auth = require('../server/auth/auth');
++ const before = auth.wsCookieReliant().jwt;
++ const wrongType = await callWs({ channelId: 'public', cookie: `ov_token=${cat.token}`, ip: '198.51.100.10', onOpen: (ws) => {
++ ws.sendJson({ type: 'join', token: cat.token });
++ ws.sendJson({ type: 'auth-update', token: cat.token });
++ } });
++ await wrongType.next((m) => m.type === 'self-updated');
++ assert.strictEqual(auth.wsCookieReliant().jwt, before, 'a join frame cannot settle call auth');
++ await bye(wrongType);
++
++ const rejected = await callWs({ channelId: 'public', cookie: `ov_token=${dan.token}`, ip: '198.51.100.11', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: 'invalid-token' }) });
++ assert.strictEqual((await rejected.next((m) => m.type === 'self-updated')).participant.username, 'dan');
++ assert.strictEqual(auth.wsCookieReliant().jwt, before + 1);
++ await bye(rejected);
++
++ const silent = await callWs({ channelId: 'public', cookie: `ov_token=${eve.token}`, ip: '198.51.100.12' });
++ await silent.next((m) => m.type === 'welcome');
++ await until(() => auth.wsCookieReliant().jwt === before + 2, 2000);
++ silent.sendJson({ type: 'auth-update', token: eve.token });
++ await silent.next((m) => m.type === 'self-updated');
++ assert.strictEqual(auth.wsCookieReliant().jwt, before + 2, 'a later message does not double count');
++ await bye(silent);
++});
++
t('limits: 3 sockets per address, 8 per channel', async () => {
const sockets = [];
for (let i = 0; i < 3; i++) sockets.push(await join({ channelId: 'public', ip: '192.0.2.10' }));
@@@ -332,8 +385,16 @@ t('call-user: ringing → accepted → active → ended; private call; Live aske
ring = await lifecycle.get(ring.id);
assert.deepStrictEqual([ring.state, ring.end_reason], ['active', null]);
assert.ok(ring.answered_at >= ring.started_at);
-- const c = await join({ channelId: annCall, token: cat.token, ip: '198.51.100.43' });
++ // No cookie and no bearer on the upgrade (a browser after J7): the token in the first message,
++ // sent a moment after the socket opened, is what admits the invitee.
++ const admission = callServer._admission;
++ callServer._admission = async (...args) => { if (!args[2]) await h.sleep(300); return admission.apply(callServer, args); };
++ let c;
++ try {
++ c = await join({ channelId: annCall, ip: '198.51.100.43', onOpen: (ws) => setTimeout(() => ws.sendJson({ type: 'auth-update', token: cat.token }), 100) });
++ } finally { callServer._admission = admission; }
assert.ok(c.welcome, c.error);
++ assert.strictEqual(c.welcome.participants.find((p) => p.peerId === c.welcome.peerId).username, 'cat');
await h.sleep(1400);
assert.strictEqual((await lifecycle.get(ring.id)).state, 'active', 'an answered call is not missed');
await bye(a, c, annChat, catChat, anon, outsider);
ddiff --git a/test/network-session.test.js b/test/network-session.test.js
iindex 1ecaf26..ff57792 100644
---- a/test/network-session.test.js
++++ b/test/network-session.test.js
@@@ -150,4 +150,58 @@ t('bots authenticate the upgrade with an Authorization header, no token in the U
assert.deepStrictEqual(require('../server/auth/auth').wsCookieUses(), cookieBefore, 'a bearer upgrade is not counted as a cookie use');
});
++t('a cookie socket that presents its token in the join is not cookie-reliant (the J7 gate)', async () => {
++ const auth = require('../server/auth/auth');
++ const token = jwt(claimsFor(alice));
++ const before = auth.wsCookieReliant();
++ const ws = await h.ws({ cookie: `ov_token=${token}` });
++ ws.sendJson({ type: 'join', token });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
++ ws.sendJson({ type: 'join' }); // later messages never count: the first one settled it
++ await ws.next((m) => m.type === 'auth');
++ ws.close();
++ assert.deepStrictEqual(auth.wsCookieReliant(), before);
++});
++
++t('a cookie socket whose first join presents no token is counted once (the J7 gate)', async () => {
++ const auth = require('../server/auth/auth');
++ const before = auth.wsCookieReliant();
++ const usesBefore = 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.sendJson({ type: 'join' });
++ await ws.next((m) => m.type === 'auth');
++ ws.sendJson({ type: 'join_room', room: 'nowhere' });
++ await ws.next((m) => m.type === 'room_error');
++ ws.close();
++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 }, 'once per socket');
++ assert.strictEqual(auth.wsCookieUses().jwt, usesBefore + 1, 'the raw series still counts the cookie seen');
++ assert.match((await h.http('GET', '/metrics')).text, /chat_ws_cookie_reliant\{[^}]*via="jwt"[^}]*\} [1-9]/);
++ // A bearer upgrade without a token in the join did not rely on the cookie.
++ const bot = await h.ws({ bearer: jwt(claimsFor(carol)) });
++ bot.sendJson({ type: 'join' });
++ await bot.next((m) => m.type === 'auth');
++ bot.close();
++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });
++});
++
++t('cookie reliance ignores unrelated frames but counts a rejected join token', async () => {
++ const auth = require('../server/auth/auth');
++ const token = jwt(claimsFor(alice));
++ const before = auth.wsCookieReliant().jwt;
++ const ws = await h.ws({ cookie: `ov_token=${token}` });
++ ws.sendJson({ type: 'ignored', token });
++ ws.sendJson({ type: 'join', token });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
++ assert.strictEqual(auth.wsCookieReliant().jwt, before);
++ ws.close();
++
++ const rejected = await h.ws({ cookie: `ov_token=${token}` });
++ rejected.sendJson({ type: 'join', token: 'invalid-token' });
++ assert.strictEqual((await rejected.next((m) => m.type === 'auth')).core_username, 'alice');
++ rejected.close();
++ assert.strictEqual(auth.wsCookieReliant().jwt, before + 1);
++});
++
t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/room-kinds.test.js b/test/room-kinds.test.js
iindex f130cb1..3e64ace 100644
---- a/test/room-kinds.test.js
++++ b/test/room-kinds.test.js
@@@ -334,6 +334,26 @@ t('chat sockets following a room learn their new role at once, or leave', async
ws.close();
});
++t('join_room: a token re-authenticates an anonymous socket, never switches a signed-in one (private rooms: members only)', async () => {
++ assert.strictEqual((await api('POST', '/', own, { name: 'Inner Circle', kind: 'community', visibility: 'private' })).body.room.slug, 'inner-circle');
++ // A guest socket presents the owner's token in join_room: signed in, and the private room opens.
++ const guest = await h.ws({ ip: '203.0.113.41' });
++ guest.sendJson({ type: 'join_room', room: 'inner-circle', token: own.token });
++ assert.strictEqual((await guest.next((m) => m.type === 'room_joined')).role, 'owner');
++ guest.close();
++ // An outsider's socket presents the owner's token: ignored, the room stays missing, still the outsider.
++ const ws = await h.ws({ token: out.token, ip: '203.0.113.42' });
++ ws.sendJson({ type: 'join_room', room: 'inner-circle', token: own.token });
++ assert.strictEqual((await ws.next((m) => m.type === 'room_error')).code, 'rooms.not_found');
++ ws.sendJson({ type: 'join' });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'outsider1', 'the socket keeps its identity');
++ // An invalid token is refused as in join: the guest stays a guest.
++ const anon = await h.ws({ ip: '203.0.113.43' });
++ anon.sendJson({ type: 'join_room', room: 'inner-circle', token: 'forged.or.expired' });
++ assert.strictEqual((await anon.next((m) => m.type === 'room_error')).code, 'rooms.not_found');
++ ws.close(); anon.close();
++});
++
t('ICE servers for calls: STUN, and TURN only with credentials (short-lived with TURN_AUTH_SECRET)', async () => {
const crypto = require('crypto');
const turn = require('../server/net/turn');
ddiff --git a/test/web.test.js b/test/web.test.js
iindex 80b3955..30dc388 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -181,4 +181,41 @@ t('unknown pages: a browser gets the site\'s HTML 404 page, the API and other cl
assert.match(post.headers.get('content-type'), /^application\/json/, 'a form post to nowhere keeps JSON');
});
++// Run a page script (public/web/) in a sandbox with fake elements and sockets: the sockets it opened
++// (each with what it sent) and the listeners it added to elements ('<id>:<event>').
++const runClient = (file, page, docCookie) => {
++ const sockets = [];
++ const listeners = {};
++ const element = (id) => ({ hidden: false, textContent: '', appendChild() {}, setAttribute() {}, removeAttribute() {}, querySelector: () => null, addEventListener: (ev, fn) => { listeners[`${id}:${ev}`] = fn; } });
++ function WebSocket(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); }
++ WebSocket.prototype.send = function (d) { this.sent.push(JSON.parse(d)); };
++ WebSocket.prototype.close = function () {};
++ require('vm').runInNewContext(require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', file), 'utf8'), {
++ window: { __OV_PAGE: { chat: page }, RTCPeerConnection: function () {}, WebSocket, addEventListener() {} },
++ WebSocket, navigator: {}, location: { protocol: 'https:', host: 'openvibe.chat' },
++ document: { cookie: docCookie, hidden: false, getElementById: element, createElement: () => element(null), addEventListener() {} },
++ fetch: async () => ({ ok: false }), setTimeout: () => 0, setInterval: () => 0,
++ });
++ return { sockets, listeners };
++};
++
++t('the page scripts present the session token in the first message of every socket (C-06)', async () => {
++ const signedIn = `ov_sso_hint=account; ov_token=${encodeURIComponent(alice.token)}`;
++ for (const [page, first] of [[{ view: 'global' }, { type: 'join' }], [{ view: 'room', room: 'lobby' }, { type: 'join_room', room: 'lobby' }]]) {
++ for (const [docCookie, token] of [[signedIn, alice.token], ['ov_sso_hint=guest', undefined]]) {
++ const { sockets } = runClient('chat.js', page, docCookie);
++ sockets[0].onopen();
++ assert.deepStrictEqual(sockets[0].sent[0], { ...first, ...(token ? { token } : {}) }, `${page.view}, ${token ? 'signed in' : 'guest'}`);
++ }
++ }
++ // The call socket opens on Join; a member's first message is auth-update with the token.
++ for (const [docCookie, sent] of [[signedIn, [{ type: 'auth-update', token: alice.token }]], ['', []]]) {
++ const { sockets, listeners } = runClient('call.js', { view: 'room', room: 'lobby', call: { join: true, talk: false, channel: 'room-lobby' } }, docCookie);
++ listeners['oc-call-join:click']();
++ await new Promise((r) => setImmediate(r));
++ sockets[0].onopen();
++ assert.deepStrictEqual(sockets[0].sent, sent, docCookie ? 'call, signed in' : 'call, guest');
++ }
++});
++
t.run(async () => { if (h && h.close) await h.close(); });