openvibers/openvibe.sources Commits

Network tokens verify with keys from openvibe-sdk/auth's JWKS client (v0.23.1) in place of Sources' own key store: the last good keys through a Network outage, backoff, a rotation honoured on an unknown kid. Every service-token rule stays openvibe-contracts' verifyServiceToken (claims schema, sandbox refused, issuer, audience, expiry), tried with the key the token names, else each key. Nothing public names the internal JWKS URL or the fetch error: a 503 says 'signing key not loaded yet' (the real error is logged), and /api/ready's network_jwks (still optional) reports counts and times only. OV_NETWORK_PUBLIC_KEY is gone (unset in production). Tests: a stub JWKS, an outage with cached keys, an unreachable JWKS without leaks. npm test and test:pg green.

OP OpenVibers committed 29 Sept 2026 · 4bea21fb84 · 13 files, +186 −91


Changes

ddiff --git a/.env.example b/.env.example
iindex 5d8606d..4da3a05 100644
---- a/.env.example
++++ b/.env.example
@@@ -15,7 +15,9 @@ NODE_ENV=development
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#OV_NETWORK_ISSUER=https://openvibe.network
--#OV_NETWORK_PUBLIC_KEY=
++# The JWKS the service verifies tokens against; by default Network's internal JWKS
++# (OV_NETWORK_INTERNAL_URL/api/.well-known/jwks). openvibe-sdk/auth caches it and keeps it fresh.
++#OV_NETWORK_JWKS_URL=https://openvibe.network/api/.well-known/jwks
# Per-actor limits (server/api/actor-limits.js): API reads one app or module (app:…, mod:…) may make per
# minute and per hour; past them 429 rate_limited with Retry-After. First-party services (svc:…) are not
ddiff --git a/deploy/systemd/openvibe-sources.service b/deploy/systemd/openvibe-sources.service
iindex 12d17f1..dbd7687 100644
---- a/deploy/systemd/openvibe-sources.service
++++ b/deploy/systemd/openvibe-sources.service
@@@ -1,7 +1,8 @@
[Unit]
Description=OpenVibe.Sources — source registry and ingestion workers (RSS/Atom, sitemaps, JSON-LD, APIs)
--# Network (openvibe-network) signs the tokens this service verifies. Sources starts without it
--# and keeps retrying the JWKS fetch; /api/ready stays 503 until the key has loaded.
++# Network (openvibe-network) signs the tokens this service verifies. Sources starts without it:
++# the SDK's JWKS client keeps the last good keys and backs off, and /api/ready degrades (200) while
++# no key has loaded.
After=network.target postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
ddiff --git a/package-lock.json b/package-lock.json
iindex 6cb4ad8..e098544 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
"pg": "^8.23.0"
},
@@@ -1059,9 +1059,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.20.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
-- "integrity": "sha512-3Zwz8sJhWhnuw9GYr6ieIDp9CIl6ug0QqJE5ifDsk4ckrethBjcDzjadE1GSCGQDFXXLf/+bVWtI7X3VOVXmFQ==",
++ "version": "0.23.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
++ "integrity": "sha512-6u1Ym8u+nSKyq+uJjel4/3QDQ9HOjpQzFa8oMasorXQhgHUlDEQ7X6F6Xf1zZTuuVdMVkxOeqJqOnQbraktosg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 5b63f14..1031316 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
"pg": "^8.23.0"
},
ddiff --git a/server/app.js b/server/app.js
iindex 787210a..212219d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -63,7 +63,7 @@ Source code: <a href="https://github.com/OpenVibers/OpenVibe.Sources">OpenVibers
</html>
`;
--function createApp({ config, db, registry, items, ingest, scheduler, auth, keys, outbox, relay, now, log = console, limitsNow = null }) {
++function createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, relay, now, log = console, limitsNow = null }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
@@@ -88,7 +88,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
// Readiness (openvibe-shared/ready): 503 only when the database fails; the Network key and the
// fetcher (worker running, queue keeping up) are optional and degrade it (see observability.js).
-- const readiness = createSourcesReadiness({ db, keys, config, registry, ingest, scheduler, outbox, relay, now, release: release.release });
++ const readiness = createSourcesReadiness({ db, config, registry, ingest, scheduler, outbox, relay, now, release: release.release });
app.get('/api/ready', readiness.handler);
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
ddiff --git a/server/auth.js b/server/auth.js
iindex 4538609..2f9773a 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -3,14 +3,15 @@
* Service-token authentication and capability checks. Every API route is for services (other
* OpenVibe services, or the Network admin surface acting through its own principal): an
* RS256 client-credentials token from OpenVibe.Network (audience openvibe.sources), verified
-- * offline, and ONE capability per route.
++ * offline against Network's JWKS (openvibe-sdk/auth, one client per URL, kept fresh and served
++ * through an outage), and ONE capability per route.
*
* The sources.* capabilities are proposed in docs/capabilities-proposal/ and are not in
* openvibe-contracts v0.7.0 yet; checkCapability() decides them with the library's own grant rule
* (exact id or a `family.*` grant) until a release knows them, then the library decides.
*/
--const crypto = require('crypto');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
++const { jwksClient } = require('openvibe-sdk/auth');
const CAPS = Object.freeze({
read: 'sources.source.read',
@@@ -19,59 +20,6 @@ const CAPS = Object.freeze({
});
const PROPOSED = new Set(Object.values(CAPS));
--function createKeyStore({ urls = [], pem = null, fetchImpl = globalThis.fetch, log = console } = {}) {
-- let key = pem ? toPem(pem) : null;
-- let retryTimer = null;
-- let refreshTimer = null;
--
-- function toPem(value) {
-- return crypto.createPublicKey(value).export({ type: 'spki', format: 'pem' });
-- }
--
-- async function fetchOnce() {
-- for (const base of urls) {
-- if (!base) continue;
-- const url = `${base}/api/.well-known/jwks`;
-- try {
-- const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
-- if (!res.ok) throw new Error(`HTTP ${res.status}`);
-- const body = await res.json();
-- const jwk = (body.keys || []).find(k => k.kty === 'RSA');
-- if (jwk) key = toPem({ key: jwk, format: 'jwk' });
-- else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) key = toPem(body.public_key);
-- else throw new Error('no RSA key in response');
-- log.log(`[auth] Network public key loaded from ${base}`);
-- return key;
-- } catch (err) {
-- log.warn(`[auth] key fetch from ${url} failed: ${err.message}`);
-- }
-- }
-- return null;
-- }
--
-- async function start() {
-- if (pem) return Promise.resolve(key);
-- const attempt = async () => {
-- const k = await fetchOnce();
-- if (!k && !key) {
-- retryTimer = setTimeout(attempt, 30 * 1000);
-- retryTimer.unref?.();
-- }
-- return k;
-- };
-- refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);
-- refreshTimer.unref?.();
-- return await attempt();
-- }
--
-- function stop() {
-- clearTimeout(retryTimer);
-- clearInterval(refreshTimer);
-- }
--
-- return { get: () => key, loaded: () => Boolean(key), start, stop };
--}
--
function checkCapability(claims, capabilityId) {
if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
return capabilities.grants(claims && claims.cap, capabilityId)
@@@ -81,24 +29,46 @@ function checkCapability(claims, capabilityId) {
return capabilities.check(claims, capabilityId);
}
--function createAuth({ config, keys }) {
++function createAuth({ config, log = console }) {
++ const headerKid = (token) => { try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; } };
++
++ /** { ok, claims } or { ok: false, code, reason }. The SDK's error names the internal JWKS URL: logged, never answered. */
++ async function verifyService(token) {
++ const kid = headerKid(token);
++ let keys;
++ try { keys = await jwksClient(config.jwksUrl, { log }).keysForKid(kid); } catch (err) {
++ log.error(`[auth] Network keys unavailable: ${(err && err.message) || err}`);
++ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
++ }
++ const byKid = kid ? keys.filter((k) => k.kid === kid) : [];
++ let last = { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
++ for (const k of byKid.length ? byKid : keys) {
++ last = serviceAuth.verifyServiceToken(token, { publicKey: k.key, issuer: config.issuer, audience: config.audience });
++ if (last.ok || last.code !== 'token.bad_signature') return last;
++ }
++ return last;
++ }
++
/** Express guard: one capability. Sets req.principal = { sub, cap, jti }. */
function requireCap(id) {
-- return function capGuard(req, res, next) {
++ return async function capGuard(req, res, next) {
const ctx = req.ov;
const h = String(req.headers.authorization || '');
if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
-- const publicKey = keys.get();
-- if (!publicKey) return http.sendProblem(res, 503, 'token.unavailable', { detail: 'signing key not loaded yet', ctx });
-- const r = serviceAuth.verifyServiceToken(h.slice(7).trim(), { publicKey, issuer: config.issuer, audience: config.audience });
-- if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
-- const c = checkCapability(r.claims, id);
++ // The keys come from the SDK's JWKS client (one per URL, started at boot: the last good keys through a
++ // Network outage, a rotation honoured on an unknown kid); every token rule is openvibe-contracts'
++ // verifyServiceToken (identity.service-token-claims@1, sandbox refused, issuer, audience, expiry).
++ let r;
++ try { r = await verifyService(h.slice(7).trim()); } catch (err) { return next(err); }
++ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
++ const claims = r.claims;
++ const c = checkCapability(claims, id);
if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
-- req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
++ req.principal = { sub: claims.sub, cap: claims.cap, jti: claims.jti };
return next();
};
}
return { requireCap };
}
--module.exports = { CAPS, PROPOSED, createKeyStore, createAuth, checkCapability };
++module.exports = { CAPS, PROPOSED, createAuth, checkCapability };
ddiff --git a/server/config.js b/server/config.js
iindex d2de3e4..72e49e0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -20,6 +20,11 @@ function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 4720);
++ const networkUrl = strip(env.OV_NETWORK_URL || 'https://openvibe.network');
++ const networkInternalUrl = strip(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000');
++ // The JWKS the SDK's client (openvibe-sdk/auth jwksClient) fetches, caches and keeps fresh; one URL,
++ // since the SDK keeps one client per URL. Defaults to Network's internal JWKS.
++ const jwksUrl = strip(env.OV_NETWORK_JWKS_URL || `${networkInternalUrl}/api/.well-known/jwks`);
return {
port,
host: env.HOST || '127.0.0.1',
@@@ -28,10 +33,10 @@ function load(env = process.env) {
serviceId: 'sources',
baseUrl: strip(env.BASE_URL || (isProduction ? 'https://sources.openvibe.network' : `http://localhost:${port}`)),
-- networkUrl: strip(env.OV_NETWORK_URL || 'https://openvibe.network'),
-- networkInternalUrl: strip(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkUrl,
++ networkInternalUrl,
++ jwksUrl,
issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
-- networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
audience: 'openvibe.sources',
// Per-actor limits (server/api/actor-limits.js, roadmap WS-R task 4): the API reads one app or
// module may make per minute and per hour. Writes set their own numbers there.
ddiff --git a/server/index.js b/server/index.js
iindex 7724190..5dca7a8 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -18,7 +18,8 @@ const { createRobots } = require('./robots');
const { createIngest } = require('./ingest');
const { createScheduler } = require('./scheduler');
const { createOutbox, createRelay } = require('./events/outbox');
--const { createKeyStore, createAuth } = require('./auth');
++const { jwksClient } = require('openvibe-sdk/auth');
++const { createAuth } = require('./auth');
const { createApp } = require('./app');
async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, lookupImpl, log = console, listen = true, limitsNow = null } = {}) {
@@@ -42,11 +43,13 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
});
const ingest = createIngest({ db, config, registry, items, robots, fetcher, spacer, outbox, now, log, relay });
const scheduler = createScheduler({ db, ingest, config, now, log });
-- const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
-- const auth = createAuth({ config, keys });
-- const app = createApp({ config, db, registry, items, ingest, scheduler, auth, keys, outbox, relay, now, log, limitsNow });
--
-- const keyLoaded = keys.start().catch(() => null);
++ const keys = jwksClient(config.jwksUrl, { fetch: fetchImpl, log });
++ const auth = createAuth({ config, log });
++ const app = createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, relay, now, log, limitsNow });
++ // One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background
++ // on an unref'd timer, keeping the last good keys through a Network outage.
++ keys.start();
++ const keyLoaded = keys.keys().catch(() => null);
relay.start();
if (config.worker.enabled) scheduler.start();
const pruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
ddiff --git a/server/observability.js b/server/observability.js
iindex 2b83647..5a54caa 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -4,9 +4,9 @@
* (openvibe-shared/ready and openvibe-shared/metrics).
*
* db required a real query on the source registry: without it nothing is served or fetched
-- * network_jwks optional the Network signing key has loaded. Without it no service token can be
-- * verified (the API answers 503), but the fetcher keeps ingesting, so it
-- * degrades rather than fails
++ * network_jwks optional the Network signing keys have loaded into the SDK's JWKS client. Without them
++ * no service token can be verified (the API answers 503), but the fetcher keeps
++ * ingesting, so it degrades rather than fails
* fetcher optional the ingestion worker is on and running, and its queue keeps up: no enabled
* source has waited longer than OVERDUE_MS. Without it the API still
* serves what was fetched, but nothing new arrives
@@@ -15,6 +15,7 @@
* flight), the time of the last finished fetch and of the last successful one, and the outbox backlog.
*/
const { createReadiness } = require('openvibe-shared/ready');
++const { jwksStatus } = require('openvibe-sdk/auth');
const OVERDUE_MS = 15 * 60 * 1000;
const STATUSES = ['healthy', 'stale', 'failing', 'never_fetched', 'disabled', 'manual'];
@@@ -39,7 +40,7 @@ function readers(db, { startedAt }) {
};
}
--function createSourcesReadiness({ db, keys, config, registry, ingest, scheduler, outbox, relay, now, release = null }) {
++function createSourcesReadiness({ db, config, registry, ingest, scheduler, outbox, relay, now, release = null }) {
const read = readers(db, { startedAt: now() });
const iso = (v) => (v == null ? null : new Date(v).toISOString());
return createReadiness({
@@@ -48,7 +49,16 @@ function createSourcesReadiness({ db, keys, config, registry, ingest, scheduler,
checks: [
// A real round trip that names the store (postgresql / pglite), and the schema present.
{ name: 'db', required: true, check: async () => { const r = await db.ready(); if (!r.ok) return r.error; await db.prepare('SELECT COUNT(*) AS n FROM sources').get(); return { ok: true, detail: r.detail }; } },
-- { name: 'network_jwks', required: false, check: () => keys.loaded() || 'Network signing key not loaded yet: no service token can be verified' },
++ { name: 'network_jwks', required: false, check: () => {
++ // The SDK's client for the JWKS this service verifies against (server/index.js started it).
++ // Public: counts and times only — never the internal JWKS URL and never the fetch error
++ // behind it (both are logged by the SDK client, not answered).
++ const st = jwksStatus().find((s) => s.url === config.jwksUrl) || null;
++ if (!st) return 'the Network JWKS client has not started: no service token can be verified';
++ const detail = { keys: st.keys || 0, stale: Boolean(st.stale), failures: st.failures || 0, fetchedAt: iso(st.fetchedAt) };
++ if (!st.ready) return { ok: false, error: 'Network signing key not loaded yet: no service token can be verified', detail };
++ return { ok: true, detail };
++ } },
{
name: 'fetcher', required: false,
check: async () => {
ddiff --git a/test/auth-jwks.test.js b/test/auth-jwks.test.js
nnew file mode 100644
iindex 0000000..156d51b
---- /dev/null
++++ b/test/auth-jwks.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * Service tokens are verified against Network's JWKS through openvibe-sdk/auth (plan T0/T1): the SDK
++ * client fetches the keys, keeps them cached and keeps the last good ones through a JWKS outage, and
++ * /api/ready reports its state. The stub JWKS helpers.boot starts serves the generated signing key;
++ * nothing here touches the internet.
++ *
++ * node test/auth-jwks.test.js
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, serviceToken, request, suite } = require('./helpers');
++
++const t = suite('auth-jwks');
++const READER = serviceToken('news', ['sources.source.read']);
++
++t('a token signed by a key from the stub JWKS verifies; one signed by another key does not', async () => {
++ const svc = await boot();
++ try {
++ await svc.keyLoaded;
++ const ok = await request(svc.base, 'GET', '/api/v1/sources', { token: READER });
++ assert.strictEqual(ok.status, 200, ok.text);
++
++ const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const forged = serviceToken('news', ['sources.source.read'], { key: other.privateKey });
++ const bad = await request(svc.base, 'GET', '/api/v1/sources', { token: forged });
++ assert.strictEqual(bad.status, 401, bad.text);
++ assert.strictEqual(bad.body.code, 'token.bad_signature');
++ } finally { await svc.stop(); }
++});
++
++t('with keys cached, a JWKS outage still verifies a token (the last good keys are served)', async () => {
++ const svc = await boot();
++ try {
++ await svc.keyLoaded;
++ await svc.jwksSite.close(); // Network is gone
++ const r = await request(svc.base, 'GET', '/api/v1/sources', { token: serviceToken('news', ['sources.source.read']) });
++ assert.strictEqual(r.status, 200, r.text);
++ // Readiness still reports the cached keys rather than a dead service.
++ const ready = await request(svc.base, 'GET', '/api/ready');
++ assert.strictEqual(ready.body.checks.network_jwks.status, 'ok', ready.text);
++ } finally { await svc.stop(); }
++});
++
++t('/api/ready carries the JWKS client state: ok with the keys, and degraded (200) while none has loaded', async () => {
++ const svc = await boot();
++ try {
++ await svc.keyLoaded;
++ const r = await request(svc.base, 'GET', '/api/ready');
++ assert.strictEqual(r.status, 200, r.text);
++ const c = r.body.checks.network_jwks;
++ assert.strictEqual(c.required, false);
++ assert.strictEqual(c.status, 'ok');
++ // Counts and times only: the internal JWKS URL and the fetch error are never public.
++ assert.strictEqual(c.detail.url, undefined);
++ assert.strictEqual(c.detail.lastError, undefined);
++ assert.ok(c.detail.keys >= 1, JSON.stringify(c.detail));
++ assert.ok(c.detail.fetchedAt, JSON.stringify(c.detail));
++ } finally { await svc.stop(); }
++
++ // An unreachable JWKS: no keys, so the check degrades — the service still answers /api/ready 200.
++ const dead = await boot({ env: { OV_NETWORK_JWKS_URL: 'http://127.0.0.1:9/api/.well-known/jwks' } });
++ try {
++ await dead.keyLoaded;
++ const r = await request(dead.base, 'GET', '/api/ready');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.checks.network_jwks.status, 'fail');
++ assert.ok(r.body.degraded.includes('network_jwks'), r.text);
++ } finally { await dead.stop(); }
++});
++
++t('an unreachable JWKS answers 503 without naming the URL or the fetch error — and neither does /api/ready', async () => {
++ const jwksUrl = 'http://127.0.0.1:9/api/.well-known/jwks';
++ const svc = await boot({ env: { OV_NETWORK_JWKS_URL: jwksUrl } });
++ try {
++ await svc.keyLoaded;
++ const r = await request(svc.base, 'GET', '/api/v1/sources', { token: serviceToken('news', ['sources.source.read']) });
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.body.code, 'token.unavailable', r.text);
++ assert.strictEqual(r.body.detail, 'signing key not loaded yet', r.text);
++ for (const leak of ['127.0.0.1:9', 'ECONNREFUSED', 'fetch failed', 'jwks']) {
++ assert.ok(!r.text.includes(leak), `the 503 body names ${leak}: ${r.text}`);
++ }
++
++ // Readiness degrades (200, the check is optional) and says nothing about the URL or the error.
++ const ready = await request(svc.base, 'GET', '/api/ready');
++ assert.strictEqual(ready.status, 200, ready.text);
++ const c = ready.body.checks.network_jwks;
++ assert.strictEqual(c.status, 'fail');
++ assert.ok(ready.body.degraded.includes('network_jwks'), ready.text);
++ for (const leak of ['127.0.0.1:9', 'ECONNREFUSED', 'fetch failed', '/api/.well-known/jwks']) {
++ assert.ok(!ready.text.includes(leak), `/api/ready names ${leak}: ${ready.text}`);
++ }
++ // It still reports the counts (0 keys, a growing failure count) — that is all it is allowed to say.
++ assert.strictEqual(c.detail.keys, 0, ready.text);
++ assert.ok(c.detail.failures >= 1, ready.text);
++ } finally { await svc.stop(); }
++});
++
++t.run();
ddiff --git a/test/helpers.js b/test/helpers.js
iindex f965f32..26bdaee 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -18,6 +18,7 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
++const publicJwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
@@@ -34,10 +35,13 @@ function serviceToken(slug, cap, { aud = 'openvibe.sources', exp = Math.floor(Da
* log: Sources' logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
*/
async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, log = silent, limitsNow = null } = {}) {
++ // A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
++ // to fetch and verify against (no test touches the internet). Its URL is overridable via env.
++ const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
const config = load({
NODE_ENV: 'test',
PORT: '0',
-- OV_NETWORK_PUBLIC_KEY: publicKey,
++ OV_NETWORK_JWKS_URL: `${jwksSite.origin}/api/.well-known/jwks`,
SOURCES_WORKER: worker,
SOURCES_ALLOW_PRIVATE_HOSTS: '127.0.0.1',
SOURCES_HOST_MIN_INTERVAL_MS: '0',
@@@ -49,7 +53,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
const testdb = await require('./db').testDb();
const h = await start({ config, db: testdb.db, log, lookupImpl, tokenClient, limitsNow, ...(now ? { now } : {}) });
const base = `http://127.0.0.1:${h.server.address().port}`;
-- return { ...h, base, async stop() { await h.close(); await testdb.close(); } };
++ return { ...h, base, jwksSite, async stop() { await h.close(); await testdb.close(); await jwksSite.close(); } };
}
async function request(base, method, p, { token, body, headers = {} } = {}) {
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex 8ec4a78..57a1125 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -102,6 +102,7 @@ t('worker on: ready; a fetch queue that falls behind, or a stopped worker, degra
const clock = { offset: 0 };
// No run slot, so nothing due is ever started; the clock moves forward by hand.
svc = await boot({ worker: 'on', env: { SOURCES_MAX_CONCURRENT: '0' }, now: () => Date.now() + clock.offset });
++ await svc.keyLoaded;
let r = await request(svc.base, 'GET', '/api/ready');
assert.strictEqual(r.body.status, 'ready', r.text);
assert.strictEqual(r.body.checks.fetcher.detail.running, true);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 06ea9a5..8576946 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -151,7 +151,6 @@ t('a source registered over the API with internal endpoints is stored, and its f
t('ratchet: every file that makes an outbound request itself is reviewed', () => {
const REVIEWED = {
'server/net/fetcher.js': 'every source fetch; the guard (checkUrl up front, lookup at connect, on every redirect hop)',
-- 'server/auth.js': 'Network JWKS / token endpoint (configured)',
'server/events/outbox.js': 'OpenVibe.Events (configured)',
};
const root = path.join(__dirname, '..');