openvibers/openvibe.live Commits

Live: Network keys and token checks from openvibe-sdk/auth (JWKS fallback, rotation) (#87)

OP OpenVibers committed 10 Oct 2026 · 8fd2066ce1 · 10 files, +168 −63

* 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.

* Live: Network keys and token checks from openvibe-sdk/auth (JWKS fallback, rotation)

Plan T1: delete hand-rolled copies. Live verified Network tokens with `jsonwebtoken` against one PEM file, read once at module load. With no file, sign-in failed outright ("authentication will NOT work"), and a Network key rotation needed the file replaced by hand. Live now uses openvibe-sdk/auth (v0.40.0; this PR carries the pin bump from #86).

- **Keys:** `createNetworkKeys`. The key file order is unchanged (`OV_NETWORK_PUBLIC_KEY`, then `<DATA_DIR>/keys/openvibe-tools-public.pem`, then the legacy path), so production behaves as before.
  - New: with no file, Live verifies against Network's JWKS, which is loaded at boot (`startNetworkKeys()` after "Ready", never in a drill) and follows a rotation on an unknown kid.
  - Nothing is fetched at module load, so drill-mode stays green.
- **Session tokens:** `verifyToken`/`verifyTokenWithReason` use `verifyUserToken` (issuer read per call, no audience, as before).
  - It refuses typed tokens (`typ` or `purpose`) and service principals. Live refused `typ` and `actor_type`; `purpose` (export tokens) is new.
  - Reasons are now the SDK codes. Only `.ok` is read by callers (`/internal/chat-context/auth`).
  - Expiry allows the platform-wide 30 s clock skew.
- **Service tokens:** `server/net/service-guard.js` uses `verifyServiceToken` with Live's pinned contracts and the same keys. No key still answers 503 `identity.unavailable`.
- **Readiness:** the `network_key` check reads `networkKeysLoaded()`.
- `jsonwebtoken` moves to devDependencies; only tests sign with it.
- SETUP.md describes the key order and the JWKS fallback.

Test: `test/network-keys.test.js` runs with no key file against a stub Network. It covers:
- nothing fetched at module load;
- a session token verifies, and readiness sees the keys;
- a rotation verifies after exactly one refetch;
- a rogue key under a published kid is refused;
- a FedCM assertion, a realtime ticket, an export token and a service principal are each refused as `token.not_user`;
- an expired token is refused;
- the service guard: 200 with the capability, 403 without it, 401 for a session token.

`ov test` (48 files) is green, plus chat-context, chat-ingress, actor-limits and the WHIP auth suites.

Changes

ddiff --git a/README.md b/README.md
iindex ec03a26b..751c195f 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/SETUP.md b/SETUP.md
iindex 20f59647..fe15d500 100644
---- a/SETUP.md
++++ b/SETUP.md
@@@ -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
ddiff --git a/STATUS.json b/STATUS.json
iindex 1016f663..84355484 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 3db8b25d..e9612f5a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex b5cb523f..93061b2c 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex ab262dd8..9d628652 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -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,
ddiff --git a/server/index.js b/server/index.js
iindex 65aac497..e1d95ea7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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).
ddiff --git a/server/net/service-guard.js b/server/net/service-guard.js
iindex a358876d..5ceba0e4 100644
---- a/server/net/service-guard.js
++++ b/server/net/service-guard.js
@@@ -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 };
ddiff --git a/server/web/observability.js b/server/web/observability.js
iindex 74abfb32..4bf39d18 100644
---- a/server/web/observability.js
++++ b/server/web/observability.js
@@@ -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, mediaUr
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.
details: (body) => ({
ddiff --git a/test/network-keys.test.js b/test/network-keys.test.js
nnew file mode 100644
iindex 00000000..0e39e94e
---- /dev/null
++++ b/test/network-keys.test.js
@@@ -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); });