openvibers/openvibe.bot Commits
Mint Bot's OpenRe token from its Network client; BOT_OPENRE_TOKEN becomes an optional override (#33)
Changes
@@@ -71,9 +71,11 @@ OV_OAUTH_CLIENT_SECRET=# In production this is OpenRe's WHIP ingest, https://ingest.openre.stream/whip (O30); the key must be one# OpenRe issued (BOT_OPENRE_* below), or the ingest refuses the device.#BOT_WHIP_BASE=https://example.invalid/whip--# OpenRe.Stream, which issues each robot's publish key (its stream's ingest key). The token is a Network--# service token holding openre.stream.read, openre.stream.write and openre.key.rotate (or openre.stream.*--# with openre.key.rotate). Either unset: devices pair without video.++# OpenRe.Stream, which issues each robot's publish key (its stream's ingest key). Bot mints its own Network++# service token (audience openvibe.openre) from OV_OAUTH_CLIENT_ID/SECRET and OV_NETWORK_INTERNAL_URL, holding++# openre.stream.read, openre.stream.write, openre.key.rotate and openre.session.read/openre.output.read/++# openre.output.write (session and outputs, for panel video and restreaming). BOT_OPENRE_TOKEN overrides it++# with an operator-minted service token of the same grant. BOT_OPENRE_URL unset: devices pair without video.#BOT_OPENRE_URL=#BOT_OPENRE_TOKEN=#BOT_OPENRE_TIMEOUT_MS=8000@@@ -81,7 +81,7 @@ The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGINSet `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.--The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.++The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and Bot mints its own Network service token for audience `openvibe.openre` from its OAuth client (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against `OV_NETWORK_INTERNAL_URL`), holding `openre.stream.read`, `openre.stream.write`, `openre.key.rotate` and `openre.session.read`/`openre.output.read`/`openre.output.write` — Network's grant to the `bot` client; the session and output capabilities are for later panel video and restreaming. `BOT_OPENRE_TOKEN` (a Network service token with the same grant, or `openre.stream.*` with `openre.key.rotate`) is an optional operator override; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call. The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With `BOT_OPENRE_URL` unset, or neither `BOT_OPENRE_TOKEN` nor the Network client credentials available, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.@@@ -116,8 +116,11 @@ without video.`publish_key` is an ingest key OpenRe.Stream issued, the only kind its WHIP ingest admits: each robot has oneOpenRe stream (external ref `bot:robot:<robot id>`, protocol `webrtc`, no recording), created at its first--pairing with `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` set and found again by that ref (the token needs--`openre.stream.read`, `openre.stream.write` and `openre.key.rotate`; `openre.stream.*` covers the first two).++pairing with `BOT_OPENRE_URL` set and found again by that ref. Bot authenticates with its own Network service++token (audience `openvibe.openre`), minted from its OAuth client, holding `openre.stream.read`,++`openre.stream.write`, `openre.key.rotate` and `openre.session.read`/`openre.output.read`/`openre.output.write`++(the last three for later panel video and restreaming); `BOT_OPENRE_TOKEN`, an operator-minted service token++with that grant (`openre.stream.*` covers the two read/write ones), overrides the minted one.A pairing or `POST /devices/bind` rotates the stream's key with no grace (the robot's previous device stops publishing), acredential rotation with the credential's grace, a revocation with no grace and the live session ended;removing the robot does the same and archives the stream when nothing is live. Bot stores the stream id and@@@ -126,8 +129,8 @@ reads the stream (`openre.stream.read`) and `POST` PATCHes `recording_mode`/`mir(`openre.stream.write`); Bot keeps neither, the OpenRe stream is the only copy.OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),not answering `503 bot.openre_unavailable`; a pairing that fails so leaves the code unused and no device.--With `BOT_OPENRE_URL` or `BOT_OPENRE_TOKEN` unset the answers carry `"video": "not_configured"` and neither--`publish_key` nor `whip_url`.++With `BOT_OPENRE_URL` unset, or neither `BOT_OPENRE_TOKEN` nor the Network client credentials available, the++answers carry `"video": "not_configured"` and neither `publish_key` nor `whip_url`.### 1.1 Command values (the profile's `commands`)@@@ -213,9 +213,14 @@ function loadConfig(env = process.env) {timeoutMs: Math.max(100, int(env.BOT_ONVIF_TIMEOUT_MS, 5000)),},// OpenRe.Stream (T15 R5): the publish key is the ingest key of the robot's OpenRe stream, the only kind-- // OpenRe's WHIP worker admits. token is a Network service token holding openre.stream.read (the lookup-- // by external ref), openre.stream.write and openre.key.rotate; it is never logged or returned. Either unset: devices pair without video (no-- // publish_key, `video: "not_configured"`) and Bot mints no key of its own.++ // OpenRe's WHIP worker admits. By default Bot mints its own Network service token (audience++ // openvibe.openre, client `bot`) holding openre.stream.read (the lookup by external ref),++ // openre.stream.write, openre.key.rotate and openre.session.read/openre.output.read/openre.output.write++ // (session status and outputs, for later panel video and restreaming); BOT_OPENRE_TOKEN, an++ // operator-minted token with the same grant, overrides it. The token is never logged or returned. With++ // BOT_OPENRE_URL unset, or neither an operator token nor the Network client credentials++ // (OV_OAUTH_CLIENT_SECRET, OV_NETWORK_INTERNAL_URL): devices pair without video (no publish_key,++ // `video: "not_configured"`) and Bot mints no key of its own.openre: {url: trim(env.BOT_OPENRE_URL || ''),token: String(env.BOT_OPENRE_TOKEN || '').trim(),@@@ -5,11 +5,16 @@** OpenRe's WHIP worker admits only keys in its own store (resolveIngestKey), so a device's publish key is* always an OpenRe ingest key: Bot creates one stream per robot (external ref bot:robot:<id>) and hands the-- * device the key OpenRe returns, shown once and never stored here. Calls carry BOT_OPENRE_TOKEN, a Network-- * service token holding openre.stream.read (find), openre.stream.write (create, archive) and openre.key.rotate-- * (rotate), and act for the robot's owner++ * device the key OpenRe returns, shown once and never stored here. Calls act for the robot's owner* (X-OV-Subject), so OpenRe limits them to that owner's streams.*++ * By default Bot mints its own Network service token (audience openvibe.openre) from its OAuth client++ * credentials, with openre.stream.read (find), openre.stream.write (create, archive, streaming toggles),++ * openre.key.rotate (rotate) and openre.session.read/openre.output.read/openre.output.write (session status++ * and outputs, for panel video and restreaming later). An operator-minted BOT_OPENRE_TOKEN overrides it. The++ * token is cached until 60 s before expiry; a 401 from OpenRe (rotated key, clock) drops the cached token and++ * the call is tried once more.++ ** find(ref, owner) GET /api/v1/streams?external_ref=… → the stream, or null* get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }@@@ -20,21 +25,48 @@** A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout* or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (get, update, rotate, archive) answers null. The token is-- * never logged, returned or put in an error. Unset BOT_OPENRE_URL or BOT_OPENRE_TOKEN: createOpenRe → null.++ * never logged, returned or put in an error. Unset BOT_OPENRE_URL, or neither an operator BOT_OPENRE_TOKEN++ * nor the Network client credentials: createOpenRe → null.*/++const { serviceAuth } = require('openvibe-contracts');const { BotError } = require('../util');++// Network's grant to the `bot` client on audience openvibe.openre (Network server/identity/principals.js++// DEFAULT_GRANTS). session.read/output.read/output.write are for later panel video and restreaming.++const OPENRE_AUDIENCE = 'openvibe.openre';++const OPENRE_SCOPE = 'openre.stream.read openre.stream.write openre.key.rotate openre.session.read openre.output.read openre.output.write';++function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {const { url, token, timeoutMs } = config.openre || {};-- if (!url || !token) return null;++ if (!url) return null;++ // An operator-minted BOT_OPENRE_TOKEN wins; otherwise Bot mints its own from its Network client.++ const { clientId, clientSecret } = config.oauth || {};++ const internalUrl = (config.network || {}).internalUrl;++ const tokens = !token && clientSecret && internalUrl++ ? serviceAuth.createTokenClient({++ tokenUrl: `${internalUrl}/oauth/token`, clientId, clientSecret, audience: OPENRE_AUDIENCE, scope: OPENRE_SCOPE, fetchImpl,++ })++ : null;++ if (!token && !tokens) return null;++++ /** The Authorization header of this call: the operator's token, or a freshly cached minted one. */++ async function authorization() {++ if (!tokens) return `Bearer ${token}`;++ try {++ return (await tokens.authHeaders()).Authorization;++ } catch (e) {++ throw new BotError(503, 'bot.openre_unavailable', 'Bot could not mint its OpenRe token from Network');++ }++ }-- async function call(method, path, { owner, body, named = false } = {}) {-- let res;++ /** One attempt; a transport failure or a timeout becomes bot.openre_unavailable. */++ async function send(method, path, { owner, body }) {++ const bearer = await authorization();try {-- res = await fetchImpl(`${url}${path}`, {++ return await fetchImpl(`${url}${path}`, {method,headers: {-- Authorization: `Bearer ${token}`, Accept: 'application/json', 'X-OV-Subject': owner,++ Authorization: bearer, Accept: 'application/json', 'X-OV-Subject': owner,...(body ? { 'Content-Type': 'application/json' } : {}),},...(body ? { body: JSON.stringify(body) } : {}),@@@ -44,6 +76,16 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? `no answer within ${timeoutMs} ms` : 'unreachable';throw new BotError(503, 'bot.openre_unavailable', `OpenRe did not answer for the robot's video: ${why}`);}++ }++++ async function call(method, path, opts = {}) {++ const { named = false } = opts;++ let res = await send(method, path, opts);++ // A minted token OpenRe no longer accepts (rotated signing key, clock): drop it and try once more.++ if (res.status === 401 && tokens) {++ tokens.invalidate();++ res = await send(method, path, opts);++ }if (res.status === 204) return {};const out = await res.json().catch(() => null);if (res.ok && out) return out;@@@ -24,7 +24,9 @@ async function boot(opts = {}) {const network = await startNetwork();const events = await startEvents();// OpenRe issues every publish key; `openre: false` boots with BOT_OPENRE_* unset (pairing without video).-- const openre = opts.openre === false ? null : await startOpenRe();++ // It is given the Network stub too, so a token Bot minted for openvibe.openre (no static BOT_OPENRE_TOKEN)++ // authenticates just as a minted one would at the real OpenRe.++ const openre = opts.openre === false ? null : await startOpenRe({ network });const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-test-'));const env = {NODE_ENV: 'test',@@@ -2,7 +2,9 @@/*** Stand-ins for the services Bot talks to, each on a random port.*-- * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); node principals as++ * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); POST /oauth/token mints++ * client-credentials tokens and records every request in tokenCalls ({client_id, audience,++ * scope}); node principals as* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,* nodes, revokes), scoped to the calling service like Network's own routes. Identity* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=@@@ -13,8 +15,11 @@* GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /* mirror_to_live), POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer* token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403-- * capability.denied). streams, calls (every request, its token and X-OV-Subject), admits(key)-- * (OpenRe's resolveIngestKey: an active key or one in grace), failNext(status | 'hang')++ * capability.denied). With `network` given, a service token Network minted for audience++ * openvibe.openre is accepted too, its own `cap` claims being the capabilities (the minted++ * path, 401 token.* otherwise). streams, calls (every request, its token and X-OV-Subject),++ * admits(key) (OpenRe's resolveIngestKey: an active key or one in grace),++ * failNext(status | 'hang', times = 1)*/const http = require('http');const crypto = require('crypto');@@@ -35,6 +40,8 @@ async function startNetwork() {const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });let issuer = 'http://network.test';let n = 100;++ // Every client-credentials request Network's /oauth/token served: { client_id, audience, scope }.++ const tokenCalls = [];function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {const now = Math.floor(Date.now() / 1000);@@@ -102,6 +109,7 @@ async function startNetwork() {if (req.url === '/api/health') return send(res, 200, { ok: true });if (req.url === '/oauth/token' && req.method === 'POST') {const body = Object.fromEntries(new URLSearchParams(await readBody(req)));++ tokenCalls.push({ client_id: body.client_id, audience: body.audience || null, scope: body.scope || null });if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });const cap = String(body.scope || '').split(/\s+/).filter(Boolean);return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });@@@ -151,7 +159,7 @@ async function startNetwork() {});const url = await listen(server);issuer = url;-- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };++ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, tokenCalls, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };}async function startEvents() {@@@ -175,13 +183,14 @@ async function startEvents() {// openre.stream.write, keys/rotate openre.key.rotate (OpenRe server/api/v1.js guards).const OPENRE_CAPS = ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate'];--async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex')}`, caps = OPENRE_CAPS } = {}) {++async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex')}`, caps = OPENRE_CAPS, network = null } = {}) {let granted = [...caps];// OpenRe's hasCap: the exact capability or a `.*` grant covering it.-- const hasCap = (id) => granted.some((g) => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));++ const hasCap = (caps_, id) => caps_.some((g) => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));const streams = new Map();const calls = [];let failure = null;++ let failuresLeft = 0; // how many further requests failNext answers before it clearslet n = 0;const newKey = () => {const key = `ork_${crypto.randomBytes(32).toString('base64url')}`;@@@ -205,11 +214,25 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hexconst url = new URL(req.url, 'http://openre.test');calls.push({ method: req.method, path: url.pathname, query: url.search, body, subject, authorization: req.headers.authorization || null });if (failure === 'hang') return; // never answered: the client's timeout ends it-- if (failure) { const status = failure; failure = null; return problem(res, status, status >= 500 ? 'openre.internal' : 'openre.forbidden', `stubbed ${status}`); }-- if (req.headers.authorization !== `Bearer ${token}`) return problem(res, 401, 'token.invalid', 'a service token is required');++ if (failure != null && failuresLeft > 0) {++ const status = failure;++ if (--failuresLeft === 0) failure = null;++ return problem(res, status, status >= 500 ? 'openre.internal' : 'openre.forbidden', `stubbed ${status}`);++ }++ // The operator's static token, or (with `network`) a token Bot minted for audience openvibe.openre++ // whose `cap` claims are the capabilities, exactly as OpenRe's own guard checks them.++ const presented = String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null;++ let shownCaps = null;++ if (presented && presented === token) shownCaps = granted;++ else if (presented && network) {++ const r = serviceAuth.verifyServiceToken(presented, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.openre' });++ if (!r.ok) return problem(res, 401, r.code, r.reason);++ shownCaps = r.claims.cap || [];++ }++ if (!shownCaps) return problem(res, 401, 'token.invalid', 'a service token is required');const m = /^\/api\/v1\/streams(?:\/([^/]+)(\/keys\/rotate)?)?$/.exec(url.pathname);const need = m && (m[2] ? 'openre.key.rotate' : req.method === 'GET' ? 'openre.stream.read' : 'openre.stream.write');-- if (need && !hasCap(need)) return problem(res, 403, 'capability.denied', `${need} not granted`);++ if (need && !hasCap(shownCaps, need)) return problem(res, 403, 'capability.denied', `${need} not granted`);if (!subject || !/^usr_/.test(subject)) return problem(res, 400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');const mine = (id) => { const st = streams.get(id); return st && st.state !== 'archived' && st.owner === subject ? st : null; };if (url.pathname === '/api/v1/streams' && req.method === 'GET') {@@@ -258,7 +281,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hexconst url = await listen(server);return {url, token, streams, calls, admits,-- failNext: (what) => { failure = what; },++ failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },setCaps: (list) => { granted = [...(list || caps)]; },close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },};@@@ -4,11 +4,18 @@// returns; a re-pair and a credential rotation rotate that stream's key, a revocation rotates it with no grace// and ends the session, a robot's removal also archives the stream when nothing is live. OpenRe refusing or// not answering is a clean error with nothing half-written; Bot's token for OpenRe is never logged or--// answered; with BOT_OPENRE_* unset devices pair without video and Bot mints no key of its own.++// answered. Without a static BOT_OPENRE_TOKEN Bot mints its own from its Network client (audience++// openvibe.openre), and a 401 makes it mint once more and retry; with neither credential devices pair without++// video and Bot mints no key of its own.const assert = require('assert');++const { serviceAuth } = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');const { createOpenRe } = require('../server/openre/client');++// Network's grant to the `bot` client on audience openvibe.openre (OpenVibe.Network DEFAULT_GRANTS).++const MINTED_AUDIENCE = 'openvibe.openre';++const MINTED_SCOPE = 'openre.stream.read openre.stream.write openre.key.rotate openre.session.read openre.output.read openre.output.write';++const WHIP_BASE = 'https://ingest.test/whip';(async () => {@@@ -28,9 +35,14 @@ const WHIP_BASE = 'https://ingest.test/whip';return { robot, paired: paired.json };}-- await check('the client is null without a URL and a token, and its errors never carry the token', async () => {++ await check('the client is null without a URL, a token or a Network client, and its errors never carry the token', async () => {assert.strictEqual(createOpenRe({ openre: { url: '', token: 'x', timeoutMs: 100 } }), null);++ // No static token and no Network client credentials: nothing to authenticate with.assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 } }), null);++ assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 }, network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'bot' } }), null);++ assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 }, network: {}, oauth: { clientId: 'bot', clientSecret: 'shh' } }), null);++ // A URL is still required even with a Network client.++ assert.strictEqual(createOpenRe({ openre: { url: '', token: '', timeoutMs: 100 }, network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'bot', clientSecret: 'shh' } }), null);const secret = 'ovt_never_shown_anywhere';const down = createOpenRe({ openre: { url: 'http://openre.test', token: secret, timeoutMs: 100 } }, { fetchImpl: async () => { throw new Error(`connect ECONNREFUSED Bearer ${secret}`); } });const e = await down.create({}, alex.subject).then(() => null, (x) => x);@@@ -224,10 +236,11 @@ const WHIP_BASE = 'https://ingest.test/whip';assert.ok(t.openre.admits(paired.publish_key));});-- await check('Bot\'s OpenRe token is never logged and never in an answer', async () => {++ await check('Bot\'s OpenRe token is never logged and never in an answer; a static token means Network is not asked', async () => {assert.ok(t.openre.calls.every((c) => c.authorization === `Bearer ${t.openre.token}` || c.authorization === null));assert.ok(!t.logs.join('\n').includes(t.openre.token), 'the token was logged');assert.ok(!responses.some((text) => text.includes(t.openre.token)), 'an answer carried the token');++ assert.strictEqual(t.network.tokenCalls.length, 0, 'BOT_OPENRE_TOKEN was set: Bot minted no token of its own');});await t.close();@@@ -255,5 +268,58 @@ const WHIP_BASE = 'https://ingest.test/whip';assert.strictEqual(del.status, 204, del.text);});await u.close();++++ // ── No static token: Bot mints its own from its Network client ────────────────────────────────++ const m = await boot({ env: { BOT_OPENRE_TOKEN: '' } });++ const mia = m.network.newUser('mia');++ const redeemM = (body) => m.call('POST', '/api/v1/pair', { token: null, body });++++ await check('without a static token Bot mints a token for audience openvibe.openre with the Network grant\'s scope', async () => {++ const before = m.network.tokenCalls.length;++ const { robot, pairing } = await m.robot(mia);++ const paired = await redeemM({ robot: robot.id, code: pairing.code });++ assert.strictEqual(paired.status, 201, paired.text);++ assert.ok(paired.json.publish_key && m.openre.admits(paired.json.publish_key), 'the device got an OpenRe-issued key');++ // One mint serves the whole pairing: the find and the create reuse the cached token.++ assert.deepStrictEqual(m.network.tokenCalls.slice(before),++ [{ client_id: 'bot', audience: MINTED_AUDIENCE, scope: MINTED_SCOPE }]);++ const create = m.openre.calls.at(-1);++ assert.strictEqual(create.method, 'POST');++ assert.strictEqual(create.subject, mia.subject);++ assert.ok(create.authorization.startsWith('Bearer '), 'a bearer was sent');++ assert.notStrictEqual(create.authorization, `Bearer ${m.openre.token}`, 'the static token was not what was sent');++ const v = serviceAuth.verifyServiceToken(create.authorization.slice(7), { publicKey: m.network.publicPem, issuer: m.network.url, audience: MINTED_AUDIENCE });++ assert.ok(v.ok, v.reason);++ assert.strictEqual(v.claims.sub, 'svc:bot');++ assert.deepStrictEqual([...v.claims.cap].sort(), MINTED_SCOPE.split(' ').sort());++ });++++ await check('a 401 from OpenRe drops the minted token and retries the call once', async () => {++ const tokensBefore = m.network.tokenCalls.length;++ const callsBefore = m.openre.calls.length;++ const { robot, pairing } = await m.robot(mia);++ m.openre.failNext(401);++ const paired = await redeemM({ robot: robot.id, code: pairing.code });++ assert.strictEqual(paired.status, 201, paired.text);++ assert.ok(m.openre.admits(paired.json.publish_key));++ assert.strictEqual(m.network.tokenCalls.length - tokensBefore, 1, 'exactly one replacement token was minted');++ const calls = m.openre.calls.slice(callsBefore);++ assert.strictEqual(calls[0].method, 'GET');++ assert.strictEqual(calls[1].method, 'GET');++ assert.strictEqual(calls[1].query, calls[0].query, 'the same call was tried again');++ assert.notStrictEqual(calls[1].authorization, calls[0].authorization, 'the retry carried a fresh token');++ });++++ await check('a second 401 is a refusal, not an endless retry', async () => {++ const { robot, pairing } = await m.robot(mia);++ const callsBefore = m.openre.calls.length;++ m.openre.failNext(401, 2);++ const r = await redeemM({ robot: robot.id, code: pairing.code });++ assert.strictEqual(r.status, 502, r.text);++ assert.strictEqual(r.json.code, 'bot.openre_refused');++ assert.strictEqual(m.openre.calls.length - callsBefore, 2, 'the call was tried exactly twice');++ });++++ await m.close();done();})();
@@ -71,9 +71,11 @@ OV_OAUTH_CLIENT_SECRET=
@@ -71,9 +71,11 @@ OV_OAUTH_CLIENT_SECRET=
# In production this is OpenRe's WHIP ingest, https://ingest.openre.stream/whip (O30); the key must be one
# In production this is OpenRe's WHIP ingest, https://ingest.openre.stream/whip (O30); the key must be one
# OpenRe issued (BOT_OPENRE_* below), or the ingest refuses the device.
# OpenRe issued (BOT_OPENRE_* below), or the ingest refuses the device.
#BOT_WHIP_BASE=https://example.invalid/whip
#BOT_WHIP_BASE=https://example.invalid/whip
-# OpenRe.Stream, which issues each robot's publish key (its stream's ingest key). The token is a Network
-# service token holding openre.stream.read, openre.stream.write and openre.key.rotate (or openre.stream.*
-# with openre.key.rotate). Either unset: devices pair without video.
+# OpenRe.Stream, which issues each robot's publish key (its stream's ingest key). Bot mints its own Network
+# service token (audience openvibe.openre) from OV_OAUTH_CLIENT_ID/SECRET and OV_NETWORK_INTERNAL_URL, holding
+# openre.stream.read, openre.stream.write, openre.key.rotate and openre.session.read/openre.output.read/
+# openre.output.write (session and outputs, for panel video and restreaming). BOT_OPENRE_TOKEN overrides it
+# with an operator-minted service token of the same grant. BOT_OPENRE_URL unset: devices pair without video.
#BOT_OPENRE_URL=
#BOT_OPENRE_URL=
#BOT_OPENRE_TOKEN=
#BOT_OPENRE_TOKEN=
#BOT_OPENRE_TIMEOUT_MS=8000
#BOT_OPENRE_TIMEOUT_MS=8000
@@ -81,7 +81,7 @@ The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGIN
@@ -81,7 +81,7 @@ The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGIN
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.
-The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
+The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and Bot mints its own Network service token for audience `openvibe.openre` from its OAuth client (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against `OV_NETWORK_INTERNAL_URL`), holding `openre.stream.read`, `openre.stream.write`, `openre.key.rotate` and `openre.session.read`/`openre.output.read`/`openre.output.write` — Network's grant to the `bot` client; the session and output capabilities are for later panel video and restreaming. `BOT_OPENRE_TOKEN` (a Network service token with the same grant, or `openre.stream.*` with `openre.key.rotate`) is an optional operator override; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call. The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With `BOT_OPENRE_URL` unset, or neither `BOT_OPENRE_TOKEN` nor the Network client credentials available, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.
Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.
@@ -116,8 +116,11 @@ without video.
@@ -116,8 +116,11 @@ without video.
`publish_key` is an ingest key OpenRe.Stream issued, the only kind its WHIP ingest admits: each robot has one
`publish_key` is an ingest key OpenRe.Stream issued, the only kind its WHIP ingest admits: each robot has one
OpenRe stream (external ref `bot:robot:<robot id>`, protocol `webrtc`, no recording), created at its first
OpenRe stream (external ref `bot:robot:<robot id>`, protocol `webrtc`, no recording), created at its first
-pairing with `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` set and found again by that ref (the token needs
-`openre.stream.read`, `openre.stream.write` and `openre.key.rotate`; `openre.stream.*` covers the first two).
+pairing with `BOT_OPENRE_URL` set and found again by that ref. Bot authenticates with its own Network service
+token (audience `openvibe.openre`), minted from its OAuth client, holding `openre.stream.read`,
+`openre.stream.write`, `openre.key.rotate` and `openre.session.read`/`openre.output.read`/`openre.output.write`
+(the last three for later panel video and restreaming); `BOT_OPENRE_TOKEN`, an operator-minted service token
+with that grant (`openre.stream.*` covers the two read/write ones), overrides the minted one.
A pairing or `POST /devices/bind` rotates the stream's key with no grace (the robot's previous device stops publishing), a
A pairing or `POST /devices/bind` rotates the stream's key with no grace (the robot's previous device stops publishing), a
credential rotation with the credential's grace, a revocation with no grace and the live session ended;
credential rotation with the credential's grace, a revocation with no grace and the live session ended;
removing the robot does the same and archives the stream when nothing is live. Bot stores the stream id and
removing the robot does the same and archives the stream when nothing is live. Bot stores the stream id and
@@ -126,8 +129,8 @@ reads the stream (`openre.stream.read`) and `POST` PATCHes `recording_mode`/`mir
@@ -126,8 +129,8 @@ reads the stream (`openre.stream.read`) and `POST` PATCHes `recording_mode`/`mir
(`openre.stream.write`); Bot keeps neither, the OpenRe stream is the only copy.
(`openre.stream.write`); Bot keeps neither, the OpenRe stream is the only copy.
OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),
OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),
not answering `503 bot.openre_unavailable`; a pairing that fails so leaves the code unused and no device.
not answering `503 bot.openre_unavailable`; a pairing that fails so leaves the code unused and no device.
-With `BOT_OPENRE_URL` or `BOT_OPENRE_TOKEN` unset the answers carry `"video": "not_configured"` and neither
-`publish_key` nor `whip_url`.
+With `BOT_OPENRE_URL` unset, or neither `BOT_OPENRE_TOKEN` nor the Network client credentials available, the
+answers carry `"video": "not_configured"` and neither `publish_key` nor `whip_url`.
### 1.1 Command values (the profile's `commands`)
### 1.1 Command values (the profile's `commands`)
@@ -213,9 +213,14 @@ function loadConfig(env = process.env) {
@@ -213,9 +213,14 @@ function loadConfig(env = process.env) {
timeoutMs: Math.max(100, int(env.BOT_ONVIF_TIMEOUT_MS, 5000)),
timeoutMs: Math.max(100, int(env.BOT_ONVIF_TIMEOUT_MS, 5000)),
},
},
// OpenRe.Stream (T15 R5): the publish key is the ingest key of the robot's OpenRe stream, the only kind
// OpenRe.Stream (T15 R5): the publish key is the ingest key of the robot's OpenRe stream, the only kind
- // OpenRe's WHIP worker admits. token is a Network service token holding openre.stream.read (the lookup
- // by external ref), openre.stream.write and openre.key.rotate; it is never logged or returned. Either unset: devices pair without video (no
- // publish_key, `video: "not_configured"`) and Bot mints no key of its own.
+ // OpenRe's WHIP worker admits. By default Bot mints its own Network service token (audience
+ // openvibe.openre, client `bot`) holding openre.stream.read (the lookup by external ref),
+ // openre.stream.write, openre.key.rotate and openre.session.read/openre.output.read/openre.output.write
+ // (session status and outputs, for later panel video and restreaming); BOT_OPENRE_TOKEN, an
+ // operator-minted token with the same grant, overrides it. The token is never logged or returned. With
+ // BOT_OPENRE_URL unset, or neither an operator token nor the Network client credentials
+ // (OV_OAUTH_CLIENT_SECRET, OV_NETWORK_INTERNAL_URL): devices pair without video (no publish_key,
+ // `video: "not_configured"`) and Bot mints no key of its own.
openre: {
openre: {
url: trim(env.BOT_OPENRE_URL || ''),
url: trim(env.BOT_OPENRE_URL || ''),
token: String(env.BOT_OPENRE_TOKEN || '').trim(),
token: String(env.BOT_OPENRE_TOKEN || '').trim(),
@@ -5,11 +5,16 @@
@@ -5,11 +5,16 @@
*
*
* OpenRe's WHIP worker admits only keys in its own store (resolveIngestKey), so a device's publish key is
* OpenRe's WHIP worker admits only keys in its own store (resolveIngestKey), so a device's publish key is
* always an OpenRe ingest key: Bot creates one stream per robot (external ref bot:robot:<id>) and hands the
* always an OpenRe ingest key: Bot creates one stream per robot (external ref bot:robot:<id>) and hands the
- * device the key OpenRe returns, shown once and never stored here. Calls carry BOT_OPENRE_TOKEN, a Network
- * service token holding openre.stream.read (find), openre.stream.write (create, archive) and openre.key.rotate
- * (rotate), and act for the robot's owner
+ * device the key OpenRe returns, shown once and never stored here. Calls act for the robot's owner
* (X-OV-Subject), so OpenRe limits them to that owner's streams.
* (X-OV-Subject), so OpenRe limits them to that owner's streams.
*
*
+ * By default Bot mints its own Network service token (audience openvibe.openre) from its OAuth client
+ * credentials, with openre.stream.read (find), openre.stream.write (create, archive, streaming toggles),
+ * openre.key.rotate (rotate) and openre.session.read/openre.output.read/openre.output.write (session status
+ * and outputs, for panel video and restreaming later). An operator-minted BOT_OPENRE_TOKEN overrides it. The
+ * token is cached until 60 s before expiry; a 401 from OpenRe (rotated key, clock) drops the cached token and
+ * the call is tried once more.
+ *
* find(ref, owner) GET /api/v1/streams?external_ref=… → the stream, or null
* find(ref, owner) GET /api/v1/streams?external_ref=… → the stream, or null
* get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404
* get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404
* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
@@ -20,21 +25,48 @@
@@ -20,21 +25,48 @@
*
*
* A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout
* A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout
* or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (get, update, rotate, archive) answers null. The token is
* or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (get, update, rotate, archive) answers null. The token is
- * never logged, returned or put in an error. Unset BOT_OPENRE_URL or BOT_OPENRE_TOKEN: createOpenRe → null.
+ * never logged, returned or put in an error. Unset BOT_OPENRE_URL, or neither an operator BOT_OPENRE_TOKEN
+ * nor the Network client credentials: createOpenRe → null.
*/
*/
+const { serviceAuth } = require('openvibe-contracts');
const { BotError } = require('../util');
const { BotError } = require('../util');
+// Network's grant to the `bot` client on audience openvibe.openre (Network server/identity/principals.js
+// DEFAULT_GRANTS). session.read/output.read/output.write are for later panel video and restreaming.
+const OPENRE_AUDIENCE = 'openvibe.openre';
+const OPENRE_SCOPE = 'openre.stream.read openre.stream.write openre.key.rotate openre.session.read openre.output.read openre.output.write';
+
function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
const { url, token, timeoutMs } = config.openre || {};
const { url, token, timeoutMs } = config.openre || {};
- if (!url || !token) return null;
+ if (!url) return null;
+ // An operator-minted BOT_OPENRE_TOKEN wins; otherwise Bot mints its own from its Network client.
+ const { clientId, clientSecret } = config.oauth || {};
+ const internalUrl = (config.network || {}).internalUrl;
+ const tokens = !token && clientSecret && internalUrl
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${internalUrl}/oauth/token`, clientId, clientSecret, audience: OPENRE_AUDIENCE, scope: OPENRE_SCOPE, fetchImpl,
+ })
+ : null;
+ if (!token && !tokens) return null;
+
+ /** The Authorization header of this call: the operator's token, or a freshly cached minted one. */
+ async function authorization() {
+ if (!tokens) return `Bearer ${token}`;
+ try {
+ return (await tokens.authHeaders()).Authorization;
+ } catch (e) {
+ throw new BotError(503, 'bot.openre_unavailable', 'Bot could not mint its OpenRe token from Network');
+ }
+ }
- async function call(method, path, { owner, body, named = false } = {}) {
- let res;
+ /** One attempt; a transport failure or a timeout becomes bot.openre_unavailable. */
+ async function send(method, path, { owner, body }) {
+ const bearer = await authorization();
try {
try {
- res = await fetchImpl(`${url}${path}`, {
+ return await fetchImpl(`${url}${path}`, {
method,
method,
headers: {
headers: {
- Authorization: `Bearer ${token}`, Accept: 'application/json', 'X-OV-Subject': owner,
+ Authorization: bearer, Accept: 'application/json', 'X-OV-Subject': owner,
...(body ? { 'Content-Type': 'application/json' } : {}),
...(body ? { 'Content-Type': 'application/json' } : {}),
},
},
...(body ? { body: JSON.stringify(body) } : {}),
...(body ? { body: JSON.stringify(body) } : {}),
@@ -44,6 +76,16 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -44,6 +76,16 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? `no answer within ${timeoutMs} ms` : 'unreachable';
const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? `no answer within ${timeoutMs} ms` : 'unreachable';
throw new BotError(503, 'bot.openre_unavailable', `OpenRe did not answer for the robot's video: ${why}`);
throw new BotError(503, 'bot.openre_unavailable', `OpenRe did not answer for the robot's video: ${why}`);
}
}
+ }
+
+ async function call(method, path, opts = {}) {
+ const { named = false } = opts;
+ let res = await send(method, path, opts);
+ // A minted token OpenRe no longer accepts (rotated signing key, clock): drop it and try once more.
+ if (res.status === 401 && tokens) {
+ tokens.invalidate();
+ res = await send(method, path, opts);
+ }
if (res.status === 204) return {};
if (res.status === 204) return {};
const out = await res.json().catch(() => null);
const out = await res.json().catch(() => null);
if (res.ok && out) return out;
if (res.ok && out) return out;
@@ -24,7 +24,9 @@ async function boot(opts = {}) {
@@ -24,7 +24,9 @@ async function boot(opts = {}) {
const network = await startNetwork();
const network = await startNetwork();
const events = await startEvents();
const events = await startEvents();
// OpenRe issues every publish key; `openre: false` boots with BOT_OPENRE_* unset (pairing without video).
// OpenRe issues every publish key; `openre: false` boots with BOT_OPENRE_* unset (pairing without video).
- const openre = opts.openre === false ? null : await startOpenRe();
+ // It is given the Network stub too, so a token Bot minted for openvibe.openre (no static BOT_OPENRE_TOKEN)
+ // authenticates just as a minted one would at the real OpenRe.
+ const openre = opts.openre === false ? null : await startOpenRe({ network });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-test-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-test-'));
const env = {
const env = {
NODE_ENV: 'test',
NODE_ENV: 'test',
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
/**
/**
* Stand-ins for the services Bot talks to, each on a random port.
* Stand-ins for the services Bot talks to, each on a random port.
*
*
- * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); node principals as
+ * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); POST /oauth/token mints
+ * client-credentials tokens and records every request in tokenCalls ({client_id, audience,
+ * scope}); node principals as
* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
* nodes, revokes), scoped to the calling service like Network's own routes. Identity
* nodes, revokes), scoped to the calling service like Network's own routes. Identity
* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=
* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=
@@ -13,8 +15,11 @@
@@ -13,8 +15,11 @@
* GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /
* GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /
* mirror_to_live), POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer
* mirror_to_live), POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer
* token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403
* token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403
- * capability.denied). streams, calls (every request, its token and X-OV-Subject), admits(key)
- * (OpenRe's resolveIngestKey: an active key or one in grace), failNext(status | 'hang')
+ * capability.denied). With `network` given, a service token Network minted for audience
+ * openvibe.openre is accepted too, its own `cap` claims being the capabilities (the minted
+ * path, 401 token.* otherwise). streams, calls (every request, its token and X-OV-Subject),
+ * admits(key) (OpenRe's resolveIngestKey: an active key or one in grace),
+ * failNext(status | 'hang', times = 1)
*/
*/
const http = require('http');
const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -35,6 +40,8 @@ async function startNetwork() {
@@ -35,6 +40,8 @@ async function startNetwork() {
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
let issuer = 'http://network.test';
let issuer = 'http://network.test';
let n = 100;
let n = 100;
+ // Every client-credentials request Network's /oauth/token served: { client_id, audience, scope }.
+ const tokenCalls = [];
function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {
function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
@@ -102,6 +109,7 @@ async function startNetwork() {
@@ -102,6 +109,7 @@ async function startNetwork() {
if (req.url === '/api/health') return send(res, 200, { ok: true });
if (req.url === '/api/health') return send(res, 200, { ok: true });
if (req.url === '/oauth/token' && req.method === 'POST') {
if (req.url === '/oauth/token' && req.method === 'POST') {
const body = Object.fromEntries(new URLSearchParams(await readBody(req)));
const body = Object.fromEntries(new URLSearchParams(await readBody(req)));
+ tokenCalls.push({ client_id: body.client_id, audience: body.audience || null, scope: body.scope || null });
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
@@ -151,7 +159,7 @@ async function startNetwork() {
@@ -151,7 +159,7 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
issuer = url;
- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
+ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, tokenCalls, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
}
}
async function startEvents() {
async function startEvents() {
@@ -175,13 +183,14 @@ async function startEvents() {
@@ -175,13 +183,14 @@ async function startEvents() {
// openre.stream.write, keys/rotate openre.key.rotate (OpenRe server/api/v1.js guards).
// openre.stream.write, keys/rotate openre.key.rotate (OpenRe server/api/v1.js guards).
const OPENRE_CAPS = ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate'];
const OPENRE_CAPS = ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate'];
-async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex')}`, caps = OPENRE_CAPS } = {}) {
+async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex')}`, caps = OPENRE_CAPS, network = null } = {}) {
let granted = [...caps];
let granted = [...caps];
// OpenRe's hasCap: the exact capability or a `.*` grant covering it.
// OpenRe's hasCap: the exact capability or a `.*` grant covering it.
- const hasCap = (id) => granted.some((g) => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
+ const hasCap = (caps_, id) => caps_.some((g) => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
const streams = new Map();
const streams = new Map();
const calls = [];
const calls = [];
let failure = null;
let failure = null;
+ let failuresLeft = 0; // how many further requests failNext answers before it clears
let n = 0;
let n = 0;
const newKey = () => {
const newKey = () => {
const key = `ork_${crypto.randomBytes(32).toString('base64url')}`;
const key = `ork_${crypto.randomBytes(32).toString('base64url')}`;
@@ -205,11 +214,25 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -205,11 +214,25 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
const url = new URL(req.url, 'http://openre.test');
const url = new URL(req.url, 'http://openre.test');
calls.push({ method: req.method, path: url.pathname, query: url.search, body, subject, authorization: req.headers.authorization || null });
calls.push({ method: req.method, path: url.pathname, query: url.search, body, subject, authorization: req.headers.authorization || null });
if (failure === 'hang') return; // never answered: the client's timeout ends it
if (failure === 'hang') return; // never answered: the client's timeout ends it
- if (failure) { const status = failure; failure = null; return problem(res, status, status >= 500 ? 'openre.internal' : 'openre.forbidden', `stubbed ${status}`); }
- if (req.headers.authorization !== `Bearer ${token}`) return problem(res, 401, 'token.invalid', 'a service token is required');
+ if (failure != null && failuresLeft > 0) {
+ const status = failure;
+ if (--failuresLeft === 0) failure = null;
+ return problem(res, status, status >= 500 ? 'openre.internal' : 'openre.forbidden', `stubbed ${status}`);
+ }
+ // The operator's static token, or (with `network`) a token Bot minted for audience openvibe.openre
+ // whose `cap` claims are the capabilities, exactly as OpenRe's own guard checks them.
+ const presented = String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null;
+ let shownCaps = null;
+ if (presented && presented === token) shownCaps = granted;
+ else if (presented && network) {
+ const r = serviceAuth.verifyServiceToken(presented, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.openre' });
+ if (!r.ok) return problem(res, 401, r.code, r.reason);
+ shownCaps = r.claims.cap || [];
+ }
+ if (!shownCaps) return problem(res, 401, 'token.invalid', 'a service token is required');
const m = /^\/api\/v1\/streams(?:\/([^/]+)(\/keys\/rotate)?)?$/.exec(url.pathname);
const m = /^\/api\/v1\/streams(?:\/([^/]+)(\/keys\/rotate)?)?$/.exec(url.pathname);
const need = m && (m[2] ? 'openre.key.rotate' : req.method === 'GET' ? 'openre.stream.read' : 'openre.stream.write');
const need = m && (m[2] ? 'openre.key.rotate' : req.method === 'GET' ? 'openre.stream.read' : 'openre.stream.write');
- if (need && !hasCap(need)) return problem(res, 403, 'capability.denied', `${need} not granted`);
+ if (need && !hasCap(shownCaps, need)) return problem(res, 403, 'capability.denied', `${need} not granted`);
if (!subject || !/^usr_/.test(subject)) return problem(res, 400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
if (!subject || !/^usr_/.test(subject)) return problem(res, 400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
const mine = (id) => { const st = streams.get(id); return st && st.state !== 'archived' && st.owner === subject ? st : null; };
const mine = (id) => { const st = streams.get(id); return st && st.state !== 'archived' && st.owner === subject ? st : null; };
if (url.pathname === '/api/v1/streams' && req.method === 'GET') {
if (url.pathname === '/api/v1/streams' && req.method === 'GET') {
@@ -258,7 +281,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -258,7 +281,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
const url = await listen(server);
const url = await listen(server);
return {
return {
url, token, streams, calls, admits,
url, token, streams, calls, admits,
- failNext: (what) => { failure = what; },
+ failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },
setCaps: (list) => { granted = [...(list || caps)]; },
setCaps: (list) => { granted = [...(list || caps)]; },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
};
};
@@ -4,11 +4,18 @@
@@ -4,11 +4,18 @@
// returns; a re-pair and a credential rotation rotate that stream's key, a revocation rotates it with no grace
// returns; a re-pair and a credential rotation rotate that stream's key, a revocation rotates it with no grace
// and ends the session, a robot's removal also archives the stream when nothing is live. OpenRe refusing or
// and ends the session, a robot's removal also archives the stream when nothing is live. OpenRe refusing or
// not answering is a clean error with nothing half-written; Bot's token for OpenRe is never logged or
// not answering is a clean error with nothing half-written; Bot's token for OpenRe is never logged or
-// answered; with BOT_OPENRE_* unset devices pair without video and Bot mints no key of its own.
+// answered. Without a static BOT_OPENRE_TOKEN Bot mints its own from its Network client (audience
+// openvibe.openre), and a 401 makes it mint once more and retry; with neither credential devices pair without
+// video and Bot mints no key of its own.
const assert = require('assert');
const assert = require('assert');
+const { serviceAuth } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { createOpenRe } = require('../server/openre/client');
const { createOpenRe } = require('../server/openre/client');
+// Network's grant to the `bot` client on audience openvibe.openre (OpenVibe.Network DEFAULT_GRANTS).
+const MINTED_AUDIENCE = 'openvibe.openre';
+const MINTED_SCOPE = 'openre.stream.read openre.stream.write openre.key.rotate openre.session.read openre.output.read openre.output.write';
+
const WHIP_BASE = 'https://ingest.test/whip';
const WHIP_BASE = 'https://ingest.test/whip';
(async () => {
(async () => {
@@ -28,9 +35,14 @@ const WHIP_BASE = 'https://ingest.test/whip';
@@ -28,9 +35,14 @@ const WHIP_BASE = 'https://ingest.test/whip';
return { robot, paired: paired.json };
return { robot, paired: paired.json };
}
}
- await check('the client is null without a URL and a token, and its errors never carry the token', async () => {
+ await check('the client is null without a URL, a token or a Network client, and its errors never carry the token', async () => {
assert.strictEqual(createOpenRe({ openre: { url: '', token: 'x', timeoutMs: 100 } }), null);
assert.strictEqual(createOpenRe({ openre: { url: '', token: 'x', timeoutMs: 100 } }), null);
+ // No static token and no Network client credentials: nothing to authenticate with.
assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 } }), null);
assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 } }), null);
+ assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 }, network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'bot' } }), null);
+ assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 }, network: {}, oauth: { clientId: 'bot', clientSecret: 'shh' } }), null);
+ // A URL is still required even with a Network client.
+ assert.strictEqual(createOpenRe({ openre: { url: '', token: '', timeoutMs: 100 }, network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'bot', clientSecret: 'shh' } }), null);
const secret = 'ovt_never_shown_anywhere';
const secret = 'ovt_never_shown_anywhere';
const down = createOpenRe({ openre: { url: 'http://openre.test', token: secret, timeoutMs: 100 } }, { fetchImpl: async () => { throw new Error(`connect ECONNREFUSED Bearer ${secret}`); } });
const down = createOpenRe({ openre: { url: 'http://openre.test', token: secret, timeoutMs: 100 } }, { fetchImpl: async () => { throw new Error(`connect ECONNREFUSED Bearer ${secret}`); } });
const e = await down.create({}, alex.subject).then(() => null, (x) => x);
const e = await down.create({}, alex.subject).then(() => null, (x) => x);
@@ -224,10 +236,11 @@ const WHIP_BASE = 'https://ingest.test/whip';
@@ -224,10 +236,11 @@ const WHIP_BASE = 'https://ingest.test/whip';
assert.ok(t.openre.admits(paired.publish_key));
assert.ok(t.openre.admits(paired.publish_key));
});
});
- await check('Bot\'s OpenRe token is never logged and never in an answer', async () => {
+ await check('Bot\'s OpenRe token is never logged and never in an answer; a static token means Network is not asked', async () => {
assert.ok(t.openre.calls.every((c) => c.authorization === `Bearer ${t.openre.token}` || c.authorization === null));
assert.ok(t.openre.calls.every((c) => c.authorization === `Bearer ${t.openre.token}` || c.authorization === null));
assert.ok(!t.logs.join('\n').includes(t.openre.token), 'the token was logged');
assert.ok(!t.logs.join('\n').includes(t.openre.token), 'the token was logged');
assert.ok(!responses.some((text) => text.includes(t.openre.token)), 'an answer carried the token');
assert.ok(!responses.some((text) => text.includes(t.openre.token)), 'an answer carried the token');
+ assert.strictEqual(t.network.tokenCalls.length, 0, 'BOT_OPENRE_TOKEN was set: Bot minted no token of its own');
});
});
await t.close();
await t.close();
@@ -255,5 +268,58 @@ const WHIP_BASE = 'https://ingest.test/whip';
@@ -255,5 +268,58 @@ const WHIP_BASE = 'https://ingest.test/whip';
assert.strictEqual(del.status, 204, del.text);
assert.strictEqual(del.status, 204, del.text);
});
});
await u.close();
await u.close();
+
+ // ── No static token: Bot mints its own from its Network client ────────────────────────────────
+ const m = await boot({ env: { BOT_OPENRE_TOKEN: '' } });
+ const mia = m.network.newUser('mia');
+ const redeemM = (body) => m.call('POST', '/api/v1/pair', { token: null, body });
+
+ await check('without a static token Bot mints a token for audience openvibe.openre with the Network grant\'s scope', async () => {
+ const before = m.network.tokenCalls.length;
+ const { robot, pairing } = await m.robot(mia);
+ const paired = await redeemM({ robot: robot.id, code: pairing.code });
+ assert.strictEqual(paired.status, 201, paired.text);
+ assert.ok(paired.json.publish_key && m.openre.admits(paired.json.publish_key), 'the device got an OpenRe-issued key');
+ // One mint serves the whole pairing: the find and the create reuse the cached token.
+ assert.deepStrictEqual(m.network.tokenCalls.slice(before),
+ [{ client_id: 'bot', audience: MINTED_AUDIENCE, scope: MINTED_SCOPE }]);
+ const create = m.openre.calls.at(-1);
+ assert.strictEqual(create.method, 'POST');
+ assert.strictEqual(create.subject, mia.subject);
+ assert.ok(create.authorization.startsWith('Bearer '), 'a bearer was sent');
+ assert.notStrictEqual(create.authorization, `Bearer ${m.openre.token}`, 'the static token was not what was sent');
+ const v = serviceAuth.verifyServiceToken(create.authorization.slice(7), { publicKey: m.network.publicPem, issuer: m.network.url, audience: MINTED_AUDIENCE });
+ assert.ok(v.ok, v.reason);
+ assert.strictEqual(v.claims.sub, 'svc:bot');
+ assert.deepStrictEqual([...v.claims.cap].sort(), MINTED_SCOPE.split(' ').sort());
+ });
+
+ await check('a 401 from OpenRe drops the minted token and retries the call once', async () => {
+ const tokensBefore = m.network.tokenCalls.length;
+ const callsBefore = m.openre.calls.length;
+ const { robot, pairing } = await m.robot(mia);
+ m.openre.failNext(401);
+ const paired = await redeemM({ robot: robot.id, code: pairing.code });
+ assert.strictEqual(paired.status, 201, paired.text);
+ assert.ok(m.openre.admits(paired.json.publish_key));
+ assert.strictEqual(m.network.tokenCalls.length - tokensBefore, 1, 'exactly one replacement token was minted');
+ const calls = m.openre.calls.slice(callsBefore);
+ assert.strictEqual(calls[0].method, 'GET');
+ assert.strictEqual(calls[1].method, 'GET');
+ assert.strictEqual(calls[1].query, calls[0].query, 'the same call was tried again');
+ assert.notStrictEqual(calls[1].authorization, calls[0].authorization, 'the retry carried a fresh token');
+ });
+
+ await check('a second 401 is a refusal, not an endless retry', async () => {
+ const { robot, pairing } = await m.robot(mia);
+ const callsBefore = m.openre.calls.length;
+ m.openre.failNext(401, 2);
+ const r = await redeemM({ robot: robot.id, code: pairing.code });
+ assert.strictEqual(r.status, 502, r.text);
+ assert.strictEqual(r.json.code, 'bot.openre_refused');
+ assert.strictEqual(m.openre.calls.length - callsBefore, 2, 'the call was tried exactly twice');
+ });
+
+ await m.close();
done();
done();
})();
})();