openvibers/openvibe.chat Commits
Reject legacy WebSocket credentials and use session or bearer auth (#7)
Changes
@@@ -524,7 +524,10 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:- **Auth.** People sign in with a Network session JWT, verified here with the Network's key (tokensissued before a subject's `token_valid_after` are refused), or an `hbt_` API token resolved through-- Live; `?token=` on `/ws/chat` is deprecated. Services use client-credentials tokens for audience++ Live; WebSocket upgrades use the session cookie or Authorization bearer (Live's legacy `token`++ cookie is ignored there); `?token=` on `/ws/chat` is deprecated (C-05), counted in++ `chat_ws_url_token_uses`, and stays until Live's bot guide and call client stop sending it.++ Services use client-credentials tokens for audience`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,`chat.presence.read`). Staff gates ask the contracts staff map.- **Private data.** DMs are delivered only to participants, and private rooms only to members@@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/calls/openvibe.live-calls.locations.conf`, includ| nginx location | Chat serves (`server/calls/`) || --- | --- |--| `^~ /ws/call` | the call WebSocket (`?channelId=` — or the legacy `?streamId=` / a bare stream number — and `?token=`; every message type unchanged) |++| `^~ /ws/call` | the call WebSocket (`?channelId=` — or the legacy `?streamId=` / a bare stream number — with session cookie or Authorization bearer, or the deprecated `?token=`; every message type unchanged) || `^~ /api/streams/voice-channels` | `GET/POST /api/streams/voice-channels`, `GET/DELETE /api/streams/voice-channels/:channelId`, `POST /api/streams/voice-channels/call-user`, `POST /api/streams/voice-channels/call-user/respond` || `~ ^/api/streams/[0-9]+/call$` | `GET/PUT /api/streams/:id/call` |@@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L| nginx location | Chat serves || --- | --- |--| `^~ /ws/chat` | the chat WebSocket (`?stream=`, `?token=`; protocol unchanged) |++| `^~ /ws/chat` | the chat WebSocket (`?stream=`; session cookie or Authorization bearer; the deprecated `?token=` still works; protocol unchanged) || `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` || `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` || `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL), `GET queue`, `POST queue/skip`, `POST queue/clear`, `POST queue/:id/report` (the TTS and sound queue) |@@@ -1,7 +1,7 @@/*** OpenVibe.Chat — request authentication.*-- * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token / token cookie,++ * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token cookie / Live's legacy token cookie (not on WebSockets),* or an hbt_ API token) with the same rules, resolved by Live through live-context.authenticate()* — Live owns the account links, auto-creates first-time accounts and knows API-token scopes.* Moved from OpenVibe.Live server/auth/auth.js; the middleware are async now because resolution@@@ -36,16 +36,17 @@ function apiTokenAllows(req, scopes) {/*** Extract the token from Authorization header or cookie*/--function extractToken(req) {++function extractToken(req, { legacyCookie = true } = {}) {const authHeader = req.headers.authorization;if (authHeader && authHeader.startsWith('Bearer ')) {return authHeader.slice(7);}-- // Check both cookie names: 'token' (legacy openvibelive) and 'ov_token' (shared network)++ // The Network session cookie is shared with the browser. REST still accepts Live's legacy++ // 'token' cookie (as Live does); WebSocket upgrades ignore it (legacyCookie: false).if (req.cookies) {if (req.cookies.ov_token) return req.cookies.ov_token;-- if (req.cookies.token) return req.cookies.token;++ if (legacyCookie && req.cookies.token) return req.cookies.token;}// Raw Node/WebSocket upgrade requests do not go through cookie-parser,@@@ -62,7 +63,7 @@ function extractToken(req) {try { parsed[key] = decodeURIComponent(value); } catch { parsed[key] = value; }}if (parsed.ov_token) return parsed.ov_token;-- if (parsed.token) return parsed.token;++ if (legacyCookie && parsed.token) return parsed.token;}return null;@@@ -75,7 +76,7 @@ const urlTokenUses = { jwt: 0, api_token: 0 };function extractWsToken(req) {// DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message// and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and-- // counted, /metrics chat_ws_url_token_uses) while bots move off it.++ // counted, /metrics chat_ws_url_token_uses) while Live's bot guide and call client still send it.try {const url = new URL(req.url || '/', 'http://localhost');const queryToken = url.searchParams.get('token');@@@ -85,12 +86,8 @@ function extractWsToken(req) {}} catch { /* fall through */ }-- // Fall back to cookie / Authorization header-- const direct = extractToken(req);-- if (direct) return direct;---- // Legacy: req.query fallback for non-URL parse environments-- return (req.query && req.query.token) || null;++ // Live's legacy `token` cookie rides along with ov_token on every browser upgrade: ignored.++ return extractToken(req, { legacyCookie: false });}/**@@@ -18,7 +18,7 @@* temporary channels. Stream-linked channels are auto-created when a* streamer enables voice on their stream (Live asks: POST /internal/calls/stream-channel).*-- * WebSocket path: /ws/call?channelId=<id>&token=X++ * WebSocket path: /ws/call?channelId=<id> (session cookie or Authorization bearer)** Channel modes:* 'mic' — Microphone only@@@ -17,13 +17,12 @@ const t = suite('calls');let h, config, callServer, lifecycle;let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;--/** Open /ws/call as a browser would (through nginx: the address in CF-Connecting-IP). */++/** Open /ws/call with a bearer (through nginx: the address in CF-Connecting-IP). */function callWs({ channelId, token = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {return new Promise((resolve, reject) => {const qs = new URLSearchParams();if (channelId != null) qs.set('channelId', channelId);-- if (token) qs.set('token', token);-- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin } });++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}) } });const all = [];const waiters = [];ws.on('message', (d) => {@@@ -361,13 +361,12 @@ async function boot({ env = {} } = {}) {};const WebSocket = require('ws');-- /** Open /ws/chat as a browser would (through nginx: the address arrives in CF-Connecting-IP). */-- h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {++ /** Open /ws/chat with a bearer or session cookie (through nginx: the address arrives in CF-Connecting-IP). */++ h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, cookie = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {const qs = new URLSearchParams();if (stream) qs.set('stream', stream);-- if (token) qs.set('token', token);const url = `ws://127.0.0.1:${h.port}/ws/chat${qs.toString() ? `?${qs}` : ''}${query}`;-- const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });++ const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer || token ? { authorization: `Bearer ${bearer || token}` } : {}), ...(cookie ? { cookie } : {}) } });const all = [];const waiters = [];ws.on('message', (d) => {@@@ -102,15 +102,23 @@ t('/metrics counts where sign-ins were decided', async () => {t('a token in the WebSocket URL still works (deprecated, C-05) and is counted', async () => {const before = require('../server/auth/auth').urlTokenUses().jwt;-- const ws = await h.ws({ token: jwt(claimsFor(alice)) });++ const ws = await h.ws({ query: `?token=${encodeURIComponent(jwt(claimsFor(alice)))}` });ws.sendJson({ type: 'join' });-- const auth = await ws.next((m) => m.type === 'auth');-- assert.strictEqual(auth.core_username, 'alice');++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');ws.close();assert.strictEqual(require('../server/auth/auth').urlTokenUses().jwt, before + 1);assert.match((await h.http('GET', '/metrics')).text, /chat_ws_url_token_uses\{[^}]*kind="jwt"[^}]*\} [1-9]/);});++t('WebSocket upgrades ignore the legacy token cookie', async () => {++ const token = jwt(claimsFor(alice));++ // Live sets the legacy `token` cookie beside ov_token on every sign-in: ignored, not refused.++ const ws = await h.ws({ cookie: `token=${token}; ov_token=${token}` });++ ws.sendJson({ type: 'join' });++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');++ ws.close();++});++t('bots authenticate the upgrade with an Authorization header, no token in the URL', async () => {const before = require('../server/auth/auth').urlTokenUses();const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });@@@ -23,8 +23,7 @@ const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`,function callWs({ channelId, token = null, ip = '203.0.113.30' } = {}) {return new Promise((resolve, reject) => {const qs = new URLSearchParams({ channelId });-- if (token) qs.set('token', token);-- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live' } });++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live', ...(token ? { authorization: `Bearer ${token}` } : {}) } });const all = [];const waiters = [];ws.on('message', (d) => {
@@ -524,7 +524,10 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -524,7 +524,10 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
- **Auth.** People sign in with a Network session JWT, verified here with the Network's key (tokens
- **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; `?token=` on `/ws/chat` is deprecated. Services use client-credentials tokens for audience
+ Live; WebSocket upgrades use the session cookie or Authorization bearer (Live's legacy `token`
+ cookie is ignored there); `?token=` on `/ws/chat` is deprecated (C-05), counted in
+ `chat_ws_url_token_uses`, and stays until Live's bot guide and call client stop sending it.
+ Services use client-credentials tokens for audience
`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,
`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.
- **Private data.** DMs are delivered only to participants, and private rooms only to members
- **Private data.** DMs are delivered only to participants, and private rooms only to members
@@ -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 — and `?token=`; every message type unchanged) |
+| `^~ /ws/call` | the call WebSocket (`?channelId=` — or the legacy `?streamId=` / a bare stream number — with session cookie or Authorization bearer, or the deprecated `?token=`; every message type unchanged) |
| `^~ /api/streams/voice-channels` | `GET/POST /api/streams/voice-channels`, `GET/DELETE /api/streams/voice-channels/:channelId`, `POST /api/streams/voice-channels/call-user`, `POST /api/streams/voice-channels/call-user/respond` |
| `^~ /api/streams/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` |
@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
@@ -21,7 +21,7 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
| nginx location | Chat serves |
| nginx location | Chat serves |
| --- | --- |
| --- | --- |
-| `^~ /ws/chat` | the chat WebSocket (`?stream=`, `?token=`; protocol unchanged) |
+| `^~ /ws/chat` | the chat WebSocket (`?stream=`; session cookie or Authorization bearer; the deprecated `?token=` still works; protocol unchanged) |
| `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` |
| `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` |
| `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` |
| `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` |
| `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL), `GET queue`, `POST queue/skip`, `POST queue/clear`, `POST queue/:id/report` (the TTS and sound queue) |
| `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL), `GET queue`, `POST queue/skip`, `POST queue/clear`, `POST queue/:id/report` (the TTS and sound queue) |
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* OpenVibe.Chat — request authentication.
* OpenVibe.Chat — request authentication.
*
*
- * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token / token cookie,
+ * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token cookie / Live's legacy token cookie (not on WebSockets),
* or an hbt_ API token) with the same rules, resolved by Live through live-context.authenticate()
* 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
@@ -36,16 +36,17 @@ function apiTokenAllows(req, scopes) {
@@ -36,16 +36,17 @@ function apiTokenAllows(req, scopes) {
/**
/**
* Extract the token from Authorization header or cookie
* Extract the token from Authorization header or cookie
*/
*/
-function extractToken(req) {
+function extractToken(req, { legacyCookie = true } = {}) {
const authHeader = req.headers.authorization;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
return authHeader.slice(7);
}
}
- // Check both cookie names: 'token' (legacy openvibelive) and 'ov_token' (shared network)
+ // The Network session cookie is shared with the browser. REST still accepts Live's legacy
+ // 'token' cookie (as Live does); WebSocket upgrades ignore it (legacyCookie: false).
if (req.cookies) {
if (req.cookies) {
if (req.cookies.ov_token) return req.cookies.ov_token;
if (req.cookies.ov_token) return req.cookies.ov_token;
- if (req.cookies.token) return req.cookies.token;
+ if (legacyCookie && req.cookies.token) return req.cookies.token;
}
}
// Raw Node/WebSocket upgrade requests do not go through cookie-parser,
// Raw Node/WebSocket upgrade requests do not go through cookie-parser,
@@ -62,7 +63,7 @@ function extractToken(req) {
@@ -62,7 +63,7 @@ function extractToken(req) {
try { parsed[key] = decodeURIComponent(value); } catch { parsed[key] = value; }
try { parsed[key] = decodeURIComponent(value); } catch { parsed[key] = value; }
}
}
if (parsed.ov_token) return parsed.ov_token;
if (parsed.ov_token) return parsed.ov_token;
- if (parsed.token) return parsed.token;
+ if (legacyCookie && parsed.token) return parsed.token;
}
}
return null;
return null;
@@ -75,7 +76,7 @@ const urlTokenUses = { jwt: 0, api_token: 0 };
@@ -75,7 +76,7 @@ const urlTokenUses = { jwt: 0, api_token: 0 };
function extractWsToken(req) {
function extractWsToken(req) {
// DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message
// DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message
// and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and
// and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and
- // counted, /metrics chat_ws_url_token_uses) while bots move off it.
+ // counted, /metrics chat_ws_url_token_uses) while Live's bot guide and call client still send 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');
@@ -85,12 +86,8 @@ function extractWsToken(req) {
@@ -85,12 +86,8 @@ function extractWsToken(req) {
}
}
} catch { /* fall through */ }
} catch { /* fall through */ }
- // Fall back to cookie / Authorization header
- const direct = extractToken(req);
- if (direct) return direct;
-
- // Legacy: req.query fallback for non-URL parse environments
- return (req.query && req.query.token) || null;
+ // Live's legacy `token` cookie rides along with ov_token on every browser upgrade: ignored.
+ return extractToken(req, { legacyCookie: false });
}
}
/**
/**
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
* temporary channels. Stream-linked channels are auto-created when a
* temporary channels. Stream-linked channels are auto-created when a
* streamer enables voice on their stream (Live asks: POST /internal/calls/stream-channel).
* streamer enables voice on their stream (Live asks: POST /internal/calls/stream-channel).
*
*
- * WebSocket path: /ws/call?channelId=<id>&token=X
+ * WebSocket path: /ws/call?channelId=<id> (session cookie or Authorization bearer)
*
*
* Channel modes:
* Channel modes:
* 'mic' — Microphone only
* 'mic' — Microphone only
@@ -17,13 +17,12 @@ const t = suite('calls');
@@ -17,13 +17,12 @@ const t = suite('calls');
let h, config, callServer, lifecycle;
let h, config, callServer, lifecycle;
let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;
let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;
-/** Open /ws/call as a browser would (through nginx: the address in CF-Connecting-IP). */
+/** Open /ws/call with a bearer (through nginx: the address in CF-Connecting-IP). */
function callWs({ channelId, token = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {
function callWs({ channelId, token = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams();
const qs = new URLSearchParams();
if (channelId != null) qs.set('channelId', channelId);
if (channelId != null) qs.set('channelId', channelId);
- if (token) qs.set('token', token);
- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin } });
+ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}) } });
const all = [];
const all = [];
const waiters = [];
const waiters = [];
ws.on('message', (d) => {
ws.on('message', (d) => {
@@ -361,13 +361,12 @@ async function boot({ env = {} } = {}) {
@@ -361,13 +361,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). */
- h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
+ /** Open /ws/chat with a bearer or session cookie (through nginx: the address arrives in CF-Connecting-IP). */
+ h.ws = ({ ip = '203.0.113.10', token = null, bearer = null, cookie = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
const qs = new URLSearchParams();
const qs = new URLSearchParams();
if (stream) qs.set('stream', stream);
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 url = `ws://127.0.0.1:${h.port}/ws/chat${qs.toString() ? `?${qs}` : ''}${query}`;
- const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });
+ const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin, ...(bearer || token ? { authorization: `Bearer ${bearer || token}` } : {}), ...(cookie ? { cookie } : {}) } });
const all = [];
const all = [];
const waiters = [];
const waiters = [];
ws.on('message', (d) => {
ws.on('message', (d) => {
@@ -102,15 +102,23 @@ t('/metrics counts where sign-ins were decided', async () => {
@@ -102,15 +102,23 @@ t('/metrics counts where sign-ins were decided', async () => {
t('a token in the WebSocket URL still works (deprecated, C-05) and is counted', async () => {
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 before = require('../server/auth/auth').urlTokenUses().jwt;
- const ws = await h.ws({ token: jwt(claimsFor(alice)) });
+ const ws = await h.ws({ query: `?token=${encodeURIComponent(jwt(claimsFor(alice)))}` });
ws.sendJson({ type: 'join' });
ws.sendJson({ type: 'join' });
- const auth = await ws.next((m) => m.type === 'auth');
- assert.strictEqual(auth.core_username, 'alice');
+ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
ws.close();
ws.close();
assert.strictEqual(require('../server/auth/auth').urlTokenUses().jwt, before + 1);
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.match((await h.http('GET', '/metrics')).text, /chat_ws_url_token_uses\{[^}]*kind="jwt"[^}]*\} [1-9]/);
});
});
+t('WebSocket upgrades ignore the legacy token cookie', async () => {
+ const token = jwt(claimsFor(alice));
+ // Live sets the legacy `token` cookie beside ov_token on every sign-in: ignored, not refused.
+ const ws = await h.ws({ cookie: `token=${token}; ov_token=${token}` });
+ ws.sendJson({ type: 'join' });
+ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
+ ws.close();
+});
+
t('bots authenticate the upgrade with an Authorization header, no token in the URL', async () => {
t('bots authenticate the upgrade with an Authorization header, no token in the URL', async () => {
const before = require('../server/auth/auth').urlTokenUses();
const before = require('../server/auth/auth').urlTokenUses();
const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
const ws = await h.ws({ bearer: jwt(claimsFor(carol)) });
@@ -23,8 +23,7 @@ const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`,
@@ -23,8 +23,7 @@ const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`,
function callWs({ channelId, token = null, ip = '203.0.113.30' } = {}) {
function callWs({ channelId, token = null, ip = '203.0.113.30' } = {}) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams({ channelId });
const qs = new URLSearchParams({ channelId });
- if (token) qs.set('token', token);
- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live' } });
+ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live', ...(token ? { authorization: `Bearer ${token}` } : {}) } });
const all = [];
const all = [];
const waiters = [];
const waiters = [];
ws.on('message', (d) => {
ws.on('message', (d) => {