openvibers/openvibe.bot Commits

Mint Bot's OpenRe token from its Network client; BOT_OPENRE_TOKEN becomes an optional override (#33)

OP OpenVibers committed 5 Oct 2026 · 81f615fe9d · 8 files, +177 −34

**Files changed**

- `server/openre/client.js`: when `BOT_OPENRE_TOKEN` is unset and `config.oauth.clientSecret` + `config.network.internalUrl` are present, builds a `serviceAuth.createTokenClient` for audience `openvibe.openre` with the six-capability scope and sends its bearer on every call (cached; 401 invalidates and retries once). Static token still wins and is used exactly as before; `createOpenRe` returns null only for a missing URL or neither credential; token never logged/returned/errored.
- `server/config.js`: rewrote the `openre` comment to document the minted default, the optional `BOT_OPENRE_TOKEN` override, the full grant (incl. session/output for later panel video/restreaming), and the new null condition.
- `README.md`: OpenRe paragraph now describes the minted default and optional override.
- `docs/protocol.md`: §1 purchase-key paragraph and the `not_configured` note updated to the minted token and its grant.
- `.env.example`: `BOT_OPENRE_*` block updated for the minted default / override.
- `test/helpers/stubs.js`: Network stub records `tokenCalls` (`client_id`, `audience`, `scope`); OpenRe stub accepts a minted `openvibe.openre` service token (verifying signature/issuer/audience, using its `cap` claims) alongside the static token; `failNext` gained an optional repeat count.
- `test/helpers/app.js`: passes the Network stub to `startOpenRe` so minted tokens authenticate as at real OpenRe.
- `test/openre.test.js`: four new checks — minted bearer with the exact audience+scope (one mint serves find+create), 401 drops the token and retries once with a fresh bearer, a second 401 is `502 bot.openre_refused` (no loop); static-token boot asserts Network is never asked; extra `createOpenRe` null cases for missing secret/URL.

**Tests**

- `ov test test/openre.test.js` → 1 passed (20/20 checks, incl. all new ones)
- `ov test test/streaming.test.js test/node-principal.test.js test/docs.test.js test/pairing.test.js` → 4 passed

**Not done:** no commit, push, deploy or package install (per instructions); the full suite was not run — only the files above.

Changes

ddiff --git a/.env.example b/.env.example
iindex c44697c..0fffdfd 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 6bb9240..965b4b0 100644
---- a/README.md
++++ b/README.md
@@@ -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.
--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.
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 4cd48dd..b0bc6d9 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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
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
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
@@@ -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`)
ddiff --git a/server/config.js b/server/config.js
iindex 9b21af1..3fefa98 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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(),
ddiff --git a/server/openre/client.js b/server/openre/client.js
iindex fff28ca..c2962a7 100644
---- a/server/openre/client.js
++++ b/server/openre/client.js
@@@ -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;
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 0160402..76a2484 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -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',
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex f9646c4..cd22a4d 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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 clears
let 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('hex
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 });
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('hex
const 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)); },
};
ddiff --git a/test/openre.test.js b/test/openre.test.js
iindex 22f7b5b..c7cb490 100644
---- a/test/openre.test.js
++++ b/test/openre.test.js
@@@ -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();
})();