openvibers/openvibe.chat Commits

WebSockets: bearer or first message only (C-05, C-06/J7 removed) (#55)

OP OpenVibers committed 10 Oct 2026 · 4253ff1728 · 10 files, +78 −258

Plan T3: delete `?token=` WebSocket auth (C-05) and the cookie fallback (C-06/J7).

**Production evidence (Prometheus, 14 days):** `chat_ws_cookie_reliant{via}` stayed 0 for both kinds. That counter covered every cookie-authenticated `/ws/chat` and `/ws/call` socket whose first auth message lacked a valid token, plus silent call sockets. `chat_ws_url_token_uses` last moved on 2026-10-06 (one JWT) and 2026-09-29 (API tokens). The call client that still sent `?token=` was deleted with Live#71. Every browser client presents its token in its first message: Live's chat.js (three sockets), broadcast.js, media-player.js, and openvibe.chat's own client. The OBS overlay and the kiosk join anonymously, as before.

**What changes:**
- `extractWsToken(req)` reads the `Authorization: Bearer` header only (bots). A browser's socket starts anonymous and signs in with `join`/`join_room` (chat) or `auth-update` (calls). The call server's admission already counted the presented token.
- A socket that goes from anonymous to signed in by its join now logs its IP under the account and refreshes the room's users list. Before, the cookie made it signed in from the upgrade.
- The network-ban exemption at the upgrade still reads the `ov_token` cookie (`banExemptionToken`). Staff share home networks with banned people, and a browser cannot present its token before that check. It only lets the upgrade through; identity still comes from the bearer or the first message. Live's legacy `token` cookie does not exempt.
- The three counters (`chat_ws_url_token_uses`, `chat_ws_cookie_uses`, `chat_ws_cookie_reliant`) and the reliance tracking are deleted.

**Coverage:** a URL token and a cookie no longer authenticate a socket, and the join token does. A socket that signs in shows in the users list. A bot's bearer works. A call socket is anonymous on the cookie alone until `auth-update`. Staff pass a network ban with the session cookie; the legacy cookie does not. network-session, calls and ws-protocol are green under `ov test`.

Changes

ddiff --git a/README.md b/README.md
iindex 6bdb506..bc21d59 100644
---- a/README.md
++++ b/README.md
@@@ -506,14 +506,12 @@ 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; 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).
++ Live. A WebSocket upgrade authenticates with the Authorization bearer only (bots); a browser's
++ socket starts anonymous and presents the token in its first message (`join`, `join_room`, the
++ call's `auth-update`). Neither a cookie nor `?token=` in the URL signs a socket in (C-05/C-06
++ removed 2026-10-10, after 14 days with no socket relying on either). The `ov_token` cookie only
++ exempts staff from a network (IP) ban at the upgrade, because staff share home networks with
++ banned people.
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/calls-cutover.md b/docs/calls-cutover.md
iindex caf569c..f5357dc 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 — with session cookie or Authorization bearer, or the deprecated `?token=`; every message type unchanged) |
++| `^~ /ws/call` | the call WebSocket (`?channelId=` — or the legacy `?streamId=` / a bare stream number — signed in by the first `auth-update` message, or the Authorization bearer for bots (the cookie and `?token=` no longer authenticate it since 2026-10-10); 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/server/app.js b/server/app.js
iindex 5938fd6..dd1f232 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -17,7 +17,7 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const ctx = require('./live-context');
const db = require('./db/database');
--const { extractWsToken, authenticateWs, requestUser } = require('./auth/auth');
++const { banExemptionToken, authenticateWs, requestUser } = require('./auth/auth');
const { trustProxy } = require('./net/client-ip');
const { createInternalIngress } = require('./chat/internal-ingress');
@@@ -82,18 +82,6 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
// How sign-ins resolve (auth/network-session.js): here, through Live, or refused.
metrics.registry.gauge({ name: 'chat_auth_resolutions', help: 'Token resolutions since start, by where they were decided', labelNames: ['via'],
collect: () => Object.entries(require('./auth/network-session').stats()).map(([via, n]) => ({ labels: { via }, value: n })) });
-- // Deprecated ?token= on /ws/chat (C-05), by kind, since start: when both stay at 0, the shim goes.
-- metrics.registry.gauge({ name: 'chat_ws_url_token_uses', help: 'WebSocket upgrades that carried the token in the URL (deprecated), by token kind', labelNames: ['kind'],
-- collect: () => Object.entries(require('./auth/auth').urlTokenUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });
-- // /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) {
@@@ -297,7 +285,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
if (ctx.isIpBanned(wsIp, null)) {
// Admins pass network bans (shared home network).
let exempt = false;
-- try { const u = await authenticateWs(extractWsToken(req)); exempt = !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt')); } catch { exempt = false; }
++ try { const u = await authenticateWs(banExemptionToken(req)); exempt = !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt')); } catch { exempt = false; }
if (!exempt) { socket.destroy(); return; }
}
} catch { /* non-critical — allow through on a policy error */ }
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex b175268..28926e3 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -1,7 +1,8 @@
/**
* OpenVibe.Chat — request authentication.
*
-- * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token cookie / Live's legacy token cookie (not on WebSockets),
++ * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token cookie / Live's legacy token cookie;
++ * a WebSocket takes only the Authorization bearer or the token its first message presents),
* 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
@@@ -35,8 +36,7 @@ function apiTokenAllows(req, scopes) {
/**
* 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).
++ * legacy `token` cookie). Internal: extractToken returns the token alone.
*/
function extractTokenFrom(req, { legacyCookie = true } = {}) {
const authHeader = req.headers.authorization;
@@@ -79,57 +79,25 @@ function extractToken(req, opts) {
}
/**
-- * Extract the token for a WebSocket upgrade request, and where it came from ('url', 'header',
-- * 'ov_token_cookie' or null)
++ * The token a WebSocket upgrade authenticates with: the Authorization bearer only (bots). A browser
++ * cannot set headers on an upgrade, so its socket starts anonymous and presents the token in its first
++ * message (chat `join`/`join_room`, the call's `auth-update`). Neither the URL (`?token=`, C-05: URLs
++ * end up in proxy logs) nor a cookie (C-06: the socket would ride whatever session the browser holds)
++ * authenticates a socket.
*/
--const urlTokenUses = { jwt: 0, api_token: 0 };
--// /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
-- // 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');
-- if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') {
-- urlTokenUses[queryToken.startsWith('hbt_') ? 'api_token' : 'jwt']++;
-- return { token: queryToken, from: 'url' };
-- }
-- } catch { /* fall through */ }
--
-- // Live's legacy `token` cookie rides along with ov_token on every browser upgrade: ignored.
-- 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[tokenKind(token)]++;
++ const authHeader = req.headers.authorization;
++ return authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
}
/**
-- * 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.
++ * The token that may exempt an upgrade from a network (IP) ban: the bearer or the Network `ov_token`
++ * cookie. Staff share home networks with banned people, and a browser cannot present its token before
++ * the ban check runs. It only lets the upgrade through; the socket's identity still comes from
++ * extractWsToken or its first message.
*/
--function wsCookieReliance(token) {
-- let settled = false;
-- return (accepted = false) => {
-- if (settled) return;
-- settled = true;
-- if (!accepted) wsCookieReliant[tokenKind(token)]++;
-- };
++function banExemptionToken(req) {
++ return extractTokenFrom(req, { legacyCookie: false }).token;
}
/**
@@@ -232,14 +200,9 @@ module.exports = {
extractToken,
requestUser,
extractWsToken,
-- extractWsTokenFrom,
-- countWsCookieUse,
-- wsCookieReliance,
++ banExemptionToken,
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 8c368c1..51fe3ba 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 { extractWsTokenFrom, wsCookieReliance, authenticateWs } = require('../auth/auth');
++const { extractWsToken, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const chatServer = require('../chat/chat-server');
const lifecycle = require('./lifecycle');
@@@ -300,7 +300,8 @@ 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, from: tokenFrom } = extractWsTokenFrom(req);
++ // A bot's bearer; a browser's socket is anonymous until its auth-update (admission counts it).
++ const token = extractWsToken(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);
@@@ -336,8 +337,7 @@ class CallServer {
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
// 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 });
++ await this._admit(ws, { resolvedId, ip, user, stream, early, stopBuffering: () => ws.off('message', onEarlyMessage) });
})().catch((err) => {
console.warn('[Call] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
@@@ -345,7 +345,7 @@ class CallServer {
}
/** The rest of Live's _handleConnection, once the socket's identity is known. */
-- async _admit(ws, { resolvedId, ip, user, stream, early, stopBuffering, cookieReliance = null }) {
++ async _admit(ws, { resolvedId, ip, user, stream, early, stopBuffering }) {
// 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; }
@@@ -356,10 +356,9 @@ class CallServer {
if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); 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.
++ // Admission counts the token the client presents in its first message (auth-update) too: 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) {
@@@ -466,11 +465,6 @@ 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.
@@@ -479,10 +473,7 @@ class CallServer {
if (++clientInfo._msgCount > 200) return;
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);
-- })
++ queue = queue.then(async () => { await this._handleMessage(ws, msg, resolvedId, peerId); })
.catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });
};
stopBuffering();
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 838e359..57f0750 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -23,7 +23,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const config = require('../config');
const ctx = require('../live-context');
--const { extractWsTokenFrom, countWsCookieUse, wsCookieReliance, authenticateWs } = require('../auth/auth');
++const { extractWsToken, authenticateWs } = require('../auth/auth');
const session = require('../auth/network-session');
const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
@@@ -270,7 +270,8 @@ class ChatServer {
}
const urlParams = new URL(req.url, 'http://localhost').searchParams;
-- const { token, from: tokenFrom } = extractWsTokenFrom(req);
++ // A bot's bearer; a browser's socket starts anonymous and signs in with its join.
++ const token = extractWsToken(req);
const streamId = parseInt(urlParams.get('stream')) || null;
ws.isAlive = true;
@@@ -291,15 +292,13 @@ 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);
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,
-- cookieReliance: user && tokenFrom === 'ov_token_cookie' ? wsCookieReliance(token) : null });
++ this._registerConnection(ws, req, { ip, streamId, user, early, tokenIat: user ? session.tokenIat(token) : null });
})().catch((err) => {
console.warn('[Chat] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
@@@ -307,7 +306,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, cookieReliance = null }) {
++ _registerConnection(ws, req, { ip, streamId, user, early, tokenIat = 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');
@@@ -362,7 +361,7 @@ class ChatServer {
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
return;
}
-- chain = chain.then(async () => await this.handleMessage(ws, msg, cookieReliance)).catch((err) => {
++ chain = chain.then(async () => await this.handleMessage(ws, msg)).catch((err) => {
console.warn('[Chat] message handling failed:', err.message);
});
};
@@@ -396,17 +395,24 @@ class ChatServer {
console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
return false;
}
++ const wasAnonymous = !client.user;
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
++ if (wasAnonymous) {
++ // A browser's socket arrives anonymous (the upgrade carries no token): the IP log names the
++ // account and the room's list shows the member as soon as the join signs it in.
++ try { ctx.effects.logIp({ userId: user.id, anonId: null, ip: client.ip, action: 'chat' }); } catch { /* non-critical */ }
++ this.broadcastUsersList(client.streamId);
++ }
return true;
}
/**
* Handle incoming chat message
*/
-- async handleMessage(ws, msg, cookieReliance = null) {
++ async handleMessage(ws, msg) {
const client = this.clients.get(ws);
if (!client) return;
@@@ -441,8 +447,7 @@ class ChatServer {
case 'join':
case 'join_stream': {
// (Re-)authenticate if a token is provided
-- const acceptedToken = await this._applyJoinToken(client, msg);
-- if (cookieReliance) cookieReliance(acceptedToken);
++ 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;
@@@ -525,8 +530,7 @@ 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.
-- const acceptedToken = await this._applyJoinToken(client, msg);
-- if (cookieReliance) cookieReliance(acceptedToken);
++ 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/server/chat/routes.js b/server/chat/routes.js
iindex fe5b1c8..d70bfb6 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -277,8 +277,7 @@ 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.
++// The browser posts over /ws/chat and presents its token in the join (the upgrade itself carries none).
// 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 8d0aad1..04c75b0 100644
---- a/test/calls.test.js
++++ b/test/calls.test.js
@@@ -213,62 +213,16 @@ t('the pre-admission buffer is capped: 201 early messages close the socket (1008
} finally { h.ctx.warm = warm; }
});
--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 }) });
++t('a cookie does not sign a call socket in: its auth-update does', async () => {
++ const s = await callWs({ channelId: 'public', cookie: `ov_token=${cat.token}`, ip: '198.51.100.5' });
++ const welcome = await s.next((m) => m.type === 'welcome');
++ assert.notStrictEqual(welcome.participants.find((p) => p.peerId === welcome.peerId).username, 'cat', 'anonymous on the cookie alone');
++ s.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);
++ await bye(s);
});
t('delayed admission: an auth-update that arrives while admission awaits still admits the socket', async () => {
-- const auth = require('../server/auth/auth');
-- const before = auth.wsCookieReliant().jwt;
// A private call room: anonymous admission fails, so the socket must use the auth-update buffered
// while admission awaited Live.
const room = await h.http('POST', '/api/chat/rooms', { token: streamer.token, body: { name: 'Delayed admit', slug: 'delayed-admit', kind: 'call', visibility: 'private' } });
@@@ -283,20 +237,6 @@ t('delayed admission: an auth-update that arrives while admission awaits still a
assert.ok(s.welcome, s.error);
assert.strictEqual(s.welcome.participants.find((p) => p.peerId === s.welcome.peerId).username, 'bob');
} finally { callServer._admission = admission; }
-- assert.strictEqual(auth.wsCookieReliant().jwt, before, 'token presented during admission: not cookie-reliant');
-- await bye(s);
--});
--
--t('a listen-only call socket that stays silent is counted as cookie-reliant', async () => {
-- const auth = require('../server/auth/auth');
-- const before = auth.wsCookieReliant().jwt;
-- const r = await h.http('POST', '/api/chat/rooms', { token: streamer.token, body: { name: 'Silent stage', slug: 'silent-stage', kind: 'call', visibility: 'public', join_role: 'viewer' } });
-- assert.strictEqual(r.status, 201, r.text);
-- const slug = r.body.room.slug;
-- const s = await join({ channelId: `room-${slug}`, cookie: `ov_token=${bob.token}`, ip: '198.51.100.14' });
-- assert.ok(s.welcome, s.error);
-- assert.strictEqual(s.welcome.canTalk, false, 'joined as viewer');
-- await until(() => auth.wsCookieReliant().jwt === before + 1, 2000);
await bye(s);
});
ddiff --git a/test/network-session.test.js b/test/network-session.test.js
iindex ff57792..c8ea529 100644
---- a/test/network-session.test.js
++++ b/test/network-session.test.js
@@@ -100,108 +100,42 @@ t('/metrics counts where sign-ins were decided', async () => {
for (const via of ['local', 'live', 'rejected']) assert.match(m, new RegExp(`chat_auth_resolutions\\{[^}]*via="${via}"[^}]*\\} [1-9]`), via);
});
--t('a token in the WebSocket URL still works (deprecated, C-05) and is counted', async () => {
-- const before = require('../server/auth/auth').urlTokenUses().jwt;
++t('a token in the WebSocket URL does not authenticate the socket (C-05 removed)', async () => {
const ws = await h.ws({ query: `?token=${encodeURIComponent(jwt(claimsFor(alice)))}` });
ws.sendJson({ type: 'join' });
-- assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).authenticated, false);
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]/);
++ assert.doesNotMatch((await h.http('GET', '/metrics')).text, /chat_ws_url_token_uses|chat_ws_cookie_(uses|reliant)/);
});
--t('WebSocket upgrades ignore the legacy token cookie', async () => {
++t('a cookie does not authenticate a socket (C-06): the token in its join does', async () => {
const token = jwt(claimsFor(alice));
-- // Live sets the legacy `token` cookie beside ov_token on every sign-in: ignored, not refused.
++ // The Network ov_token and Live's legacy `token` cookie both ride along on a browser's upgrade.
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('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');
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).authenticated, false, 'anonymous until it presents the token');
++ ws.sendJson({ type: 'join', token });
++ const auth = await ws.next((m) => m.type === 'auth');
++ assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, '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);
++t('a socket that signs in with its join is listed under its account', async () => {
++ const token = jwt(claimsFor(carol));
++ const ws = await h.ws({ ip: '198.51.100.77' });
++ await ws.next((m) => m.type === 'users-list');
++ ws.sendJson({ type: 'join', token });
++ await ws.next((m) => m.type === 'auth' && m.authenticated);
++ await ws.next((m) => m.type === 'users-list' && m.users.logged.some((u) => u.username === 'carol'));
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 cookieBefore = require('../server/auth/auth').wsCookieUses();
++t('bots authenticate the upgrade with an Authorization header', async () => {
const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
ws.sendJson({ type: 'join' });
const auth = await ws.next((m) => m.type === 'auth');
assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, 'carol']);
ws.close();
-- assert.deepStrictEqual(require('../server/auth/auth').urlTokenUses(), before, 'not counted as a URL token');
-- 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/ws-protocol.test.js b/test/ws-protocol.test.js
iindex 4bd36e7..10d390e 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -338,6 +338,9 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exempt
await h.ctx.invalidateBans();
assert.strictEqual(await h.wsRefused({ ip: '192.0.2.55' }), true, 'CIDR network ban');
assert.strictEqual(await h.wsRefused({ ip: '192.0.2.56', token: admin.token }), false, 'admin passes');
++ // A browser cannot show its token before this check: the session cookie exempts it (identity still comes from the join).
++ assert.strictEqual(await h.wsRefused({ ip: '192.0.2.57', cookie: `ov_token=${admin.token}` }), false, 'admin passes with the session cookie');
++ assert.strictEqual(await h.wsRefused({ ip: '192.0.2.58', cookie: `token=${admin.token}` }), true, "Live's legacy cookie does not exempt");
// And REST from a banned network (through Cloudflare: the hop before nginx is a Cloudflare edge).
const r = await h.http('GET', '/api/chat/global/history', { headers: { 'X-Forwarded-For': '192.0.2.9, 104.16.0.1' } });
assert.strictEqual(r.status, 403);