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.
Changes
@@@ -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`). Thenthe 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@@@ -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",@@@ -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",@@@ -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 = {@@@ -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);nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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 });
@@ -178,9 +178,9 @@ Rules for both job and backfill:
@@ -178,9 +178,9 @@ Rules for both job and backfill:
`server/objects/owner-subject.js`: `live` -> `live`). Others are reported and left alone.
`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
- 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.
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
node scripts/backfill-owner-subject.js [--batch 500] [--json] # dry run: counts per tenant
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2010,9 +2010,9 @@
@@ -2010,9 +2010,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -104,16 +104,12 @@ const ready = (async () => {
@@ -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')));
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) ──
// ── 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');
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 userAuth = require('./user-auth');
const userAuthConfig = {
const userAuthConfig = {
@@ -20,9 +20,9 @@
@@ -20,9 +20,9 @@
* - an id Network does not know stays NULL and is reported; nothing is guessed;
* - 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.
* - 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 { ids, serviceAuth } = require('openvibe-contracts');
const config = require('../config');
const config = require('../config');
@@ -45,7 +45,6 @@ const ownerKey = (app, userId) => `${app}\u0000${userId}`;
@@ -45,7 +45,6 @@ const ownerKey = (app, userId) => `${app}\u0000${userId}`;
*/
*/
function createResolver({
function createResolver({
networkUrl = config.network.internalUrl,
networkUrl = config.network.internalUrl,
- internalKey = config.network.internalApiKey,
clientId = process.env.OV_OAUTH_CLIENT_ID || 'media',
clientId = process.env.OV_OAUTH_CLIENT_ID || 'media',
clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '',
fetchImpl = globalThis.fetch,
fetchImpl = globalThis.fetch,
@@ -66,10 +65,9 @@ function createResolver({
@@ -66,10 +65,9 @@ function createResolver({
tokenRetryAt = Date.now() + TOKEN_RETRY_MS;
tokenRetryAt = Date.now() + TOKEN_RETRY_MS;
}
}
}
}
- if (internalKey) return { via: 'internal-key', headers: { 'X-Internal-Key': internalKey } };
throw new Error(tokens
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) {
async function send(cred, payload) {
@@ -91,12 +89,11 @@ function createResolver({
@@ -91,12 +89,11 @@ function createResolver({
async function post(payload) {
async function post(payload) {
let cred = await credential();
let cred = await credential();
let res = await send(cred, payload);
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();
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);
res = await send(cred, payload);
}
}
const body = await res.json().catch(() => null);
const body = await res.json().catch(() => null);
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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); });
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
'use strict';
'use strict';
// Owner subjects (server/objects/owner-subject.js, server/objects/owner-subject-job.js) against an
// 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
// 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -29,6 +29,7 @@ const { ids } = require('openvibe-contracts');
@@ -29,6 +29,7 @@ const { ids } = require('openvibe-contracts');
map: new Map(), // 'live:<id>' -> usr_…
map: new Map(), // 'live:<id>' -> usr_…
calls: [], // { via, system, n }
calls: [], // { via, system, n }
tokens: 0,
tokens: 0,
+ keySeen: false, // did any resolve-batch call carry X-Internal-Key? (it must not)
down: false,
down: false,
};
};
const network = http.createServer((req, res) => {
const network = http.createServer((req, res) => {
@@ -47,7 +48,8 @@ const { ids } = require('openvibe-contracts');
@@ -47,7 +48,8 @@ const { ids } = require('openvibe-contracts');
}
}
if (req.method === 'POST' && req.url === '/internal/identity/resolve-batch') {
if (req.method === 'POST' && req.url === '/internal/identity/resolve-batch') {
const bearer = String(req.headers.authorization || '');
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' });
if (!via) return send(403, { error: 'Invalid or missing internal key' });
const b = JSON.parse(raw || '{}');
const b = JSON.parse(raw || '{}');
if (!Array.isArray(b.ids) || !b.ids.length || !b.system) return send(400, { code: 'identity.bad_request' });
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');
@@ -98,17 +100,18 @@ const { ids } = require('openvibe-contracts');
const TO_FILL = 3 + many.length;
const TO_FILL = 3 + many.length;
// (The one-off backfill script is retired: the reconcile job below fills what is left.)
// (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 });
const resolver = ownerSubject.createResolver({ networkUrl: NET });
net.grant = true;
net.grant = true;
let got = await resolver.resolve('live', ['12', '99', 12]);
let got = await resolver.resolve('live', ['12', '99', 12]);
assert.deepStrictEqual([...got.entries()], [['12', s12], ['99', null]]);
assert.deepStrictEqual([...got.entries()], [['12', s12], ['99', null]]);
assert.strictEqual(resolver.state.via, 'service-token');
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;
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;
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 ──
// ── New objects: X-OV-Subject at creation ──
const express = require('express');
const express = require('express');
@@ -180,6 +183,8 @@ const { ids } = require('openvibe-contracts');
@@ -180,6 +183,8 @@ const { ids } = require('openvibe-contracts');
config.ownerSubject.enabled = true;
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');
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();
server.close(); network.close();
await db.close();
await db.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });