openvibers/openvibe.chat Commits
Dock token: read-only DM access for the extension's private-issuer token (#270)
Changes
@@@ -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' }));@@@ -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,nnew file mode 100644@@@ -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 };@@@ -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('.');nnew file mode 100644@@@ -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(); });
@@ -20,6 +20,7 @@ const db = require('./db/database');
@@ -20,6 +20,7 @@ const db = require('./db/database');
const { banExemptionToken, 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');
+const { isExtensionOrigin } = require('./auth/extension-origin');
function normalizeOrigin(origin) {
function normalizeOrigin(origin) {
if (!origin || typeof origin !== 'string') return null;
if (!origin || typeof origin !== 'string') return null;
@@ -193,7 +194,20 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -193,7 +194,20 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
// refuses the others itself (server/inbox/routes.js).
// refuses the others itself (server/inbox/routes.js).
const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });
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/');
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.
// 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.post('/api/chat/hooks/:id', express.raw({ type: () => true, limit: '64kb' }), require('./rooms/hooks').ingress);
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '1mb' }));
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
const ctx = require('../live-context');
const ctx = require('../live-context');
const session = require('./network-session');
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).
* What an hbt_ API token may do over REST, by scope (Live's apiTokenAllows, verbatim).
@@ -45,6 +46,10 @@ function extractTokenFrom(req) {
@@ -45,6 +46,10 @@ function extractTokenFrom(req) {
return { token: authHeader.slice(7), from: 'header' };
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.
// The Network session cookie is shared with the browser.
if (req.cookies) {
if (req.cookies) {
if (req.cookies.ov_token) return { token: req.cookies.ov_token, from: 'ov_token_cookie' };
if (req.cookies.ov_token) return { token: req.cookies.ov_token, from: 'ov_token_cookie' };
@@ -117,11 +122,25 @@ async function authenticateWs(token) {
@@ -117,11 +122,25 @@ async function authenticateWs(token) {
function requestUser(req) {
function requestUser(req) {
if (!req._ovAuthUser) {
if (!req._ovAuthUser) {
const token = extractToken(req);
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;
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.
* Express middleware — requires a valid openvibe.network JWT or API token.
*/
*/
@@ -152,8 +171,11 @@ async function requireAuth(req, res, next) {
@@ -152,8 +171,11 @@ async function requireAuth(req, res, next) {
if (user.is_banned) {
if (user.is_banned) {
return res.status(403).json({ error: 'Account is banned', reason: user.ban_reason });
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.user = user;
- req.authSource = 'network';
+ req.authSource = user.auth_source === 'dock' ? 'dock' : 'network';
next();
next();
}
}
@@ -172,9 +194,9 @@ async function optionalAuth(req, res, next) {
@@ -172,9 +194,9 @@ async function optionalAuth(req, res, next) {
req.authSource = 'api_token';
req.authSource = 'api_token';
req.tokenScopes = user.scopes || [];
req.tokenScopes = user.scopes || [];
}
}
- } else {
+ } else if (user.auth_source !== 'dock' || isDmRead(req)) {
req.user = user;
req.user = user;
- req.authSource = 'network';
+ req.authSource = user.auth_source === 'dock' ? 'dock' : 'network';
}
}
}
}
}
}
@@ -194,6 +216,7 @@ async function requireAdmin(req, res, next) {
@@ -194,6 +216,7 @@ async function requireAdmin(req, res, next) {
}
}
module.exports = {
module.exports = {
+ isDmPath, isDmRead,
apiTokenAllows,
apiTokenAllows,
extractToken,
extractToken,
requestUser,
requestUser,
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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 };
@@ -28,7 +28,11 @@ const ctx = require('../live-context');
@@ -28,7 +28,11 @@ const ctx = require('../live-context');
const revocations = require('./revocations');
const revocations = require('./revocations');
const ROLE_RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
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 _reasons = new Map(); // sha256(token) → 'invalid' for tokens refused here (bounded)
const tokenKey = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
const tokenKey = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
@@ -78,8 +82,12 @@ function refuse(token) {
@@ -78,8 +82,12 @@ function refuse(token) {
return null;
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;
if (!token) return null;
const viaLive = async () => {
const viaLive = async () => {
stats.live++;
stats.live++;
@@ -91,6 +99,21 @@ async function authenticate(token) {
@@ -91,6 +99,21 @@ async function authenticate(token) {
};
};
if (String(token).startsWith('hbt_')) return await viaLive();
if (String(token).startsWith('hbt_')) return await viaLive();
const r = await verify(token);
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
// 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.
// signer) is Live's to judge.
if (!r.ok) return ['signature', 'malformed', 'no_key'].includes(r.reason) ? await viaLive() : refuse(token);
if (!r.ok) return ['signature', 'malformed', 'no_key'].includes(r.reason) ? await viaLive() : refuse(token);
@@ -102,6 +125,13 @@ async function authenticate(token) {
@@ -102,6 +125,13 @@ async function authenticate(token) {
return user;
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. */
/** A JWT's iat (seconds), read without verifying (the token was verified when it was accepted); null otherwise. */
function tokenIat(token) {
function tokenIat(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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(); });