openvibers/openvibe.media Commits

Media: one set of Network keys and openvibe-sdk/auth checks (site sign-in refuses typed tokens) (#78)

OP OpenVibers committed 10 Oct 2026 · bd38124ca6 · 9 files, +159 −128

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

* Media: one set of Network keys and openvibe-sdk/auth checks (site sign-in refuses typed tokens)

Plan T1: delete hand-rolled copies. Media had two Network key loaders: the API's (`server/auth.js`, one PEM, no kid) and the site sign-in's (`server/user-auth.js`, which read only the legacy `public_key` field). It also had two user-token checks: a hand-rolled one, and openvibe-shared's `jsonwebtoken` client. All of it now goes through openvibe-sdk/auth (v0.40.0; this PR carries the pin bump from #77).

**Security fix:** the site sign-in (`/me`, `/auth/me`) accepted any Network-signed JWT with the right issuer as a session, including a FedCM assertion, an export token or a service principal token. It now uses `verifyUserToken`, which refuses all of them.

- **One key set:** `createNetworkKeys` over `OV_NETWORK_URL`'s JWKS, the same URL the API used.
  - `startJwksRefresh()` at boot (never in a drill) loads it, retries every 30 s while Network boots, and refreshes every 15 minutes.
  - A rotation is followed on an unknown kid.
  - The API, the internal guard and the site sign-in all read it.
  - The `network_jwks` readiness check is unchanged.
- **Service tokens:** `verifyServiceToken` with Media's pinned contracts. The sandbox rules and every code are as before.
- **The API's user check** uses `verifyUserToken` plus the existing `aud` rule (it must include openvibe.media when present). It still only picks the 403 message on tenant routes.
- **Async:** `tenantAuth` awaits the token checks. The internal guard is async, with errors going to `next`.
- **Tests:**
  - `_setNetworkPublicKeyForTests(pem)` pins a key as before; null goes back to the JWKS.
  - The SSRF inventory drops `server/auth.js`, which no longer makes requests of its own.

Test: `test/network-keys.test.js` runs against a stub Network JWKS. It covers:
- nothing fetched at module load, and the keys loaded at boot;
- a session signs in on the site;
- a FedCM assertion, a realtime ticket, an export token and a service principal are each refused as a session;
- a rogue key is refused;
- after a rotation, both the internal guard and the site sign-in verify the new key after one refetch;
- the guard answers 403 without the capability and 401 for a forged token or a session token.

`ov test` (31 files) is green, plus drill-mode and security-ssrf.

Changes

ddiff --git a/README.md b/README.md
iindex 8442f00..ee8e98a 100644
---- a/README.md
++++ b/README.md
@@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
--- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.38.0 (tokens, events outbox, usage readings, per-actor limits),
++- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.40.0 (tokens, events outbox, usage readings, per-actor limits),
`openvibe-shared` v3.0.0, pinned by release tarball
## Capabilities
@@@ -124,7 +124,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
--(openvibe-shared v3.0.0, openvibe-contracts v0.129.0, openvibe-sdk v0.38.0: pinned release tarballs, installed by npm)
++(openvibe-shared v3.0.0, openvibe-contracts v0.129.0, openvibe-sdk v0.40.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
@@@ -721,6 +721,6 @@ restart; afterwards `sudo ovhost rollback media --to <sha>`.
<!-- 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/STATUS.json b/STATUS.json
iindex acdd982..0c6de44 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"port": 4100,
"domain": "openvibe.media",
"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": [
"media.object.upload",
ddiff --git a/package-lock.json b/package-lock.json
iindex cbe74e8..f80dc45 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"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.0",
"sharp": "^0.33.4"
@@@ -1863,9 +1863,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 b7760b6..6db87fb 100644
---- a/package.json
++++ b/package.json
@@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"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.0",
"sharp": "^0.33.4"
ddiff --git a/server/auth.js b/server/auth.js
iindex 8cd59bc..5bc1a74 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -5,8 +5,10 @@
* 1. App API key — `Authorization: Bearer <app_api_key>` (server-to-server).
* Constant-time hash compare against the `apps` row; a key is ONLY valid
* for its own `:app` path segment.
-- * 2. Network user JWT — RS256, verified OFFLINE against the JWKS public key
-- * fetched from OV_NETWORK_URL at boot (cached, refreshed periodically).
++ * 2. Network user JWT — RS256, verified OFFLINE (openvibe-sdk/auth verifyUserToken) against Network's keys:
++ * createNetworkKeys over OV_NETWORK_URL's JWKS, loaded at boot (retried every 30 s until it loads,
++ * refreshed every 15 minutes, a rotation followed on an unknown kid). The same keys verify service
++ * tokens and the site sign-in (server/user-auth.js).
* Browser endpoints additionally check the Origin header against the
* app's allowed_origins list.
*
@@@ -20,84 +22,45 @@
'use strict';
const crypto = require('crypto');
--const { serviceAuth, capabilities, http } = require('openvibe-contracts');
++const contracts = require('openvibe-contracts');
++const sdkAuth = require('openvibe-sdk/auth');
const db = require('./db/database');
const config = require('./config');
--// ── JWKS → PEM (offline user-JWT verification) ───────────────
++const { capabilities, http } = contracts;
--let _networkPublicKeyPem = null;
--let _jwksTimer = null;
++// ── Network keys (offline user-JWT and service-token verification) ──
--async function fetchNetworkPublicKey() {
-- const url = `${config.network.url}/api/.well-known/jwks`;
-- try {
-- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
-- if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
-- const body = await res.json();
-- const jwk = (body.keys || []).find(k => k.kty === 'RSA') || (body.keys || [])[0];
-- if (jwk) {
-- const keyObj = crypto.createPublicKey({ key: jwk, format: 'jwk' });
-- _networkPublicKeyPem = keyObj.export({ type: 'spki', format: 'pem' });
-- } else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) {
-- // Network's inherited endpoint shape: { public_key: <PEM>, algorithm }
-- _networkPublicKeyPem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
-- } else {
-- throw new Error('JWKS contained no keys');
-- }
-- console.log('[Auth] Network JWKS public key loaded');
-- return _networkPublicKeyPem;
-- } catch (err) {
-- console.warn(`[Auth] Could not fetch JWKS from ${url}: ${err.message} — user-JWT auth unavailable until it loads`);
-- return null;
-- }
--}
++// Created here, fetched only by startJwksRefresh() at boot (never in a drill) or by the first verification.
++let keys = sdkAuth.createNetworkKeys({ network: config.network.url, log: console });
--function startJwksRefresh() {
-- // Until the key loads, retry every 30s — Network may still be booting
-- // (user-JWT auth is down for browsers the whole time the key is missing).
-- const tryLoad = () => fetchNetworkPublicKey().then((pem) => {
-- if (!pem) setTimeout(tryLoad, 30 * 1000).unref?.();
-- }).catch(() => { setTimeout(tryLoad, 30 * 1000).unref?.(); });
-- tryLoad();
-- _jwksTimer = setInterval(() => fetchNetworkPublicKey().catch(() => {}), 6 * 60 * 60 * 1000);
-- if (_jwksTimer.unref) _jwksTimer.unref();
--}
++/** Network's keys, for server/user-auth.js (the site sign-in verifies with the same ones). */
++function networkKeys() { return keys; }
--/** Whether the Network public key has loaded (user JWTs and service tokens verify only once it has). */
--function jwksLoaded() { return !!_networkPublicKeyPem; }
++/** Boot: load the keys now, retry every 30 s while Network boots, refresh every 15 minutes. */
++function startJwksRefresh() { keys.start().catch(() => { /* logged by the JWKS client */ }); }
--function stopJwksRefresh() {
-- if (_jwksTimer) { clearInterval(_jwksTimer); _jwksTimer = null; }
--}
++/** Whether the Network keys have loaded (user JWTs and service tokens verify only once they have). */
++function jwksLoaded() { return keys.loaded(); }
--/** Verify an RS256 user JWT offline. Returns the payload or null. */
--function verifyUserJwt(token) {
-- if (!_networkPublicKeyPem || !token) return null;
-- const parts = String(token).split('.');
-- if (parts.length !== 3) return null;
-- try {
-- const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
-- if (header.alg !== 'RS256') return null;
-- const ok = crypto.verify(
-- 'RSA-SHA256',
-- Buffer.from(`${parts[0]}.${parts[1]}`),
-- _networkPublicKeyPem,
-- Buffer.from(parts[2], 'base64url')
-- );
-- if (!ok) return null;
-- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
-- if (payload.exp && Date.now() / 1000 > payload.exp) return null;
-- if (payload.iss && payload.iss !== config.network.url) return null;
-- // aud must include this service when present.
-- if (payload.aud) {
-- const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
-- if (!aud.includes('openvibe.media')) return null;
-- }
-- return payload;
-- } catch {
-- return null;
++function stopJwksRefresh() { keys.stop(); }
++
++/** One fetch now: the keys, or null. */
++async function fetchNetworkPublicKey() { return await keys.refresh(); }
++
++/**
++ * A Network session token for this service, or null: RS256, issuer, expiry, never a service principal or a typed
++ * token (openvibe-sdk/auth verifyUserToken), and `aud` must include openvibe.media when it is present.
++ */
++async function verifyUserJwt(token) {
++ if (!token) return null;
++ let payload;
++ try { payload = await sdkAuth.verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.url }); } catch { return null; }
++ if (payload.aud) {
++ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
++ if (!aud.includes('openvibe.media')) return null;
}
++ return payload;
}
/**
@@@ -105,14 +68,13 @@ function verifyUserJwt(token) {
* Returns { ok, claims } or { ok: false, code, reason }. `acceptSandbox` is true only on the
* developer-project tenant routes; everywhere else an env=sandbox token is token.sandbox_refused.
*/
--function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {
-- if (!_networkPublicKeyPem) return { ok: false, code: 'token.unavailable', reason: 'Network signing key not loaded yet' };
-- return serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media', acceptSandbox });
++async function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {
++ return await sdkAuth.verifyServiceToken(token, { ...keys.verifyOptions, issuer: config.network.url, audience: 'openvibe.media', contracts, acceptSandbox });
}
/** A Network-issued service token for this service (sandbox tokens refused), or null. */
--function verifyServiceToken(token) {
-- const r = verifyServiceTokenResult(token);
++async function verifyServiceToken(token) {
++ const r = await verifyServiceTokenResult(token);
return r.ok ? r.claims : null;
}
@@@ -320,7 +282,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
const appRoute = project && !!named && PROJECT_ID_RE.test(appId);
let svc = null;
if (token && token.split('.').length === 3) {
-- const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });
++ const r = await verifyServiceTokenResult(token, { acceptSandbox: appRoute });
if (r.ok) svc = r.claims;
else if (r.code === 'token.sandbox_refused') {
return problem(res, 401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)');
@@@ -394,7 +356,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
//
// Answered specifically rather than falling through to a bare 401, so anyone
// who builds against the old shape is told what to do instead.
-- if (allowUser && token && verifyUserJwt(token)) {
++ if (allowUser && token && await verifyUserJwt(token)) {
return res.status(403).json({
error: 'A user JWT cannot identify a user here. Call from your server with '
+ 'your app key and set X-OV-User-Id to the caller\'s id in YOUR user space.',
@@@ -509,7 +471,12 @@ module.exports = {
verifyServiceToken,
verifyServiceTokenResult,
PROJECT_ID_RE,
-- _setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },
++ // Tests pin a key (a PEM) instead of Network's JWKS; null goes back to the JWKS.
++ _setNetworkPublicKeyForTests(pem) {
++ keys.stop();
++ keys = pem ? sdkAuth.createNetworkKeys({ publicKey: pem }) : sdkAuth.createNetworkKeys({ network: config.network.url, log: console });
++ },
++ networkKeys,
tenantCors,
optionalIdentity,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
ddiff --git a/server/service-guard.js b/server/service-guard.js
iindex 91e9e47..0b6248c 100644
---- a/server/service-guard.js
++++ b/server/service-guard.js
@@@ -3,7 +3,7 @@
* OpenVibe.Media as a receiver of Network service tokens (plan T2, X-Internal-Key retirement).
*
* guard(capability) protects an internal route: loopback only (nothing that came through nginx) and a
-- * Network service token holding `capability` for audience openvibe.media, verified with the same key
++ * Network service token holding `capability` for audience openvibe.media, verified with the same keys
* Media verifies user JWTs with (server/auth.js verifyServiceTokenResult; sandbox refused). The token
* is judged on its own: a Bearer is never downgraded to another credential, and nothing else opens the
* route (the X-Internal-Key path is retired).
@@@ -17,16 +17,19 @@ const isLocal = (req) => /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remo
function guard(capability) {
return function mediaServiceGuard(req, res, next) {
++ check(req, res, next).catch(next);
++ };
++ async function check(req, res, next) {
if (viaProxy(req) || !isLocal(req)) return res.status(404).json({ error: 'Not found' });
const bearer = String(req.headers.authorization || '');
if (!bearer.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required' });
-- const r = auth.verifyServiceTokenResult(bearer.slice(7).trim());
++ const r = await auth.verifyServiceTokenResult(bearer.slice(7).trim());
if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason });
const c = capabilities.check(r.claims, capability);
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason });
req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
next();
-- };
++ }
}
module.exports = { guard };
ddiff --git a/server/user-auth.js b/server/user-auth.js
iindex 996b996..62aa9f3 100644
---- a/server/user-auth.js
++++ b/server/user-auth.js
@@@ -35,6 +35,7 @@
const express = require('express');
const crypto = require('crypto');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++const { verifyUserToken } = require('openvibe-sdk/auth');
const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
@@@ -45,59 +46,36 @@ const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
/**
-- * Create the auth client + JWKS fetcher shared by the whole app.
-- * Verification is OFFLINE: we cache the Network's RS256 public key from
-- * GET /api/.well-known/jwks and verify JWTs locally on every request.
++ * Create the auth client shared by the whole app: OAuth (code and refresh grants) through OpenVibeAuthClient, and
++ * OFFLINE session verification with openvibe-sdk/auth verifyUserToken against the Network keys server/auth.js holds
++ * (loaded at boot, never in a drill). A typed token (a FedCM assertion, a realtime ticket, an export token) or a
++ * service principal is never a session here.
*/
function createAuthClient(config) {
const client = new OpenVibeAuthClient({
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
redirectUri: config.oauth.redirectUri,
-- publicKey: null, // filled by ensureKey()
++ publicKey: null, // sessions are verified below, never by this client
authBase: config.networkUrl,
internalBase: config.networkInternalUrl,
});
-- let lastFetch = 0;
-- async function ensureKey() {
-- if (client.publicKey) return client.publicKey;
-- // Don't hammer the Network if it's down — retry at most every 30s
-- if (Date.now() - lastFetch < 30_000) return null;
-- lastFetch = Date.now();
-- for (const base of [config.networkInternalUrl, config.networkUrl]) {
-- if (!base) continue;
-- try {
-- const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
-- if (!res.ok) continue;
-- const jwks = await res.json();
-- if (jwks.public_key) {
-- client.publicKey = jwks.public_key;
-- console.log(`[Auth] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
-- return client.publicKey;
-- }
-- } catch (err) {
-- console.warn(`[Auth] JWKS fetch failed from ${base}: ${err.message}`);
-- }
-- }
-- return null;
-- }
--
/** Offline JWT verification. Returns decoded claims or null. */
async function verify(token) {
if (!token) return null;
-- await ensureKey();
-- const claims = await client.verifyToken(token);
++ let claims;
++ try {
++ claims = await verifyUserToken(String(token), { ...require('./auth').networkKeys().verifyOptions, issuer: config.networkUrl });
++ } catch {
++ return null;
++ }
// Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).
-- if (claims && require('./revocations').isRevoked(claims)) return null;
++ if (require('./revocations').isRevoked(claims)) return null;
return claims;
}
-- // Warm the key cache at boot (non-fatal if the Network is down). A restore drill asks Network
-- // for nothing (MEDIA_DRILL; its /auth routes answer 503).
-- if (!require('./drill').enabled) ensureKey().catch(() => {});
--
-- return { client, ensureKey, verify };
++ return { client, verify };
}
/** Token from Authorization header or the ov_token cookie. */
ddiff --git a/test/network-keys.test.js b/test/network-keys.test.js
nnew file mode 100644
iindex 0000000..1095e05
---- /dev/null
++++ b/test/network-keys.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++// Media's Network keys (server/auth.js, openvibe-sdk/auth createNetworkKeys): the API, the internal guard and the
++// site sign-in (server/user-auth.js) verify with the same keys, loaded from Network's JWKS at boot and following a
++// rotation on an unknown kid. The site sign-in never takes a typed token (a FedCM assertion, a realtime ticket, an
++// export token) or a service principal for a session, whoever signed it.
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++
++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 }));
++});
++
++(async () => {
++ await new Promise((r) => network.listen(0, '127.0.0.1', r));
++ const ISS = `http://127.0.0.1:${network.address().port}`;
++ process.env.OV_NETWORK_URL = ISS;
++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++ const warn = console.warn; const log = console.log;
++ console.warn = () => {}; console.log = () => {};
++ const express = require('express');
++ const auth = require('../server/auth');
++ const userAuth = require('../server/user-auth');
++ const { guard } = require('../server/service-guard');
++
++ 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, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', username: 'x', role: 'user', iss: ISS, aud: ['openvibe.network', 'openvibe.media'], iat: now(), exp: now() + 600, ...extra });
++ const service = (cap) => ({ iss: ISS, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.media'], cap, ns: [], iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });
++ const site = userAuth.createAuthClient({ networkUrl: ISS, networkInternalUrl: ISS, oauth: { clientId: 'media', clientSecret: '', redirectUri: 'https://media.test/auth/callback' } });
++
++ const app = express();
++ app.post('/internal/probe', guard('media.avatar.ingest'), (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`, { method: 'POST', headers: { authorization: `Bearer ${token}` } });
++
++ try {
++ assert.strictEqual(auth.jwksLoaded(), false, 'nothing fetched at module load');
++ auth.startJwksRefresh();
++ for (let i = 0; i < 100 && !auth.jwksLoaded(); i++) await new Promise((r) => setTimeout(r, 10));
++ assert.strictEqual(auth.jwksLoaded(), true, 'loaded at boot');
++
++ const claims = await site.verify(sign(K1, 'k1', session()));
++ assert.ok(claims && claims.subject_id === 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'a session signs in on the site');
++ 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:live', actor_type: 'service' }]]) {
++ assert.strictEqual(await site.verify(sign(K1, 'k1', session(extra))), null, `${what} is not a session on the site`);
++ }
++ assert.strictEqual(await site.verify(sign(ROGUE, 'k1', session())), null, 'a key Network never published');
++
++ const before = fetches;
++ published = [jwkOf(K2, 'k2'), jwkOf(K1, 'k1')];
++ let r = await probe(sign(K2, 'k2', service(['media.avatar.ingest'])));
++ assert.strictEqual(r.status, 200, 'the guard follows a rotation');
++ assert.strictEqual(fetches, before + 1, 'after one refetch');
++ assert.ok(await site.verify(sign(K2, 'k2', session())), 'and so does the site sign-in');
++ r = await probe(sign(K2, 'k2', service([])));
++ assert.strictEqual(r.status, 403);
++ r = await probe(sign(ROGUE, 'k2', service(['media.avatar.ingest'])));
++ assert.strictEqual(r.status, 401, 'a forged service token');
++ r = await probe(sign(K2, 'k2', session()));
++ assert.strictEqual(r.status, 401, 'a session token is not a service token');
++ } finally {
++ auth.stopJwksRefresh();
++ server.close();
++ network.close();
++ console.warn = warn; console.log = log;
++ }
++ console.log('network-keys: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex db9a61b..5f57805 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -29,9 +29,8 @@ const https = require('https');
// ── Inventory: every outbound call in server/, and whose address it uses ──
const OUTBOUND = {
'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
-- 'server/auth.js': 'Network JWKS (OV_NETWORK_URL)',
'server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations',
-- 'server/user-auth.js': 'Network OAuth and JWKS (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL)',
++ 'server/user-auth.js': 'Network OAuth (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL); the signing keys come from openvibe-sdk/auth',
'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',
'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
'server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path',