openvibers/openvibe.chat Commits

Dock token: read-only DM access for the extension's private-issuer token (#270)

OP OpenVibers committed 14 days ago · ef91d76d9e · 5 files, +164 −8

Changes

ddiff --git a/server/app.js b/server/app.js
iindex d53f621..07187ec 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -20,6 +20,7 @@ const db = require('./db/database');
const { banExemptionToken, authenticateWs, requestUser } = require('./auth/auth');
const { trustProxy } = require('./net/client-ip');
const { createInternalIngress } = require('./chat/internal-ingress');
++const { isExtensionOrigin } = require('./auth/extension-origin');
function normalizeOrigin(origin) {
if (!origin || typeof origin !== 'string') return null;
@@@ -193,7 +194,20 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
// refuses the others itself (server/inbox/routes.js).
const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });
const openPath = (p) => p.startsWith('/api/chat/widget/') || p === '/api/chat/feeds' || p.startsWith('/api/chat/feeds/');
-- app.use((req, res, next) => (openPath(req.path) ? openCors : allowlistedCors)(req, res, next));
++ // Extension pages get no CORS grant (no echoed Origin, no credentials) and are not refused for the
++ // origin alone. Only a Bearer may act there; auth/auth.js refuses the cookie separately.
++ const extensionCors = (req, res, next) => {
++ if (req.method === 'OPTIONS') {
++ res.statusCode = 204;
++ res.setHeader('Content-Length', '0');
++ return res.end();
++ }
++ return next();
++ };
++ app.use((req, res, next) => {
++ if (isExtensionOrigin(req.headers.origin)) return extensionCors(req, res, next);
++ return (openPath(req.path) ? openCors : allowlistedCors)(req, res, next);
++ });
// A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.
app.post('/api/chat/hooks/:id', express.raw({ type: () => true, limit: '64kb' }), require('./rooms/hooks').ingress);
app.use(express.json({ limit: '1mb' }));
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 13702e2..87cf0a3 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -12,6 +12,7 @@
const ctx = require('../live-context');
const session = require('./network-session');
++const { isExtensionOrigin } = require('./extension-origin');
/**
* What an hbt_ API token may do over REST, by scope (Live's apiTokenAllows, verbatim).
@@@ -45,6 +46,10 @@ function extractTokenFrom(req) {
return { token: authHeader.slice(7), from: 'header' };
}
++ // An extension page never authenticates with a cookie: only its Bearer counts. The page has host
++ // permissions, so the request works without CORS; a stray ov_token must not act for it.
++ if (isExtensionOrigin(req.headers.origin)) return { token: null, from: null };
++
// The Network session cookie is shared with the browser.
if (req.cookies) {
if (req.cookies.ov_token) return { token: req.cookies.ov_token, from: 'ov_token_cookie' };
@@@ -117,11 +122,25 @@ async function authenticateWs(token) {
function requestUser(req) {
if (!req._ovAuthUser) {
const token = extractToken(req);
-- req._ovAuthUser = token ? session.authenticate(token).catch(() => null) : Promise.resolve(null);
++ req._ovAuthUser = token ? session.authenticate(token, { allowDock: isDmPath(req) }).catch(() => null) : Promise.resolve(null);
}
return req._ovAuthUser;
}
++/** The DM API path (the only place a Dock token is allowed to go in Chat). */
++function isDmPath(req) {
++ const path = String(req.originalUrl || req.url || '').split('?')[0];
++ return path === '/api/dm' || path.startsWith('/api/dm/');
++}
++
++/** Reads only: DM GET/HEAD, plus marking a conversation read. Sending/creating/renaming are not allowed. */
++function isDmRead(req) {
++ if (!isDmPath(req)) return false;
++ if (req.method === 'GET' || req.method === 'HEAD') return true;
++ const path = String(req.originalUrl || req.url || '').split('?')[0];
++ return req.method === 'POST' && /^\/api\/dm\/conversations\/[^/]+\/read$/.test(path);
++}
++
/**
* Express middleware — requires a valid openvibe.network JWT or API token.
*/
@@@ -152,8 +171,11 @@ async function requireAuth(req, res, next) {
if (user.is_banned) {
return res.status(403).json({ error: 'Account is banned', reason: user.ban_reason });
}
++ if (user.auth_source === 'dock' && !isDmRead(req)) {
++ return res.status(403).json({ error: 'This extension can only read messages.' });
++ }
req.user = user;
-- req.authSource = 'network';
++ req.authSource = user.auth_source === 'dock' ? 'dock' : 'network';
next();
}
@@@ -172,9 +194,9 @@ async function optionalAuth(req, res, next) {
req.authSource = 'api_token';
req.tokenScopes = user.scopes || [];
}
-- } else {
++ } else if (user.auth_source !== 'dock' || isDmRead(req)) {
req.user = user;
-- req.authSource = 'network';
++ req.authSource = user.auth_source === 'dock' ? 'dock' : 'network';
}
}
}
@@@ -194,6 +216,7 @@ async function requireAdmin(req, res, next) {
}
module.exports = {
++ isDmPath, isDmRead,
apiTokenAllows,
extractToken,
requestUser,
ddiff --git a/server/auth/extension-origin.js b/server/auth/extension-origin.js
nnew file mode 100644
iindex 0000000..f0ca99f
---- /dev/null
++++ b/server/auth/extension-origin.js
@@@ -0,0 +1,16 @@
++'use strict';
++// A browser extension page is not a web origin. Chrome can send `Origin: chrome-extension://<id>` and
++// Firefox `Origin: moz-extension://<uuid>`; the UUID is random per install, so there is no origin to
++// put on an allow-list. The rules:
++//
++// * the origin alone is never a reason to answer 403;
++// * no Access-Control-Allow-Origin / Access-Control-Allow-Credentials is sent (an extension page with
++// its host permissions does not need CORS, and a page origin must never get a credentialed grant);
++// * a cookie session is never accepted from such an origin — only a Bearer token is.
++const EXTENSION_ORIGIN_RE = /^(?:chrome-extension|moz-extension):\/\/[^/]+$/;
++
++function isExtensionOrigin(origin) {
++ return typeof origin === 'string' && EXTENSION_ORIGIN_RE.test(origin.trim());
++}
++
++module.exports = { isExtensionOrigin };
ddiff --git a/server/auth/network-session.js b/server/auth/network-session.js
iindex 0ac5dfe..24fe2fb 100644
---- a/server/auth/network-session.js
++++ b/server/auth/network-session.js
@@@ -28,7 +28,11 @@ const ctx = require('../live-context');
const revocations = require('./revocations');
const ROLE_RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
--const stats = { local: 0, live: 0, rejected: 0 };
++const DOCK_CLIENT_ID = 'companion';
++const DOCK_SCOPE = 'chat.read';
++const dockIssuer = () => `${String(config.networkUrl || '').replace(/\/+$/, '')}/dock`;
++const scopesOf = (claims) => String((claims && claims.scope) || '').split(/\s+/).filter(Boolean);
++const stats = { local: 0, dock: 0, live: 0, rejected: 0 };
const _reasons = new Map(); // sha256(token) → 'invalid' for tokens refused here (bounded)
const tokenKey = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
@@@ -78,8 +82,12 @@ function refuse(token) {
return null;
}
--/** Resolve a browser/bot token to a chat user (a fresh object) or null. */
--async function authenticate(token) {
++/**
++ * Resolve a browser/bot token to a chat user (a fresh object) or null. `allowDock` opens the private
++ * Dock issuer (the browser extension) and requires `chat.read`; the caller decides whether the request
++ * path is a read (auth/auth.js). A Dock token never falls through to Live.
++ */
++async function authenticate(token, { allowDock = false } = {}) {
if (!token) return null;
const viaLive = async () => {
stats.live++;
@@@ -91,6 +99,21 @@ async function authenticate(token) {
};
if (String(token).startsWith('hbt_')) return await viaLive();
const r = await verify(token);
++ // The extension's Dock token: private issuer, companion client, chat.read scope, and only on paths
++ // the request guard marks as reads. It is never sent to Live and never becomes a normal session.
++ if (!r.ok && r.reason === 'issuer' && allowDock && dockIssuerOf(token) === dockIssuer()) {
++ const d = await verify(token, undefined, { issuer: dockIssuer() });
++ if (!d.ok || d.claims.client_id !== DOCK_CLIENT_ID || !scopesOf(d.claims).includes(DOCK_SCOPE)) return refuse(token);
++ if (await revocations.isRevoked(d.claims)) return refuse(token);
++ const dockUser = await userFor(d.claims);
++ if (!dockUser) return refuse(token);
++ dockUser.auth_source = 'dock';
++ stats.dock++;
++ return dockUser;
++ }
++ // A token that names the Dock issuer but does not verify here is never sent to Live: its issuer is
++ // not the Network session issuer Live knows.
++ if (r.reason === 'signature' && dockIssuerOf(token) === dockIssuer()) return refuse(token);
// Only a token signed by a key Network publishes is decided here; anything else (no key yet, not a JWT, another
// signer) is Live's to judge.
if (!r.ok) return ['signature', 'malformed', 'no_key'].includes(r.reason) ? await viaLive() : refuse(token);
@@@ -102,6 +125,13 @@ async function authenticate(token) {
return user;
}
++/** The unverified issuer of a JWT, only to choose which issuer to verify against; null otherwise. */
++function dockIssuerOf(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return b64json(parts[1]).iss || null; } catch { return null; }
++}
++
/** A JWT's iat (seconds), read without verifying (the token was verified when it was accepted); null otherwise. */
function tokenIat(token) {
const parts = String(token || '').split('.');
ddiff --git a/test/dock-signin.test.js b/test/dock-signin.test.js
nnew file mode 100644
iindex 0000000..543c15e
---- /dev/null
++++ b/test/dock-signin.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++// The Dock's private-issuer token in Chat (2026-10-30): only `chat.read` and only DM reads (GET/HEAD
++// plus the read receipt). Sending, creating and every chat route are refused. An extension origin gets
++// no CORS grant and never authenticates with a cookie — only its Bearer counts.
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, suite, b64url } = require('./helpers');
++
++const t = suite('dock signin');
++let h, alice, bob, convId;
++const now = () => Math.floor(Date.now() / 1000);
++const jwt = (claims, key = h.keys.privateKey) => {
++ const head = `${b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${b64url(JSON.stringify(claims))}`;
++ return `${head}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(head), key))}`;
++};
++const normal = (u) => jwt({ iss: h.ISS, sub: String(u.id), subject_id: u.subject_id, username: u.username, role: u.role, iat: now(), exp: now() + 3600 });
++const dock = (u, scope = 'chat.read') => jwt({
++ iss: `${h.ISS}/dock`, sub: String(u.id), id: u.id, subject_id: u.subject_id, username: u.username,
++ client_id: 'companion', scope, fam: `fam_${'a'.repeat(24)}`, iat: now(), exp: now() + 3600,
++});
++
++t('boot', async () => {
++ h = await boot();
++ alice = h.addUser('dockalice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' });
++ bob = h.addUser('dockbob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4' });
++ for (const u of [alice, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ const r = await h.http('POST', '/api/dm/conversations', { token: normal(alice), body: { user_ids: [bob.id] } });
++ assert.strictEqual(r.status, 200, r.text);
++ convId = r.body.conversation.id;
++});
++
++t('a Dock token reads DM conversations and messages, and marks read', async () => {
++ const tok = dock(alice);
++ const list = await h.http('GET', '/api/dm/conversations', { token: tok });
++ assert.strictEqual(list.status, 200, list.text);
++ assert.ok(list.body.conversations.some((c) => c.id === convId));
++ assert.strictEqual((await h.http('GET', `/api/dm/conversations/${convId}/messages`, { token: tok })).status, 200);
++ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: tok })).status, 200);
++ assert.strictEqual((await h.http('POST', `/api/dm/conversations/${convId}/read`, { token: tok })).status, 200);
++});
++
++t('the same token cannot send, create, rename or reach another chat route', async () => {
++ const tok = dock(alice);
++ const send = await h.http('POST', `/api/dm/conversations/${convId}/messages`, { token: tok, body: { message: 'no' } });
++ assert.strictEqual(send.status, 403, send.text);
++ assert.strictEqual((await h.http('POST', '/api/dm/conversations', { token: tok, body: { user_ids: [bob.id] } })).status, 403);
++ assert.strictEqual((await h.http('PATCH', `/api/dm/conversations/${convId}`, { token: tok, body: { name: 'x' } })).status, 403);
++ assert.strictEqual((await h.http('GET', '/api/chat/search?q=hello', { token: tok })).status, 401);
++});
++
++t('scope is enforced: a Dock token without chat.read is refused', async () => {
++ assert.strictEqual((await h.http('GET', '/api/dm/conversations', { token: dock(alice, 'dock') })).status, 401);
++ assert.strictEqual((await h.http('GET', '/api/dm/conversations', { token: normal(alice) })).status, 200);
++});
++
++t('an extension origin gets no CORS grant and never authenticates with a cookie', async () => {
++ const origin = 'chrome-extension://bjcjcfcecpkkmfndegkjekoipdmbgakd';
++ const tok = dock(alice);
++ const r = await h.http('GET', '/api/dm/conversations', { token: tok, headers: { origin } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, 'no echoed origin');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null, 'no credentialed grant');
++ const cookieOnly = await h.http('GET', '/api/dm/conversations', { headers: { origin, cookie: `ov_token=${normal(alice)}` } });
++ assert.strictEqual(cookieOnly.status, 401, 'a cookie is not a session for an extension');
++ const both = await h.http('GET', '/api/dm/conversations', { token: tok, headers: { origin, cookie: `ov_token=${normal(alice)}` } });
++ assert.strictEqual(both.status, 200, 'the Bearer acts, the cookie is ignored');
++ assert.strictEqual((await h.http('GET', '/api/dm/conversations', { token: tok, headers: { origin: 'https://evil.example' } })).status, 403, 'a foreign web origin is still refused');
++ const moz = await h.http('GET', '/api/dm/conversations', { token: tok, headers: { origin: 'moz-extension://8d3b2a5e-0a9c-4f7e-9c3b-2a1f6d4e8c11' } });
++ assert.strictEqual(moz.status, 200);
++ assert.strictEqual(moz.headers.get('access-control-allow-origin'), null);
++});
++
++t.run(async () => { await h.shutdown(); });