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.
Changes
@@@ -15,7 +15,9 @@ NODE_ENV=developmentOV_NETWORK_URL=https://openvibe.networkOV_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@@@ -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.serviceWants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service@@@ -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"@@@ -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"},@@@ -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 });@@@ -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 };@@@ -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.@@@ -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);@@@ -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 () => {nnew file mode 100644@@@ -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();@@@ -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, loconst 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 = {} } = {}) {@@@ -102,6 +102,7 @@ t('worker on: ready; a fetch queue that falls behind, or a stopped worker, degraconst 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);@@@ -151,7 +151,6 @@ t('a source registered over the API with internal endpoints is stored, and its ft('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, '..');
@@ -15,7 +15,9 @@ NODE_ENV=development
@@ -15,7 +15,9 @@ NODE_ENV=development
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#OV_NETWORK_ISSUER=https://openvibe.network
#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
# 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
# minute and per hour; past them 429 rate_limited with Retry-After. First-party services (svc:…) are not
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
[Unit]
[Unit]
Description=OpenVibe.Sources — source registry and ingestion workers (RSS/Atom, sitemaps, JSON-LD, APIs)
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
After=network.target postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1059,9 +1059,9 @@
@@ -1059,9 +1059,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -63,7 +63,7 @@ Source code: <a href="https://github.com/OpenVibers/OpenVibe.Sources">OpenVibers
@@ -63,7 +63,7 @@ Source code: <a href="https://github.com/OpenVibers/OpenVibe.Sources">OpenVibers
</html>
</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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -88,7 +88,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
@@ -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
// 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).
// 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);
app.get('/api/ready', readiness.handler);
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
@@ -3,14 +3,15 @@
@@ -3,14 +3,15 @@
* Service-token authentication and capability checks. Every API route is for services (other
* 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
* OpenVibe services, or the Network admin surface acting through its own principal): an
* RS256 client-credentials token from OpenVibe.Network (audience openvibe.sources), verified
* 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
* 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
* 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.
* (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 { serviceAuth, capabilities, http } = require('openvibe-contracts');
+const { jwksClient } = require('openvibe-sdk/auth');
const CAPS = Object.freeze({
const CAPS = Object.freeze({
read: 'sources.source.read',
read: 'sources.source.read',
@@ -19,59 +20,6 @@ const CAPS = Object.freeze({
@@ -19,59 +20,6 @@ const CAPS = Object.freeze({
});
});
const PROPOSED = new Set(Object.values(CAPS));
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) {
function checkCapability(claims, capabilityId) {
if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
return capabilities.grants(claims && claims.cap, capabilityId)
return capabilities.grants(claims && claims.cap, capabilityId)
@@ -81,24 +29,46 @@ function checkCapability(claims, capabilityId) {
@@ -81,24 +29,46 @@ function checkCapability(claims, capabilityId) {
return capabilities.check(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 }. */
/** Express guard: one capability. Sets req.principal = { sub, cap, jti }. */
function requireCap(id) {
function requireCap(id) {
- return function capGuard(req, res, next) {
+ return async function capGuard(req, res, next) {
const ctx = req.ov;
const ctx = req.ov;
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
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 });
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 next();
};
};
}
}
return { requireCap };
return { requireCap };
}
}
-module.exports = { CAPS, PROPOSED, createKeyStore, createAuth, checkCapability };
+module.exports = { CAPS, PROPOSED, createAuth, checkCapability };
@@ -20,6 +20,11 @@ function load(env = process.env) {
@@ -20,6 +20,11 @@ function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 4720);
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 {
return {
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
@@ -28,10 +33,10 @@ function load(env = process.env) {
@@ -28,10 +33,10 @@ function load(env = process.env) {
serviceId: 'sources',
serviceId: 'sources',
baseUrl: strip(env.BASE_URL || (isProduction ? 'https://sources.openvibe.network' : `http://localhost:${port}`)),
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'),
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',
audience: 'openvibe.sources',
// Per-actor limits (server/api/actor-limits.js, roadmap WS-R task 4): the API reads one app or
// 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.
// module may make per minute and per hour. Writes set their own numbers there.
@@ -18,7 +18,8 @@ const { createRobots } = require('./robots');
@@ -18,7 +18,8 @@ const { createRobots } = require('./robots');
const { createIngest } = require('./ingest');
const { createIngest } = require('./ingest');
const { createScheduler } = require('./scheduler');
const { createScheduler } = require('./scheduler');
const { createOutbox, createRelay } = require('./events/outbox');
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');
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 } = {}) {
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
@@ -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 ingest = createIngest({ db, config, registry, items, robots, fetcher, spacer, outbox, now, log, relay });
const scheduler = createScheduler({ db, ingest, config, now, log });
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();
relay.start();
if (config.worker.enabled) scheduler.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);
const pruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
@@ -4,9 +4,9 @@
@@ -4,9 +4,9 @@
* (openvibe-shared/ready and openvibe-shared/metrics).
* (openvibe-shared/ready and openvibe-shared/metrics).
*
*
* db required a real query on the source registry: without it nothing is served or fetched
* 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
* 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
* source has waited longer than OVERDUE_MS. Without it the API still
* serves what was fetched, but nothing new arrives
* serves what was fetched, but nothing new arrives
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* flight), the time of the last finished fetch and of the last successful one, and the outbox backlog.
* 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 { createReadiness } = require('openvibe-shared/ready');
+const { jwksStatus } = require('openvibe-sdk/auth');
const OVERDUE_MS = 15 * 60 * 1000;
const OVERDUE_MS = 15 * 60 * 1000;
const STATUSES = ['healthy', 'stale', 'failing', 'never_fetched', 'disabled', 'manual'];
const STATUSES = ['healthy', 'stale', 'failing', 'never_fetched', 'disabled', 'manual'];
@@ -39,7 +40,7 @@ function readers(db, { startedAt }) {
@@ -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 read = readers(db, { startedAt: now() });
const iso = (v) => (v == null ? null : new Date(v).toISOString());
const iso = (v) => (v == null ? null : new Date(v).toISOString());
return createReadiness({
return createReadiness({
@@ -48,7 +49,16 @@ function createSourcesReadiness({ db, keys, config, registry, ingest, scheduler,
@@ -48,7 +49,16 @@ function createSourcesReadiness({ db, keys, config, registry, ingest, scheduler,
checks: [
checks: [
// A real round trip that names the store (postgresql / pglite), and the schema present.
// 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: '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,
name: 'fetcher', required: false,
check: async () => {
check: async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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();
@@ -18,6 +18,7 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
@@ -18,6 +18,7 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', 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); } };
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
@@ -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).
* 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 } = {}) {
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({
const config = load({
NODE_ENV: 'test',
NODE_ENV: 'test',
PORT: '0',
PORT: '0',
- OV_NETWORK_PUBLIC_KEY: publicKey,
+ OV_NETWORK_JWKS_URL: `${jwksSite.origin}/api/.well-known/jwks`,
SOURCES_WORKER: worker,
SOURCES_WORKER: worker,
SOURCES_ALLOW_PRIVATE_HOSTS: '127.0.0.1',
SOURCES_ALLOW_PRIVATE_HOSTS: '127.0.0.1',
SOURCES_HOST_MIN_INTERVAL_MS: '0',
SOURCES_HOST_MIN_INTERVAL_MS: '0',
@@ -49,7 +53,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
@@ -49,7 +53,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const h = await start({ config, db: testdb.db, log, lookupImpl, tokenClient, limitsNow, ...(now ? { now } : {}) });
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}`;
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 = {} } = {}) {
async function request(base, method, p, { token, body, headers = {} } = {}) {
@@ -102,6 +102,7 @@ t('worker on: ready; a fetch queue that falls behind, or a stopped worker, degra
@@ -102,6 +102,7 @@ t('worker on: ready; a fetch queue that falls behind, or a stopped worker, degra
const clock = { offset: 0 };
const clock = { offset: 0 };
// No run slot, so nothing due is ever started; the clock moves forward by hand.
// 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 });
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');
let r = await request(svc.base, 'GET', '/api/ready');
assert.strictEqual(r.body.status, 'ready', r.text);
assert.strictEqual(r.body.status, 'ready', r.text);
assert.strictEqual(r.body.checks.fetcher.detail.running, true);
assert.strictEqual(r.body.checks.fetcher.detail.running, true);
@@ -151,7 +151,6 @@ t('a source registered over the API with internal endpoints is stored, and its f
@@ -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', () => {
t('ratchet: every file that makes an outbound request itself is reviewed', () => {
const REVIEWED = {
const REVIEWED = {
'server/net/fetcher.js': 'every source fetch; the guard (checkUrl up front, lookup at connect, on every redirect hop)',
'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)',
'server/events/outbox.js': 'OpenVibe.Events (configured)',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');