openvibers/openvibe.media Commits

X-Internal-Key retirement, Media's step (plan T2): POST /internal/avatar-ingest takes Network's service token with media.avatar.ingest (server/service-guard.js, loopback only; a Bearer is judged on the token alone, the key still passes without one until Network stops sending it). The owner resolution to Network's resolve-batch sends only Media's token: the key fallback is gone, and a refused token is retried once with a fresh one. openvibe-contracts v0.79.0. Tests: capability, audience, sandbox, a bad Bearer beside the key, the public edge, the key alone; the key is never sent to Network. npm test and test:pg green.

OP OpenVibers committed 29 Sept 2026 · 8364eeec44 · 8 files, +173 −37


Changes

ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex a766848..0c54fcf 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -178,9 +178,9 @@ Rules for both job and backfill:
`server/objects/owner-subject.js`: `live` -> `live`). Others are reported and left alone.
- A re-projection keeps the subject, unless it changes the owner (`owner_app`/`owner_user_id`). Then
the subject is dropped until the job resolves the new owner.
--- Network is asked with a service token (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, capability
-- `identity.subject.resolve`, audience `openvibe.network`). When Network has not granted it, the
-- request uses `INTERNAL_API_KEY`.
++- Network is asked with a service token only (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, capability
++ `identity.subject.resolve`, audience `openvibe.network`). There is no fallback credential: when Network
++ has not granted it, the pass fails and the job retries.
```
node scripts/backfill-owner-subject.js [--batch 500] [--json] # dry run: counts per tenant
ddiff --git a/package-lock.json b/package-lock.json
iindex f805aa1..c932c96 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pg": "^8.23.0",
@@@ -2010,9 +2010,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.76.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
-- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
++ "version": "0.79.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
++ "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex e561414..6c534ed 100644
---- a/package.json
++++ b/package.json
@@@ -21,7 +21,7 @@
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pg": "^8.23.0",
ddiff --git a/server/index.js b/server/index.js
iindex 55729ba..fa31811 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -104,16 +104,12 @@ const ready = (async () => {
app.get('/manifest.webmanifest', (_req, res) => res.type('application/manifest+json').sendFile(require('path').join(__dirname, 'brand', 'manifest.webmanifest')));
// ── Internal: avatar ingestion (the Network asks; see server/avatars/ingest.js) ──
{
-- const crypto = require('crypto');
-- const internalOnly = (req, res, next) => {
-- const want = String(config.network.internalApiKey || ''), got = String(req.headers['x-internal-key'] || '');
-- const viaProxy = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip'];
-- const local = /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remoteAddress || ''));
-- if (viaProxy || !local || want.length < 16 || got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) return res.status(404).json({ error: 'Not found' });
-- next();
-- };
const pastes = require('./pastes/routes');
-- app.post('/internal/avatar-ingest', internalOnly, require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config, screenshotsDir: pastes.SCREENSHOTS_DIR, generateSlug: pastes.generateSlug }));
++ // Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
++ // while Network moves to tokens, a request with no Bearer still passes with INTERNAL_API_KEY
++ // (server/service-guard.js). A Bearer is judged on the token alone (401 bad, 403 no capability).
++ app.post('/internal/avatar-ingest', require('./service-guard').guardOrKey('media.avatar.ingest'),
++ require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config, screenshotsDir: pastes.SCREENSHOTS_DIR, generateSlug: pastes.generateSlug }));
}
const userAuth = require('./user-auth');
const userAuthConfig = {
ddiff --git a/server/objects/owner-subject.js b/server/objects/owner-subject.js
iindex 157d2e6..6bc79b9 100644
---- a/server/objects/owner-subject.js
++++ b/server/objects/owner-subject.js
@@@ -20,9 +20,9 @@
* - an id Network does not know stays NULL and is reported; nothing is guessed;
* - only the tenants in SOURCE_SYSTEMS are resolved, because the system names the user-id space.
*
-- * Network is asked with a service token (capability identity.subject.resolve, audience
-- * openvibe.network; OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET). When no token can be had
-- * (a Network that has not granted it yet), it falls back to INTERNAL_API_KEY.
++ * Network is asked with a service token only (capability identity.subject.resolve, audience
++ * openvibe.network; OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET). There is no fallback credential: when
++ * no token can be had (a Network that has not granted it yet), the call fails and the job retries.
*/
const { ids, serviceAuth } = require('openvibe-contracts');
const config = require('../config');
@@@ -45,7 +45,6 @@ const ownerKey = (app, userId) => `${app}\u0000${userId}`;
*/
function createResolver({
networkUrl = config.network.internalUrl,
-- internalKey = config.network.internalApiKey,
clientId = process.env.OV_OAUTH_CLIENT_ID || 'media',
clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '',
fetchImpl = globalThis.fetch,
@@@ -66,10 +65,9 @@ function createResolver({
tokenRetryAt = Date.now() + TOKEN_RETRY_MS;
}
}
-- if (internalKey) return { via: 'internal-key', headers: { 'X-Internal-Key': internalKey } };
throw new Error(tokens
-- ? `no service token for identity.subject.resolve (${state.token_error}) and no INTERNAL_API_KEY`
-- : 'set OV_OAUTH_CLIENT_SECRET (service token) or INTERNAL_API_KEY to ask Network');
++ ? `no service token for identity.subject.resolve (${state.token_error})`
++ : `set OV_OAUTH_CLIENT_SECRET so ${clientId} can mint the identity.subject.resolve service token`);
}
async function send(cred, payload) {
@@@ -91,12 +89,11 @@ function createResolver({
async function post(payload) {
let cred = await credential();
let res = await send(cred, payload);
-- if ((res.status === 401 || res.status === 403) && cred.via === 'service-token' && internalKey) {
-- // A token Network refuses here (grant withdrawn, key rotated): the key, and no token for a while.
++ if (res.status === 401) {
++ // A token Network refuses (a key rotation, a withdrawn grant): mint a fresh one and try once more.
tokens.invalidate();
-- tokenRetryAt = Date.now() + TOKEN_RETRY_MS;
-- state.token_error = `resolve-batch refused the token (${res.status})`;
-- cred = { via: 'internal-key', headers: { 'X-Internal-Key': internalKey } };
++ state.token_error = 'resolve-batch refused the token (401)';
++ cred = await credential();
res = await send(cred, payload);
}
const body = await res.json().catch(() => null);
ddiff --git a/server/service-guard.js b/server/service-guard.js
nnew file mode 100644
iindex 0000000..efad908
---- /dev/null
++++ b/server/service-guard.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * OpenVibe.Media as a receiver of Network service tokens (plan T2, X-Internal-Key retirement).
++ *
++ * guard(capability) protects an internal route: loopback only (nothing that came through nginx) and a
++ * Network service token holding `capability` for audience openvibe.media, verified with the same key
++ * Media verifies user JWTs with (server/auth.js verifyServiceTokenResult; sandbox refused). The token
++ * is judged on its own: a Bearer is never downgraded to another credential.
++ *
++ * guardOrKey(capability) is the migration form used today by POST /internal/avatar-ingest: a request
++ * that presents a Bearer is judged on the token alone; one without a Bearer still passes with the
++ * INTERNAL_API_KEY exactly as before. Once Network sends tokens, the route becomes guard(capability)
++ * and the key path goes.
++ */
++const crypto = require('crypto');
++const { capabilities, http } = require('openvibe-contracts');
++const auth = require('./auth');
++const config = require('./config');
++
++// Loopback only: the local reverse proxy marks what it forwarded with one of these headers.
++const viaProxy = (req) => !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);
++const isLocal = (req) => /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remoteAddress || ''));
++
++function guard(capability) {
++ return function mediaServiceGuard(req, res, next) {
++ if (viaProxy(req) || !isLocal(req)) return res.status(404).json({ error: 'Not found' });
++ const bearer = String(req.headers.authorization || '');
++ if (!bearer.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required' });
++ const r = auth.verifyServiceTokenResult(bearer.slice(7).trim());
++ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason });
++ const c = capabilities.check(r.claims, capability);
++ if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason });
++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
++ next();
++ };
++}
++
++/**
++ * While X-Internal-Key is retired: a request that presents a Bearer is judged on the token alone (never
++ * downgraded to the key); one without a Bearer still passes with the key from loopback, exactly as the
++ * old guard did (404 otherwise, so the route is not enumerated).
++ */
++function guardOrKey(capability) {
++ const tokenGuard = guard(capability);
++ return function mediaServiceGuardOrKey(req, res, next) {
++ if (viaProxy(req) || !isLocal(req)) return res.status(404).json({ error: 'Not found' });
++ if (String(req.headers.authorization || '').startsWith('Bearer ')) return tokenGuard(req, res, next);
++ const want = String(config.network.internalApiKey || ''), got = String(req.headers['x-internal-key'] || '');
++ if (want.length < 16 || got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) return res.status(404).json({ error: 'Not found' });
++ next();
++ };
++}
++
++module.exports = { guard, guardOrKey };
ddiff --git a/test/avatar-ingest-auth.test.js b/test/avatar-ingest-auth.test.js
nnew file mode 100644
iindex 0000000..7bfa339
---- /dev/null
++++ b/test/avatar-ingest-auth.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++// POST /internal/avatar-ingest authorization (plan T2, X-Internal-Key retirement): a Network service
++// token holding media.avatar.ingest reaches the handler; a token without it is 403; another audience
++// or a sandbox token is 401; a Bearer is judged on the token alone and never downgraded to
++// INTERNAL_API_KEY; the key alone still works; and anything that came through the proxy is refused
++// whatever it carries (server/service-guard.js).
++const assert = require('assert');
++const http = require('http');
++const crypto = require('crypto');
++
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++process.env.INTERNAL_API_KEY = 'k'.repeat(40); // a fake key, built at runtime so no literal looks like a secret
++
++(async () => {
++ const express = require('express');
++ const auth = require('../server/auth');
++ const { guardOrKey } = require('../server/service-guard');
++ const { serviceAuth } = require('openvibe-contracts');
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ auth._setNetworkPublicKeyForTests(keys.publicKey);
++
++ const now = Math.floor(Date.now() / 1000);
++ const tok = (over = {}) => serviceAuth.signServiceToken({
++ iss: 'https://openvibe.network', sub: 'svc:network', actor_type: 'service',
++ aud: ['openvibe.media'], cap: ['media.avatar.ingest'], iat: now, exp: now + 300, jti: 'tok_avatar_0001', ...over,
++ }, keys.privateKey);
++ const forged = serviceAuth.signServiceToken({
++ iss: 'https://openvibe.network', sub: 'svc:network', actor_type: 'service',
++ aud: ['openvibe.media'], cap: ['media.avatar.ingest'], iat: now, exp: now + 300, jti: 'tok_avatar_0002',
++ }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey);
++
++ let reached = 0;
++ const app = express();
++ app.use(express.json());
++ app.post('/internal/avatar-ingest', guardOrKey('media.avatar.ingest'), (req, res) => { reached++; res.json({ ok: true, principal: req.principal || null }); });
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const post = (headers = {}) => fetch(`${base}/internal/avatar-ingest`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: '{}' }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++
++ const KEY = process.env.INTERNAL_API_KEY;
++ try {
++ let r = await post({ authorization: `Bearer ${tok()}` });
++ assert.deepStrictEqual([r.status, r.body.ok], [200, true], 'a token with media.avatar.ingest reaches the handler');
++ assert.strictEqual(r.body.principal.cap[0], 'media.avatar.ingest');
++
++ r = await post({ authorization: `Bearer ${tok({ cap: ['media.object.read'] })}` });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'a token without the capability is 403');
++
++ r = await post({ authorization: `Bearer ${tok({ aud: ['openvibe.network'] })}` });
++ assert.deepStrictEqual([r.status, r.body.code], [401, 'token.wrong_audience'], 'another audience is 401');
++
++ r = await post({ authorization: `Bearer ${tok({ env: 'sandbox' })}` });
++ assert.deepStrictEqual([r.status, r.body.code], [401, 'token.sandbox_refused'], 'a sandbox token is 401');
++
++ r = await post({ authorization: `Bearer ${forged}` });
++ assert.strictEqual(r.status, 401, 'a forged token is 401');
++
++ r = await post({ authorization: `Bearer ${forged}`, 'x-internal-key': KEY });
++ assert.strictEqual(r.status, 401, 'a Bearer is never downgraded to the key');
++
++ r = await post({});
++ assert.strictEqual(r.status, 404, 'no credential looks like a route that is not there');
++
++ r = await post({ 'x-internal-key': 'wrong-key-0000000000000000' });
++ assert.strictEqual(r.status, 404, 'a wrong key is refused');
++
++ r = await post({ 'x-internal-key': KEY });
++ assert.deepStrictEqual([r.status, r.body.ok], [200, true], 'the key alone still reaches the handler');
++
++ r = await post({ 'x-forwarded-for': '203.0.113.7', authorization: `Bearer ${tok()}`, 'x-internal-key': KEY });
++ assert.strictEqual(r.status, 404, 'a request that came through the proxy is refused whatever it carries');
++
++ r = await post({ 'x-real-ip': '203.0.113.7', 'x-internal-key': KEY });
++ assert.strictEqual(r.status, 404, 'a proxy header with the key only is refused');
++
++ assert.strictEqual(reached, 2, 'only the two authorized requests reached the handler');
++ console.log('avatar-ingest auth: all checks passed');
++ } finally {
++ server.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/owner-subject.test.js b/test/owner-subject.test.js
iindex 0e0c5b9..b5710f8 100644
---- a/test/owner-subject.test.js
++++ b/test/owner-subject.test.js
@@@ -1,9 +1,9 @@
'use strict';
// Owner subjects (server/objects/owner-subject.js, server/objects/owner-subject-job.js) against an
// in-process Network stand-in that answers /oauth/token and /internal/identity/resolve-batch like Network
--// does: the service token is preferred and the internal key is the fallback; and new objects get their
--// subject (X-OV-Subject at creation, the reconcile job for everything else), with a re-projection to
--// another owner dropping it.
++// does: the service token is the only credential (the X-Internal-Key fallback is gone and must never be
++// sent); and new objects get their subject (X-OV-Subject at creation, the reconcile job for everything
++// else), with a re-projection to another owner dropping it.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
@@@ -29,6 +29,7 @@ const { ids } = require('openvibe-contracts');
map: new Map(), // 'live:<id>' -> usr_…
calls: [], // { via, system, n }
tokens: 0,
++ keySeen: false, // did any resolve-batch call carry X-Internal-Key? (it must not)
down: false,
};
const network = http.createServer((req, res) => {
@@@ -47,7 +48,8 @@ const { ids } = require('openvibe-contracts');
}
if (req.method === 'POST' && req.url === '/internal/identity/resolve-batch') {
const bearer = String(req.headers.authorization || '');
-- const via = req.headers['x-internal-key'] === env.INTERNAL_API_KEY ? 'internal-key' : (bearer === 'Bearer svc-media-token' && net.grant ? 'service-token' : null);
++ if (req.headers['x-internal-key'] !== undefined) net.keySeen = true; // the key fallback is gone: it must never be sent
++ const via = bearer === 'Bearer svc-media-token' && net.grant ? 'service-token' : null;
if (!via) return send(403, { error: 'Invalid or missing internal key' });
const b = JSON.parse(raw || '{}');
if (!Array.isArray(b.ids) || !b.ids.length || !b.system) return send(400, { code: 'identity.bad_request' });
@@@ -98,17 +100,18 @@ const { ids } = require('openvibe-contracts');
const TO_FILL = 3 + many.length;
// (The one-off backfill script is retired: the reconcile job below fills what is left.)
-- // A token Network refuses at resolve-batch: the key answers instead.
++ // The service token is the only credential: there is no INTERNAL_API_KEY fallback.
const resolver = ownerSubject.createResolver({ networkUrl: NET });
net.grant = true;
let got = await resolver.resolve('live', ['12', '99', 12]);
assert.deepStrictEqual([...got.entries()], [['12', s12], ['99', null]]);
assert.strictEqual(resolver.state.via, 'service-token');
-- const tokenOnly = ownerSubject.createResolver({ networkUrl: NET, internalKey: '' });
++ // Network has not granted the scope: no token can be minted, and no key is tried.
++ const noGrant = ownerSubject.createResolver({ networkUrl: NET });
net.grant = false;
-- await assert.rejects(tokenOnly.resolve('live', ['12']), /no service token/);
++ await assert.rejects(noGrant.resolve('live', ['12']), /no service token/);
net.grant = true;
-- console.log('✅ Network auth: service token first, INTERNAL_API_KEY when Network has not granted it; failures are errors');
++ console.log('✅ Network auth: service token only (a refused grant is an error, never INTERNAL_API_KEY)');
// ── New objects: X-OV-Subject at creation ──
const express = require('express');
@@@ -180,6 +183,8 @@ const { ids } = require('openvibe-contracts');
config.ownerSubject.enabled = true;
console.log('✅ new objects: X-OV-Subject stored at creation; the reconcile job fills the rest, re-projection keeps or drops it');
++ assert.strictEqual(net.keySeen, false, 'X-Internal-Key is never sent to Network');
++
server.close(); network.close();
await db.close();
fs.rmSync(tmp, { recursive: true, force: true });