openvibers/openrestream Commits

OpenRestream: the Network keys and token checks come from openvibe-sdk/auth (typed tokens refused) (#48)

OP OpenVibers committed 10 Oct 2026 · 9ce0bf11ed · 8 files, +67 −106

* openvibe-sdk v0.40.0 (was v0.38.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.40.0 release.

* OpenRestream: the Network keys and token checks come from openvibe-sdk/auth (typed tokens refused)

Plan T1: delete hand-rolled copies. OpenRestream had its own Network key store and its own user-token verifier. Both now come from openvibe-sdk/auth (v0.40.0; this PR carries the pin bump from #47).

**Security fix:** the hand-rolled user check did not refuse typed tokens. Network signs realtime tickets and FedCM assertions with the session key, so one could pass as a session wherever its audience matched. The SDK's `verifyUserToken` refuses any token with `typ` or `purpose`, as Events and Bot do.

- **Keys:** `createNetworkKeys({ network, publicKey })` in `server/index.js`. A pinned `OV_NETWORK_PUBLIC_KEY` is used as is. Otherwise Network's JWKS: a rotation is honoured on an unknown kid, the last good keys are kept through an outage, and it retries every 30 s until the first load (the `network_key` readiness check is unchanged). `createKeyStore` and `verifyUserJwt` are deleted.
- **Service tokens:** `verifyServiceToken` with OpenRestream's pinned contracts, so the same rules apply, with the key the kid names.
- **Async:** `resolve` and the middleware are async, with errors going to `next`. `/auth/me` awaits the check.
- README updated.

Test: `test/api.test.js` now sends a realtime ticket, a FedCM assertion and an export-purpose token, each signed with the session key and naming openvibe.openre. Each is refused as a Bearer (401 `token.invalid`) and as the `ov_token` cookie on `/auth/me`, while the session token still signs in.

`ov test` is green. `webrtc-e2e` cannot run here because the shared deps cache has no mediasoup worker binary; CI builds it.

Changes

ddiff --git a/README.md b/README.md
iindex f65aad5..dc6e0ab 100644
---- a/README.md
++++ b/README.md
@@@ -103,7 +103,7 @@ Network" below.
## Auth
--Services call with an OpenVibe.Network client-credentials token (audience `openvibe.openre`), verified offline against the Network JWKS; each route checks one capability. A service may send `X-OV-Subject: usr_…` to act for one owner (it is then limited to that owner's streams). Browsers/owners use the Network user JWT (`ov_token` cookie from `/auth/login`, or Bearer) and act on their own streams; Network role `admin` is staff (read all, end sessions).
++Services call with an OpenVibe.Network client-credentials token (audience `openvibe.openre`), verified offline against the Network JWKS (openvibe-sdk/auth `createNetworkKeys` + `verifyServiceToken`: a key rotation is honoured on an unknown kid); each route checks one capability. A service may send `X-OV-Subject: usr_…` to act for one owner (it is then limited to that owner's streams). Browsers/owners use the Network user JWT (`ov_token` cookie from `/auth/login`, or Bearer; `verifyUserToken`, which refuses service principals and typed tokens such as realtime tickets and FedCM assertions) and act on their own streams; Network role `admin` is staff (read all, end sessions).
| Capability | Routes |
|---|---|
@@@ -311,6 +311,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.131.0
--- openvibe-sdk: v0.38.0
++- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex e1b587c..3c27879 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"rtmpPort": 1936,
"domain": "openre.stream",
"contracts": "openvibe-contracts v0.131.0 (openvibe-sdk v0.26.0, openvibe-shared v2.13.0)",
-- "sdk": "openvibe-sdk v0.38.0",
++ "sdk": "openvibe-sdk v0.40.0",
"shared": "openvibe-shared v3.0.0",
"protocols": {
"rtmp": "ingest ported (openre-rtmp-ingest)",
ddiff --git a/package-lock.json b/package-lock.json
iindex 86d2b28..62eca27 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
@@@ -1654,9 +1654,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.38.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
-- "integrity": "sha512-/Z+1kB5l4Hff0E1O1Zo+DcXaZX93wrmISOPP75sB7NqVUZVeKuJgjYu6vBwG8ufJjlk0vD3ZJ0ntBLI/3IKdpg==",
++ "version": "0.40.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
++ "integrity": "sha512-fYX0zijJZDdwjXJGwroLDlb/wniY6NXVtgdPVJJHNgcdRmaAydpr6SKuxcLffmkTquyZQptwChsCvo+D6Wx+IA==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex efad8bb..8239bb9 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
ddiff --git a/server/auth/index.js b/server/auth/index.js
iindex 2c9f4aa..2900894 100644
---- a/server/auth/index.js
++++ b/server/auth/index.js
@@@ -4,78 +4,34 @@
*
* { kind: 'service', sub: 'svc:live', claims, subject: 'usr_…'|null }
* An OpenVibe.Network client-credentials token (RS256, audience openvibe.openre), verified
-- * offline. Each route checks ONE capability (guard('openre.<noun>.<verb>')). A service may
++ * offline (openvibe-sdk/auth verifyServiceToken with the pinned openvibe-contracts rules and the
++ * key the token's kid names). Each route checks ONE capability (guard('openre.<noun>.<verb>')). A service may
* name the person it acts for in X-OV-Subject; it is then limited to that owner's streams.
* Without it the grant applies to every stream (first-party integrations such as Live's
* mirror read sessions for any channel that opted in).
* { kind: 'user', subject: 'usr_…', staff, claims }
-- * A browser/owner with the Network user JWT (ov_token cookie or Bearer). Owners act on
-- * their own streams; Network role admin is staff (read everything, end sessions).
++ * A browser/owner with the Network user JWT (ov_token cookie or Bearer; openvibe-sdk/auth
++ * verifyUserToken, which refuses service principals and typed tokens such as a realtime ticket
++ * or a FedCM assertion). Owners act on their own streams; Network role admin is staff (read
++ * everything, end sessions).
* { kind: 'anonymous' }
*
* The capability ids are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts
* yet. Until the release that defines them, allows() grants them with the contracts rule (exact id
* or a `family.*` grant) and hands the decision to capabilities.check() once contracts know the id
* (the same bridge OpenVibe.Events used).
++ *
++ * The keys are openvibe-sdk/auth createNetworkKeys (server/index.js): a pinned OV_NETWORK_PUBLIC_KEY, or Network's
++ * JWKS with a rotation honoured on an unknown kid and the last good keys kept through an outage.
*/
--const crypto = require('crypto');
--const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
++const contracts = require('openvibe-contracts');
++const sdkAuth = require('openvibe-sdk/auth');
++
++const { capabilities, http, ids } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const STAFF_ROLES = new Set(['admin']);
--// ── Network public key ─────────────────────────────────────────
--
--function createKeyStore({ urls = [], pem = null, fetchImpl = globalThis.fetch, log = console } = {}) {
-- let key = pem ? toPem(pem) : null;
-- let retryTimer = null;
-- let refreshTimer = null;
--
-- function toPem(value) { return crypto.createPublicKey(value).export({ type: 'spki', format: 'pem' }); }
--
-- async function fetchOnce() {
-- for (const base of urls) {
-- if (!base) continue;
-- const url = `${base}/api/.well-known/jwks`;
-- try {
-- const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
-- if (!res.ok) throw new Error(`HTTP ${res.status}`);
-- const body = await res.json();
-- const jwk = (body.keys || []).find(k => k.kty === 'RSA');
-- if (jwk) key = toPem({ key: jwk, format: 'jwk' });
-- else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) key = toPem(body.public_key);
-- else throw new Error('no RSA key in response');
-- log.log(`[auth] Network public key loaded from ${base}`);
-- return key;
-- } catch (err) {
-- log.warn(`[auth] key fetch from ${url} failed: ${err.message}`);
-- }
-- }
-- return null;
-- }
--
-- async function start() {
-- if (pem) return Promise.resolve(key);
-- const attempt = async () => {
-- try {
-- const k = await fetchOnce();
-- if (!k && !key) { retryTimer = setTimeout(() => { attempt().catch(() => {}); }, 30000); retryTimer.unref?.(); }
-- return k;
-- } catch (err) {
-- // The retry timer never awaits the attempt it starts: an unhandled rejection would
-- // end the API process (Node 22) over one failed Network key fetch.
-- log.warn(`[auth] key retry failed: ${err.message}`);
-- return null;
-- }
-- };
-- refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);
-- refreshTimer.unref?.();
-- return await attempt();
-- }
--
-- return { get: () => key, loaded: () => Boolean(key), start, stop() { clearTimeout(retryTimer); clearInterval(refreshTimer); }, fetchOnce };
--}
--
// ── Capabilities ───────────────────────────────────────────────
function hasCap(claims, id) {
@@@ -94,27 +50,6 @@ function allows(claims, id) {
const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
--function verifyUserJwt(token, { publicKey, issuer, audiences, now = Date.now() }) {
-- if (!publicKey || typeof token !== 'string') return null;
-- const parts = token.split('.');
-- if (parts.length !== 3) return null;
-- try {
-- const header = b64json(parts[0]);
-- if (header.alg !== 'RS256') return null;
-- if (!crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'))) return null;
-- const claims = b64json(parts[1]);
-- const t = Math.floor(now / 1000);
-- if (typeof claims.exp !== 'number' || claims.exp + 30 < t) return null;
-- if (issuer && claims.iss !== issuer) return null;
-- const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
-- if (!aud.some(a => audiences.includes(a))) return null;
-- if (claims.actor_type || PRINCIPAL_SUB.test(String(claims.sub))) return null;
-- return claims;
-- } catch {
-- return null;
-- }
--}
--
function bearer(req) {
const h = String(req.headers.authorization || '');
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
@@@ -141,14 +76,18 @@ class AuthError extends Error {
}
function createAuth({ config, keys }) {
-- function verifyService(token) {
-- const publicKey = keys.get();
-- if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
-- return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
++ /** → { ok: true, claims } | { ok: false, code, reason }; token.unavailable while no key has loaded. */
++ async function verifyService(token) {
++ return await sdkAuth.verifyServiceToken(token, { ...keys.verifyOptions, issuer: config.issuer, audience: config.audience, contracts });
}
-- function verifyUser(token) {
-- return verifyUserJwt(token, { publicKey: keys.get(), issuer: config.issuer, audiences: config.userAudiences });
++ /** A person's session token → its claims, or null. */
++ async function verifyUser(token) {
++ try {
++ return await sdkAuth.verifyUserToken(token, { ...keys.verifyOptions, issuer: config.issuer, audience: config.userAudiences });
++ } catch {
++ return null;
++ }
}
function userCaller(claims, token) {
@@@ -160,13 +99,13 @@ function createAuth({ config, keys }) {
* resolve(req, { services }) — services: false for server-rendered pages (a service token is
* no identity there). A presented service token must verify: it is never downgraded.
*/
-- function resolve(req, { services = true } = {}) {
++ async function resolve(req, { services = true } = {}) {
const token = bearer(req);
if (token) {
const payload = decodePayload(token);
if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {
if (!services) return { kind: 'anonymous' };
-- const r = verifyService(token);
++ const r = await verifyService(token);
if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);
const subjectHeader = req.get('x-ov-subject');
let subject = null;
@@@ -176,26 +115,26 @@ function createAuth({ config, keys }) {
}
return { kind: 'service', sub: r.claims.sub, claims: r.claims, subject };
}
-- const claims = verifyUser(token);
++ const claims = await verifyUser(token);
if (claims) return userCaller(claims, token);
if (!keys.loaded()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');
throw new AuthError(401, 'token.invalid', 'token does not verify');
}
const fromCookie = cookie(req, 'ov_token');
-- const claims = fromCookie ? verifyUser(fromCookie) : null;
++ const claims = fromCookie ? await verifyUser(fromCookie) : null;
if (claims) return userCaller(claims, fromCookie);
return { kind: 'anonymous' };
}
function middleware(opts) {
return (req, res, next) => {
-- try {
-- req.caller = resolve(req, opts);
++ resolve(req, opts).then((caller) => {
++ req.caller = caller;
next();
-- } catch (err) {
++ }, (err) => {
if (!(err instanceof AuthError)) return next(err);
http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
-- }
++ });
};
}
@@@ -240,4 +179,4 @@ function createAuth({ config, keys }) {
return { resolve, middleware, guard, canAccess, actorOf, verifyService, verifyUser };
}
--module.exports = { createKeyStore, createAuth, allows, hasCap, verifyUserJwt, bearer, cookie, AuthError };
++module.exports = { createAuth, allows, hasCap, bearer, cookie, AuthError };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 5bd645b..3860bde 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -97,9 +97,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
res.redirect(sanitizeNext(req.query.next));
});
-- router.get('/me', (req, res) => {
++ router.get('/me', async (req, res) => {
const token = (req.cookies && req.cookies[ACCESS]) || null;
-- const claims = token ? auth.verifyUser(token) : null;
++ const claims = token ? await auth.verifyUser(token) : null;
if (!claims) return res.status(401).json({ error: 'Not authenticated' });
const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
return res.json({ user, expires_at: exp ? exp * 1000 : null });
ddiff --git a/server/index.js b/server/index.js
iindex 5ab83a1..e0bda0e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -12,13 +12,14 @@ const path = require('path');
const { gracefulStop } = require('openvibe-sdk/service');
const { load } = require('./config');
const { openRuntime } = require('./store');
--const { createKeyStore, createAuth } = require('./auth');
++const { createNetworkKeys } = require('openvibe-sdk/auth');
++const { createAuth } = require('./auth');
const { createApp } = require('./app');
async function start({ config, clock, fetchImpl = globalThis.fetch, log = console, listen = true, db } = {}) {
config = config || load();
const rt = await openRuntime({ config, clock, fetchImpl, log, db });
-- const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
++ const keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: config.networkPublicKey, fetch: fetchImpl, log });
const auth = createAuth({ config, keys });
const app = createApp({ rt, auth, keys, log, fetchImpl });
const keyLoaded = keys.start().catch(() => null);
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 75df9bb..4a3e6f9 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -2,7 +2,9 @@
// openre-api: capability guards, owner scoping, keys shown once, destination secrets never
// returned, problem+json, rotation and end requests over HTTP, the playback descriptor.
const assert = require('assert');
--const { bootApi, request, serviceToken, userToken, suite, OWNER, OTHER } = require('./helpers');
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++const { bootApi, request, serviceToken, userToken, suite, OWNER, OTHER, ISSUER, privateKey } = require('./helpers');
const t = suite('api');
let api;
@@@ -67,6 +69,25 @@ t('anonymous and ungranted callers are refused with problem+json', async () => {
assert.strictEqual(badSubject.status, 400);
});
++t('a typed Network token (a realtime ticket, a FedCM assertion) is never a session, as a Bearer or the cookie', async () => {
++ const now = Math.floor(Date.now() / 1000);
++ // Network signs both with the session key; only typ/purpose tell them apart from a session token.
++ const typed = (extra) => serviceAuth.signServiceToken({
++ sub: OWNER, subject_id: OWNER, iss: ISSUER, aud: ['openvibe.network', 'openvibe.openre'], iat: now, exp: now + 120,
++ jti: `rtk_${crypto.randomBytes(12).toString('hex')}`, ...extra,
++ }, privateKey);
++ for (const token of [typed({ typ: 'realtime', purpose: 'realtime' }), typed({ typ: 'fedcm' }), typed({ purpose: 'export' })]) {
++ const bearer = await request(api.base, 'GET', '/api/v1/streams', { token });
++ assert.strictEqual(bearer.status, 401, JSON.stringify(bearer.body));
++ assert.strictEqual(bearer.body.code, 'token.invalid');
++ const me = await request(api.base, 'GET', '/auth/me', { cookie: `ov_token=${token}` });
++ assert.strictEqual(me.status, 401, 'not signed in with it');
++ }
++ const session = await request(api.base, 'GET', '/auth/me', { cookie: `ov_token=${owner}` });
++ assert.strictEqual(session.status, 200, 'the session token itself still signs in');
++ assert.strictEqual(session.body.user.subject_id, OWNER);
++});
++
let streamId;
let firstKey;