openvibers/openvibe.live Commits
Let Companion Dock tokens read their own agent stream (#810)
Changes
@@@ -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 };@@@ -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); }
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
* Watching is the visitor's session when there is one; the view rule itself is service.mayView.
* Watching is the visitor's session when there is one; the view rule itself is service.mayView.
*/
*/
const contracts = require('openvibe-contracts');
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 { optionalAuth, verifyToken, resolveNetworkUser, networkVerifyOptions, getNetworkIssuer } = require('../auth/auth');
const db = require('../db/database');
const db = require('../db/database');
@@ -28,6 +28,32 @@ const CAPABILITY = CAPABILITIES[0];
@@ -28,6 +28,32 @@ const CAPABILITY = CAPABILITIES[0];
// pinned Contracts release names it (a capability id carries no hyphen).
// pinned Contracts release names it (a capability id carries no hyphen).
const SANDBOX_CAPABILITY = 'live.agent_sandbox.register';
const SANDBOX_CAPABILITY = 'live.agent_sandbox.register';
const FIRST_PARTY_SUB = /^(svc|node):/;
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. */
/** The local account linked to a Network subject, or null. */
async function userForSubject(subject) {
async function userForSubject(subject) {
@@ -161,7 +187,7 @@ async function sandboxRegistrar(req, res, next) {
@@ -161,7 +187,7 @@ async function sandboxRegistrar(req, res, next) {
async function viewer(req, res, next) {
async function viewer(req, res, next) {
try {
try {
await optionalAuth(req, res, () => {});
await optionalAuth(req, res, () => {});
- req.viewer = req.user || null;
+ req.viewer = req.user || await dockViewer(req);
} catch { req.viewer = null; }
} catch { req.viewer = null; }
next();
next();
}
}
@@ -179,4 +205,4 @@ async function userFromToken(token) {
@@ -179,4 +205,4 @@ async function userFromToken(token) {
return decoded ? await resolveNetworkUser(decoded) : null;
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 };
@@ -16,6 +16,17 @@
@@ -16,6 +16,17 @@
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
const express = require('express');
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';
process.env.NODE_ENV = 'test';
const quiet = console.log;
const quiet = console.log;
@@ -133,7 +144,26 @@ async function check(name, fn) {
@@ -133,7 +144,26 @@ async function check(name, fn) {
return { status: res.status, data, headers: res.headers };
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 {
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');
quiet('agent streams: the resources');
await check('the owner reads the resource, with no stream key and nothing secret', async () => {
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 });
const r = await call('GET', `/api/streams/agent/${agent.public_id}`, { as: 1 });
@@ -426,6 +456,7 @@ async function check(name, fn) {
@@ -426,6 +456,7 @@ async function check(name, fn) {
});
});
} finally {
} finally {
server.close();
server.close();
+ fs.rmSync(keyDir, { recursive: true, force: true });
}
}
if (failures) { quiet(`${failures} agent stream check(s) failed`); process.exit(1); }
if (failures) { quiet(`${failures} agent stream check(s) failed`); process.exit(1); }