openvibers/openvibe.live Commits
Live: Network keys and token checks from openvibe-sdk/auth (JWKS fallback, rotation) (#87)
Changes
@@@ -172,6 +172,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.129.0--- openvibe-sdk: v0.38.0++- openvibe-sdk: v0.40.0- openvibe-shared: v3.0.0<!-- versions:end -->@@@ -52,13 +52,16 @@ cp .env.example .env#### Public key for token verification--OpenVibe.Live verifies RS256 tokens from OpenVibe.Network using one of these paths:++OpenVibe.Live verifies RS256 tokens from OpenVibe.Network with openvibe-sdk/auth (`createNetworkKeys`), using the first++key file that exists:--1. `OV_NETWORK_PUBLIC_KEY` environment variable.--2. `./data/keys/openvibe-tools-public.pem`.++1. the path in the `OV_NETWORK_PUBLIC_KEY` environment variable;++2. `<DATA_DIR>/keys/openvibe-tools-public.pem`;3. `/opt/openvibe/openvibe-tools/data/keys/public.pem`.--If the public key cannot be loaded, authentication will fail.++With none of them, Live verifies against Network's JWKS (`<OV_NETWORK_INTERNAL_URL>/api/.well-known/jwks`), loaded at boot++and kept fresh, and follows a key rotation on the first token that names a new key. `/api/ready` reports the++`network_key` check either way. A key file pins one key: after Network rotates its key, replace the file.## Database@@@ -6,7 +6,7 @@"port": 3000,"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.129.0",-- "sdk": "openvibe-sdk v0.38.0",++ "sdk": "openvibe-sdk v0.40.0","shared": "openvibe-shared v3.0.0","capabilities": ["live.chat_context.read",@@@ -18,14 +18,13 @@"express-rate-limit": "^7.4.0","geoip-lite": "^2.0.0","helmet": "^7.1.0",-- "jsonwebtoken": "^9.0.2","mediasoup": "^3.14.14","mediasoup-client": "^3.15.7","multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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.1","sanitize-html": "^2.17.7",@@@ -36,7 +35,8 @@"ws": "^8.18.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.3"},"engines": {"node": ">=18.0.0"@@@ -2660,9 +2660,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"@@@ -36,14 +36,13 @@"express-rate-limit": "^7.4.0","geoip-lite": "^2.0.0","helmet": "^7.1.0",-- "jsonwebtoken": "^9.0.2","mediasoup": "^3.14.14","mediasoup-client": "^3.15.7","multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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.1","sanitize-html": "^2.17.7",@@@ -57,6 +56,7 @@"node": ">=18.0.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.3"}}@@@ -1,17 +1,21 @@/*** OpenVibe.Live — JWT Auth Middleware-- * All authentication is handled via OpenVibe.Tools RS256 tokens.++ * All authentication is handled via OpenVibe.Network RS256 tokens, checked by openvibe-sdk/auth.* Users sign in on openvibe.network and are redirected back via OAuth2.* Local user records are resolved via linked_accounts.*/--const jwt = require('jsonwebtoken');++const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');const fs = require('fs');const path = require('path');const { v4: uuidv4 } = require('uuid');const db = require('../db/database');--// ── OpenVibe.Tools Public Key (RS256 verification) ──────────────--let openvibeToolsPublicKey = null;++// ── OpenVibe.Network signing keys (RS256 verification) ──────────++// openvibe-sdk/auth createNetworkKeys: the key file when there is one (as before), else Network's JWKS, with a++// rotation honoured on an unknown kid and the last good keys kept through an outage. Nothing is fetched at module++// load: the JWKS (when used) loads on the first verification, or at boot through startNetworkKeys().++let networkKeys = null;++let keySource = null;// The issuer is the public-facing URL of openvibe.network (the SSO provider).// It's initialized from env or registry after config loads; we read it lazily@@@ -28,43 +32,37 @@ function loadNetworkPublicKey() {'/opt/openvibe/openvibe-tools/data/keys/public.pem',].filter(Boolean);++ let pem = null;++ keySource = null;for (const p of keyPaths) {try {if (fs.existsSync(p)) {-- openvibeToolsPublicKey = fs.readFileSync(p, 'utf8');-- console.log(`[Auth] Loaded openvibe.network public key from ${p}`);-- return;++ pem = fs.readFileSync(p, 'utf8');++ keySource = p;++ break;}} catch { /* try next */ }}-- console.error('[Auth] ❌ openvibe.network public key not found — authentication will NOT work!');++ if (networkKeys) networkKeys.stop();++ networkKeys = createNetworkKeys({ network: config.openvibeToolsInternalUrl || process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000', publicKey: pem, log: console });++ if (pem) console.log(`[Auth] Loaded openvibe.network public key from ${keySource}`);++ else console.log(`[Auth] No openvibe.network key file: verifying against Network's JWKS (${networkKeys.url})`);}loadNetworkPublicKey();++/** Boot (never a drill): load Network's JWKS now and keep it fresh, when there is no key file. */++function startNetworkKeys() { networkKeys.start().catch(() => { /* logged by the JWKS client */ }); }++++/*** Verify a openvibe.network RS256 JWT token.* Returns decoded payload or null.*/--// A user session token carries neither `typ` (FedCM assertions: 5 minutes, minted for one origin, to be--// exchanged at Network, never a session) nor `actor_type` (service/app principal tokens). Both are--// signed with the same Network key, so the signature alone does not make a token a login.--function isUserSessionClaims(decoded) {-- return !!decoded && typeof decoded === 'object' && decoded.typ === undefined && decoded.actor_type === undefined;--}--++// verifyUserToken refuses what is not a person's session though Network signs it with the same key: a typed token++// (`typ`/`purpose`: a FedCM assertion, a realtime ticket, an export token) and a service/app principal token.async function verifyToken(token) {-- if (!openvibeToolsPublicKey) return null;-- try {-- const decoded = jwt.verify(token, openvibeToolsPublicKey, {-- algorithms: ['RS256'],-- issuer: getNetworkIssuer(),-- });-- if (!isUserSessionClaims(decoded)) return null;-- // Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).-- return await require('./revocations').isRevoked(decoded) ? null : decoded;-- } catch {-- return null;-- }++ const r = await verifyTokenWithReason(token);++ return r.ok ? r.decoded : null;}/**@@@ -72,18 +70,16 @@ async function verifyToken(token) {* For WS auth logging only.*/async function verifyTokenWithReason(token) {-- if (!openvibeToolsPublicKey) return { ok: false, reason: 'no_public_key' };++ let decoded;try {-- const decoded = jwt.verify(token, openvibeToolsPublicKey, {-- algorithms: ['RS256'],-- issuer: getNetworkIssuer(),-- });-- if (!isUserSessionClaims(decoded)) return { ok: false, reason: 'not_a_session_token' };-- if (await require('./revocations').isRevoked(decoded)) return { ok: false, reason: 'revoked' };-- return { ok: true, decoded };++ decoded = await verifyUserToken(String(token || ''), { ...networkKeys.verifyOptions, issuer: getNetworkIssuer() });} catch (err) {-- return { ok: false, reason: err.name, message: err.message };++ // openvibe-sdk/auth codes: token.malformed, token.bad_signature, token.expired, token.not_user, token.no_key, …++ return { ok: false, reason: (err && err.code) || 'token.invalid', message: err && err.message };}++ // Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).++ if (await require('./revocations').isRevoked(decoded)) return { ok: false, reason: 'revoked' };++ return { ok: true, decoded };}/**@@@ -430,8 +426,10 @@ module.exports = {verifyToken,verifyTokenWithReason,getNetworkIssuer,-- // The Network's RS256 key also signs service tokens (server/net/service-guard.js).-- getNetworkPublicKey: () => openvibeToolsPublicKey,++ // The Network's keys also verify service tokens (server/net/service-guard.js).++ networkVerifyOptions: () => networkKeys.verifyOptions,++ networkKeysLoaded: () => networkKeys.loaded(),++ startNetworkKeys,requireAuth,optionalAuth,requireAdmin,@@@ -847,7 +847,7 @@ const readiness = observability.createLiveReadiness({sfuReady: () => webrtcSFU.ready === true && !!webrtcSFU.worker,// A drill asks no other service anything, Media's health included.mediaUrl: drill.enabled ? null : mediaClient.MEDIA_URL,-- networkKey: () => require('./auth/auth').getNetworkPublicKey(),++ networkKey: () => require('./auth/auth').networkKeysLoaded(),drill: drill.enabled,});app.get('/api/ready', readiness.handler);@@@ -1373,6 +1373,8 @@ async function start() {// returns 503 before this line, which is what makes a deploy health gate meaningful._bootComplete = true;console.log('[Server] Ready. Good vibes only. ▶');++ // Network's JWKS, when there is no key file: loaded now and kept fresh (a key file needs nothing).++ require('./auth/auth').startNetworkKeys();console.log('');// AI stream-memory job (no-op until enabled in openvibe.network/admin → AI).@@@ -5,15 +5,19 @@* guard(capability) checks a Network client-credentials token (RS256, contract* identity.service-token-claims@1) for audience openvibe.live on Live's own internal routes —* today the ones OpenVibe.Chat calls (/internal/chat-context/*, /internal/chat-effects/*). The-- * token is verified with the same Network key Live verifies user JWTs with.++ * token is verified by openvibe-sdk/auth verifyServiceToken (the pinned contracts rules, the key the++ * token's kid names) with the same Network keys Live verifies user JWTs with.** The capability ids are registered in openvibe-contracts (checked with capabilities.check()); an* id the pinned registry does not know yet is checked as a plain grant with capabilities.grants()* until the pin moves. Loopback only: anything that came through nginx is refused. X-Internal-Key is* gone (plan T2): every internal route is guard(capability).*/--const { serviceAuth, capabilities, http } = require('openvibe-contracts');--const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');++const contracts = require('openvibe-contracts');++const { verifyServiceToken } = require('openvibe-sdk/auth');++const { networkVerifyOptions, getNetworkIssuer } = require('../auth/auth');++++const { capabilities, http } = contracts;/** The request came through nginx or Cloudflare: nginx adds X-Forwarded-For / X-Real-IP to everything from outside* (Cloudflare adds CF-Connecting-IP), and loopback callers never send them. Internal routes refuse such a request. */@@@ -27,13 +31,15 @@ const AUDIENCE = 'openvibe.live';function guard(capability) {const registered = !!capabilities.get(capability);return function liveServiceGuard(req, res, next) {++ check(req, res, next).catch(next);++ };++ async function check(req, res, next) {const ctx = http.requestContext(req.headers);if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });const auth = String(req.headers.authorization || '');if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });-- const publicKey = getNetworkPublicKey();-- if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });-- const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: getNetworkIssuer(), audience: AUDIENCE });++ const r = await verifyServiceToken(auth.slice(7).trim(), { ...networkVerifyOptions(), issuer: getNetworkIssuer(), audience: AUDIENCE, contracts });++ if (!r.ok && r.code === 'token.unavailable') return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });const c = registered? capabilities.check(r.claims, capability)@@@ -41,7 +47,7 @@ function guard(capability) {if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };next();-- };++ }}module.exports = { guard, viaProxy, AUDIENCE };@@@ -74,7 +74,7 @@ function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxSta* sfuReady() -> boolean optional (WebRTC broadcasting/viewing through mediasoup)* mediaUrl optional (OpenVibe.Media: VODs, clips, thumbnails), checked by* GET <mediaUrl>/healthz, cached 30 s-- * networkKey() -> PEM or null optional (sign-in: RS256 key from OpenVibe.Network)++ * networkKey() -> true, or a PEM, when loaded optional (sign-in: RS256 keys from OpenVibe.Network)*/function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUrl, networkKey, drill = false, fetchImpl = globalThis.fetch }) {return createReadiness({@@@ -93,7 +93,7 @@ function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUrreturn res.ok ? true : `Media answered ${res.status}`;},},-- { name: 'network_key', required: false, description: 'OpenVibe.Network RS256 key (sign-in)', check: () => (/BEGIN [A-Z ]*PUBLIC KEY/.test(String(networkKey() || '')) ? true : 'Network public key not loaded: sign-in cannot be verified') },++ { name: 'network_key', required: false, description: 'OpenVibe.Network RS256 key (sign-in)', check: () => { const k = networkKey(); return k === true || /BEGIN [A-Z ]*PUBLIC KEY/.test(String(k || '')) ? true : 'Network public key not loaded: sign-in cannot be verified'; } },],// Fields the previous /api/ready served, so nothing that read them breaks.details: (body) => ({nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++// Live's Network keys (server/auth/auth.js, openvibe-sdk/auth createNetworkKeys). With no key file, Live verifies++// against Network's JWKS instead of refusing every sign-in: a person's session token verifies, a key rotation is++// followed after one refetch, and the service guard reads the same keys. Typed tokens (a FedCM assertion, a realtime++// ticket, an export token) and service principal tokens are never a session, whoever signed them.++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const LEGACY = '/opt/openvibe/openvibe-tools/data/keys/public.pem';++if (fs.existsSync(LEGACY)) { console.log(`network-keys: skipped (a key file at ${LEGACY} wins over the JWKS here)`); process.exit(0); }++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-keys-'));++const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++const K1 = pair(), K2 = pair(), ROGUE = pair();++const jwkOf = (k, kid) => ({ ...k.publicKey.export({ format: 'jwk' }), alg: 'RS256', use: 'sig', kid });++let published = [jwkOf(K1, 'k1')];++let fetches = 0;++const network = http.createServer((req, res) => {++ if (req.url !== '/api/.well-known/jwks') { res.statusCode = 404; return res.end(); }++ fetches++;++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ keys: published }));++});++++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++(async () => {++ await new Promise((r) => network.listen(0, '127.0.0.1', r));++ process.env.NODE_ENV = 'test';++ process.env.DATA_DIR = tmp; // no data/keys/openvibe-tools-public.pem++ process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'absent.pem');++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;++ const express = require('express');++ const { serviceAuth } = require('openvibe-contracts');++ const db = require('../server/db/database');++ const auth = require('../server/auth/auth');++ const { guard } = require('../server/net/service-guard');++ await db.initDb();++++ const now = () => Math.floor(Date.now() / 1000);++ const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++ const sign = (k, kid, claims) => {++ const input = `${enc({ alg: 'RS256', typ: 'JWT', kid })}.${enc(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), k.privateKey).toString('base64url')}`;++ };++ const session = (extra = {}) => ({ sub: 7, id: 7, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', username: 'x', role: 'user', iss: auth.getNetworkIssuer(), iat: now(), exp: now() + 600, ...extra });++++ const app = express();++ app.get('/internal/probe', guard('live.chat_context.read'), (req, res) => res.json({ sub: req.principal.sub }));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const probe = (token) => fetch(`http://127.0.0.1:${server.address().port}/internal/probe`, { headers: { authorization: `Bearer ${token}` } });++++ try {++ assert.strictEqual(auth.networkKeysLoaded(), false, 'nothing fetched at module load');++ const claims = await auth.verifyToken(sign(K1, 'k1', session()));++ assert.ok(claims && claims.subject_id === 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'a session token verifies against the JWKS');++ assert.strictEqual(auth.networkKeysLoaded(), true, 'the readiness check sees the keys');++++ const before = fetches;++ published = [jwkOf(K2, 'k2'), jwkOf(K1, 'k1')];++ assert.ok(await auth.verifyToken(sign(K2, 'k2', session())), 'a rotated key verifies');++ assert.strictEqual(fetches, before + 1, 'after one refetch');++ assert.strictEqual(await auth.verifyToken(sign(ROGUE, 'k1', session())), null, 'a key Network never published');++++ for (const [what, extra] of [['a FedCM assertion', { typ: 'fedcm' }], ['a realtime ticket', { typ: 'realtime', purpose: 'realtime' }],++ ['an export token', { purpose: 'export' }], ['a service principal', { sub: 'svc:chat', actor_type: 'service' }]]) {++ const r = await auth.verifyTokenWithReason(sign(K2, 'k2', session(extra)));++ assert.deepStrictEqual([r.ok, r.reason], [false, 'token.not_user'], what);++ }++ const expired = await auth.verifyTokenWithReason(sign(K2, 'k2', session({ exp: now() - 120 })));++ assert.strictEqual(expired.reason, 'token.expired');++++ const svc = (cap) => serviceAuth.signServiceToken({ iss: auth.getNetworkIssuer(), sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.live'], cap, ns: [], iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` },++ K2.privateKey.export({ type: 'pkcs8', format: 'pem' }));++ let r = await probe(svc(['live.chat_context.read']));++ assert.strictEqual(r.status, 200, 'the service guard reads the same keys');++ assert.strictEqual((await r.json()).sub, 'svc:chat');++ r = await probe(svc([]));++ assert.strictEqual(r.status, 403);++ r = await probe(sign(K2, 'k2', session()));++ assert.strictEqual(r.status, 401, 'a session token is not a service token');++ } finally {++ server.close();++ network.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ quiet('network-keys: all checks passed');++ process.exit(0);++})().catch((e) => { quiet(e); process.exit(1); });
@@ -172,6 +172,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -172,6 +172,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.129.0
- openvibe-contracts: v0.129.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 -->
@@ -52,13 +52,16 @@ cp .env.example .env
@@ -52,13 +52,16 @@ cp .env.example .env
#### Public key for token verification
#### Public key for token verification
-OpenVibe.Live verifies RS256 tokens from OpenVibe.Network using one of these paths:
+OpenVibe.Live verifies RS256 tokens from OpenVibe.Network with openvibe-sdk/auth (`createNetworkKeys`), using the first
+key file that exists:
-1. `OV_NETWORK_PUBLIC_KEY` environment variable.
-2. `./data/keys/openvibe-tools-public.pem`.
+1. the path in the `OV_NETWORK_PUBLIC_KEY` environment variable;
+2. `<DATA_DIR>/keys/openvibe-tools-public.pem`;
3. `/opt/openvibe/openvibe-tools/data/keys/public.pem`.
3. `/opt/openvibe/openvibe-tools/data/keys/public.pem`.
-If the public key cannot be loaded, authentication will fail.
+With none of them, Live verifies against Network's JWKS (`<OV_NETWORK_INTERNAL_URL>/api/.well-known/jwks`), loaded at boot
+and kept fresh, and follows a key rotation on the first token that names a new key. `/api/ready` reports the
+`network_key` check either way. A key file pins one key: after Network rotates its key, replace the file.
## Database
## Database
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.129.0",
"contracts": "openvibe-contracts v0.129.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",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
@@ -18,14 +18,13 @@
@@ -18,14 +18,13 @@
"express-rate-limit": "^7.4.0",
"express-rate-limit": "^7.4.0",
"geoip-lite": "^2.0.0",
"geoip-lite": "^2.0.0",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
- "jsonwebtoken": "^9.0.2",
"mediasoup": "^3.14.14",
"mediasoup": "^3.14.14",
"mediasoup-client": "^3.15.7",
"mediasoup-client": "^3.15.7",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -36,7 +35,8 @@
@@ -36,7 +35,8 @@
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.3"
},
},
"engines": {
"engines": {
"node": ">=18.0.0"
"node": ">=18.0.0"
@@ -2660,9 +2660,9 @@
@@ -2660,9 +2660,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"
@@ -36,14 +36,13 @@
@@ -36,14 +36,13 @@
"express-rate-limit": "^7.4.0",
"express-rate-limit": "^7.4.0",
"geoip-lite": "^2.0.0",
"geoip-lite": "^2.0.0",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
- "jsonwebtoken": "^9.0.2",
"mediasoup": "^3.14.14",
"mediasoup": "^3.14.14",
"mediasoup-client": "^3.15.7",
"mediasoup-client": "^3.15.7",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -57,6 +56,7 @@
@@ -57,6 +56,7 @@
"node": ">=18.0.0"
"node": ">=18.0.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.3"
}
}
}
}
@@ -1,17 +1,21 @@
@@ -1,17 +1,21 @@
/**
/**
* OpenVibe.Live — JWT Auth Middleware
* OpenVibe.Live — JWT Auth Middleware
- * All authentication is handled via OpenVibe.Tools RS256 tokens.
+ * All authentication is handled via OpenVibe.Network RS256 tokens, checked by openvibe-sdk/auth.
* Users sign in on openvibe.network and are redirected back via OAuth2.
* Users sign in on openvibe.network and are redirected back via OAuth2.
* Local user records are resolved via linked_accounts.
* Local user records are resolved via linked_accounts.
*/
*/
-const jwt = require('jsonwebtoken');
+const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const db = require('../db/database');
const db = require('../db/database');
-// ── OpenVibe.Tools Public Key (RS256 verification) ──────────────
-let openvibeToolsPublicKey = null;
+// ── OpenVibe.Network signing keys (RS256 verification) ──────────
+// openvibe-sdk/auth createNetworkKeys: the key file when there is one (as before), else Network's JWKS, with a
+// rotation honoured on an unknown kid and the last good keys kept through an outage. Nothing is fetched at module
+// load: the JWKS (when used) loads on the first verification, or at boot through startNetworkKeys().
+let networkKeys = null;
+let keySource = null;
// The issuer is the public-facing URL of openvibe.network (the SSO provider).
// The issuer is the public-facing URL of openvibe.network (the SSO provider).
// It's initialized from env or registry after config loads; we read it lazily
// It's initialized from env or registry after config loads; we read it lazily
@@ -28,43 +32,37 @@ function loadNetworkPublicKey() {
@@ -28,43 +32,37 @@ function loadNetworkPublicKey() {
'/opt/openvibe/openvibe-tools/data/keys/public.pem',
'/opt/openvibe/openvibe-tools/data/keys/public.pem',
].filter(Boolean);
].filter(Boolean);
+ let pem = null;
+ keySource = null;
for (const p of keyPaths) {
for (const p of keyPaths) {
try {
try {
if (fs.existsSync(p)) {
if (fs.existsSync(p)) {
- openvibeToolsPublicKey = fs.readFileSync(p, 'utf8');
- console.log(`[Auth] Loaded openvibe.network public key from ${p}`);
- return;
+ pem = fs.readFileSync(p, 'utf8');
+ keySource = p;
+ break;
}
}
} catch { /* try next */ }
} catch { /* try next */ }
}
}
- console.error('[Auth] ❌ openvibe.network public key not found — authentication will NOT work!');
+ if (networkKeys) networkKeys.stop();
+ networkKeys = createNetworkKeys({ network: config.openvibeToolsInternalUrl || process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000', publicKey: pem, log: console });
+ if (pem) console.log(`[Auth] Loaded openvibe.network public key from ${keySource}`);
+ else console.log(`[Auth] No openvibe.network key file: verifying against Network's JWKS (${networkKeys.url})`);
}
}
loadNetworkPublicKey();
loadNetworkPublicKey();
+/** Boot (never a drill): load Network's JWKS now and keep it fresh, when there is no key file. */
+function startNetworkKeys() { networkKeys.start().catch(() => { /* logged by the JWKS client */ }); }
+
+
/**
/**
* Verify a openvibe.network RS256 JWT token.
* Verify a openvibe.network RS256 JWT token.
* Returns decoded payload or null.
* Returns decoded payload or null.
*/
*/
-// A user session token carries neither `typ` (FedCM assertions: 5 minutes, minted for one origin, to be
-// exchanged at Network, never a session) nor `actor_type` (service/app principal tokens). Both are
-// signed with the same Network key, so the signature alone does not make a token a login.
-function isUserSessionClaims(decoded) {
- return !!decoded && typeof decoded === 'object' && decoded.typ === undefined && decoded.actor_type === undefined;
-}
-
+// verifyUserToken refuses what is not a person's session though Network signs it with the same key: a typed token
+// (`typ`/`purpose`: a FedCM assertion, a realtime ticket, an export token) and a service/app principal token.
async function verifyToken(token) {
async function verifyToken(token) {
- if (!openvibeToolsPublicKey) return null;
- try {
- const decoded = jwt.verify(token, openvibeToolsPublicKey, {
- algorithms: ['RS256'],
- issuer: getNetworkIssuer(),
- });
- if (!isUserSessionClaims(decoded)) return null;
- // Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).
- return await require('./revocations').isRevoked(decoded) ? null : decoded;
- } catch {
- return null;
- }
+ const r = await verifyTokenWithReason(token);
+ return r.ok ? r.decoded : null;
}
}
/**
/**
@@ -72,18 +70,16 @@ async function verifyToken(token) {
@@ -72,18 +70,16 @@ async function verifyToken(token) {
* For WS auth logging only.
* For WS auth logging only.
*/
*/
async function verifyTokenWithReason(token) {
async function verifyTokenWithReason(token) {
- if (!openvibeToolsPublicKey) return { ok: false, reason: 'no_public_key' };
+ let decoded;
try {
try {
- const decoded = jwt.verify(token, openvibeToolsPublicKey, {
- algorithms: ['RS256'],
- issuer: getNetworkIssuer(),
- });
- if (!isUserSessionClaims(decoded)) return { ok: false, reason: 'not_a_session_token' };
- if (await require('./revocations').isRevoked(decoded)) return { ok: false, reason: 'revoked' };
- return { ok: true, decoded };
+ decoded = await verifyUserToken(String(token || ''), { ...networkKeys.verifyOptions, issuer: getNetworkIssuer() });
} catch (err) {
} catch (err) {
- return { ok: false, reason: err.name, message: err.message };
+ // openvibe-sdk/auth codes: token.malformed, token.bad_signature, token.expired, token.not_user, token.no_key, …
+ return { ok: false, reason: (err && err.code) || 'token.invalid', message: err && err.message };
}
}
+ // Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).
+ if (await require('./revocations').isRevoked(decoded)) return { ok: false, reason: 'revoked' };
+ return { ok: true, decoded };
}
}
/**
/**
@@ -430,8 +426,10 @@ module.exports = {
@@ -430,8 +426,10 @@ module.exports = {
verifyToken,
verifyToken,
verifyTokenWithReason,
verifyTokenWithReason,
getNetworkIssuer,
getNetworkIssuer,
- // The Network's RS256 key also signs service tokens (server/net/service-guard.js).
- getNetworkPublicKey: () => openvibeToolsPublicKey,
+ // The Network's keys also verify service tokens (server/net/service-guard.js).
+ networkVerifyOptions: () => networkKeys.verifyOptions,
+ networkKeysLoaded: () => networkKeys.loaded(),
+ startNetworkKeys,
requireAuth,
requireAuth,
optionalAuth,
optionalAuth,
requireAdmin,
requireAdmin,
@@ -847,7 +847,7 @@ const readiness = observability.createLiveReadiness({
@@ -847,7 +847,7 @@ const readiness = observability.createLiveReadiness({
sfuReady: () => webrtcSFU.ready === true && !!webrtcSFU.worker,
sfuReady: () => webrtcSFU.ready === true && !!webrtcSFU.worker,
// A drill asks no other service anything, Media's health included.
// A drill asks no other service anything, Media's health included.
mediaUrl: drill.enabled ? null : mediaClient.MEDIA_URL,
mediaUrl: drill.enabled ? null : mediaClient.MEDIA_URL,
- networkKey: () => require('./auth/auth').getNetworkPublicKey(),
+ networkKey: () => require('./auth/auth').networkKeysLoaded(),
drill: drill.enabled,
drill: drill.enabled,
});
});
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
@@ -1373,6 +1373,8 @@ async function start() {
@@ -1373,6 +1373,8 @@ async function start() {
// returns 503 before this line, which is what makes a deploy health gate meaningful.
// returns 503 before this line, which is what makes a deploy health gate meaningful.
_bootComplete = true;
_bootComplete = true;
console.log('[Server] Ready. Good vibes only. ▶');
console.log('[Server] Ready. Good vibes only. ▶');
+ // Network's JWKS, when there is no key file: loaded now and kept fresh (a key file needs nothing).
+ require('./auth/auth').startNetworkKeys();
console.log('');
console.log('');
// AI stream-memory job (no-op until enabled in openvibe.network/admin → AI).
// AI stream-memory job (no-op until enabled in openvibe.network/admin → AI).
@@ -5,15 +5,19 @@
@@ -5,15 +5,19 @@
* guard(capability) checks a Network client-credentials token (RS256, contract
* guard(capability) checks a Network client-credentials token (RS256, contract
* identity.service-token-claims@1) for audience openvibe.live on Live's own internal routes —
* identity.service-token-claims@1) for audience openvibe.live on Live's own internal routes —
* today the ones OpenVibe.Chat calls (/internal/chat-context/*, /internal/chat-effects/*). The
* today the ones OpenVibe.Chat calls (/internal/chat-context/*, /internal/chat-effects/*). The
- * token is verified with the same Network key Live verifies user JWTs with.
+ * token is verified by openvibe-sdk/auth verifyServiceToken (the pinned contracts rules, the key the
+ * token's kid names) with the same Network keys Live verifies user JWTs with.
*
*
* The capability ids are registered in openvibe-contracts (checked with capabilities.check()); an
* The capability ids are registered in openvibe-contracts (checked with capabilities.check()); an
* id the pinned registry does not know yet is checked as a plain grant with capabilities.grants()
* id the pinned registry does not know yet is checked as a plain grant with capabilities.grants()
* until the pin moves. Loopback only: anything that came through nginx is refused. X-Internal-Key is
* until the pin moves. Loopback only: anything that came through nginx is refused. X-Internal-Key is
* gone (plan T2): every internal route is guard(capability).
* gone (plan T2): every internal route is guard(capability).
*/
*/
-const { serviceAuth, capabilities, http } = require('openvibe-contracts');
-const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
+const contracts = require('openvibe-contracts');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
+const { networkVerifyOptions, getNetworkIssuer } = require('../auth/auth');
+
+const { capabilities, http } = contracts;
/** The request came through nginx or Cloudflare: nginx adds X-Forwarded-For / X-Real-IP to everything from outside
/** The request came through nginx or Cloudflare: nginx adds X-Forwarded-For / X-Real-IP to everything from outside
* (Cloudflare adds CF-Connecting-IP), and loopback callers never send them. Internal routes refuse such a request. */
* (Cloudflare adds CF-Connecting-IP), and loopback callers never send them. Internal routes refuse such a request. */
@@ -27,13 +31,15 @@ const AUDIENCE = 'openvibe.live';
@@ -27,13 +31,15 @@ const AUDIENCE = 'openvibe.live';
function guard(capability) {
function guard(capability) {
const registered = !!capabilities.get(capability);
const registered = !!capabilities.get(capability);
return function liveServiceGuard(req, res, next) {
return function liveServiceGuard(req, res, next) {
+ check(req, res, next).catch(next);
+ };
+ async function check(req, res, next) {
const ctx = http.requestContext(req.headers);
const ctx = http.requestContext(req.headers);
if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
const auth = String(req.headers.authorization || '');
const auth = String(req.headers.authorization || '');
if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
- const publicKey = getNetworkPublicKey();
- if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });
- const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: getNetworkIssuer(), audience: AUDIENCE });
+ const r = await verifyServiceToken(auth.slice(7).trim(), { ...networkVerifyOptions(), issuer: getNetworkIssuer(), audience: AUDIENCE, contracts });
+ if (!r.ok && r.code === 'token.unavailable') return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });
if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
const c = registered
const c = registered
? capabilities.check(r.claims, capability)
? capabilities.check(r.claims, capability)
@@ -41,7 +47,7 @@ function guard(capability) {
@@ -41,7 +47,7 @@ function guard(capability) {
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
next();
next();
- };
+ }
}
}
module.exports = { guard, viaProxy, AUDIENCE };
module.exports = { guard, viaProxy, AUDIENCE };
@@ -74,7 +74,7 @@ function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxSta
@@ -74,7 +74,7 @@ function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxSta
* sfuReady() -> boolean optional (WebRTC broadcasting/viewing through mediasoup)
* sfuReady() -> boolean optional (WebRTC broadcasting/viewing through mediasoup)
* mediaUrl optional (OpenVibe.Media: VODs, clips, thumbnails), checked by
* mediaUrl optional (OpenVibe.Media: VODs, clips, thumbnails), checked by
* GET <mediaUrl>/healthz, cached 30 s
* GET <mediaUrl>/healthz, cached 30 s
- * networkKey() -> PEM or null optional (sign-in: RS256 key from OpenVibe.Network)
+ * networkKey() -> true, or a PEM, when loaded optional (sign-in: RS256 keys from OpenVibe.Network)
*/
*/
function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUrl, networkKey, drill = false, fetchImpl = globalThis.fetch }) {
function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUrl, networkKey, drill = false, fetchImpl = globalThis.fetch }) {
return createReadiness({
return createReadiness({
@@ -93,7 +93,7 @@ function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUr
@@ -93,7 +93,7 @@ function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUr
return res.ok ? true : `Media answered ${res.status}`;
return res.ok ? true : `Media answered ${res.status}`;
},
},
},
},
- { name: 'network_key', required: false, description: 'OpenVibe.Network RS256 key (sign-in)', check: () => (/BEGIN [A-Z ]*PUBLIC KEY/.test(String(networkKey() || '')) ? true : 'Network public key not loaded: sign-in cannot be verified') },
+ { name: 'network_key', required: false, description: 'OpenVibe.Network RS256 key (sign-in)', check: () => { const k = networkKey(); return k === true || /BEGIN [A-Z ]*PUBLIC KEY/.test(String(k || '')) ? true : 'Network public key not loaded: sign-in cannot be verified'; } },
],
],
// Fields the previous /api/ready served, so nothing that read them breaks.
// Fields the previous /api/ready served, so nothing that read them breaks.
details: (body) => ({
details: (body) => ({
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+// Live's Network keys (server/auth/auth.js, openvibe-sdk/auth createNetworkKeys). With no key file, Live verifies
+// against Network's JWKS instead of refusing every sign-in: a person's session token verifies, a key rotation is
+// followed after one refetch, and the service guard reads the same keys. Typed tokens (a FedCM assertion, a realtime
+// ticket, an export token) and service principal tokens are never a session, whoever signed them.
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const LEGACY = '/opt/openvibe/openvibe-tools/data/keys/public.pem';
+if (fs.existsSync(LEGACY)) { console.log(`network-keys: skipped (a key file at ${LEGACY} wins over the JWKS here)`); process.exit(0); }
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-keys-'));
+const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const K1 = pair(), K2 = pair(), ROGUE = pair();
+const jwkOf = (k, kid) => ({ ...k.publicKey.export({ format: 'jwk' }), alg: 'RS256', use: 'sig', kid });
+let published = [jwkOf(K1, 'k1')];
+let fetches = 0;
+const network = http.createServer((req, res) => {
+ if (req.url !== '/api/.well-known/jwks') { res.statusCode = 404; return res.end(); }
+ fetches++;
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ keys: published }));
+});
+
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+(async () => {
+ await new Promise((r) => network.listen(0, '127.0.0.1', r));
+ process.env.NODE_ENV = 'test';
+ process.env.DATA_DIR = tmp; // no data/keys/openvibe-tools-public.pem
+ process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'absent.pem');
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;
+ const express = require('express');
+ const { serviceAuth } = require('openvibe-contracts');
+ const db = require('../server/db/database');
+ const auth = require('../server/auth/auth');
+ const { guard } = require('../server/net/service-guard');
+ await db.initDb();
+
+ const now = () => Math.floor(Date.now() / 1000);
+ const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+ const sign = (k, kid, claims) => {
+ const input = `${enc({ alg: 'RS256', typ: 'JWT', kid })}.${enc(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), k.privateKey).toString('base64url')}`;
+ };
+ const session = (extra = {}) => ({ sub: 7, id: 7, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', username: 'x', role: 'user', iss: auth.getNetworkIssuer(), iat: now(), exp: now() + 600, ...extra });
+
+ const app = express();
+ app.get('/internal/probe', guard('live.chat_context.read'), (req, res) => res.json({ sub: req.principal.sub }));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const probe = (token) => fetch(`http://127.0.0.1:${server.address().port}/internal/probe`, { headers: { authorization: `Bearer ${token}` } });
+
+ try {
+ assert.strictEqual(auth.networkKeysLoaded(), false, 'nothing fetched at module load');
+ const claims = await auth.verifyToken(sign(K1, 'k1', session()));
+ assert.ok(claims && claims.subject_id === 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'a session token verifies against the JWKS');
+ assert.strictEqual(auth.networkKeysLoaded(), true, 'the readiness check sees the keys');
+
+ const before = fetches;
+ published = [jwkOf(K2, 'k2'), jwkOf(K1, 'k1')];
+ assert.ok(await auth.verifyToken(sign(K2, 'k2', session())), 'a rotated key verifies');
+ assert.strictEqual(fetches, before + 1, 'after one refetch');
+ assert.strictEqual(await auth.verifyToken(sign(ROGUE, 'k1', session())), null, 'a key Network never published');
+
+ for (const [what, extra] of [['a FedCM assertion', { typ: 'fedcm' }], ['a realtime ticket', { typ: 'realtime', purpose: 'realtime' }],
+ ['an export token', { purpose: 'export' }], ['a service principal', { sub: 'svc:chat', actor_type: 'service' }]]) {
+ const r = await auth.verifyTokenWithReason(sign(K2, 'k2', session(extra)));
+ assert.deepStrictEqual([r.ok, r.reason], [false, 'token.not_user'], what);
+ }
+ const expired = await auth.verifyTokenWithReason(sign(K2, 'k2', session({ exp: now() - 120 })));
+ assert.strictEqual(expired.reason, 'token.expired');
+
+ const svc = (cap) => serviceAuth.signServiceToken({ iss: auth.getNetworkIssuer(), sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.live'], cap, ns: [], iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` },
+ K2.privateKey.export({ type: 'pkcs8', format: 'pem' }));
+ let r = await probe(svc(['live.chat_context.read']));
+ assert.strictEqual(r.status, 200, 'the service guard reads the same keys');
+ assert.strictEqual((await r.json()).sub, 'svc:chat');
+ r = await probe(svc([]));
+ assert.strictEqual(r.status, 403);
+ r = await probe(sign(K2, 'k2', session()));
+ assert.strictEqual(r.status, 401, 'a session token is not a service token');
+ } finally {
+ server.close();
+ network.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ quiet('network-keys: all checks passed');
+ process.exit(0);
+})().catch((e) => { quiet(e); process.exit(1); });