openvibers/openvibe.search Commits

Network tokens verify with keys from openvibe-sdk/auth's JWKS client (v0.23.1) in place of the hand-written key store: fresh keys, the last good ones through a Network outage, backoff, and a rotation honoured on an unknown kid. Service tokens keep every openvibe-contracts verifyServiceToken rule (the key a token names first, else each key until one is not a bad signature); browsers' user JWTs verify with the SDK's verifyUserToken (principals refused as users, as before). Nothing public names the internal JWKS URL or the fetch error: a write's 503 says 'signing key not loaded yet' 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 and a rotated kid, and a JWKS that never loads (degraded, no leak in /api/ready or the 503). npm test 14/14, test:pg green.

OP OpenVibers committed 29 Sept 2026 · a3235a5110 · 14 files, +207 −135


Changes

ddiff --git a/.env.example b/.env.example
iindex be8a1ec..488a902 100644
---- a/.env.example
++++ b/.env.example
@@@ -12,13 +12,12 @@ NODE_ENV=development
#BASE_URL=https://search.openvibe.network
# Identity — OpenVibe.Network signs service tokens (client_credentials, audience
--# openvibe.search) and user JWTs. The public key is fetched from
--# <url>/api/.well-known/jwks (internal URL first), retried every 30 s until it loads.
++# openvibe.search) and user JWTs. The keys come from the SDK's JWKS client
++# (openvibe-sdk/auth) at <OV_NETWORK_INTERNAL_URL>/api/.well-known/jwks: one
++# refresher, last good keys through an outage, a rotation honoured at once.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#OV_NETWORK_ISSUER=https://openvibe.network
--# Pin the key instead of fetching it (PEM; \n escapes allowed).
--#OV_NETWORK_PUBLIC_KEY=
# A browser's user JWT is accepted when its aud contains one of these.
#SEARCH_USER_AUDIENCES=openvibe.search,openvibe.network
ddiff --git a/README.md b/README.md
iindex 4ad0d5e..93434d7 100644
---- a/README.md
++++ b/README.md
@@@ -268,7 +268,7 @@ readiness, identical query answers, row counts; see OpenVibe.Host `docs/restore-
Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
--- **Auth.** Tokens are RS256 JWTs signed by OpenVibe.Network (its JWKS, or `OV_NETWORK_PUBLIC_KEY`):
++- **Auth.** Tokens are RS256 JWTs signed by OpenVibe.Network (verified through the SDK's JWKS client at `/api/.well-known/jwks`):
service tokens need audience `openvibe.search` and the capability for the route, and an owner may
only write documents under its own service slug. A Bearer that does not verify is a 401, never
anonymous. Delegation headers (`X-OV-Subject`, `X-OV-Groups`, `X-OV-Entitlements`) count only with a
ddiff --git a/package-lock.json b/package-lock.json
iindex 247121a..8093f5a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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.0",
++ "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"
},
@@@ -1040,9 +1040,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.20.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
-- "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==",
++ "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 9bcdd44..86cd490 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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.0",
++ "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/api/query.js b/server/api/query.js
iindex 9f3a0f9..98da735 100644
---- a/server/api/query.js
++++ b/server/api/query.js
@@@ -172,7 +172,7 @@ function withViewer(auth, handler, limit = null) {
const ctx = req.ov;
let viewer;
try {
-- viewer = auth.viewer(req);
++ viewer = await auth.viewer(req);
} catch (err) {
if (err instanceof AuthError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx });
return next(err);
ddiff --git a/server/app.js b/server/app.js
iindex f05c5d2..22a0b03 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -14,7 +14,7 @@ const { webhookRouter } = require('./api/webhook');
const { createLimits } = require('./actor-limits');
const pkg = require('../package.json');
--function createApp({ config, db, store, engine, auth, keys, outbox, relay, purges, purger, saved, log = console, now }) {
++function createApp({ config, db, store, engine, auth, outbox, relay, purges, purger, saved, log = console, now }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
@@@ -45,7 +45,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, purge
// Readiness (openvibe-shared/ready): 503 only when the database (documents and full-text tables)
// fails; the Network key and index consistency are optional and degrade it (see observability.js).
-- const readiness = createSearchReadiness({ db, keys, config, engine, store, outbox, relay, purges, purger, saved, release: release.release });
++ const readiness = createSearchReadiness({ db, config, engine, store, outbox, relay, purges, purger, saved, 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 2ea8b94..c33dd82 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -4,14 +4,19 @@
*
* - Service principals: RS256 client-credentials tokens from OpenVibe.Network (audience
* openvibe.search), verified offline with openvibe-contracts serviceAuth.verifyServiceToken.
-- * - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer.
++ * - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer, verified with the
++ * SDK's verifyUserToken.
++ *
++ * The verification keys come from the SDK's JWKS client (openvibe-sdk/auth): one refresher per URL
++ * that keeps the last good keys through an outage, honours a rotation at once and backs off on
++ * failure. This service no longer fetches or caches the JWKS itself.
*
* search.document.write and search.query.delegate are proposed in docs/capabilities-proposal/ and
* are not in openvibe-contracts yet. checkCapability() decides them with the library's own grant
* rule (exact id or a `family.*` grant) until a contracts release knows them; from then on the
* library decides and nothing changes here.
*/
--const crypto = require('crypto');
++const { verifyUserToken } = require('openvibe-sdk/auth');
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const CAPS = Object.freeze({
@@@ -24,66 +29,6 @@ const PRINCIPAL_SUB = /^(svc|app|mod):/;
const GROUP_RE = /^[a-z][a-z0-9_.:-]{0,127}$/;
const MAX_VIEWER_KEYS = 100;
--// ── Network public key ─────────────────────────────────────
--
--/**
-- * Loads the Network signing key from GET /api/.well-known/jwks ({ keys: [jwk] } or the older
-- * { public_key: PEM }), retrying every 30 s until it loads and refreshing every 6 h after that.
-- * A PEM given in config (OV_NETWORK_PUBLIC_KEY) is used as is and never fetched.
-- */
--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, fetchOnce };
--}
--
// ── Capabilities ───────────────────────────────────────────
/** → { allowed, code, reason } like capabilities.check(). */
@@@ -106,29 +51,6 @@ function decodePayload(token) {
try { return b64json(parts[1]); } catch { return null; }
}
--/** Network user JWT (RS256). Returns claims or null. Service tokens are never accepted as users. */
--function verifyUserJwt(token, { publicKey, issuer, audiences, now = Date.now() }) {
-- if (!publicKey || typeof token !== 'string') return null;
-- const parts = token.split('.');
-- if (parts.length !== 3) return null;
-- try {
-- const header = b64json(parts[0]);
-- if (header.alg !== 'RS256') return null;
-- const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'));
-- if (!ok) return null;
-- const claims = b64json(parts[1]);
-- const t = Math.floor(now / 1000);
-- if (typeof claims.exp !== 'number' || claims.exp + 30 < t) return null;
-- if (issuer && claims.iss !== issuer) return null;
-- const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
-- if (!aud.some(a => audiences.includes(a))) return null;
-- if (claims.actor_type || PRINCIPAL_SUB.test(String(claims.sub))) return null;
-- return claims;
-- } catch {
-- return null;
-- }
--}
--
function bearer(req) {
const h = String(req.headers.authorization || '');
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
@@@ -166,15 +88,46 @@ function keyList(value, header) {
const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, groups: [], entitlements: [] });
--function createAuth({ config, keys }) {
-- function verifyService(token) {
-- const publicKey = keys.get();
-- if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
-- return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
++function createAuth({ config, jwks, log = console }) {
++ /**
++ * A service principal token. openvibe-contracts keeps the token rules (RS256, issuer, the one
++ * audience, expiry, identity.service-token-claims@1, sandbox refusal); the signing key comes from
++ * the SDK's JWKS client, which refetches at once for an unknown `kid` (a rotation) and keeps the
++ * last good keys while Network is down. Returns { ok, claims } or { ok, code, reason }.
++ */
++ async function verifyService(token) {
++ const parts = String(token || '').split('.');
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8')).kid || null; } catch { /* contracts refuses a malformed token */ }
++ let keys;
++ try {
++ keys = await jwks.keysForKid(kid);
++ } catch (err) {
++ // The SDK's message names the internal JWKS URL and the fetch error: the client logs it, nobody is told.
++ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
++ }
++ // The key the token names, else every key (a kid-less token, or a document without kids); first verdict that
++ // is not a bad signature wins.
++ 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;
}
-- function verifyUser(token) {
-- return verifyUserJwt(token, { publicKey: keys.get(), issuer: config.issuer, audiences: config.userAudiences });
++ /**
++ * A Network user JWT (RS256). Resolves to the claims, or null when it does not verify. Rejects with
++ * the SDK's 503-class error while no key has been fetched yet, so the caller can answer 503.
++ */
++ async function verifyUser(token) {
++ try {
++ return await verifyUserToken(token, { jwks: jwks.url, issuer: config.issuer, audience: config.userAudiences, log });
++ } catch (err) {
++ if (err && err.status === 503) throw err;
++ return null;
++ }
}
/**
@@@ -182,11 +135,12 @@ function createAuth({ config, keys }) {
* Only service principals (svc:<slug>) pass: documents are always owned by a service.
*/
function requireCap(id) {
-- return function capGuard(req, res, next) {
++ return async function capGuard(req, res, next) {
const ctx = req.ov;
const token = bearer(req);
if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
-- const r = verifyService(token);
++ let r;
++ try { r = await verifyService(token); } 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 c = checkCapability(r.claims, id);
if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
@@@ -215,12 +169,12 @@ function createAuth({ config, keys }) {
* A presented Bearer must verify (never downgraded to anonymous); an unverifiable cookie is
* treated as signed out.
*/
-- function viewer(req) {
++ async function viewer(req) {
const token = bearer(req);
if (token) {
const payload = decodePayload(token);
if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {
-- const r = verifyService(token);
++ const r = await verifyService(token);
if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);
const c = checkCapability(r.claims, CAPS.delegate);
if (!c.allowed) throw new AuthError(403, c.code, c.reason);
@@@ -242,13 +196,13 @@ function createAuth({ config, keys }) {
entitlements: keyList(req.get('x-ov-entitlements'), 'X-OV-Entitlements'),
};
}
-- if (!keys.get()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');
-- const user = verifyUser(token);
++ let user;
++ try { user = await verifyUser(token); } catch { throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet'); }
if (!user) throw new AuthError(401, 'token.invalid', 'token does not verify');
return userViewer(user);
}
const fromCookie = cookie(req, 'ov_token');
-- const user = fromCookie ? verifyUser(fromCookie) : null;
++ const user = fromCookie ? await verifyUser(fromCookie).catch(() => null) : null;
if (!user) return ANONYMOUS;
const v = userViewer(user);
// `via` lets state-changing routes demand a same-origin request for ambient credentials.
@@@ -258,4 +212,4 @@ function createAuth({ config, keys }) {
return { verifyService, verifyUser, requireCap, viewer };
}
--module.exports = { CAPS, PROPOSED, ANONYMOUS, AuthError, createKeyStore, createAuth, checkCapability, verifyUserJwt, serviceSlug, bearer, cookie };
++module.exports = { CAPS, PROPOSED, ANONYMOUS, AuthError, createAuth, checkCapability, serviceSlug, bearer, cookie };
ddiff --git a/server/config.js b/server/config.js
iindex 9c2ea28..a45d7ab 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -57,11 +57,10 @@ function load(env = process.env) {
serviceId: 'search',
baseUrl: strip(env.BASE_URL || (isProduction ? 'https://search.openvibe.network' : `http://localhost:${port}`)),
-- // Identity: OpenVibe.Network signs service tokens (audience openvibe.search) and user JWTs.
-- networkUrl: strip(env.OV_NETWORK_URL || 'https://openvibe.network'),
++ // Identity: OpenVibe.Network signs service tokens (audience openvibe.search) and user JWTs; the
++ // SDK's JWKS client fetches the keys from <networkInternalUrl>/api/.well-known/jwks.
networkInternalUrl: strip(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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.search',
// A browser's user JWT is accepted when its aud contains one of these.
userAudiences: list(env.SEARCH_USER_AUDIENCES, ['openvibe.search', 'openvibe.network']),
ddiff --git a/server/index.js b/server/index.js
iindex 3f6b018..5587b69 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -12,7 +12,8 @@ const { openDb } = require('./db');
const { createEngine } = require('./engine/pg');
const { createStore } = require('./store');
const { createOutbox, createRelay } = require('./events/outbox');
--const { createKeyStore, createAuth } = require('./auth');
++const { jwksClient } = require('openvibe-sdk/auth');
++const { createAuth } = require('./auth');
const { createPurgeQueue, createPurger } = require('./purge');
const { createSavedSearches } = require('./saved');
const { createApp } = require('./app');
@@@ -35,11 +36,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
tokenClient,
tokenOpts: config.oauth.clientSecret ? { tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret } : null,
});
-- const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
-- const auth = createAuth({ config, keys });
-- const app = createApp({ config, db, store, engine, auth, keys, outbox, relay, purges, purger, saved, log, now });
++ // Identity keys: the SDK keeps one JWKS client per URL (openvibe-sdk/auth). It serves the last good
++ // keys through an outage, refetches at once for an unknown kid (a rotation) and backs off on failure.
++ const jwksUrl = `${config.networkInternalUrl}/api/.well-known/jwks`;
++ const jwks = jwksClient(jwksUrl, { log, fetch: fetchImpl });
++ const auth = createAuth({ config, jwks, log });
++ const app = createApp({ config, db, store, engine, auth, outbox, relay, purges, purger, saved, log, now });
-- const keyLoaded = keys.start().catch(() => null);
++ // The background refresher is not started under the test runtime (the tests stub the fetch and load
++ // the keys once below); a real boot refreshes on an unref'd timer. keyLoaded settles the first load.
++ if (config.nodeEnv !== 'test') jwks.start();
++ const keyLoaded = jwks.keys().catch(() => null);
relay.start();
purger.start();
const pruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
@@@ -56,7 +63,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
async function close() {
clearInterval(pruneTimer);
-- keys.stop();
++ jwks.stop();
await relay.stop();
await purger.stop();
if (server) {
@@@ -66,7 +73,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
}
-- return { config, db, engine, store, outbox, relay, purges, purger, saved, keys, keyLoaded, auth, app, server, close };
++ return { config, db, engine, store, outbox, relay, purges, purger, saved, jwks, keyLoaded, auth, app, server, close };
}
if (require.main === module) {
ddiff --git a/server/observability.js b/server/observability.js
iindex 2832efa..d75b813 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -5,9 +5,11 @@
*
* db required a real read of the documents table and of the full-text table (they
* live in the same database): without them nothing can be indexed or found
-- * network_jwks optional the Network signing key has loaded. Without it anonymous queries still
-- * answer (public documents only), but no token can be verified: owner
-- * writes and signed-in queries fail, so it degrades rather than fails
++ * network_jwks optional the SDK's JWKS client (openvibe-sdk/auth) has keys loaded. Without them
++ * anonymous queries still answer (public documents only), but no token can
++ * be verified: owner writes and signed-in queries fail, so it degrades
++ * rather than fails. Reports each client's keys, staleness, failures and
++ * next try (never its URL or error) from jwksStatus()
* index optional the full-text index agrees with the documents table: search_fts holds
* exactly the public_listed and restricted documents. A mismatch means
* queries miss (or would surface) documents
@@@ -15,6 +17,7 @@
* Gauges: documents by exposure, full-text entries by audience, and the events outbox backlog.
*/
const { createReadiness } = require('openvibe-shared/ready');
++const { jwksStatus } = require('openvibe-sdk/auth');
const { EXPOSURE } = require('./document');
const EXPOSURE_NAMES = Object.fromEntries(Object.entries(EXPOSURE).map(([name, n]) => [n, name]));
@@@ -32,7 +35,7 @@ function readers(db, engine) {
};
}
--function createSearchReadiness({ db, keys, config, engine, store, outbox, relay, purges = null, purger = null, saved = null, release = null }) {
++function createSearchReadiness({ db, config, engine, store, outbox, relay, purges = null, purger = null, saved = null, release = null }) {
const read = readers(db, engine);
return createReadiness({
service: 'search',
@@@ -49,7 +52,19 @@ function createSearchReadiness({ db, keys, config, engine, store, outbox, relay,
return { ok: true, detail: r.detail };
},
},
-- { name: 'network_jwks', required: false, check: () => keys.loaded() || 'Network signing key not loaded yet: owner writes and signed-in queries cannot be verified' },
++ {
++ name: 'network_jwks', required: false,
++ check: () => {
++ const states = jwksStatus();
++ // Public: counts and times only. The JWKS URL is internal and the fetch error names it.
++ const detail = states.map((s) => ({
++ ready: s.ready, keys: s.keys, stale: s.stale, failures: s.failures,
++ next_try_at: s.nextTryAt ? new Date(s.nextTryAt).toISOString() : null,
++ }));
++ if (states.length && states.every((s) => s.ready)) return { ok: true, detail };
++ return { ok: false, error: 'Network signing key not loaded yet: owner writes and signed-in queries cannot be verified', detail };
++ },
++ },
{
name: 'index', required: false, cacheMs: 10_000,
check: async () => {
ddiff --git a/server/web/page.js b/server/web/page.js
iindex 4d58cb5..604ce21 100644
---- a/server/web/page.js
++++ b/server/web/page.js
@@@ -193,7 +193,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
let viewer;
try {
-- viewer = auth.viewer(req);
++ viewer = await auth.viewer(req);
} catch (err) {
if (!(err instanceof AuthError)) return next(err);
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 4a04818..ae863cb 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -20,12 +20,21 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
--function serviceToken(slug, cap, { aud = 'openvibe.search', exp = Math.floor(Date.now() / 1000) + 300, iss = ISSUER, key = privateKey, sub } = {}) {
++// The SDK's JWKS client (server/auth.js) fetches the Network keys; tests never touch the network, so a
++// stub answers the JWKS URL with this key pair's public JWK and delegates every other URL to `inner`.
++const JWKS_KID = 'test-network-key';
++function jwksDocument() {
++ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
++ return { keys: [{ ...jwk, kid: JWKS_KID, use: 'sig', alg: 'RS256' }] };
++}
++const stubJwks = async () => ({ ok: true, status: 200, json: async () => jwksDocument() });
++
++function serviceToken(slug, cap, { aud = 'openvibe.search', exp = Math.floor(Date.now() / 1000) + 300, iss = ISSUER, key = privateKey, sub, kid } = {}) {
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({
iss, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,
jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
-- }, key);
++ }, key, { kid });
}
function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openvibe.live', 'openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600, key = privateKey, iss = ISSUER } = {}) {
@@@ -34,17 +43,19 @@ function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openv
}, key);
}
--async function boot({ env = {}, fetchImpl, tokenClient, now } = {}) {
++async function boot({ env = {}, fetchImpl, jwksFetch, tokenClient, now } = {}) {
const config = load({
NODE_ENV: 'test',
PORT: '0',
-- OV_NETWORK_PUBLIC_KEY: publicKey,
SEARCH_EVENTS_SECRET: WEBHOOK_SECRET,
...env,
});
// One database per boot (PGlite, or SEARCH_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await require('./db').testDb();
-- const h = await start({ config, db: testdb.db, log: silent, fetchImpl, tokenClient, now });
++ const inner = fetchImpl || globalThis.fetch;
++ const jwks = jwksFetch || stubJwks;
++ const fetchImplBoth = async (url, opts) => String(url).includes('/api/.well-known/jwks') ? jwks(url, opts) : inner(url, opts);
++ const h = await start({ config, db: testdb.db, log: silent, fetchImpl: fetchImplBoth, tokenClient, now });
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, async stop() { await h.close(); await testdb.close(); } };
}
@@@ -132,5 +143,5 @@ function suite(name) {
module.exports = {
ISSUER, WEBHOOK_SECRET, privateKey, publicKey, silent, serviceToken, userToken, boot, request,
-- doc, indexEvent, deliver, suite,
++ doc, indexEvent, deliver, suite, jwksDocument, stubJwks, JWKS_KID,
};
ddiff --git a/test/jwks-degraded.test.js b/test/jwks-degraded.test.js
nnew file mode 100644
iindex 0000000..f959eb0
---- /dev/null
++++ b/test/jwks-degraded.test.js
@@@ -0,0 +1,32 @@
++'use strict';
++// Plan T0/T1: /api/ready's network_jwks check is optional and reports the state of the SDK's JWKS
++// client. With no keys ever fetched, the service stays up (200) but reports degraded.
++const assert = require('assert');
++const { boot, request, suite, serviceToken, doc } = require('./helpers');
++
++const t = suite('jwks-degraded');
++
++t('with no keys fetched, /api/ready degrades but stays 200', async () => {
++ const jwksFetch = async () => { throw new Error('JWKS unreachable'); };
++ const svc = await boot({ jwksFetch });
++ await svc.keyLoaded; // resolves null: the first fetch failed, nothing to verify with
++ const r = await request(svc.base, 'GET', '/api/ready');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.status, 'degraded');
++ assert.ok(r.body.degraded.includes('network_jwks'), JSON.stringify(r.body.degraded));
++ const c = r.body.checks.network_jwks;
++ assert.strictEqual(c.status, 'fail');
++ assert.strictEqual(c.required, false);
++ assert.strictEqual(c.detail[0].ready, false);
++ assert.match(c.error, /Network signing key not loaded yet/);
++ // A review caught the SDK's error (the internal JWKS URL and the fetch error) answered in public: in the
++ // readiness body and as a service write's 503 detail.
++ const leaks = (text) => /JWKS unreachable|well-known|127\.0\.0\.1/.test(text);
++ assert.ok(!leaks(r.text), r.text);
++ const w = await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${doc().id}`, { token: serviceToken('wiki', ['search.document.write']), body: doc() });
++ assert.strictEqual(w.status, 503, w.text);
++ assert.ok(!leaks(w.text), w.text);
++ await svc.stop();
++});
++
++t.run();
ddiff --git a/test/jwks.test.js b/test/jwks.test.js
nnew file mode 100644
iindex 0000000..b59fa27
---- /dev/null
++++ b/test/jwks.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++// Plan T0/T1: the service's own JWKS fetching/caching is gone; the SDK's client (openvibe-sdk/auth)
++// fetches the keys. These tests drive it through a stub JWKS: a token signed by the stub's key
++// verifies, the cached keys keep verifying through a stub outage (an unknown kid forces a refetch),
++// and /api/ready reports the client's state.
++const assert = require('assert');
++const { boot, request, serviceToken, userToken, doc, suite, jwksDocument } = require('./helpers');
++
++const t = suite('jwks');
++let svc;
++const state = { fail: false, fetches: 0 };
++const jwksFetch = async () => {
++ state.fetches += 1;
++ if (state.fail) throw new Error('JWKS outage');
++ return { ok: true, status: 200, json: async () => jwksDocument() };
++};
++const write = (token, d) => request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token, body: d });
++
++t('a token signed by a key from the stub JWKS verifies', async () => {
++ svc = await boot({ jwksFetch });
++ await svc.keyLoaded;
++ assert.ok(state.fetches >= 1, 'the SDK client fetched the stub JWKS');
++ const d = doc();
++ const w = await write(serviceToken('wiki', ['search.document.write']), d);
++ assert.ok(w.status === 200 || w.status === 201, w.text);
++ const u = await request(svc.base, 'GET', '/api/v1/search?q=example', { token: userToken({ aud: ['openvibe.search'] }) });
++ assert.strictEqual(u.status, 200, u.text);
++});
++
++t('with keys cached, a JWKS outage still verifies (and refetches for a rotation)', async () => {
++ const before = state.fetches;
++ state.fail = true;
++ try {
++ // An unknown kid makes the SDK refetch at once; the refetch fails, the last good keys are served.
++ const w = await write(serviceToken('wiki', ['search.document.write'], { kid: 'rotated-away' }), doc());
++ assert.ok(w.status === 200 || w.status === 201, w.text);
++ } finally { state.fail = false; }
++ assert.ok(state.fetches > before, 'the unknown kid forced a refetch during the outage');
++});
++
++t('/api/ready reports the JWKS state as a non-required check', async () => {
++ 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.status, 'ok', c.error);
++ assert.strictEqual(c.required, false);
++ assert.ok(Array.isArray(c.detail) && c.detail.length === 1, JSON.stringify(c.detail));
++ assert.ok(!('url' in c.detail[0]) && !r.text.includes('.well-known'), 'the internal JWKS URL is never public');
++ assert.strictEqual(c.detail[0].ready, true);
++ assert.ok(c.detail[0].keys >= 1);
++ assert.strictEqual(c.detail[0].stale, false);
++ await svc.stop();
++});
++
++t.run();