openvibers/openvibe.live Commits

Let Companion Dock tokens read their own agent stream (#810)

OP OpenVibers committed 2 days ago · 8decc58e9b · 2 files, +60 −3

* Let Companion Dock tokens read their own agent stream on Live

* Requeue stalled Live security checks

* Merge remote-tracking branch 'origin/main' into feat/agents-everywhere-dock-live-1111b

Changes

ddiff --git a/server/agent-streams/auth.js b/server/agent-streams/auth.js
iindex a81d2615..92ab34fa 100644
---- a/server/agent-streams/auth.js
++++ b/server/agent-streams/auth.js
@@@ -14,7 +14,7 @@
* Watching is the visitor's session when there is one; the view rule itself is service.mayView.
*/
const contracts = require('openvibe-contracts');
--const { verifyServiceToken } = require('openvibe-sdk/auth');
++const { verifyServiceToken, verifyUserToken } = require('openvibe-sdk/auth');
const { optionalAuth, verifyToken, resolveNetworkUser, networkVerifyOptions, getNetworkIssuer } = require('../auth/auth');
const db = require('../db/database');
@@@ -28,6 +28,32 @@ const CAPABILITY = CAPABILITIES[0];
// pinned Contracts release names it (a capability id carries no hyphen).
const SANDBOX_CAPABILITY = 'live.agent_sandbox.register';
const FIRST_PARTY_SUB = /^(svc|node):/;
++const DOCK_FAMILY_RE = /^fam_[0-9a-f]{24}$/;
++
++// Codes forwards the Companion's private-issuer Dock token to these reads. It is never a Live session or a
++// publisher. The ticket route is a POST because minting a short-lived viewer ticket is part of reading the stream.
++function dockReadPath(method, path) {
++ if (method === 'GET') return path === '/mine' || /^\/[^/]+\/(frame|voice|steps|controls)$/.test(path);
++ return method === 'POST' && /^\/[^/]+\/view-ticket$/.test(path);
++}
++
++async function dockViewer(req) {
++ if (!dockReadPath(req.method, req.path)) return null;
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ')) return null;
++ let claims;
++ try {
++ claims = await verifyUserToken(header.slice(7).trim(), {
++ ...networkVerifyOptions(), issuer: `${getNetworkIssuer().replace(/\/+$/, '')}/dock`, audience: 'openvibe.network',
++ });
++ } catch { return null; }
++ if (claims.actor_type !== undefined || claims.role !== undefined || claims.staff_caps !== undefined
++ || claims.client_id !== 'companion' || !DOCK_FAMILY_RE.test(String(claims.fam || ''))
++ || !SUBJECT_RE.test(String(claims.subject_id || ''))
++ || !String(claims.scope || '').split(/\s+/).includes('agent.view')) return null;
++ const user = await userForSubject(claims.subject_id);
++ return user && !user.is_banned ? user : null;
++}
/** The local account linked to a Network subject, or null. */
async function userForSubject(subject) {
@@@ -161,7 +187,7 @@ async function sandboxRegistrar(req, res, next) {
async function viewer(req, res, next) {
try {
await optionalAuth(req, res, () => {});
-- req.viewer = req.user || null;
++ req.viewer = req.user || await dockViewer(req);
} catch { req.viewer = null; }
next();
}
@@@ -179,4 +205,4 @@ async function userFromToken(token) {
return decoded ? await resolveNetworkUser(decoded) : null;
}
--module.exports = { publisher, viewer, viewerSubject, userForSubject, userFromToken, holdsCapability, holdsSandboxCapability, sandboxRegistrar, AUDIENCE, CAPABILITY, CAPABILITIES, SANDBOX_CAPABILITY };
++module.exports = { publisher, viewer, viewerSubject, userForSubject, userFromToken, dockReadPath, dockViewer, holdsCapability, holdsSandboxCapability, sandboxRegistrar, AUDIENCE, CAPABILITY, CAPABILITIES, SANDBOX_CAPABILITY };
ddiff --git a/test/agent-streams.test.js b/test/agent-streams.test.js
iindex 12fc0ce6..d3553e72 100644
---- a/test/agent-streams.test.js
++++ b/test/agent-streams.test.js
@@@ -16,6 +16,17 @@
const assert = require('assert');
const http = require('http');
const express = require('express');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const jwt = require('jsonwebtoken');
++
++const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-agent-dock-'));
++const dockKeys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++fs.writeFileSync(path.join(keyDir, 'network.pem'), dockKeys.publicKey);
++process.env.OV_NETWORK_PUBLIC_KEY = path.join(keyDir, 'network.pem');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.NODE_ENV = 'test';
const quiet = console.log;
@@@ -133,7 +144,26 @@ async function check(name, fn) {
return { status: res.status, data, headers: res.headers };
};
++ const dockToken = (overrides = {}) => jwt.sign({ sub: '1', username: 'ann', subject_id: SID.ann,
++ client_id: 'companion', fam: `fam_${'a'.repeat(24)}`, scope: 'agent.view', ...overrides }, dockKeys.privateKey,
++ { algorithm: 'RS256', issuer: 'https://openvibe.network/dock', audience: 'openvibe.network', expiresIn: 600 });
++
try {
++ await check('Companion Dock token reads its own stream and mints a view ticket, but cannot control it', async () => {
++ const headers = { authorization: `Bearer ${dockToken()}` };
++ const mine = await call('GET', '/api/streams/agent/mine', { as: 0, headers });
++ assert.equal(mine.status, 200);
++ assert.ok(mine.data.streams.some((s) => s.id === agent.public_id));
++ const ticket = await call('POST', `/api/streams/agent/${agent.public_id}/view-ticket`, { as: 0, headers, body: {} });
++ assert.equal(ticket.status, 200);
++ assert.ok(ticket.data.ticket || ticket.data.view_ticket);
++ const control = await call('POST', `/api/streams/agent/${agent.public_id}/control`, { as: 0, headers, body: { control: 'pause' } });
++ assert.notEqual(control.status, 200);
++ for (const claims of [{ scope: 'profile' }, { client_id: 'other' }, { subject_id: SID.bob }]) {
++ const denied = await call('GET', '/api/streams/agent/mine', { as: 0, headers: { authorization: `Bearer ${dockToken(claims)}` } });
++ assert.ok(!denied.data.streams.some((s) => s.id === agent.public_id));
++ }
++ });
quiet('agent streams: the resources');
await check('the owner reads the resource, with no stream key and nothing secret', async () => {
const r = await call('GET', `/api/streams/agent/${agent.public_id}`, { as: 1 });
@@@ -426,6 +456,7 @@ async function check(name, fn) {
});
} finally {
server.close();
++ fs.rmSync(keyDir, { recursive: true, force: true });
}
if (failures) { quiet(`${failures} agent stream check(s) failed`); process.exit(1); }