openvibers/openrestream Commits
OpenRestream: the Network keys and token checks come from openvibe-sdk/auth (typed tokens refused) (#48)
Changes
@@@ -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 -->@@@ -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)",@@@ -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"@@@ -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",@@@ -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 };@@@ -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 });@@@ -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);@@@ -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;
@@ -103,7 +103,7 @@ Network" below.
@@ -103,7 +103,7 @@ Network" below.
## Auth
## 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 |
| Capability | Routes |
|---|---|
|---|---|
@@ -311,6 +311,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -311,6 +311,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.131.0
- openvibe-contracts: v0.131.0
-- openvibe-sdk: v0.38.0
+- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"rtmpPort": 1936,
"rtmpPort": 1936,
"domain": "openre.stream",
"domain": "openre.stream",
"contracts": "openvibe-contracts v0.131.0 (openvibe-sdk v0.26.0, openvibe-shared v2.13.0)",
"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",
"shared": "openvibe-shared v3.0.0",
"protocols": {
"protocols": {
"rtmp": "ingest ported (openre-rtmp-ingest)",
"rtmp": "ingest ported (openre-rtmp-ingest)",
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"mediasoup": "^3.14.14",
"mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -1654,9 +1654,9 @@
@@ -1654,9 +1654,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"mediasoup": "^3.14.14",
"mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -4,78 +4,34 @@
@@ -4,78 +4,34 @@
*
*
* { kind: 'service', sub: 'svc:live', claims, subject: 'usr_…'|null }
* { kind: 'service', sub: 'svc:live', claims, subject: 'usr_…'|null }
* An OpenVibe.Network client-credentials token (RS256, audience openvibe.openre), verified
* 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.
* 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
* Without it the grant applies to every stream (first-party integrations such as Live's
* mirror read sessions for any channel that opted in).
* mirror read sessions for any channel that opted in).
* { kind: 'user', subject: 'usr_…', staff, claims }
* { 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' }
* { kind: 'anonymous' }
*
*
* The capability ids are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts
* 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
* 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
* or a `family.*` grant) and hands the decision to capabilities.check() once contracts know the id
* (the same bridge OpenVibe.Events used).
* (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 PRINCIPAL_SUB = /^(svc|app|mod):/;
const STAFF_ROLES = new Set(['admin']);
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 ───────────────────────────────────────────────
// ── Capabilities ───────────────────────────────────────────────
function hasCap(claims, id) {
function hasCap(claims, id) {
@@ -94,27 +50,6 @@ function allows(claims, id) {
@@ -94,27 +50,6 @@ function allows(claims, id) {
const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
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) {
function bearer(req) {
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
@@ -141,14 +76,18 @@ class AuthError extends Error {
@@ -141,14 +76,18 @@ class AuthError extends Error {
}
}
function createAuth({ config, keys }) {
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) {
function userCaller(claims, token) {
@@ -160,13 +99,13 @@ function createAuth({ config, keys }) {
@@ -160,13 +99,13 @@ function createAuth({ config, keys }) {
* resolve(req, { services }) — services: false for server-rendered pages (a service token is
* 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.
* 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);
const token = bearer(req);
if (token) {
if (token) {
const payload = decodePayload(token);
const payload = decodePayload(token);
if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {
if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {
if (!services) return { kind: 'anonymous' };
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);
if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);
const subjectHeader = req.get('x-ov-subject');
const subjectHeader = req.get('x-ov-subject');
let subject = null;
let subject = null;
@@ -176,26 +115,26 @@ function createAuth({ config, keys }) {
@@ -176,26 +115,26 @@ function createAuth({ config, keys }) {
}
}
return { kind: 'service', sub: r.claims.sub, claims: r.claims, subject };
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 (claims) return userCaller(claims, token);
if (!keys.loaded()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');
if (!keys.loaded()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');
throw new AuthError(401, 'token.invalid', 'token does not verify');
throw new AuthError(401, 'token.invalid', 'token does not verify');
}
}
const fromCookie = cookie(req, 'ov_token');
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);
if (claims) return userCaller(claims, fromCookie);
return { kind: 'anonymous' };
return { kind: 'anonymous' };
}
}
function middleware(opts) {
function middleware(opts) {
return (req, res, next) => {
return (req, res, next) => {
- try {
- req.caller = resolve(req, opts);
+ resolve(req, opts).then((caller) => {
+ req.caller = caller;
next();
next();
- } catch (err) {
+ }, (err) => {
if (!(err instanceof AuthError)) return next(err);
if (!(err instanceof AuthError)) return next(err);
http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
- }
+ });
};
};
}
}
@@ -240,4 +179,4 @@ function createAuth({ config, keys }) {
@@ -240,4 +179,4 @@ function createAuth({ config, keys }) {
return { resolve, middleware, guard, canAccess, actorOf, verifyService, verifyUser };
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 };
@@ -97,9 +97,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -97,9 +97,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
res.redirect(sanitizeNext(req.query.next));
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 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' });
if (!claims) return res.status(401).json({ error: 'Not authenticated' });
const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
return res.json({ user, expires_at: exp ? exp * 1000 : null });
return res.json({ user, expires_at: exp ? exp * 1000 : null });
@@ -12,13 +12,14 @@ const path = require('path');
@@ -12,13 +12,14 @@ const path = require('path');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
const { load } = require('./config');
const { load } = require('./config');
const { openRuntime } = require('./store');
const { openRuntime } = require('./store');
-const { createKeyStore, createAuth } = require('./auth');
+const { createNetworkKeys } = require('openvibe-sdk/auth');
+const { createAuth } = require('./auth');
const { createApp } = require('./app');
const { createApp } = require('./app');
async function start({ config, clock, fetchImpl = globalThis.fetch, log = console, listen = true, db } = {}) {
async function start({ config, clock, fetchImpl = globalThis.fetch, log = console, listen = true, db } = {}) {
config = config || load();
config = config || load();
const rt = await openRuntime({ config, clock, fetchImpl, log, db });
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 auth = createAuth({ config, keys });
const app = createApp({ rt, auth, keys, log, fetchImpl });
const app = createApp({ rt, auth, keys, log, fetchImpl });
const keyLoaded = keys.start().catch(() => null);
const keyLoaded = keys.start().catch(() => null);
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
// openre-api: capability guards, owner scoping, keys shown once, destination secrets never
// openre-api: capability guards, owner scoping, keys shown once, destination secrets never
// returned, problem+json, rotation and end requests over HTTP, the playback descriptor.
// returned, problem+json, rotation and end requests over HTTP, the playback descriptor.
const assert = require('assert');
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');
const t = suite('api');
let api;
let api;
@@ -67,6 +69,25 @@ t('anonymous and ungranted callers are refused with problem+json', async () => {
@@ -67,6 +69,25 @@ t('anonymous and ungranted callers are refused with problem+json', async () => {
assert.strictEqual(badSubject.status, 400);
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 streamId;
let firstKey;
let firstKey;