openvibers/openvibe.codes Commits

Agent view: the Dock token may read, and nothing else (#301)

OP OpenVibers committed 6 days ago · cb90f9b89b · 4 files, +143 −12

The Companion extension's side panel holds Network's Dock token (private
`<network>/dock` issuer, scopes dock/activity/chat.read). Codes' SSO middleware
rightly refuses that as a session, so GET /api/v1/me/agent-view and its /frame
answered 401 and the panel had nothing to show.

The two read routes now also accept a Dock token, verified the way Network's own
Dock guard verifies it: signature against Network's JWKS, the private issuer,
audience openvibe.network, client `companion`, no role or staff caps, a refresh
family, and a canonical subject. Only a token carrying `agent.view` is a reader;
the person it names is the caller. Every POST (pause, curtain, mute, end) still
refuses a Dock token, with 403 auth.read_only and the plain reason that the
panel's buttons open openvibe.codes. An ordinary Network session is unchanged.

Tests in test/agent-view.test.js: a Dock token with agent.view reads the view
and the still; one without it is 403; forged and expired are 401; all four write
routes are 403 and reach no control; a normal token still reads.

Changes

ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 0b84abd..d6acdd7 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -29,6 +29,14 @@ const ACCESS_COOKIE = 'codes_at';
const REFRESH_COOKIE = 'codes_rt';
const HINT_COOKIE = 'ov_sso_hint';
++// The Dock's private-issuer token (Network server/auth/dock-tokens.js): the Companion extension's one-click
++// sign-in. It is never a session here — only the agent view's two read routes opt in, and only for `agent.view`.
++const DOCK_CLIENT_ID = 'companion';
++const DOCK_AUDIENCE = 'openvibe.network';
++const DOCK_SCOPE = 'agent.view';
++const DOCK_FAMILY_RE = /^fam_[0-9a-f]{24}$/;
++const DOCK_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
function pkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
@@@ -151,6 +159,28 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
};
}
++ /**
++ * The Dock token, verified exactly as Network's own Dock guard verifies it (signature against Network's keys,
++ * the private `<network issuer>/dock` issuer, audience openvibe.network) and then its claim shape: no typed
++ * token, no role or staff caps, client `companion`, a refresh family, and a canonical person. Only a token
++ * that names the scope `agent.view` is a reader; the rest is a 403, and anything that does not verify is a 401.
++ * Codes cannot see Network's oauth_tokens table, so a revoked access token is refused by its expiry here.
++ */
++ async function dockViewer(token) {
++ if (!token) return { status: 401, code: 'auth.required' };
++ const issuer = `${String(config.networkIssuer || config.networkUrl).replace(/\/+$/, '')}/dock`;
++ const v = await keys.verifyUser(token, { issuer, audience: DOCK_AUDIENCE });
++ if (!v.ok) return { status: 401, code: 'auth.required', reason: v.reason };
++ const claims = v.claims;
++ const shaped = claims.typ === undefined && claims.purpose === undefined && claims.actor_type === undefined
++ && claims.client_id === DOCK_CLIENT_ID && claims.role === undefined && claims.staff_caps === undefined
++ && DOCK_FAMILY_RE.test(String(claims.fam || '')) && DOCK_SUBJECT_RE.test(String(claims.subject_id || ''));
++ if (!shaped) return { status: 401, code: 'auth.required' };
++ const scopes = String(claims.scope || '').split(/\s+/).filter(Boolean);
++ if (!scopes.includes(DOCK_SCOPE)) return { status: 403, code: 'auth.scope', scopes };
++ return { viewer: { ...viewerFrom(claims, token), dock: true }, token, scopes };
++ }
++
/** A call with the person's own token; a 401 refreshes the session once and tries again. */
async function asViewer(req, res, fn) {
const token = req.token || (req.viewer && req.viewer.token) || null;
@@@ -226,7 +256,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
return r;
}
-- return { middleware, routes, asViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };
++ return { middleware, routes, asViewer, dockViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };
}
module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/http/agent-view.js b/server/http/agent-view.js
iindex d24648e..8f805fe 100644
---- a/server/http/agent-view.js
++++ b/server/http/agent-view.js
@@@ -35,6 +35,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
const json = express.json({ limit: '4kb' });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const signedIn = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const bearerOf = (req) => {
++ const h = String(req.get('authorization') || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : '';
++ };
const liveBase = () => String((config.live && config.live.url) || 'https://openvibe.live').replace(/\/+$/, '');
/** One call to Live with the person's own token. Its own deadline: the Dock must not hang on a slow Live. */
@@@ -143,10 +147,46 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
return next();
});
-- r.get('/me/agent-view', actorLimits.reads('codes.read'), async (req, res) => {
-- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
++ // ── The Dock's read-only token (Companion's one-click sign-in) ──
++ // The panel holds a Network token with the private `<network>/dock` issuer, which the SSO middleware rightly
++ // refuses as a session. The two GET routes below are the only ones that accept it, and only with `agent.view`;
++ // the person it names is the caller, with read rights. Every POST stays the owner's own session.
++ const dockReader = async (req, res, next) => {
++ if (signedIn(req)) return next();
++ const v = await sso.dockViewer(bearerOf(req));
++ if (v.viewer) {
++ req.viewer = v.viewer; // the person, for the actor limit and the handlers
++ req.dock = true;
++ } else if (v.status === 403) {
++ req.dockReadDenied = v; // a verified Dock token without agent.view
++ }
++ return next();
++ };
++
++ // A Dock token on a write is a 403 with the way to do it, never a 401: the panel is signed in, it is just read-only.
++ const dockWrite = async (req, res, next) => {
++ if (signedIn(req)) return next();
++ const v = await sso.dockViewer(bearerOf(req));
++ if (v.viewer || v.status === 403) {
++ return problem(req, res, 403, 'auth.read_only', 'the side panel shows the agent; its buttons open openvibe.codes');
++ }
++ return next();
++ };
++
++ /** The read routes' answer for a caller who is neither a session nor a Dock reader; true when it answered. */
++ const refusedRead = (req, res) => {
++ if (req.dockReadDenied) return !!problem(req, res, 403, 'auth.scope', 'this sign-in is not allowed to see your agent');
++ if (!signedIn(req)) return !!problem(req, res, 401, 'auth.required', 'sign in to see your agent');
++ return false;
++ };
++
++ /** The person's token to Live: the session's (refreshed once on a 401), or the Dock token as it is. */
++ const withViewer = (req, res, fn) => (req.dock ? fn(req.viewer.token) : sso.asViewer(req, res, fn));
++
++ r.get('/me/agent-view', dockReader, actorLimits.reads('codes.read'), async (req, res) => {
++ if (refusedRead(req, res)) return undefined;
try {
-- const agent = await sso.asViewer(req, res, (token) => viewOf(token));
++ const agent = await withViewer(req, res, (token) => viewOf(token));
return res.json({ agent });
} catch (e) {
if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
@@@ -156,11 +196,11 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
});
// The last frame: Live serves it to the owner only, so Codes asks with their token and passes the picture on.
-- r.get('/me/agent-view/frame', actorLimits.reads('codes.read'), async (req, res) => {
-- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
++ r.get('/me/agent-view/frame', dockReader, actorLimits.reads('codes.read'), async (req, res) => {
++ if (refusedRead(req, res)) return undefined;
let frame = null;
try {
-- frame = await sso.asViewer(req, res, async (token) => {
++ frame = await withViewer(req, res, async (token) => {
const row = await streamOf(token);
if (!row || !row.last_frame_url) return null;
// Built here from the stream's id and our own Live base: Codes never fetches an address Live names.
@@@ -224,10 +264,10 @@ function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch
}
};
-- r.post('/me/agent-view/pause', actorLimits.budget('codes.agent_view.control'), json, controlWithBody('pause', actControl('pause', (req) => ({ control: 'pause', paused: !!(req.body && req.body.paused) }))));
-- r.post('/me/agent-view/curtain', actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));
-- r.post('/me/agent-view/mute', actorLimits.budget('codes.agent_view.control'), json, controlWithBody('mute', actControl('mute', (req) => ({ control: 'mute', muted: !!(req.body && req.body.muted) }))));
-- r.post('/me/agent-view/end', actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));
++ r.post('/me/agent-view/pause', dockWrite, actorLimits.budget('codes.agent_view.control'), json, controlWithBody('pause', actControl('pause', (req) => ({ control: 'pause', paused: !!(req.body && req.body.paused) }))));
++ r.post('/me/agent-view/curtain', dockWrite, actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));
++ r.post('/me/agent-view/mute', dockWrite, actorLimits.budget('codes.agent_view.control'), json, controlWithBody('mute', actControl('mute', (req) => ({ control: 'mute', muted: !!(req.body && req.body.muted) }))));
++ r.post('/me/agent-view/end', dockWrite, actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));
return r;
}
ddiff --git a/test/agent-view.test.js b/test/agent-view.test.js
iindex 60570df..ab298ea 100644
---- a/test/agent-view.test.js
++++ b/test/agent-view.test.js
@@@ -155,6 +155,56 @@ const STREAM = (o = {}) => ({
assert.strictEqual(res.json().code, 'agent_view.unavailable');
live.fail = false;
});
++
++ // ── The Dock token (Companion's one-click sign-in) ─────────
++ // Network's Dock issuer and keys, the `agent.view` scope, the person it names: the two read routes
++ // accept it. Every write stays the owner's own session.
++
++ await check('a Dock token with agent.view reads the view and the still, as that person', async () => {
++ live.streams = [STREAM({ last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })];
++ live.authorizations.length = 0;
++ const token = t.network.dockToken(ada);
++ const res = await t.get('/api/v1/me/agent-view', { bearer: token });
++ assert.strictEqual(res.status, 200, res.text);
++ const a = res.json().agent;
++ assert.strictEqual(a.id, LIVE_ID);
++ assert.strictEqual(a.state, 'live');
++ assert.strictEqual(a.embed_url, `https://openvibe.live/embed/agent/${LIVE_ID}?vt=wvt_the_view_ticket`);
++ for (const auth of live.authorizations) assert.strictEqual(auth, `Bearer ${token}`, 'the Dock token is the person\'s own bearer to Live');
++
++ const frame = await t.get('/api/v1/me/agent-view/frame', { bearer: token });
++ assert.strictEqual(frame.status, 200, frame.text);
++ assert.deepStrictEqual([...frame.buffer], [...FRAME]);
++ });
++
++ await check('a Dock token without agent.view is refused the read', async () => {
++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.dockToken(ada, { scope: 'dock activity chat.read' }) });
++ assert.strictEqual(res.status, 403, res.text);
++ assert.strictEqual(res.json().code, 'auth.scope');
++ const frame = await t.get('/api/v1/me/agent-view/frame', { bearer: t.network.dockToken(ada, { scope: 'dock' }) });
++ assert.strictEqual(frame.status, 403, frame.text);
++ });
++
++ await check('a forged or expired Dock token is a 401', async () => {
++ const good = t.network.dockToken(ada);
++ const [h, p] = good.split('.');
++ const forged = `${h}.${p}.${'A'.repeat(342)}`;
++ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: forged })).status, 401);
++ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: t.network.dockToken(ada, { ttl: -120 }) })).status, 401);
++ const notDock = t.network.userToken(ada);
++ assert.strictEqual((await t.get('/api/v1/me/agent-view', { bearer: notDock })).status, 200, 'a normal session still reads');
++ });
++
++ await check('every write route refuses a Dock token with a plain reason', async () => {
++ const before = calls.length;
++ for (const what of ['pause', 'curtain', 'mute', 'end']) {
++ const res = await t.get(`/api/v1/me/agent-view/${what}`, { bearer: t.network.dockToken(ada), method: 'POST', json: {} });
++ assert.strictEqual(res.status, 403, `${what}: ${res.text}`);
++ assert.strictEqual(res.json().code, 'auth.read_only', what);
++ assert.match(res.json().detail, /openvibe\.codes/, what);
++ }
++ assert.strictEqual(calls.length, before, 'no control reached Live');
++ });
} finally {
await t.close();
server.close();
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 7638134..ba718c5 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -73,6 +73,17 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
}
++ /**
++ * A Dock token exactly as Network mints one for the public client `companion`: the private `<network>/dock`
++ * issuer, audience openvibe.network, client_id, scope and refresh family, and no role or staff caps.
++ */
++ function dockToken(u, { ttl = 3600, iat = null, scope = 'agent.view', clientId = 'companion', subject = u.subject, fam = 'fam_0123456789abcdef01234567' } = {}) {
++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
++ return signJwt({
++ iss: `${issuer}/dock`, sub: u.id, id: u.id, subject_id: subject, username: u.username, display_name: u.display_name,
++ client_id: clientId, scope, fam, aud: ['openvibe.network', 'openvibe.chat'], iat: now, exp: now + ttl,
++ }, privatePem);
++ }
function userFrom(req) {
const h = String(req.headers.authorization || '');
if (!h.startsWith('Bearer ')) return null;
@@@ -398,7 +409,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return {
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
-- addUser, userToken,
++ addUser, userToken, dockToken,
/** An authorization code the mock will exchange for this user if the verifier matches. */
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },