openvibers/openvibe.media Commits

Sign-out everywhere reaches openvibe.media (WS-B task 4, SDK 0.9.1): POST /internal/events (MEDIA_INBOUND_EVENTS_SECRET, signature v2, never through the proxy) applies network.user.token_valid_after into the SDK revocation store; the site's token check refuses older tokens; the subscription is created at boot (not on a drill). test/revocations.test.js

OP OpenVibers committed 25 Sept 2026 · 4c3fdac15f · 6 files, +151 −6


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 7ff0348..70dd39c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"sharp": "^0.33.4"
}
@@@ -1935,9 +1935,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.5.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
-- "integrity": "sha512-QVJkR5iazQX0ZwIrDp2A88ZeYYBSXEmSzhpgURPUSnzezIM3jz0HBhVKowyhkvg2aBO61XNf77eb4zJGkMjZiw==",
++ "version": "0.9.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
++ "integrity": "sha512-P0DffeA3t/GHWtLICqvVqOBrL6momFD5Xvgbbdo6/98pN4dqs1oPF8iXROJXi+jA5C0lHgCC866gFr9+PVLr5g==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 475a07a..ad3772d 100644
---- a/package.json
++++ b/package.json
@@@ -12,7 +12,7 @@
"license": "MIT",
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
ddiff --git a/server/index.js b/server/index.js
iindex 6d8cf19..16801d2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -93,6 +93,8 @@ app.get('/manifest.webmanifest', (_req, res) => res.type('application/manifest+j
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 }));
}
++// Network's sign-out-everywhere cutoffs (server/revocations.js): signed, loopback-only, like the rest of /internal.
++app.post('/internal/events', ...require('./revocations').handler());
const userAuth = require('./user-auth');
const userAuthConfig = {
baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
@@@ -259,6 +261,14 @@ if (!drill.enabled) {
// ── Listen + graceful shutdown ───────────────────────────────
const server = app.listen(config.port, config.host, () => {
++ // Subscribe to Network's sign-out-everywhere cutoffs (not on a restore drill; retried a few times).
++ if (!drill.enabled) {
++ const subscribe = (n) => require('./revocations').ensureSubscription().catch((e) => {
++ console.warn('[Events] token cutoff subscription not ready:', e.message);
++ if (n < 5) setTimeout(() => subscribe(n + 1), 60_000 * n).unref();
++ });
++ subscribe(1);
++ }
if (drill.enabled) { console.log(`[Drill] Ready: http://${config.host}:${config.port} (reads only)`); return; }
console.log(`[Media] OpenVibe.Media listening on ${config.host}:${config.port} (${config.nodeEnv})`);
console.log(`[Media] Data: db=${config.db.path} vods=${config.vod.path} clips=${config.vod.clipsPath}`);
ddiff --git a/server/revocations.js b/server/revocations.js
nnew file mode 100644
iindex 0000000..db1068c
---- /dev/null
++++ b/server/revocations.js
@@@ -0,0 +1,68 @@
++'use strict';
++/**
++ * Sign-out everywhere reaches openvibe.media (roadmap WS-B task 4; Contracts 0.39.0
++ * network.user.token_valid_after).
++ *
++ * POST /internal/events is the endpoint of Media's Events subscription to that topic, signed with
++ * MEDIA_INBOUND_EVENTS_SECRET (signature v2 only) and refused when it came through the proxy. A
++ * delivery moves the person's cutoff (openvibe-sdk createRevocationStore: only ever forward, source
++ * network only); user-auth.js then refuses their older tokens, so openvibe.media's navbar shows them
++ * signed out at once. The subscription is created at boot when EVENTS_URL, the service secret and
++ * MEDIA_INBOUND_EVENTS_SECRET are set (grant media events.subscription.manage).
++ */
++const express = require('express');
++
++const TOPIC = 'network.user.token_valid_after';
++let store = null;
++function cutoffs() {
++ if (!store) store = require('openvibe-sdk/auth').createRevocationStore(require('./db/database').getDb(), { table: 'token_revocations' });
++ return store;
++}
++const secrets = () => String(process.env.MEDIA_INBOUND_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 32);
++const stats = { received: 0, revoked: 0, refused: 0 };
++
++function isRevoked(claims) { try { return cutoffs().isRevoked(claims); } catch { return false; } }
++
++function handler() {
++ const { parseDelivery } = require('openvibe-sdk/events');
++ return [express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return res.status(404).json({ error: 'Not found' });
++ const keys = secrets();
++ if (!keys.length) return res.status(503).json({ error: 'MEDIA_INBOUND_EVENTS_SECRET is not set' });
++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
++ let d = null;
++ for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
++ if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
++ stats.received++;
++ const outcome = cutoffs().apply(d.event);
++ if (outcome === 'revoked') stats.revoked++;
++ res.json({ event_id: d.event.event_id || null, outcome });
++ }];
++}
++
++/** Create Media's subscription to TOPIC if missing (idempotent; an operator-disabled one is left alone). */
++async function ensureSubscription({ fetchImpl = globalThis.fetch, log = console } = {}) {
++ const eventsUrl = String(process.env.EVENTS_URL || '').replace(/\/+$/, '');
++ const secret = secrets()[0];
++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
++ if (!eventsUrl || !secret || !clientSecret) return 'off';
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const tokens = createServiceTokenClient({
++ tokenUrl: `${String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '')}/oauth/token`,
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'media', clientSecret, fetch: fetchImpl,
++ });
++ const endpoint = process.env.MEDIA_EVENTS_ENDPOINT || `http://127.0.0.1:${require('./config').port}/internal/events`;
++ const token = await tokens.getToken({ audience: 'openvibe.events', scope: 'events.subscription.manage' });
++ const headers = { authorization: `Bearer ${token}`, accept: 'application/json' };
++ const list = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { headers });
++ if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`);
++ const subs = ((await list.json()).subscriptions || []);
++ if (subs.some((s) => s.topic_pattern === TOPIC && s.endpoint === endpoint)) return 'exists';
++ const r = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ topic_pattern: TOPIC, endpoint, secret }) });
++ if (r.status === 409) return 'exists';
++ if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${TOPIC} subscription`);
++ log.log && log.log(`[Events] subscription created: ${TOPIC} → ${endpoint}`);
++ return 'created';
++}
++
++module.exports = { handler, ensureSubscription, isRevoked, stats, TOPIC, _cutoffs: cutoffs };
ddiff --git a/server/user-auth.js b/server/user-auth.js
iindex 0724bf3..9dda551 100644
---- a/server/user-auth.js
++++ b/server/user-auth.js
@@@ -86,7 +86,10 @@ function createAuthClient(config) {
async function verify(token) {
if (!token) return null;
await ensureKey();
-- return client.verifyToken(token);
++ const claims = await client.verifyToken(token);
++ // Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).
++ if (claims && require('./revocations').isRevoked(claims)) return null;
++ return claims;
}
// Warm the key cache at boot (non-fatal if the Network is down). A restore drill asks Network
ddiff --git a/test/revocations.test.js b/test/revocations.test.js
nnew file mode 100644
iindex 0000000..b98fd31
---- /dev/null
++++ b/test/revocations.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++// Sign-out everywhere reaches openvibe.media (WS-B task 4): POST /internal/events (signature v2,
++// never through the proxy) moves the person's cutoff, and the site's token check then refuses their
++// older tokens; a forged, foreign or proxied delivery changes nothing; the boot subscription is made once.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-revoke-'));
++process.env.DB_PATH = path.join(tmp, 'media.db');
++process.env.MEDIA_INBOUND_EVENTS_SECRET = 'm'.repeat(40);
++const express = require('express');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const revocations = require('../server/revocations');
++
++const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const at = Date.parse('2026-09-25T12:00:00Z');
++const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_type: revocations.TOPIC, version: 1, source: 'network',
++ payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' }, ...over });
++
++(async () => {
++ const app = express();
++ app.post('/internal/events', ...revocations.handler());
++ const srv = http.createServer(app);
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const url = `http://127.0.0.1:${srv.address().port}/internal/events`;
++ const post = (event, { secret = process.env.MEDIA_INBOUND_EVENTS_SECRET, headers = {} } = {}) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ return fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++ };
++ try {
++ const old = { subject_id: SUBJECT, iat: at / 1000 - 30 };
++ assert.strictEqual(revocations.isRevoked(old), false);
++ assert.strictEqual((await post(ev(), { secret: 'x'.repeat(40) })).status, 401, 'forged');
++ assert.strictEqual((await post(ev(), { headers: { 'x-forwarded-for': '1.2.3.4' } })).status, 404, 'never through the proxy');
++ assert.strictEqual((await post(ev({ source: 'live' }))).body.outcome, 'ignored:source');
++ assert.strictEqual(revocations.isRevoked(old), false);
++ assert.strictEqual((await post(ev())).body.outcome, 'revoked');
++ assert.strictEqual(revocations.isRevoked(old), true, 'older tokens are refused');
++ assert.strictEqual(revocations.isRevoked({ subject_id: SUBJECT, iat: at / 1000 + 5 }), false, 'newer ones are fine');
++ assert.strictEqual((await post(ev())).body.outcome, 'unchanged', 'a redelivery');
++ const src = fs.readFileSync(path.join(__dirname, '../server/user-auth.js'), 'utf8');
++ assert.ok(/require\('\.\/revocations'\)\.isRevoked\(claims\)/.test(src), 'the site token check consults it');
++
++ // The boot subscription: created once, then found.
++ process.env.EVENTS_URL = 'http://events.test'; process.env.OV_OAUTH_CLIENT_SECRET = 's'.repeat(40);
++ const subs = [];
++ const fake = async (u, init = {}) => {
++ if (/\/oauth\/token$/.test(u)) return new Response(JSON.stringify({ access_token: 't', token_type: 'Bearer', expires_in: 300 }), { status: 200, headers: { 'content-type': 'application/json' } });
++ if (init.method === 'POST') { subs.push(JSON.parse(init.body)); return Response.json({ id: 'sub_1' }, { status: 201 }); }
++ return Response.json({ subscriptions: subs.map((s, i) => ({ id: `sub_${i}`, ...s })) });
++ };
++ assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'created');
++ assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'exists');
++ assert.strictEqual(subs.length, 1);
++ assert.strictEqual(subs[0].topic_pattern, revocations.TOPIC);
++ } finally {
++ srv.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('media revocations: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });