openvibers/openvibe.chat Commits
WebSockets: bearer or first message only (C-05, C-06/J7 removed) (#55)
Changes
@@@ -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 (tokensissued 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.@@@ -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` |@@@ -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 */ }@@@ -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 }),};@@@ -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();@@@ -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 listclient.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;@@@ -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) => {@@@ -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 aassert.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);});@@@ -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(); });@@@ -338,6 +338,9 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exemptawait 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);
@@ -506,14 +506,12 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -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
- **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
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
Services use client-credentials tokens for audience
`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,
`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,
`chat.presence.read`). Staff gates ask the contracts staff map.
`chat.presence.read`). Staff gates ask the contracts staff map.
@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/calls/openvibe.live-calls.locations.conf`, includ
@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/calls/openvibe.live-calls.locations.conf`, includ
| nginx location | Chat serves (`server/calls/`) |
| 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/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` |
| `~ ^/api/streams/[0-9]+/call$` | `GET/PUT /api/streams/:id/call` |
@@ -17,7 +17,7 @@ const rateLimit = require('express-rate-limit');
@@ -17,7 +17,7 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const config = require('./config');
const ctx = require('./live-context');
const ctx = require('./live-context');
const db = require('./db/database');
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 { trustProxy } = require('./net/client-ip');
const { createInternalIngress } = require('./chat/internal-ingress');
const { createInternalIngress } = require('./chat/internal-ingress');
@@ -82,18 +82,6 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -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.
// 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'],
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 })) });
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). */
/** Exact allow-list only (no subdomain wildcard). */
function isAllowedOrigin(origin) {
function isAllowedOrigin(origin) {
@@ -297,7 +285,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -297,7 +285,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
if (ctx.isIpBanned(wsIp, null)) {
if (ctx.isIpBanned(wsIp, null)) {
// Admins pass network bans (shared home network).
// Admins pass network bans (shared home network).
let exempt = false;
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; }
if (!exempt) { socket.destroy(); return; }
}
}
} catch { /* non-critical — allow through on a policy error */ }
} catch { /* non-critical — allow through on a policy error */ }
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
/**
/**
* OpenVibe.Chat — request authentication.
* 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()
* 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.
* — 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
* Moved from OpenVibe.Live server/auth/auth.js; the middleware are async now because resolution
@@ -35,8 +36,7 @@ function apiTokenAllows(req, scopes) {
@@ -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
* 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 } = {}) {
function extractTokenFrom(req, { legacyCookie = true } = {}) {
const authHeader = req.headers.authorization;
const authHeader = req.headers.authorization;
@@ -79,57 +79,25 @@ function extractToken(req, opts) {
@@ -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) {
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 = {
@@ -232,14 +200,9 @@ module.exports = {
extractToken,
extractToken,
requestUser,
requestUser,
extractWsToken,
extractWsToken,
- extractWsTokenFrom,
- countWsCookieUse,
- wsCookieReliance,
+ banExemptionToken,
authenticateWs,
authenticateWs,
requireAuth,
requireAuth,
optionalAuth,
optionalAuth,
requireAdmin,
requireAdmin,
- urlTokenUses: () => ({ ...urlTokenUses }),
- wsCookieUses: () => ({ ...wsCookieUses }),
- wsCookieReliant: () => ({ ...wsCookieReliant }),
};
};
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
const WebSocket = require('ws');
const WebSocket = require('ws');
const config = require('../config');
const config = require('../config');
const ctx = require('../live-context');
const ctx = require('../live-context');
-const { extractWsTokenFrom, wsCookieReliance, authenticateWs } = require('../auth/auth');
+const { extractWsToken, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const chatServer = require('../chat/chat-server');
const chatServer = require('../chat/chat-server');
const lifecycle = require('./lifecycle');
const lifecycle = require('./lifecycle');
@@ -300,7 +300,8 @@ class CallServer {
@@ -300,7 +300,8 @@ class CallServer {
async _handleConnection(ws, req) {
async _handleConnection(ws, req) {
const url = new URL(req.url, 'http://localhost');
const url = new URL(req.url, 'http://localhost');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
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.
// 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);
const ip = chatServer.getClientIp(req);
@@ -336,8 +337,7 @@ class CallServer {
@@ -336,8 +337,7 @@ class CallServer {
ws.off('error', onEarlyClose);
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
// Messages keep buffering until _admit attaches the socket's handler: none falls in between.
// 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) => {
})().catch((err) => {
console.warn('[Call] connection setup failed:', err.message);
console.warn('[Call] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
try { ws.close(1011, 'setup failed'); } catch {}
@@ -345,7 +345,7 @@ class CallServer {
@@ -345,7 +345,7 @@ class CallServer {
}
}
/** The rest of Live's _handleConnection, once the socket's identity is known. */
/** 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.
// The channel may have gone while the socket signed in.
const channel = this.channels.get(resolvedId);
const channel = this.channels.get(resolvedId);
if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
@@ -356,10 +356,9 @@ class CallServer {
@@ -356,10 +356,9 @@ class CallServer {
if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }
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);
user = await this._presentedUser(early, user);
let admission = await this._admission(channel, resolvedId, user);
let admission = await this._admission(channel, resolvedId, user);
if (admission.error) {
if (admission.error) {
@@ -466,11 +465,6 @@ class CallServer {
@@ -466,11 +465,6 @@ class CallServer {
// One message at a time per socket, in arrival order (auth-update resolves a token first).
// One message at a time per socket, in arrival order (auth-update resolves a token first).
let queue = Promise.resolve();
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) => {
const onMessage = (data) => {
// Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in
// 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.
// a burst; the old 50/s silently dropped some of them and the join stalled.
@@ -479,10 +473,7 @@ class CallServer {
@@ -479,10 +473,7 @@ class CallServer {
if (++clientInfo._msgCount > 200) return;
if (++clientInfo._msgCount > 200) return;
let msg;
let msg;
try { msg = JSON.parse(data); } catch (err) { console.warn('[Call] Message error for peer', peerId, ':', err.message); return; }
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); });
.catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });
};
};
stopBuffering();
stopBuffering();
@@ -23,7 +23,7 @@ const crypto = require('crypto');
@@ -23,7 +23,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const ctx = require('../live-context');
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 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');
@@ -270,7 +270,8 @@ class ChatServer {
@@ -270,7 +270,8 @@ class ChatServer {
}
}
const urlParams = new URL(req.url, 'http://localhost').searchParams;
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;
const streamId = parseInt(urlParams.get('stream')) || null;
ws.isAlive = true;
ws.isAlive = true;
@@ -291,15 +292,13 @@ class ChatServer {
@@ -291,15 +292,13 @@ 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);
ws.off('close', onEarlyClose);
ws.off('close', onEarlyClose);
ws.off('error', onEarlyClose);
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
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) => {
})().catch((err) => {
console.warn('[Chat] connection setup failed:', err.message);
console.warn('[Chat] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
try { ws.close(1011, 'setup failed'); } catch {}
@@ -307,7 +306,7 @@ class ChatServer {
@@ -307,7 +306,7 @@ class ChatServer {
}
}
/** The rest of Live's handleConnection, once the socket's identity is known. */
/** 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;
const perIp = this._ipSockets.get(ip) || 0;
if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {
if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {
ws.close(4029, 'Too many connections');
ws.close(4029, 'Too many connections');
@@ -362,7 +361,7 @@ class ChatServer {
@@ -362,7 +361,7 @@ class ChatServer {
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
return;
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);
console.warn('[Chat] message handling failed:', err.message);
});
});
};
};
@@ -396,17 +395,24 @@ class ChatServer {
@@ -396,17 +395,24 @@ class ChatServer {
console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
return false;
return false;
}
}
+ const wasAnonymous = !client.user;
client.user = user;
client.user = user;
await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
client.tokenIat = session.tokenIat(msg.token);
client.tokenIat = session.tokenIat(msg.token);
client.anonId = null; // no longer anonymous
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;
return true;
}
}
/**
/**
* Handle incoming chat message
* Handle incoming chat message
*/
*/
- async handleMessage(ws, msg, cookieReliance = null) {
+ async handleMessage(ws, msg) {
const client = this.clients.get(ws);
const client = this.clients.get(ws);
if (!client) return;
if (!client) return;
@@ -441,8 +447,7 @@ class ChatServer {
@@ -441,8 +447,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
- const acceptedToken = await this._applyJoinToken(client, msg);
- if (cookieReliance) cookieReliance(acceptedToken);
+ 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;
@@ -525,8 +530,7 @@ class ChatServer {
@@ -525,8 +530,7 @@ class ChatServer {
case 'join_room': {
case 'join_room': {
// A room socket can still arrive anonymous or as a guest: authenticate a token the
// 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.
// 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 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;
@@ -277,8 +277,7 @@ async function hydrateReplies(messages) {
@@ -277,8 +277,7 @@ async function hydrateReplies(messages) {
}
}
// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
// ── 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
// 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.
// 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) => {
router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {
@@ -213,62 +213,16 @@ t('the pre-admission buffer is capped: 201 early messages close the socket (1008
@@ -213,62 +213,16 @@ t('the pre-admission buffer is capped: 201 early messages close the socket (1008
} finally { h.ctx.warm = warm; }
} 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.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 () => {
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
// A private call room: anonymous admission fails, so the socket must use the auth-update buffered
// while admission awaited Live.
// 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' } });
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
@@ -283,20 +237,6 @@ t('delayed admission: an auth-update that arrives while admission awaits still a
assert.ok(s.welcome, s.error);
assert.ok(s.welcome, s.error);
assert.strictEqual(s.welcome.participants.find((p) => p.peerId === s.welcome.peerId).username, 'bob');
assert.strictEqual(s.welcome.participants.find((p) => p.peerId === s.welcome.peerId).username, 'bob');
} finally { callServer._admission = admission; }
} 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);
await bye(s);
});
});
@@ -100,108 +100,42 @@ t('/metrics counts where sign-ins were decided', async () => {
@@ -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);
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)))}` });
const ws = await h.ws({ query: `?token=${encodeURIComponent(jwt(claimsFor(alice)))}` });
ws.sendJson({ type: 'join' });
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();
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));
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}` });
const ws = await h.ws({ cookie: `token=${token}; ov_token=${token}` });
ws.sendJson({ type: 'join' });
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();
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();
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)) });
const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
ws.sendJson({ type: 'join' });
ws.sendJson({ type: 'join' });
const auth = await ws.next((m) => m.type === 'auth');
const auth = await ws.next((m) => m.type === 'auth');
assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, 'carol']);
assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, 'carol']);
ws.close();
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(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -338,6 +338,9 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exempt
@@ -338,6 +338,9 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exempt
await h.ctx.invalidateBans();
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.55' }), true, 'CIDR network ban');
assert.strictEqual(await h.wsRefused({ ip: '192.0.2.56', token: admin.token }), false, 'admin passes');
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).
// 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' } });
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);
assert.strictEqual(r.status, 403);