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
Changes
@@@ -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"@@@ -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",@@@ -93,6 +93,8 @@ app.get('/manifest.webmanifest', (_req, res) => res.type('application/manifest+jconst 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}`);nnew file mode 100644@@@ -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 };@@@ -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 Networknnew file mode 100644@@@ -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); });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"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.33.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
}
}
@@ -1935,9 +1935,9 @@
@@ -1935,9 +1935,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
"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/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
@@ -93,6 +93,8 @@ app.get('/manifest.webmanifest', (_req, res) => res.type('application/manifest+j
@@ -93,6 +93,8 @@ app.get('/manifest.webmanifest', (_req, res) => res.type('application/manifest+j
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 }));
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 userAuth = require('./user-auth');
const userAuthConfig = {
const userAuthConfig = {
baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
@@ -259,6 +261,14 @@ if (!drill.enabled) {
@@ -259,6 +261,14 @@ if (!drill.enabled) {
// ── Listen + graceful shutdown ───────────────────────────────
// ── Listen + graceful shutdown ───────────────────────────────
const server = app.listen(config.port, config.host, () => {
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; }
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] 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}`);
console.log(`[Media] Data: db=${config.db.path} vods=${config.vod.path} clips=${config.vod.clipsPath}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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 };
@@ -86,7 +86,10 @@ function createAuthClient(config) {
@@ -86,7 +86,10 @@ function createAuthClient(config) {
async function verify(token) {
async function verify(token) {
if (!token) return null;
if (!token) return null;
await ensureKey();
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
// Warm the key cache at boot (non-fatal if the Network is down). A restore drill asks Network
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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); });