openvibers/openvibe.chat Commits
Instrument the WS cookie fallback and authenticate joinroom tokens (#10)
Changes
@@@ -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@@@ -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) {@@@ -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 }),};@@@ -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;@@@ -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)) });@@@ -92,6 +92,20 @@ t('moderation: delete and block, owner-only mods, logged; live delivery only tofor (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);
@@ -27,6 +27,13 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
@@ -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/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` |
| `= /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),
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/*`,
`/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
`/ws/call` (until the calls cutover, `docs/calls-cutover.md`), `/ws/broadcast`, `/ws/control`, and
@@ -84,6 +84,10 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -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.
// 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'],
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 })) });
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). */
/** Exact allow-list only (no subdomain wildcard). */
function isAllowedOrigin(origin) {
function isAllowedOrigin(origin) {
@@ -34,19 +34,21 @@ function apiTokenAllows(req, scopes) {
@@ -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;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
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
// 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).
// 'token' cookie (as Live does); WebSocket upgrades ignore it (legacyCookie: false).
if (req.cookies) {
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,
// Raw Node/WebSocket upgrade requests do not go through cookie-parser,
@@ -62,18 +64,30 @@ function extractToken(req, { legacyCookie = true } = {}) {
@@ -62,18 +64,30 @@ function extractToken(req, { legacyCookie = true } = {}) {
if (!key) continue;
if (!key) continue;
try { parsed[key] = decodeURIComponent(value); } catch { parsed[key] = value; }
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 };
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
// 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
// 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.
// 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) {
@@ -82,12 +96,21 @@ function extractWsToken(req) {
const queryToken = url.searchParams.get('token');
const queryToken = url.searchParams.get('token');
if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') {
if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') {
urlTokenUses[queryToken.startsWith('hbt_') ? 'api_token' : 'jwt']++;
urlTokenUses[queryToken.startsWith('hbt_') ? 'api_token' : 'jwt']++;
- return queryToken;
+ return { token: queryToken, from: 'url' };
}
}
} catch { /* fall through */ }
} catch { /* fall through */ }
// Live's legacy `token` cookie rides along with ov_token on every browser upgrade: ignored.
// 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 = {
@@ -190,9 +213,12 @@ module.exports = {
extractToken,
extractToken,
requestUser,
requestUser,
extractWsToken,
extractWsToken,
+ extractWsTokenFrom,
+ countWsCookieUse,
authenticateWs,
authenticateWs,
requireAuth,
requireAuth,
optionalAuth,
optionalAuth,
requireAdmin,
requireAdmin,
urlTokenUses: () => ({ ...urlTokenUses }),
urlTokenUses: () => ({ ...urlTokenUses }),
+ wsCookieUses: () => ({ ...wsCookieUses }),
};
};
@@ -22,7 +22,7 @@ const fs = require('fs');
@@ -22,7 +22,7 @@ const fs = require('fs');
const crypto = require('crypto');
const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const ctx = require('../live-context');
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 session = require('../auth/network-session');
const { clientIpOf } = require('../net/client-ip');
const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
@@ -268,7 +268,7 @@ class ChatServer {
@@ -268,7 +268,7 @@ class ChatServer {
}
}
const urlParams = new URL(req.url, 'http://localhost').searchParams;
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;
const streamId = parseInt(urlParams.get('stream')) || null;
ws.isAlive = true;
ws.isAlive = true;
@@ -289,6 +289,7 @@ class ChatServer {
@@ -289,6 +289,7 @@ class ChatServer {
(async () => {
(async () => {
// Authenticate (optional — anon if no token)
// Authenticate (optional — anon if no token)
const user = await authenticateWs(token).catch(() => null);
const user = await authenticateWs(token).catch(() => null);
+ if (user && tokenFrom === 'ov_token_cookie') countWsCookieUse(token);
if (!user) await this._resolveUnifiedAnonNum(this.normalizeIp(ip)).catch(() => {});
if (!user) await this._resolveUnifiedAnonNum(this.normalizeIp(ip)).catch(() => {});
await ctx.warm({ user, streamId, ip }).catch(() => {});
await ctx.warm({ user, streamId, ip }).catch(() => {});
ws.off('message', onEarlyMessage);
ws.off('message', onEarlyMessage);
@@ -379,6 +380,25 @@ class ChatServer {
@@ -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
* Handle incoming chat message
*/
*/
@@ -417,19 +437,7 @@ class ChatServer {
@@ -417,19 +437,7 @@ class ChatServer {
case 'join':
case 'join':
case 'join_stream': {
case 'join_stream': {
// (Re-)authenticate if a token is provided
// (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 oldStream = client.streamId;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
@@ -510,6 +518,9 @@ class ChatServer {
@@ -510,6 +518,9 @@ class ChatServer {
break;
break;
// Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.
// Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.
case 'join_room': {
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 rooms = require('../rooms/rooms');
const room = await rooms.bySlug(msg.room);
const room = await rooms.bySlug(msg.room);
const a = room ? await rooms.access(room, client.user) : null;
const a = room ? await rooms.access(room, client.user) : null;
@@ -119,6 +119,25 @@ t('WebSocket upgrades ignore the legacy token cookie', async () => {
@@ -119,6 +119,25 @@ t('WebSocket upgrades ignore the legacy token cookie', async () => {
ws.close();
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 () => {
t('bots authenticate the upgrade with an Authorization header, no token in the URL', async () => {
const before = require('../server/auth/auth').urlTokenUses();
const before = require('../server/auth/auth').urlTokenUses();
const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
@@ -92,6 +92,20 @@ t('moderation: delete and block, owner-only mods, logged; live delivery only to
@@ -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();
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 () => {
t('slow mode and site bans', async () => {
let r = await api('PATCH', '/night-owls', ann, { slow_seconds: 30 });
let r = await api('PATCH', '/night-owls', ann, { slow_seconds: 30 });
assert.strictEqual(r.body.room.slow_seconds, 30);
assert.strictEqual(r.body.room.slow_seconds, 30);