openvibers/openvibe.chat Commits

Reject legacy WebSocket credentials and use session or bearer auth (#7)

OP OpenVibers committed 2 Oct 2026 · 2dcca25142 · 9 files, +33 −28

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

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

Changes

ddiff --git a/README.md b/README.md
iindex 8b53b29..b7893c3 100644
---- a/README.md
++++ b/README.md
@@@ -524,7 +524,10 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
- **Auth.** People sign in with a Network session JWT, verified here with the Network's key (tokens
issued before a subject's `token_valid_after` are refused), or an `hbt_` API token resolved through
-- Live; `?token=` on `/ws/chat` is deprecated. Services use client-credentials tokens for audience
++ 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.
++ 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.
- **Private data.** DMs are delivered only to participants, and private rooms only to members
ddiff --git a/docs/calls-cutover.md b/docs/calls-cutover.md
iindex 85276e6..039af8b 100644
---- a/docs/calls-cutover.md
++++ b/docs/calls-cutover.md
@@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/calls/openvibe.live-calls.locations.conf`, includ
| nginx location | Chat serves (`server/calls/`) |
| --- | --- |
--| `^~ /ws/call` | the call WebSocket (`?channelId=` — or the legacy `?streamId=` / a bare stream number — and `?token=`; every message type unchanged) |
++| `^~ /ws/call` | the call WebSocket (`?channelId=` — or the legacy `?streamId=` / a bare stream number — with session cookie or Authorization bearer, or the deprecated `?token=`; every message type unchanged) |
| `^~ /api/streams/voice-channels` | `GET/POST /api/streams/voice-channels`, `GET/DELETE /api/streams/voice-channels/:channelId`, `POST /api/streams/voice-channels/call-user`, `POST /api/streams/voice-channels/call-user/respond` |
| `~ ^/api/streams/[0-9]+/call$` | `GET/PUT /api/streams/:id/call` |
ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex 0e7e3e9..644ee10 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
| nginx location | Chat serves |
| --- | --- |
--| `^~ /ws/chat` | the chat WebSocket (`?stream=`, `?token=`; protocol unchanged) |
++| `^~ /ws/chat` | the chat WebSocket (`?stream=`; session cookie or Authorization bearer; the deprecated `?token=` still works; protocol unchanged) |
| `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` |
| `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` |
| `^~ /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) |
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex a6c0b9f..e3a39bd 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -1,7 +1,7 @@
/**
* OpenVibe.Chat — request authentication.
*
-- * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token / token cookie,
++ * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token cookie / Live's legacy token cookie (not on WebSockets),
* or an hbt_ API token) with the same rules, resolved by Live through live-context.authenticate()
* — Live owns the account links, auto-creates first-time accounts and knows API-token scopes.
* Moved from OpenVibe.Live server/auth/auth.js; the middleware are async now because resolution
@@@ -36,16 +36,17 @@ function apiTokenAllows(req, scopes) {
/**
* Extract the token from Authorization header or cookie
*/
--function extractToken(req) {
++function extractToken(req, { legacyCookie = true } = {}) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
-- // Check both cookie names: 'token' (legacy openvibelive) and 'ov_token' (shared network)
++ // 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 (req.cookies.token) return req.cookies.token;
++ if (legacyCookie && req.cookies.token) return req.cookies.token;
}
// Raw Node/WebSocket upgrade requests do not go through cookie-parser,
@@@ -62,7 +63,7 @@ function extractToken(req) {
try { parsed[key] = decodeURIComponent(value); } catch { parsed[key] = value; }
}
if (parsed.ov_token) return parsed.ov_token;
-- if (parsed.token) return parsed.token;
++ if (legacyCookie && parsed.token) return parsed.token;
}
return null;
@@@ -75,7 +76,7 @@ const urlTokenUses = { jwt: 0, api_token: 0 };
function extractWsToken(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 bots move off it.
++ // counted, /metrics chat_ws_url_token_uses) while Live's bot guide and call client still send it.
try {
const url = new URL(req.url || '/', 'http://localhost');
const queryToken = url.searchParams.get('token');
@@@ -85,12 +86,8 @@ function extractWsToken(req) {
}
} catch { /* fall through */ }
-- // Fall back to cookie / Authorization header
-- const direct = extractToken(req);
-- if (direct) return direct;
--
-- // Legacy: req.query fallback for non-URL parse environments
-- return (req.query && req.query.token) || null;
++ // Live's legacy `token` cookie rides along with ov_token on every browser upgrade: ignored.
++ return extractToken(req, { legacyCookie: false });
}
/**
ddiff --git a/server/calls/call-server.js b/server/calls/call-server.js
iindex 34a69d1..f15301b 100644
---- a/server/calls/call-server.js
++++ b/server/calls/call-server.js
@@@ -18,7 +18,7 @@
* temporary channels. Stream-linked channels are auto-created when a
* streamer enables voice on their stream (Live asks: POST /internal/calls/stream-channel).
*
-- * WebSocket path: /ws/call?channelId=<id>&token=X
++ * WebSocket path: /ws/call?channelId=<id> (session cookie or Authorization bearer)
*
* Channel modes:
* 'mic' — Microphone only
ddiff --git a/test/calls.test.js b/test/calls.test.js
iindex 04c8a4b..bdd196b 100644
---- a/test/calls.test.js
++++ b/test/calls.test.js
@@@ -17,13 +17,12 @@ const t = suite('calls');
let h, config, callServer, lifecycle;
let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;
--/** Open /ws/call as a browser would (through nginx: the address in CF-Connecting-IP). */
++/** 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 } = {}) {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams();
if (channelId != null) qs.set('channelId', channelId);
-- if (token) qs.set('token', token);
-- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin } });
++ 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 all = [];
const waiters = [];
ws.on('message', (d) => {
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 0e7ee44..5686992 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -361,13 +361,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, bearer = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
++ /** Open /ws/chat with a bearer or session cookie (through nginx: the address arrives in CF-Connecting-IP). */
++ h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, cookie = 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, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });
++ const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer || token ? { authorization: `Bearer ${bearer || token}` } : {}), ...(cookie ? { cookie } : {}) } });
const all = [];
const waiters = [];
ws.on('message', (d) => {
ddiff --git a/test/network-session.test.js b/test/network-session.test.js
iindex 60b5f6f..f670b8f 100644
---- a/test/network-session.test.js
++++ b/test/network-session.test.js
@@@ -102,15 +102,23 @@ t('/metrics counts where sign-ins were decided', async () => {
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)) });
++ const ws = await h.ws({ query: `?token=${encodeURIComponent(jwt(claimsFor(alice)))}` });
ws.sendJson({ type: 'join' });
-- const auth = await ws.next((m) => m.type === 'auth');
-- assert.strictEqual(auth.core_username, 'alice');
++ assert.strictEqual((await ws.next((m) => m.type === '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('WebSocket upgrades ignore the legacy token cookie', async () => {
++ const token = jwt(claimsFor(alice));
++ // Live sets the legacy `token` cookie beside ov_token on every sign-in: ignored, not refused.
++ const ws = await h.ws({ cookie: `token=${token}; ov_token=${token}` });
++ ws.sendJson({ type: 'join' });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
++ ws.close();
++});
++
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/room-kinds.test.js b/test/room-kinds.test.js
iindex dd76271..f130cb1 100644
---- a/test/room-kinds.test.js
++++ b/test/room-kinds.test.js
@@@ -23,8 +23,7 @@ const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`,
function callWs({ channelId, token = null, ip = '203.0.113.30' } = {}) {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams({ channelId });
-- if (token) qs.set('token', token);
-- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live' } });
++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live', ...(token ? { authorization: `Bearer ${token}` } : {}) } });
const all = [];
const waiters = [];
ws.on('message', (d) => {