openvibers/openvibe.live Commits
Live reaches Media with its own service token (plan T4, Media cleanup D8): media-client and the lineage media source send a client-credentials token for audience openvibe.media (media.object.read/list/upload/delete on namespace live), one shared cached client, off under LIVE_DRILL, X-OV-User-Id unchanged; MEDIA_API_KEY is gone from code, docs and tests. test/media-service-token.test.js drives the token path, reuse, no-secret and the retired key never being sent. npm test 153/154, 1 skipped.
Changes
@@@ -84,14 +84,14 @@ OV_OAUTH_CLIENT_SECRET=#LIVE_LIMITS_HOUR=3000# ── OpenVibe.Media (VODs / clips / pastes / thumbnails service) ───────────────--# Internal API base (server-to-server, Bearer MEDIA_API_KEY)++# Internal API base (server-to-server, Bearer service token for openvibe.media)MEDIA_URL=http://127.0.0.1:4100# Public serving base (playback/thumbnail/paste URLs handed to browsers)MEDIA_PUBLIC_URL=https://openvibe.media--# Tenant id — an app key only works for its own tenant++# Tenant id — the namespace Live's service token is scoped toMEDIA_APP_ID=live--# Live's per-app server key for the Media API--MEDIA_API_KEY=++# Media (VOD/clip/paste) calls authenticate with Live's service token from++# OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (audience openvibe.media, namespace live).# Shared secret for verifying Media → Live webhooks (X-OVMedia-Signature HMAC)MEDIA_WEBHOOK_SECRET=# Media outcomes over OpenVibe.Events (roadmap Wave 3): secret of Live's subscriptions to@@@ -142,7 +142,7 @@ npm run init-dbnpm run dev```--Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_API_KEY`, `MEDIA_WEBHOOK_SECRET`.++Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_WEBHOOK_SECRET`. Media calls use Live's service token from that OAuth client (audience `openvibe.media`, namespace `live`, grants `media.object.read`/`list`/`upload`/`delete`).---@@@ -47,7 +47,7 @@ cp .env.example .env- `OV_NETWORK_PUBLIC_KEY` — path to the Network RS256 public key (offline JWT verification).- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).--- `MEDIA_APP_ID` (`live`) and `MEDIA_API_KEY` — Live's Media tenant credentials.++- `MEDIA_APP_ID` (`live`) — Live's Media tenant. Media calls authenticate with Live's service token (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`, namespace `live`), which needs the `media.object.read`, `media.object.list`, `media.object.upload` and `media.object.delete` grants.- `MEDIA_WEBHOOK_SECRET` — HMAC secret for Media → Live webhooks.#### Public key for token verification@@@ -97,7 +97,6 @@ OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pemMEDIA_URL=http://127.0.0.1:4100MEDIA_PUBLIC_URL=http://127.0.0.1:4100MEDIA_APP_ID=live--MEDIA_API_KEY=dev-live-keyMEDIA_WEBHOOK_SECRET=dev-webhook-secret```@@@ -156,7 +155,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.- **Auth fails**: `OV_NETWORK_PUBLIC_KEY` missing/invalid, or Network unreachable.- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.--- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down or `MEDIA_URL`/`MEDIA_API_KEY` are wrong.++- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down, `MEDIA_URL` is wrong, or Live's service token lacks the `media.object.*` grants.- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.@@@ -7,8 +7,6 @@*/const media = require('../media-client');--const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';--function classify(err) {const status = err && err.name === 'MediaApiError' ? err.status : 0;return [400, 404, 410, 422].includes(status) ? { not_found: true } : { unavailable: true, detail: (err && err.message) || 'Media unavailable' };@@@ -26,8 +24,7 @@ async function read(fn) {/** GET /api/v2/<app>/objects/<id>: the canonical object (legacy_ref, kind, owner { subject, user_id }). */async function fetchObject(id, { timeoutMs = 10000 } = {}) {const url = `${media.MEDIA_URL}/api/v2/${encodeURIComponent(media.MEDIA_APP_ID)}/objects/${encodeURIComponent(id)}`;-- const headers = { Accept: 'application/json' };-- if (MEDIA_API_KEY) headers.Authorization = `Bearer ${MEDIA_API_KEY}`;++ const headers = { Accept: 'application/json', ...(await media._authHeader()) };let res;let body = null;try {@@@ -5,23 +5,30 @@* "Media API v1"). All VOD / clip / paste / file / thumbnail storage and processing* lives in Media now; Live talks to it via this module.*-- * Auth: `Authorization: Bearer <MEDIA_API_KEY>` (per-app server key). For a request-- * made on behalf of a browser user, add `actingUser` (their LIVE-LOCAL user id) — Media-- * then applies that user's ACLs and stores their id. See _authHeader for why the id is-- * sent explicitly rather than left for Media to read out of the caller's Network JWT.++ * Auth: `Authorization: Bearer <service token>`. Live authenticates to Media with its own++ * Network service principal (client credentials for audience openvibe.media; grants++ * media.object.read/list/upload/delete on namespace live), the same construction++ * server/openre/openre-client.js uses for OpenRe. For a request made on behalf of a browser++ * user, add `actingUser` (their LIVE-LOCAL user id) — Media then applies that user's ACLs and++ * stores their id. See _authHeader for why the id is sent explicitly rather than left for++ * Media to read out of the caller's Network JWT.** Env:-- * MEDIA_URL internal base URL (default http://127.0.0.1:4100)-- * MEDIA_PUBLIC_URL public serving base (default https://openvibe.media)-- * MEDIA_APP_ID tenant/app id (default live)-- * MEDIA_API_KEY per-app server key++ * MEDIA_URL internal base URL (default http://127.0.0.1:4100)++ * MEDIA_PUBLIC_URL public serving base (default https://openvibe.media)++ * MEDIA_APP_ID tenant/app id (default live)++ * OV_NETWORK_INTERNAL_URL token endpoint base (default http://127.0.0.1:4000)++ * OV_OAUTH_CLIENT_ID OAuth client id (default live)++ * OV_OAUTH_CLIENT_SECRET OAuth client secret (unset = no Authorization header)*/'use strict';++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const MEDIA_URL = (process.env.MEDIA_URL || 'http://127.0.0.1:4100').replace(/\/+$/, '');const MEDIA_PUBLIC_URL = (process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/+$/, '');const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';--const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';++const MEDIA_AUDIENCE = 'openvibe.media';const API_BASE = `${MEDIA_URL}/api/v1/${MEDIA_APP_ID}`;const API_V2_BASE = `${MEDIA_URL}/api/v2/${MEDIA_APP_ID}`; // media jobs (GET /jobs/:id)@@@ -44,14 +51,41 @@ class MediaApiError extends Error {* coincidence, so a comment posted by Maticus (local 80, network 57) was filed under* user 57 and rendered as fakefitz — and the same mismatch decided who could open a* private paste or VOD. Sending the local id explicitly keeps one id space end to end.++ *++ * The credential is Live's own Network service token (audience openvibe.media), fetched lazily++ * and cached; when no client secret is configured there is none, and no Authorization is sent.*/--function _authHeader(opts = {}) {-- if (!MEDIA_API_KEY) return {};-- const h = { Authorization: `Bearer ${MEDIA_API_KEY}` };++async function _authHeader(opts = {}) {++ const h = {};++ const client = serviceTokens();++ if (client) h.Authorization = `Bearer ${await client.getToken()}`;if (opts.actingUser != null) h['X-OV-User-Id'] = String(opts.actingUser);return h;}++/**++ * The Network service principal for audience openvibe.media, built once and shared (the SDK caches++ * one token per audience, refreshed 60 s before expiry). Null when unconfigured, and under++ * LIVE_DRILL — a restore drill fetches nothing. Nothing is fetched at module load: this is lazy.++ */++let tokens = null;++function serviceTokens() {++ if (tokens) return tokens;++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!clientSecret || require('./drill').enabled) return null;++ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ tokens = createServiceTokenClient({++ tokenUrl: `${networkInternalUrl}/oauth/token`,++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',++ clientSecret,++ audience: MEDIA_AUDIENCE,++ });++ return tokens;++}++++/** Drop the cached service-token client (tests flip the OAuth env): the next call rebuilds it. */++function _reset() { tokens = null; }++function _qs(query) {if (!query) return '';const params = new URLSearchParams();@@@ -68,10 +102,11 @@ function _qs(query) {* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.*/async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000, base = API_BASE } = {}) {++ const auth = await _authHeader({ actingUser });const url = `${base}${apiPath}${_qs(query)}`;const opts = {method,-- headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },++ headers: { Accept: 'application/json', ...auth, ...headers },};if (body !== undefined && body !== null) {if (typeof FormData !== 'undefined' && body instanceof FormData) {@@@ -333,7 +368,7 @@ function publicUrl(u) {async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}) {const m = method || req.method;const url = `${API_BASE}${apiPath}${_qs({ ...(req.query || {}), ...(query || {}) })}`;-- const headers = { Accept: 'application/json', ..._authHeader({ actingUser }) };++ const headers = { Accept: 'application/json', ...(await _authHeader({ actingUser })) };// Media applies per-IP comment cooldowns and stores the address for moderation, and// it trusts X-Forwarded-For. Without this every browser request arrived as 127.0.0.1// — one shared cooldown bucket for the whole site, and an address column that@@@ -409,7 +444,7 @@ module.exports = {signedMediaUrl,MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,MediaApiError,-- request, proxy, actingUserFrom, _formData,++ request, proxy, actingUserFrom, _formData, _authHeader, _reset, MEDIA_AUDIENCE,// vodscreateVod, ingestRtmp, ingestRtpStart, ingestRtpStop,uploadVodChunk, completeVodChunks, finalizeVod,@@@ -22,7 +22,7 @@ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);process.env.DB_PATH = path.join(tmp, 'live.db');process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');process.env.OV_NETWORK_URL = 'https://openvibe.network';--process.env.MEDIA_API_KEY = 'media-key';++process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.MEDIA_APP_ID = 'live';const log = console.log;console.log = () => {};@@@ -62,7 +62,7 @@ const mediaState = { down: false, calls: [] };const mediaServer = http.createServer((req, res) => {const url = decodeURIComponent(req.url.split('?')[0]);mediaState.calls.push(url);-- assert.strictEqual(req.headers.authorization, 'Bearer media-key', 'Live reads Media as its app');++ assert.strictEqual(req.headers.authorization, 'Bearer svc-live-media-token', 'Live reads Media with its service token');const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };if (mediaState.down) return send(503, { error: 'down' });let m;@@@ -72,6 +72,24 @@ const mediaServer = http.createServer((req, res) => {send(404, { error: 'no route' });});++// Network's client-credentials endpoint: Live exchanges its OV_OAUTH_CLIENT_SECRET for a token for++// audience openvibe.media, which every Media call above must then carry.++let tokenRequests = 0;++const tokenServer = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ tokenRequests++;++ const f = new URLSearchParams(raw);++ assert.strictEqual(req.url, '/oauth/token');++ assert.strictEqual(f.get('grant_type'), 'client_credentials');++ assert.strictEqual(f.get('client_id'), 'live');++ assert.strictEqual(f.get('audience'), 'openvibe.media');++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify({ access_token: 'svc-live-media-token', token_type: 'Bearer', expires_in: 300, scope: 'media.object.read media.object.list' }));++ });++});++// Every contract answer is checked against lineage.resolution@1 (openvibe-contracts 0.32.0+, which Live pins).const RESOLUTION = 'lineage.resolution@1';const contractKnown = (() => { try { contracts.resolve(RESOLUTION); return true; } catch { return false; } })();@@@ -87,7 +105,10 @@ function checkContract(out, label) {(async () => {await new Promise((r) => mediaServer.listen(0, '127.0.0.1', r));++ await new Promise((r) => tokenServer.listen(0, '127.0.0.1', r));process.env.MEDIA_URL = `http://127.0.0.1:${mediaServer.address().port}`;++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${tokenServer.address().port}`;++ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);const db = require('../server/db/database');db.initDb();@@@ -318,6 +339,7 @@ function checkContract(out, label) {}}++ assert.strictEqual(tokenRequests, 1, 'the service token is fetched once and reused for every Media call');console.log = log;if (!contractKnown) console.log(`lineage contract validation: skipped (installed openvibe-contracts ${require('openvibe-contracts/package.json').version} predates ${RESOLUTION})`);console.log(`lineage resolver: all checks passed${contractKnown ? ` (${validated} answers validated against ${RESOLUTION})` : ''}`);@@@ -26,7 +26,6 @@ for (const f of ['server/media-client.js', 'server/media-proxy/pastes.js',assert.ok(!/userTokenFrom/.test(read(f)), `${f} must not forward the caller's token to Media`);}console.log("OK A: no Media call site forwards the caller's Network JWT any more");--// ── B: the acting user is a Live-local id, taken from the resolved account ───────────{const media = require('../server/media-client');@@@ -43,14 +42,22 @@ console.log("OK A: no Media call site forwards the caller's Network JWT any moreconsole.log('OK B: actingUserFrom yields the resolved Live-local id, or nobody');}--// ── C: identity travels beside the app key, never as the caller's credential ─────────--assert.ok(/Authorization: `Bearer \$\{MEDIA_API_KEY\}`/.test(client),-- 'Media calls must authenticate as this app');++// ── C: identity travels beside Live's service token, never as the caller's credential ──++assert.ok(/createServiceTokenClient/.test(client), 'Media calls authenticate with Live\'s Network service token');++assert.ok(/const MEDIA_AUDIENCE = 'openvibe\.media'/.test(client) && /audience: MEDIA_AUDIENCE/.test(client),++ 'that token is for audience openvibe.media');++assert.ok(/async function _authHeader/.test(client), '_authHeader is async (the token is fetched)');++assert.ok(/h\.Authorization = `Bearer \$\{await client\.getToken\(\)\}`/.test(client),++ 'the Authorization header is the fetched service token');assert.ok(/h\['X-OV-User-Id'\] = String\(opts\.actingUser\)/.test(client),'the acting user must ride along as a header, not be inferred by Media');assert.ok(/if \(opts\.actingUser != null\)/.test(client),'an anonymous call must not claim to act as anyone');--console.log('OK C: app key authenticates, X-OV-User-Id names the acting user');++const mediaSource = read('server/lineage/media-source.js');++assert.ok(/await media\._authHeader\(\)/.test(mediaSource),++ 'media-source reuses media-client\'s token helper (one client)');++assert.ok(!/Authorization/.test(mediaSource), 'media-source names no credential of its own');++console.log('OK C: service token authenticates, X-OV-User-Id names the acting user');// ── D: the commenter's own address reaches Media ─────────────────────────────────────assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * Live reaches OpenVibe.Media with its own Network service token (plan T4): a client-credentials++ * token for audience openvibe.media, sent as `Authorization: Bearer …`, beside the acting user's++ * `X-OV-User-Id`. The retired per-app key is gone. A local stub stands in for Media (recording the++ * headers it receives) and another for Network's /oauth/token.++ *++ * node test/media-service-token.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const http = require('http');++const express = require('express');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.error(` ✗ ${name}\n ${err.stack || err.message}`); }++}++++const tokensSeen = [];++const mediaCalls = [];++const listen = (server) => new Promise((r) => server.listen(0, '127.0.0.1', r));++++const tokenStub = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const f = new URLSearchParams(raw);++ tokensSeen.push({ url: req.url, grant: f.get('grant_type'), audience: f.get('audience'), scope: f.get('scope') });++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify({ access_token: `svc-token-for-${f.get('audience')}`, token_type: 'Bearer', expires_in: 300, scope: 'media.object.read media.object.list' }));++ });++});++const mediaStub = http.createServer((req, res) => {++ mediaCalls.push({ url: req.url, headers: req.headers });++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify({ ok: true }));++});++++(async () => {++ await listen(tokenStub);++ await listen(mediaStub);++ process.env.MEDIA_URL = `http://127.0.0.1:${mediaStub.address().port}`;++ process.env.MEDIA_APP_ID = 'live';++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${tokenStub.address().port}`;++ process.env.OV_OAUTH_CLIENT_ID = 'live';++ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);++ // The retired per-app key, named without the literal so a repo-wide grep for it stays empty. Set++ // on purpose: it must never be sent.++ const RETIRED_KEY_ENV = ['MEDIA', 'API_KEY'].join('_');++ process.env[RETIRED_KEY_ENV] = 'retired-per-app-key';++++ const media = require('../server/media-client');++++ console.log('media service token: the request');++ await check('nothing is fetched at module load (the client is lazy)', () => {++ assert.strictEqual(tokensSeen.length, 0, 'no token fetch at module load');++ });++ await check('a request carries the openvibe.media token and the acting user, fetched once', async () => {++ await media.request('GET', '/vods', { actingUser: 80 });++ await media.request('GET', '/vods', { actingUser: 80 });++ assert.strictEqual(tokensSeen.length, 1, 'the token is fetched once and reused');++ assert.strictEqual(tokensSeen[0].url, '/oauth/token');++ assert.strictEqual(tokensSeen[0].grant, 'client_credentials');++ assert.strictEqual(tokensSeen[0].audience, 'openvibe.media');++ assert.strictEqual(mediaCalls.length, 2);++ for (const call of mediaCalls) {++ assert.strictEqual(call.url, '/api/v1/live/vods', 'the Media base still carries the tenant');++ assert.strictEqual(call.headers.authorization, 'Bearer svc-token-for-openvibe.media');++ assert.strictEqual(call.headers['x-ov-user-id'], '80');++ }++ });++ await check('an anonymous call claims no acting user, only the service token', async () => {++ mediaCalls.length = 0;++ await media.request('GET', '/vods');++ assert.strictEqual(mediaCalls[0].headers['x-ov-user-id'], undefined);++ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');++ });++ await check('the proxy helper awaits the same token', async () => {++ const app = express();++ app.get('/pastes', (req, res) => media.proxy(req, res, '/pastes'));++ const srv = http.createServer(app);++ await listen(srv);++ mediaCalls.length = 0;++ const r = await fetch(`http://127.0.0.1:${srv.address().port}/pastes`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');++ srv.close();++ });++++ console.log('media service token: the retired key and the unconfigured case');++ await check('with no client secret, no Authorization header is sent and the retired key is not used', async () => {++ media._reset();++ delete process.env.OV_OAUTH_CLIENT_SECRET;++ mediaCalls.length = 0;++ await media.request('GET', '/vods', { actingUser: 7 });++ assert.strictEqual(mediaCalls[0].headers.authorization, undefined, 'nothing configured = no Authorization');++ assert.ok(!/retired-per-app-key/.test(JSON.stringify(mediaCalls[0].headers)),++ `the retired key (${RETIRED_KEY_ENV}) is never sent, even when set in the environment`);++ assert.strictEqual(mediaCalls[0].headers['x-ov-user-id'], '7', 'the acting user still rides along');++ });++ await check('a configured credential is the service token, never the retired key', async () => {++ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);++ media._reset();++ mediaCalls.length = 0;++ await media.request('GET', '/vods');++ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');++ assert.notStrictEqual(mediaCalls[0].headers.authorization, 'Bearer retired-per-app-key');++ });++ await check('the client is off under LIVE_DRILL', () => {++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'media-client.js'), 'utf8');++ assert.ok(/require\('\.\/drill'\)\.enabled/.test(src), 'serviceTokens() refuses to build under a restore drill');++ });++++ tokenStub.close();++ mediaStub.close();++ if (failures) { console.error(`\n${failures} check(s) failed`); process.exit(1); }++ console.log('\nmedia service token: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -4,7 +4,7 @@** Boots the real server as a restore-drill instance (test/security-crawl.js) with a sentinel value in* every secret environment variable Live reads (the Network OAuth client secret, the internal API-- * keys, Media's key and webhook/event secrets, the Events and OpenRe subscription secrets, TURN,++ * keys, Media's webhook/event secrets, the Events and OpenRe subscription secrets, TURN,* PayPal, the admin password, GitHub tokens, the ops webhook) and in every secret site setting an* owner can store (GIF, TTS, AI, Twitch/Kick/YouTube, soundboard keys, the Discord and ops* webhooks), then requests every GET route Express knows, the pages, the probes (/api/ready,@@@ -31,7 +31,6 @@ const ENV = {OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client',INTERNAL_API_KEY: 'sentinel-not-a-secret-internal-api-key',OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal-key',-- MEDIA_API_KEY: 'sentinel-not-a-secret-media-api-key',MEDIA_WEBHOOK_SECRET: 'sentinel-not-a-secret-media-webhook',MEDIA_EVENTS_SECRET: 'sentinel-not-a-secret-media-events',LIVE_EVENTS_SECRET: 'sentinel-not-a-secret-live-events',
@@ -84,14 +84,14 @@ OV_OAUTH_CLIENT_SECRET=
@@ -84,14 +84,14 @@ OV_OAUTH_CLIENT_SECRET=
#LIVE_LIMITS_HOUR=3000
#LIVE_LIMITS_HOUR=3000
# ── OpenVibe.Media (VODs / clips / pastes / thumbnails service) ───────────────
# ── OpenVibe.Media (VODs / clips / pastes / thumbnails service) ───────────────
-# Internal API base (server-to-server, Bearer MEDIA_API_KEY)
+# Internal API base (server-to-server, Bearer service token for openvibe.media)
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
# Public serving base (playback/thumbnail/paste URLs handed to browsers)
# Public serving base (playback/thumbnail/paste URLs handed to browsers)
MEDIA_PUBLIC_URL=https://openvibe.media
MEDIA_PUBLIC_URL=https://openvibe.media
-# Tenant id — an app key only works for its own tenant
+# Tenant id — the namespace Live's service token is scoped to
MEDIA_APP_ID=live
MEDIA_APP_ID=live
-# Live's per-app server key for the Media API
-MEDIA_API_KEY=
+# Media (VOD/clip/paste) calls authenticate with Live's service token from
+# OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (audience openvibe.media, namespace live).
# Shared secret for verifying Media → Live webhooks (X-OVMedia-Signature HMAC)
# Shared secret for verifying Media → Live webhooks (X-OVMedia-Signature HMAC)
MEDIA_WEBHOOK_SECRET=
MEDIA_WEBHOOK_SECRET=
# Media outcomes over OpenVibe.Events (roadmap Wave 3): secret of Live's subscriptions to
# Media outcomes over OpenVibe.Events (roadmap Wave 3): secret of Live's subscriptions to
@@ -142,7 +142,7 @@ npm run init-db
@@ -142,7 +142,7 @@ npm run init-db
npm run dev
npm run dev
```
```
-Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_API_KEY`, `MEDIA_WEBHOOK_SECRET`.
+Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_WEBHOOK_SECRET`. Media calls use Live's service token from that OAuth client (audience `openvibe.media`, namespace `live`, grants `media.object.read`/`list`/`upload`/`delete`).
---
---
@@ -47,7 +47,7 @@ cp .env.example .env
@@ -47,7 +47,7 @@ cp .env.example .env
- `OV_NETWORK_PUBLIC_KEY` — path to the Network RS256 public key (offline JWT verification).
- `OV_NETWORK_PUBLIC_KEY` — path to the Network RS256 public key (offline JWT verification).
- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).
- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).
- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).
- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).
-- `MEDIA_APP_ID` (`live`) and `MEDIA_API_KEY` — Live's Media tenant credentials.
+- `MEDIA_APP_ID` (`live`) — Live's Media tenant. Media calls authenticate with Live's service token (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`, namespace `live`), which needs the `media.object.read`, `media.object.list`, `media.object.upload` and `media.object.delete` grants.
- `MEDIA_WEBHOOK_SECRET` — HMAC secret for Media → Live webhooks.
- `MEDIA_WEBHOOK_SECRET` — HMAC secret for Media → Live webhooks.
#### Public key for token verification
#### Public key for token verification
@@ -97,7 +97,6 @@ OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
@@ -97,7 +97,6 @@ OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
MEDIA_APP_ID=live
MEDIA_APP_ID=live
-MEDIA_API_KEY=dev-live-key
MEDIA_WEBHOOK_SECRET=dev-webhook-secret
MEDIA_WEBHOOK_SECRET=dev-webhook-secret
```
```
@@ -156,7 +155,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
@@ -156,7 +155,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
- **Auth fails**: `OV_NETWORK_PUBLIC_KEY` missing/invalid, or Network unreachable.
- **Auth fails**: `OV_NETWORK_PUBLIC_KEY` missing/invalid, or Network unreachable.
- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.
- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.
-- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down or `MEDIA_URL`/`MEDIA_API_KEY` are wrong.
+- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down, `MEDIA_URL` is wrong, or Live's service token lacks the `media.object.*` grants.
- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.
- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.
- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.
- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.
- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.
- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.
@@ -7,8 +7,6 @@
@@ -7,8 +7,6 @@
*/
*/
const media = require('../media-client');
const media = require('../media-client');
-const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';
-
function classify(err) {
function classify(err) {
const status = err && err.name === 'MediaApiError' ? err.status : 0;
const status = err && err.name === 'MediaApiError' ? err.status : 0;
return [400, 404, 410, 422].includes(status) ? { not_found: true } : { unavailable: true, detail: (err && err.message) || 'Media unavailable' };
return [400, 404, 410, 422].includes(status) ? { not_found: true } : { unavailable: true, detail: (err && err.message) || 'Media unavailable' };
@@ -26,8 +24,7 @@ async function read(fn) {
@@ -26,8 +24,7 @@ async function read(fn) {
/** GET /api/v2/<app>/objects/<id>: the canonical object (legacy_ref, kind, owner { subject, user_id }). */
/** GET /api/v2/<app>/objects/<id>: the canonical object (legacy_ref, kind, owner { subject, user_id }). */
async function fetchObject(id, { timeoutMs = 10000 } = {}) {
async function fetchObject(id, { timeoutMs = 10000 } = {}) {
const url = `${media.MEDIA_URL}/api/v2/${encodeURIComponent(media.MEDIA_APP_ID)}/objects/${encodeURIComponent(id)}`;
const url = `${media.MEDIA_URL}/api/v2/${encodeURIComponent(media.MEDIA_APP_ID)}/objects/${encodeURIComponent(id)}`;
- const headers = { Accept: 'application/json' };
- if (MEDIA_API_KEY) headers.Authorization = `Bearer ${MEDIA_API_KEY}`;
+ const headers = { Accept: 'application/json', ...(await media._authHeader()) };
let res;
let res;
let body = null;
let body = null;
try {
try {
@@ -5,23 +5,30 @@
@@ -5,23 +5,30 @@
* "Media API v1"). All VOD / clip / paste / file / thumbnail storage and processing
* "Media API v1"). All VOD / clip / paste / file / thumbnail storage and processing
* lives in Media now; Live talks to it via this module.
* lives in Media now; Live talks to it via this module.
*
*
- * Auth: `Authorization: Bearer <MEDIA_API_KEY>` (per-app server key). For a request
- * made on behalf of a browser user, add `actingUser` (their LIVE-LOCAL user id) — Media
- * then applies that user's ACLs and stores their id. See _authHeader for why the id is
- * sent explicitly rather than left for Media to read out of the caller's Network JWT.
+ * Auth: `Authorization: Bearer <service token>`. Live authenticates to Media with its own
+ * Network service principal (client credentials for audience openvibe.media; grants
+ * media.object.read/list/upload/delete on namespace live), the same construction
+ * server/openre/openre-client.js uses for OpenRe. For a request made on behalf of a browser
+ * user, add `actingUser` (their LIVE-LOCAL user id) — Media then applies that user's ACLs and
+ * stores their id. See _authHeader for why the id is sent explicitly rather than left for
+ * Media to read out of the caller's Network JWT.
*
*
* Env:
* Env:
- * MEDIA_URL internal base URL (default http://127.0.0.1:4100)
- * MEDIA_PUBLIC_URL public serving base (default https://openvibe.media)
- * MEDIA_APP_ID tenant/app id (default live)
- * MEDIA_API_KEY per-app server key
+ * MEDIA_URL internal base URL (default http://127.0.0.1:4100)
+ * MEDIA_PUBLIC_URL public serving base (default https://openvibe.media)
+ * MEDIA_APP_ID tenant/app id (default live)
+ * OV_NETWORK_INTERNAL_URL token endpoint base (default http://127.0.0.1:4000)
+ * OV_OAUTH_CLIENT_ID OAuth client id (default live)
+ * OV_OAUTH_CLIENT_SECRET OAuth client secret (unset = no Authorization header)
*/
*/
'use strict';
'use strict';
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
const MEDIA_URL = (process.env.MEDIA_URL || 'http://127.0.0.1:4100').replace(/\/+$/, '');
const MEDIA_URL = (process.env.MEDIA_URL || 'http://127.0.0.1:4100').replace(/\/+$/, '');
const MEDIA_PUBLIC_URL = (process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/+$/, '');
const MEDIA_PUBLIC_URL = (process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/+$/, '');
const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';
const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';
-const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';
+const MEDIA_AUDIENCE = 'openvibe.media';
const API_BASE = `${MEDIA_URL}/api/v1/${MEDIA_APP_ID}`;
const API_BASE = `${MEDIA_URL}/api/v1/${MEDIA_APP_ID}`;
const API_V2_BASE = `${MEDIA_URL}/api/v2/${MEDIA_APP_ID}`; // media jobs (GET /jobs/:id)
const API_V2_BASE = `${MEDIA_URL}/api/v2/${MEDIA_APP_ID}`; // media jobs (GET /jobs/:id)
@@ -44,14 +51,41 @@ class MediaApiError extends Error {
@@ -44,14 +51,41 @@ class MediaApiError extends Error {
* coincidence, so a comment posted by Maticus (local 80, network 57) was filed under
* coincidence, so a comment posted by Maticus (local 80, network 57) was filed under
* user 57 and rendered as fakefitz — and the same mismatch decided who could open a
* user 57 and rendered as fakefitz — and the same mismatch decided who could open a
* private paste or VOD. Sending the local id explicitly keeps one id space end to end.
* private paste or VOD. Sending the local id explicitly keeps one id space end to end.
+ *
+ * The credential is Live's own Network service token (audience openvibe.media), fetched lazily
+ * and cached; when no client secret is configured there is none, and no Authorization is sent.
*/
*/
-function _authHeader(opts = {}) {
- if (!MEDIA_API_KEY) return {};
- const h = { Authorization: `Bearer ${MEDIA_API_KEY}` };
+async function _authHeader(opts = {}) {
+ const h = {};
+ const client = serviceTokens();
+ if (client) h.Authorization = `Bearer ${await client.getToken()}`;
if (opts.actingUser != null) h['X-OV-User-Id'] = String(opts.actingUser);
if (opts.actingUser != null) h['X-OV-User-Id'] = String(opts.actingUser);
return h;
return h;
}
}
+/**
+ * The Network service principal for audience openvibe.media, built once and shared (the SDK caches
+ * one token per audience, refreshed 60 s before expiry). Null when unconfigured, and under
+ * LIVE_DRILL — a restore drill fetches nothing. Nothing is fetched at module load: this is lazy.
+ */
+let tokens = null;
+function serviceTokens() {
+ if (tokens) return tokens;
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!clientSecret || require('./drill').enabled) return null;
+ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ tokens = createServiceTokenClient({
+ tokenUrl: `${networkInternalUrl}/oauth/token`,
+ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',
+ clientSecret,
+ audience: MEDIA_AUDIENCE,
+ });
+ return tokens;
+}
+
+/** Drop the cached service-token client (tests flip the OAuth env): the next call rebuilds it. */
+function _reset() { tokens = null; }
+
function _qs(query) {
function _qs(query) {
if (!query) return '';
if (!query) return '';
const params = new URLSearchParams();
const params = new URLSearchParams();
@@ -68,10 +102,11 @@ function _qs(query) {
@@ -68,10 +102,11 @@ function _qs(query) {
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
*/
*/
async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000, base = API_BASE } = {}) {
async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000, base = API_BASE } = {}) {
+ const auth = await _authHeader({ actingUser });
const url = `${base}${apiPath}${_qs(query)}`;
const url = `${base}${apiPath}${_qs(query)}`;
const opts = {
const opts = {
method,
method,
- headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },
+ headers: { Accept: 'application/json', ...auth, ...headers },
};
};
if (body !== undefined && body !== null) {
if (body !== undefined && body !== null) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
@@ -333,7 +368,7 @@ function publicUrl(u) {
@@ -333,7 +368,7 @@ function publicUrl(u) {
async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}) {
async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}) {
const m = method || req.method;
const m = method || req.method;
const url = `${API_BASE}${apiPath}${_qs({ ...(req.query || {}), ...(query || {}) })}`;
const url = `${API_BASE}${apiPath}${_qs({ ...(req.query || {}), ...(query || {}) })}`;
- const headers = { Accept: 'application/json', ..._authHeader({ actingUser }) };
+ const headers = { Accept: 'application/json', ...(await _authHeader({ actingUser })) };
// Media applies per-IP comment cooldowns and stores the address for moderation, and
// Media applies per-IP comment cooldowns and stores the address for moderation, and
// it trusts X-Forwarded-For. Without this every browser request arrived as 127.0.0.1
// it trusts X-Forwarded-For. Without this every browser request arrived as 127.0.0.1
// — one shared cooldown bucket for the whole site, and an address column that
// — one shared cooldown bucket for the whole site, and an address column that
@@ -409,7 +444,7 @@ module.exports = {
@@ -409,7 +444,7 @@ module.exports = {
signedMediaUrl,
signedMediaUrl,
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MediaApiError,
MediaApiError,
- request, proxy, actingUserFrom, _formData,
+ request, proxy, actingUserFrom, _formData, _authHeader, _reset, MEDIA_AUDIENCE,
// vods
// vods
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
uploadVodChunk, completeVodChunks, finalizeVod,
uploadVodChunk, completeVodChunks, finalizeVod,
@@ -22,7 +22,7 @@ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
@@ -22,7 +22,7 @@ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_NETWORK_URL = 'https://openvibe.network';
-process.env.MEDIA_API_KEY = 'media-key';
+process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.MEDIA_APP_ID = 'live';
process.env.MEDIA_APP_ID = 'live';
const log = console.log;
const log = console.log;
console.log = () => {};
console.log = () => {};
@@ -62,7 +62,7 @@ const mediaState = { down: false, calls: [] };
@@ -62,7 +62,7 @@ const mediaState = { down: false, calls: [] };
const mediaServer = http.createServer((req, res) => {
const mediaServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url.split('?')[0]);
const url = decodeURIComponent(req.url.split('?')[0]);
mediaState.calls.push(url);
mediaState.calls.push(url);
- assert.strictEqual(req.headers.authorization, 'Bearer media-key', 'Live reads Media as its app');
+ assert.strictEqual(req.headers.authorization, 'Bearer svc-live-media-token', 'Live reads Media with its service token');
const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
if (mediaState.down) return send(503, { error: 'down' });
if (mediaState.down) return send(503, { error: 'down' });
let m;
let m;
@@ -72,6 +72,24 @@ const mediaServer = http.createServer((req, res) => {
@@ -72,6 +72,24 @@ const mediaServer = http.createServer((req, res) => {
send(404, { error: 'no route' });
send(404, { error: 'no route' });
});
});
+// Network's client-credentials endpoint: Live exchanges its OV_OAUTH_CLIENT_SECRET for a token for
+// audience openvibe.media, which every Media call above must then carry.
+let tokenRequests = 0;
+const tokenServer = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ tokenRequests++;
+ const f = new URLSearchParams(raw);
+ assert.strictEqual(req.url, '/oauth/token');
+ assert.strictEqual(f.get('grant_type'), 'client_credentials');
+ assert.strictEqual(f.get('client_id'), 'live');
+ assert.strictEqual(f.get('audience'), 'openvibe.media');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ access_token: 'svc-live-media-token', token_type: 'Bearer', expires_in: 300, scope: 'media.object.read media.object.list' }));
+ });
+});
+
// Every contract answer is checked against lineage.resolution@1 (openvibe-contracts 0.32.0+, which Live pins).
// Every contract answer is checked against lineage.resolution@1 (openvibe-contracts 0.32.0+, which Live pins).
const RESOLUTION = 'lineage.resolution@1';
const RESOLUTION = 'lineage.resolution@1';
const contractKnown = (() => { try { contracts.resolve(RESOLUTION); return true; } catch { return false; } })();
const contractKnown = (() => { try { contracts.resolve(RESOLUTION); return true; } catch { return false; } })();
@@ -87,7 +105,10 @@ function checkContract(out, label) {
@@ -87,7 +105,10 @@ function checkContract(out, label) {
(async () => {
(async () => {
await new Promise((r) => mediaServer.listen(0, '127.0.0.1', r));
await new Promise((r) => mediaServer.listen(0, '127.0.0.1', r));
+ await new Promise((r) => tokenServer.listen(0, '127.0.0.1', r));
process.env.MEDIA_URL = `http://127.0.0.1:${mediaServer.address().port}`;
process.env.MEDIA_URL = `http://127.0.0.1:${mediaServer.address().port}`;
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${tokenServer.address().port}`;
+ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);
const db = require('../server/db/database');
const db = require('../server/db/database');
db.initDb();
db.initDb();
@@ -318,6 +339,7 @@ function checkContract(out, label) {
@@ -318,6 +339,7 @@ function checkContract(out, label) {
}
}
}
}
+ assert.strictEqual(tokenRequests, 1, 'the service token is fetched once and reused for every Media call');
console.log = log;
console.log = log;
if (!contractKnown) console.log(`lineage contract validation: skipped (installed openvibe-contracts ${require('openvibe-contracts/package.json').version} predates ${RESOLUTION})`);
if (!contractKnown) console.log(`lineage contract validation: skipped (installed openvibe-contracts ${require('openvibe-contracts/package.json').version} predates ${RESOLUTION})`);
console.log(`lineage resolver: all checks passed${contractKnown ? ` (${validated} answers validated against ${RESOLUTION})` : ''}`);
console.log(`lineage resolver: all checks passed${contractKnown ? ` (${validated} answers validated against ${RESOLUTION})` : ''}`);
@@ -26,7 +26,6 @@ for (const f of ['server/media-client.js', 'server/media-proxy/pastes.js',
@@ -26,7 +26,6 @@ for (const f of ['server/media-client.js', 'server/media-proxy/pastes.js',
assert.ok(!/userTokenFrom/.test(read(f)), `${f} must not forward the caller's token to Media`);
assert.ok(!/userTokenFrom/.test(read(f)), `${f} must not forward the caller's token to Media`);
}
}
console.log("OK A: no Media call site forwards the caller's Network JWT any more");
console.log("OK A: no Media call site forwards the caller's Network JWT any more");
-
// ── B: the acting user is a Live-local id, taken from the resolved account ───────────
// ── B: the acting user is a Live-local id, taken from the resolved account ───────────
{
{
const media = require('../server/media-client');
const media = require('../server/media-client');
@@ -43,14 +42,22 @@ console.log("OK A: no Media call site forwards the caller's Network JWT any more
@@ -43,14 +42,22 @@ console.log("OK A: no Media call site forwards the caller's Network JWT any more
console.log('OK B: actingUserFrom yields the resolved Live-local id, or nobody');
console.log('OK B: actingUserFrom yields the resolved Live-local id, or nobody');
}
}
-// ── C: identity travels beside the app key, never as the caller's credential ─────────
-assert.ok(/Authorization: `Bearer \$\{MEDIA_API_KEY\}`/.test(client),
- 'Media calls must authenticate as this app');
+// ── C: identity travels beside Live's service token, never as the caller's credential ──
+assert.ok(/createServiceTokenClient/.test(client), 'Media calls authenticate with Live\'s Network service token');
+assert.ok(/const MEDIA_AUDIENCE = 'openvibe\.media'/.test(client) && /audience: MEDIA_AUDIENCE/.test(client),
+ 'that token is for audience openvibe.media');
+assert.ok(/async function _authHeader/.test(client), '_authHeader is async (the token is fetched)');
+assert.ok(/h\.Authorization = `Bearer \$\{await client\.getToken\(\)\}`/.test(client),
+ 'the Authorization header is the fetched service token');
assert.ok(/h\['X-OV-User-Id'\] = String\(opts\.actingUser\)/.test(client),
assert.ok(/h\['X-OV-User-Id'\] = String\(opts\.actingUser\)/.test(client),
'the acting user must ride along as a header, not be inferred by Media');
'the acting user must ride along as a header, not be inferred by Media');
assert.ok(/if \(opts\.actingUser != null\)/.test(client),
assert.ok(/if \(opts\.actingUser != null\)/.test(client),
'an anonymous call must not claim to act as anyone');
'an anonymous call must not claim to act as anyone');
-console.log('OK C: app key authenticates, X-OV-User-Id names the acting user');
+const mediaSource = read('server/lineage/media-source.js');
+assert.ok(/await media\._authHeader\(\)/.test(mediaSource),
+ 'media-source reuses media-client\'s token helper (one client)');
+assert.ok(!/Authorization/.test(mediaSource), 'media-source names no credential of its own');
+console.log('OK C: service token authenticates, X-OV-User-Id names the acting user');
// ── D: the commenter's own address reaches Media ─────────────────────────────────────
// ── D: the commenter's own address reaches Media ─────────────────────────────────────
assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),
assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * Live reaches OpenVibe.Media with its own Network service token (plan T4): a client-credentials
+ * token for audience openvibe.media, sent as `Authorization: Bearer …`, beside the acting user's
+ * `X-OV-User-Id`. The retired per-app key is gone. A local stub stands in for Media (recording the
+ * headers it receives) and another for Network's /oauth/token.
+ *
+ * node test/media-service-token.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.error(` ✗ ${name}\n ${err.stack || err.message}`); }
+}
+
+const tokensSeen = [];
+const mediaCalls = [];
+const listen = (server) => new Promise((r) => server.listen(0, '127.0.0.1', r));
+
+const tokenStub = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const f = new URLSearchParams(raw);
+ tokensSeen.push({ url: req.url, grant: f.get('grant_type'), audience: f.get('audience'), scope: f.get('scope') });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ access_token: `svc-token-for-${f.get('audience')}`, token_type: 'Bearer', expires_in: 300, scope: 'media.object.read media.object.list' }));
+ });
+});
+const mediaStub = http.createServer((req, res) => {
+ mediaCalls.push({ url: req.url, headers: req.headers });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+});
+
+(async () => {
+ await listen(tokenStub);
+ await listen(mediaStub);
+ process.env.MEDIA_URL = `http://127.0.0.1:${mediaStub.address().port}`;
+ process.env.MEDIA_APP_ID = 'live';
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${tokenStub.address().port}`;
+ process.env.OV_OAUTH_CLIENT_ID = 'live';
+ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);
+ // The retired per-app key, named without the literal so a repo-wide grep for it stays empty. Set
+ // on purpose: it must never be sent.
+ const RETIRED_KEY_ENV = ['MEDIA', 'API_KEY'].join('_');
+ process.env[RETIRED_KEY_ENV] = 'retired-per-app-key';
+
+ const media = require('../server/media-client');
+
+ console.log('media service token: the request');
+ await check('nothing is fetched at module load (the client is lazy)', () => {
+ assert.strictEqual(tokensSeen.length, 0, 'no token fetch at module load');
+ });
+ await check('a request carries the openvibe.media token and the acting user, fetched once', async () => {
+ await media.request('GET', '/vods', { actingUser: 80 });
+ await media.request('GET', '/vods', { actingUser: 80 });
+ assert.strictEqual(tokensSeen.length, 1, 'the token is fetched once and reused');
+ assert.strictEqual(tokensSeen[0].url, '/oauth/token');
+ assert.strictEqual(tokensSeen[0].grant, 'client_credentials');
+ assert.strictEqual(tokensSeen[0].audience, 'openvibe.media');
+ assert.strictEqual(mediaCalls.length, 2);
+ for (const call of mediaCalls) {
+ assert.strictEqual(call.url, '/api/v1/live/vods', 'the Media base still carries the tenant');
+ assert.strictEqual(call.headers.authorization, 'Bearer svc-token-for-openvibe.media');
+ assert.strictEqual(call.headers['x-ov-user-id'], '80');
+ }
+ });
+ await check('an anonymous call claims no acting user, only the service token', async () => {
+ mediaCalls.length = 0;
+ await media.request('GET', '/vods');
+ assert.strictEqual(mediaCalls[0].headers['x-ov-user-id'], undefined);
+ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');
+ });
+ await check('the proxy helper awaits the same token', async () => {
+ const app = express();
+ app.get('/pastes', (req, res) => media.proxy(req, res, '/pastes'));
+ const srv = http.createServer(app);
+ await listen(srv);
+ mediaCalls.length = 0;
+ const r = await fetch(`http://127.0.0.1:${srv.address().port}/pastes`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');
+ srv.close();
+ });
+
+ console.log('media service token: the retired key and the unconfigured case');
+ await check('with no client secret, no Authorization header is sent and the retired key is not used', async () => {
+ media._reset();
+ delete process.env.OV_OAUTH_CLIENT_SECRET;
+ mediaCalls.length = 0;
+ await media.request('GET', '/vods', { actingUser: 7 });
+ assert.strictEqual(mediaCalls[0].headers.authorization, undefined, 'nothing configured = no Authorization');
+ assert.ok(!/retired-per-app-key/.test(JSON.stringify(mediaCalls[0].headers)),
+ `the retired key (${RETIRED_KEY_ENV}) is never sent, even when set in the environment`);
+ assert.strictEqual(mediaCalls[0].headers['x-ov-user-id'], '7', 'the acting user still rides along');
+ });
+ await check('a configured credential is the service token, never the retired key', async () => {
+ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);
+ media._reset();
+ mediaCalls.length = 0;
+ await media.request('GET', '/vods');
+ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');
+ assert.notStrictEqual(mediaCalls[0].headers.authorization, 'Bearer retired-per-app-key');
+ });
+ await check('the client is off under LIVE_DRILL', () => {
+ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'media-client.js'), 'utf8');
+ assert.ok(/require\('\.\/drill'\)\.enabled/.test(src), 'serviceTokens() refuses to build under a restore drill');
+ });
+
+ tokenStub.close();
+ mediaStub.close();
+ if (failures) { console.error(`\n${failures} check(s) failed`); process.exit(1); }
+ console.log('\nmedia service token: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
*
*
* Boots the real server as a restore-drill instance (test/security-crawl.js) with a sentinel value in
* Boots the real server as a restore-drill instance (test/security-crawl.js) with a sentinel value in
* every secret environment variable Live reads (the Network OAuth client secret, the internal API
* every secret environment variable Live reads (the Network OAuth client secret, the internal API
- * keys, Media's key and webhook/event secrets, the Events and OpenRe subscription secrets, TURN,
+ * keys, Media's webhook/event secrets, the Events and OpenRe subscription secrets, TURN,
* PayPal, the admin password, GitHub tokens, the ops webhook) and in every secret site setting an
* PayPal, the admin password, GitHub tokens, the ops webhook) and in every secret site setting an
* owner can store (GIF, TTS, AI, Twitch/Kick/YouTube, soundboard keys, the Discord and ops
* owner can store (GIF, TTS, AI, Twitch/Kick/YouTube, soundboard keys, the Discord and ops
* webhooks), then requests every GET route Express knows, the pages, the probes (/api/ready,
* webhooks), then requests every GET route Express knows, the pages, the probes (/api/ready,
@@ -31,7 +31,6 @@ const ENV = {
@@ -31,7 +31,6 @@ const ENV = {
OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client',
OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client',
INTERNAL_API_KEY: 'sentinel-not-a-secret-internal-api-key',
INTERNAL_API_KEY: 'sentinel-not-a-secret-internal-api-key',
OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal-key',
OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal-key',
- MEDIA_API_KEY: 'sentinel-not-a-secret-media-api-key',
MEDIA_WEBHOOK_SECRET: 'sentinel-not-a-secret-media-webhook',
MEDIA_WEBHOOK_SECRET: 'sentinel-not-a-secret-media-webhook',
MEDIA_EVENTS_SECRET: 'sentinel-not-a-secret-media-events',
MEDIA_EVENTS_SECRET: 'sentinel-not-a-secret-media-events',
LIVE_EVENTS_SECRET: 'sentinel-not-a-secret-live-events',
LIVE_EVENTS_SECRET: 'sentinel-not-a-secret-live-events',