openvibers/openvibe.bot Commits

Security: a typed Network token (realtime ticket, FedCM assertion) is never a session (#69)

OP OpenVibers committed 10 Oct 2026 · baa21e0161 · 3 files, +13 −2

Security: a **typed Network token is never a session** in Bot.

Bot verified the Network user JWT by signature and issuer only, with no audience check. Network signs two typed tokens with the same key:
- **Realtime tickets** (`typ: realtime`), which a person's browser holds for the Events stream.
- **FedCM assertions** (`typ: fedcm`, audience = the site that asked), which the browser hands to the relying site.

Either was accepted as a Bot session for the person it names. A FedCM assertion given to another site could therefore be replayed to Bot's API, page sessions and operator socket. `verify` now refuses any token with `typ` or `purpose`, which is the rule `openvibe-sdk/auth`'s `verifyUserToken` applies everywhere.

Scope note: Bot's full move to `openvibe-sdk/auth` (plan T1) waits. Its operator socket authorizes synchronously on connect and on re-auth, so it needs message buffering first. This change closes the gap now.

`test/api.test.js`: a session token reads `/api/v1/robots`, while a realtime ticket and a FedCM assertion signed by the same Network key get 401. The stub's `signUser` takes extra claims.

Changes

ddiff --git a/server/network.js b/server/network.js
iindex a8fc8af..09a29b8 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -68,6 +68,9 @@ function createUserAuth(config, keys) {
if (!claims || typeof claims !== 'object') return null;
if (typeof claims.sub === 'string' && /^(svc|app|mod|node):/.test(claims.sub)) return null;
if (claims.actor_type === 'service' || claims.actor_type === 'node') return null;
++ // A typed token is never a session: a realtime ticket (typ realtime) or a FedCM assertion (typ fedcm, issued to
++ // another site) is signed by the same key, and this check takes no audience (openvibe-sdk/auth's rule).
++ if (claims.typ !== undefined || claims.purpose !== undefined) return null;
return claims;
}
return { client, verify };
ddiff --git a/test/api.test.js b/test/api.test.js
iindex e9dc418..726c196 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -35,6 +35,14 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(missing.headers.get('content-type'), 'application/problem+json');
});
++ await check('a typed Network token (realtime ticket, FedCM assertion) is never a session, even signed by the Network key', async () => {
++ assert.strictEqual((await t.call('GET', '/api/v1/robots', { user: alex })).status, 200, 'a session token works');
++ for (const extra of [{ typ: 'realtime', purpose: 'realtime' }, { typ: 'fedcm', aud: 'https://elsewhere.example' }]) {
++ const r = await t.call('GET', '/api/v1/robots', { token: t.network.signUser(alex, extra) });
++ assert.strictEqual(r.status, 401, `${extra.typ}: ${r.status}`);
++ }
++ });
++
await check('a stranger cannot read or manage someone else\'s robot; the owner can', async () => {
const { robot, pairing } = await t.robot(alex);
assert.match(pairing.code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex f1d94bd..846cff7 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -50,8 +50,8 @@ async function startNetwork() {
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
}
-- function signUser(u) {
-- return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },
++ function signUser(u, extra = {}) {
++ return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user', ...extra },
privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
}
// Node principals: the view Network's principalView answers (server/registry/node-principals.js).