openvibers/openvibe.chat Commits
?token= on /ws/chat is deprecated (C-05): still honoured for the bots that use it, counted in /metrics chat_ws_url_token_uses{kind}; bots authenticate the upgrade with an Authorization header (tested) and browsers with their first join message. POST /api/chat/send is the bots' global-chat API, no longer a browser fallback (C-06)
Changes
@@@ -79,6 +79,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = 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 })) });/** Exact allow-list only (no subdomain wildcard). */function isAllowedOrigin(origin) {@@@ -70,14 +70,18 @@ function extractToken(req) {/*** Extract the token for a WebSocket upgrade request*/++const urlTokenUses = { jwt: 0, api_token: 0 };function extractWsToken(req) {-- // Prefer explicit ?token= query param — clients (e.g. broadcast signaling)-- // put the freshest localStorage token in the URL, which may be newer than-- // a stale httpOnly cookie from an earlier session.++ // DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message++ // and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and++ // counted, /metrics chat_ws_url_token_uses) while bots move off it.try {const url = new URL(req.url || '/', 'http://localhost');const queryToken = url.searchParams.get('token');-- if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') return queryToken;++ if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') {++ urlTokenUses[queryToken.startsWith('hbt_') ? 'api_token' : 'jwt']++;++ return queryToken;++ }} catch { /* fall through */ }// Fall back to cookie / Authorization header@@@ -178,4 +182,5 @@ module.exports = {requireAuth,optionalAuth,requireAdmin,++ urlTokenUses: () => ({ ...urlTokenUses }),};@@@ -4,7 +4,7 @@* Same paths, bodies and responses as Live's /api/chat. Messages are Chat's; authors, streams,* slots, cosmetics, tags, settings and profile cards are Live's and come through live-context.*-- * POST /api/chat/send - Send a chat message (REST fallback)++ * POST /api/chat/send - Post a message to global chat (bots; API tokens need the chat scope)* GET /api/chat/:streamId/history - Get chat history for a stream* GET /api/chat/:streamId/users - Get users in chat* GET /api/chat/search - Search chat messages@@@ -224,7 +224,7 @@ function hydrateReplies(messages) {});}--// ── Send Chat Message (REST fallback when WS is down) ────────++// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──router.post('/send', requireAuth, async (req, res) => {try {const text = (req.body.message || '').trim();@@@ -342,12 +342,12 @@ async function boot({ env = {} } = {}) {const WebSocket = require('ws');/** Open /ws/chat as a browser would (through nginx: the address arrives in CF-Connecting-IP). */-- h.ws = ({ ip = '203.0.113.10', token = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {++ h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {const qs = new URLSearchParams();if (stream) qs.set('stream', stream);if (token) qs.set('token', token);const url = `ws://127.0.0.1:${h.port}/ws/chat${qs.toString() ? `?${qs}` : ''}${query}`;-- const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin } });++ const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });const all = [];const waiters = [];ws.on('message', (d) => {@@@ -100,4 +100,25 @@ 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;++ const ws = await h.ws({ token: jwt(claimsFor(alice)) });++ ws.sendJson({ type: 'join' });++ const auth = await ws.next((m) => m.type === 'auth');++ assert.strictEqual(auth.core_username, 'alice');++ ws.close();++ assert.strictEqual(require('../server/auth/auth').urlTokenUses().jwt, before + 1);++ assert.match((await h.http('GET', '/metrics')).text, /chat_ws_url_token_uses\{[^}]*kind="jwt"[^}]*\} [1-9]/);++});++++t('bots authenticate the upgrade with an Authorization header, no token in the URL', async () => {++ const before = require('../server/auth/auth').urlTokenUses();++ const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });++ 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');++});++t.run(async () => { if (h && h.close) await h.close(); });
@@ -79,6 +79,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
@@ -79,6 +79,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = 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 })) });
/** Exact allow-list only (no subdomain wildcard). */
/** Exact allow-list only (no subdomain wildcard). */
function isAllowedOrigin(origin) {
function isAllowedOrigin(origin) {
@@ -70,14 +70,18 @@ function extractToken(req) {
@@ -70,14 +70,18 @@ function extractToken(req) {
/**
/**
* Extract the token for a WebSocket upgrade request
* Extract the token for a WebSocket upgrade request
*/
*/
+const urlTokenUses = { jwt: 0, api_token: 0 };
function extractWsToken(req) {
function extractWsToken(req) {
- // Prefer explicit ?token= query param — clients (e.g. broadcast signaling)
- // put the freshest localStorage token in the URL, which may be newer than
- // a stale httpOnly cookie from an earlier session.
+ // DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message
+ // and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and
+ // counted, /metrics chat_ws_url_token_uses) while bots move off it.
try {
try {
const url = new URL(req.url || '/', 'http://localhost');
const url = new URL(req.url || '/', 'http://localhost');
const queryToken = url.searchParams.get('token');
const queryToken = url.searchParams.get('token');
- if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') return queryToken;
+ if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') {
+ urlTokenUses[queryToken.startsWith('hbt_') ? 'api_token' : 'jwt']++;
+ return queryToken;
+ }
} catch { /* fall through */ }
} catch { /* fall through */ }
// Fall back to cookie / Authorization header
// Fall back to cookie / Authorization header
@@ -178,4 +182,5 @@ module.exports = {
@@ -178,4 +182,5 @@ module.exports = {
requireAuth,
requireAuth,
optionalAuth,
optionalAuth,
requireAdmin,
requireAdmin,
+ urlTokenUses: () => ({ ...urlTokenUses }),
};
};
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* Same paths, bodies and responses as Live's /api/chat. Messages are Chat's; authors, streams,
* Same paths, bodies and responses as Live's /api/chat. Messages are Chat's; authors, streams,
* slots, cosmetics, tags, settings and profile cards are Live's and come through live-context.
* slots, cosmetics, tags, settings and profile cards are Live's and come through live-context.
*
*
- * POST /api/chat/send - Send a chat message (REST fallback)
+ * POST /api/chat/send - Post a message to global chat (bots; API tokens need the chat scope)
* GET /api/chat/:streamId/history - Get chat history for a stream
* GET /api/chat/:streamId/history - Get chat history for a stream
* GET /api/chat/:streamId/users - Get users in chat
* GET /api/chat/:streamId/users - Get users in chat
* GET /api/chat/search - Search chat messages
* GET /api/chat/search - Search chat messages
@@ -224,7 +224,7 @@ function hydrateReplies(messages) {
@@ -224,7 +224,7 @@ function hydrateReplies(messages) {
});
});
}
}
-// ── Send Chat Message (REST fallback when WS is down) ────────
+// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
router.post('/send', requireAuth, async (req, res) => {
router.post('/send', requireAuth, async (req, res) => {
try {
try {
const text = (req.body.message || '').trim();
const text = (req.body.message || '').trim();
@@ -342,12 +342,12 @@ async function boot({ env = {} } = {}) {
@@ -342,12 +342,12 @@ async function boot({ env = {} } = {}) {
const WebSocket = require('ws');
const WebSocket = require('ws');
/** Open /ws/chat as a browser would (through nginx: the address arrives in CF-Connecting-IP). */
/** Open /ws/chat as a browser would (through nginx: the address arrives in CF-Connecting-IP). */
- h.ws = ({ ip = '203.0.113.10', token = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
+ h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
const qs = new URLSearchParams();
const qs = new URLSearchParams();
if (stream) qs.set('stream', stream);
if (stream) qs.set('stream', stream);
if (token) qs.set('token', token);
if (token) qs.set('token', token);
const url = `ws://127.0.0.1:${h.port}/ws/chat${qs.toString() ? `?${qs}` : ''}${query}`;
const url = `ws://127.0.0.1:${h.port}/ws/chat${qs.toString() ? `?${qs}` : ''}${query}`;
- const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin } });
+ const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });
const all = [];
const all = [];
const waiters = [];
const waiters = [];
ws.on('message', (d) => {
ws.on('message', (d) => {
@@ -100,4 +100,25 @@ t('/metrics counts where sign-ins were decided', async () => {
@@ -100,4 +100,25 @@ 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;
+ const ws = await h.ws({ token: jwt(claimsFor(alice)) });
+ ws.sendJson({ type: 'join' });
+ const auth = await ws.next((m) => m.type === 'auth');
+ assert.strictEqual(auth.core_username, 'alice');
+ ws.close();
+ assert.strictEqual(require('../server/auth/auth').urlTokenUses().jwt, before + 1);
+ assert.match((await h.http('GET', '/metrics')).text, /chat_ws_url_token_uses\{[^}]*kind="jwt"[^}]*\} [1-9]/);
+});
+
+t('bots authenticate the upgrade with an Authorization header, no token in the URL', async () => {
+ const before = require('../server/auth/auth').urlTokenUses();
+ const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
+ 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');
+});
+
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });