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.
Changes
@@@ -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.networkOV_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@@@ -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 mayonly write documents under its own service slug. A Bearer that does not verify is a 401, neveranonymous. Delegation headers (`X-OV-Subject`, `X-OV-Groups`, `X-OV-Entitlements`) count only with a@@@ -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"@@@ -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"},@@@ -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);@@@ -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 });@@@ -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 };@@@ -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']),@@@ -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(), fetchtokenClient,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(), fetchasync 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(), fetchif (!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) {@@@ -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 () => {@@@ -193,7 +193,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.networklet 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@@@ -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,};nnew file mode 100644@@@ -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();nnew file mode 100644@@@ -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();
@@ -12,13 +12,12 @@ NODE_ENV=development
@@ -12,13 +12,12 @@ NODE_ENV=development
#BASE_URL=https://search.openvibe.network
#BASE_URL=https://search.openvibe.network
# Identity — OpenVibe.Network signs service tokens (client_credentials, audience
# 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_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
-# 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.
# A browser's user JWT is accepted when its aud contains one of these.
#SEARCH_USER_AUDIENCES=openvibe.search,openvibe.network
#SEARCH_USER_AUDIENCES=openvibe.search,openvibe.network
@@ -268,7 +268,7 @@ readiness, identical query answers, row counts; see OpenVibe.Host `docs/restore-
@@ -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:
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
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
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
anonymous. Delegation headers (`X-OV-Subject`, `X-OV-Groups`, `X-OV-Entitlements`) count only with a
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"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.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1040,9 +1040,9 @@
@@ -1040,9 +1040,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"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.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -172,7 +172,7 @@ function withViewer(auth, handler, limit = null) {
@@ -172,7 +172,7 @@ function withViewer(auth, handler, limit = null) {
const ctx = req.ov;
const ctx = req.ov;
let viewer;
let viewer;
try {
try {
- viewer = auth.viewer(req);
+ viewer = await auth.viewer(req);
} catch (err) {
} catch (err) {
if (err instanceof AuthError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx });
if (err instanceof AuthError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx });
return next(err);
return next(err);
@@ -14,7 +14,7 @@ const { webhookRouter } = require('./api/webhook');
@@ -14,7 +14,7 @@ const { webhookRouter } = require('./api/webhook');
const { createLimits } = require('./actor-limits');
const { createLimits } = require('./actor-limits');
const pkg = require('../package.json');
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -45,7 +45,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, purge
@@ -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)
// 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).
// 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);
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 });
@@ -4,14 +4,19 @@
@@ -4,14 +4,19 @@
*
*
* - Service principals: RS256 client-credentials tokens from OpenVibe.Network (audience
* - Service principals: RS256 client-credentials tokens from OpenVibe.Network (audience
* openvibe.search), verified offline with openvibe-contracts serviceAuth.verifyServiceToken.
* 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
* 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
* 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
* rule (exact id or a `family.*` grant) until a contracts release knows them; from then on the
* library decides and nothing changes here.
* 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 { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const CAPS = Object.freeze({
const CAPS = Object.freeze({
@@ -24,66 +29,6 @@ const PRINCIPAL_SUB = /^(svc|app|mod):/;
@@ -24,66 +29,6 @@ const PRINCIPAL_SUB = /^(svc|app|mod):/;
const GROUP_RE = /^[a-z][a-z0-9_.:-]{0,127}$/;
const GROUP_RE = /^[a-z][a-z0-9_.:-]{0,127}$/;
const MAX_VIEWER_KEYS = 100;
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 ───────────────────────────────────────────
// ── Capabilities ───────────────────────────────────────────
/** → { allowed, code, reason } like capabilities.check(). */
/** → { allowed, code, reason } like capabilities.check(). */
@@ -106,29 +51,6 @@ function decodePayload(token) {
@@ -106,29 +51,6 @@ function decodePayload(token) {
try { return b64json(parts[1]); } catch { return null; }
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) {
function bearer(req) {
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
@@ -166,15 +88,46 @@ function keyList(value, header) {
@@ -166,15 +88,46 @@ function keyList(value, header) {
const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, groups: [], entitlements: [] });
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 }) {
@@ -182,11 +135,12 @@ function createAuth({ config, keys }) {
* Only service principals (svc:<slug>) pass: documents are always owned by a service.
* Only service principals (svc:<slug>) pass: documents are always owned by a service.
*/
*/
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 token = bearer(req);
const token = bearer(req);
if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
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 });
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);
const c = checkCapability(r.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 });
@@ -215,12 +169,12 @@ function createAuth({ config, keys }) {
@@ -215,12 +169,12 @@ function createAuth({ config, keys }) {
* A presented Bearer must verify (never downgraded to anonymous); an unverifiable cookie is
* A presented Bearer must verify (never downgraded to anonymous); an unverifiable cookie is
* treated as signed out.
* treated as signed out.
*/
*/
- function viewer(req) {
+ async function viewer(req) {
const token = bearer(req);
const token = bearer(req);
if (token) {
if (token) {
const payload = decodePayload(token);
const payload = decodePayload(token);
if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {
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);
if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);
const c = checkCapability(r.claims, CAPS.delegate);
const c = checkCapability(r.claims, CAPS.delegate);
if (!c.allowed) throw new AuthError(403, c.code, c.reason);
if (!c.allowed) throw new AuthError(403, c.code, c.reason);
@@ -242,13 +196,13 @@ function createAuth({ config, keys }) {
@@ -242,13 +196,13 @@ function createAuth({ config, keys }) {
entitlements: keyList(req.get('x-ov-entitlements'), 'X-OV-Entitlements'),
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');
if (!user) throw new AuthError(401, 'token.invalid', 'token does not verify');
return userViewer(user);
return userViewer(user);
}
}
const fromCookie = cookie(req, 'ov_token');
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;
if (!user) return ANONYMOUS;
const v = userViewer(user);
const v = userViewer(user);
// `via` lets state-changing routes demand a same-origin request for ambient credentials.
// `via` lets state-changing routes demand a same-origin request for ambient credentials.
@@ -258,4 +212,4 @@ function createAuth({ config, keys }) {
@@ -258,4 +212,4 @@ function createAuth({ config, keys }) {
return { verifyService, verifyUser, requireCap, viewer };
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 };
@@ -57,11 +57,10 @@ function load(env = process.env) {
@@ -57,11 +57,10 @@ function load(env = process.env) {
serviceId: 'search',
serviceId: 'search',
baseUrl: strip(env.BASE_URL || (isProduction ? 'https://search.openvibe.network' : `http://localhost:${port}`)),
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'),
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'),
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',
audience: 'openvibe.search',
// A browser's user JWT is accepted when its aud contains one of these.
// A browser's user JWT is accepted when its aud contains one of these.
userAudiences: list(env.SEARCH_USER_AUDIENCES, ['openvibe.search', 'openvibe.network']),
userAudiences: list(env.SEARCH_USER_AUDIENCES, ['openvibe.search', 'openvibe.network']),
@@ -12,7 +12,8 @@ const { openDb } = require('./db');
@@ -12,7 +12,8 @@ const { openDb } = require('./db');
const { createEngine } = require('./engine/pg');
const { createEngine } = require('./engine/pg');
const { createStore } = require('./store');
const { createStore } = require('./store');
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 { createPurgeQueue, createPurger } = require('./purge');
const { createPurgeQueue, createPurger } = require('./purge');
const { createSavedSearches } = require('./saved');
const { createSavedSearches } = require('./saved');
const { createApp } = require('./app');
const { createApp } = require('./app');
@@ -35,11 +36,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -35,11 +36,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
tokenClient,
tokenClient,
tokenOpts: config.oauth.clientSecret ? { tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret } : null,
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();
relay.start();
purger.start();
purger.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);
@@ -56,7 +63,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -56,7 +63,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
async function close() {
async function close() {
clearInterval(pruneTimer);
clearInterval(pruneTimer);
- keys.stop();
+ jwks.stop();
await relay.stop();
await relay.stop();
await purger.stop();
await purger.stop();
if (server) {
if (server) {
@@ -66,7 +73,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -66,7 +73,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
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) {
if (require.main === module) {
@@ -5,9 +5,11 @@
@@ -5,9 +5,11 @@
*
*
* db required a real read of the documents table and of the full-text table (they
* 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
* 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
* index optional the full-text index agrees with the documents table: search_fts holds
* exactly the public_listed and restricted documents. A mismatch means
* exactly the public_listed and restricted documents. A mismatch means
* queries miss (or would surface) documents
* queries miss (or would surface) documents
@@ -15,6 +17,7 @@
@@ -15,6 +17,7 @@
* Gauges: documents by exposure, full-text entries by audience, and the events outbox backlog.
* Gauges: documents by exposure, full-text entries by audience, and the events outbox backlog.
*/
*/
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
+const { jwksStatus } = require('openvibe-sdk/auth');
const { EXPOSURE } = require('./document');
const { EXPOSURE } = require('./document');
const EXPOSURE_NAMES = Object.fromEntries(Object.entries(EXPOSURE).map(([name, n]) => [n, name]));
const EXPOSURE_NAMES = Object.fromEntries(Object.entries(EXPOSURE).map(([name, n]) => [n, name]));
@@ -32,7 +35,7 @@ function readers(db, engine) {
@@ -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);
const read = readers(db, engine);
return createReadiness({
return createReadiness({
service: 'search',
service: 'search',
@@ -49,7 +52,19 @@ function createSearchReadiness({ db, keys, config, engine, store, outbox, relay,
@@ -49,7 +52,19 @@ function createSearchReadiness({ db, keys, config, engine, store, outbox, relay,
return { ok: true, detail: r.detail };
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,
name: 'index', required: false, cacheMs: 10_000,
check: async () => {
check: async () => {
@@ -193,7 +193,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -193,7 +193,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
let viewer;
let viewer;
try {
try {
- viewer = auth.viewer(req);
+ viewer = await auth.viewer(req);
} catch (err) {
} catch (err) {
if (!(err instanceof AuthError)) return next(err);
if (!(err instanceof AuthError)) return next(err);
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
@@ -20,12 +20,21 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
@@ -20,12 +20,21 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
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); } };
-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);
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({
return serviceAuth.signServiceToken({
iss, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,
iss, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,
jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
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 } = {}) {
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
@@ -34,17 +43,19 @@ function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openv
}, key);
}, key);
}
}
-async function boot({ env = {}, fetchImpl, tokenClient, now } = {}) {
+async function boot({ env = {}, fetchImpl, jwksFetch, tokenClient, now } = {}) {
const config = load({
const config = load({
NODE_ENV: 'test',
NODE_ENV: 'test',
PORT: '0',
PORT: '0',
- OV_NETWORK_PUBLIC_KEY: publicKey,
SEARCH_EVENTS_SECRET: WEBHOOK_SECRET,
SEARCH_EVENTS_SECRET: WEBHOOK_SECRET,
...env,
...env,
});
});
// One database per boot (PGlite, or SEARCH_TEST_STORE=pg: the containers), dropped when the boot stops.
// One database per boot (PGlite, or SEARCH_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await require('./db').testDb();
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}`;
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, async stop() { await h.close(); await testdb.close(); } };
}
}
@@ -132,5 +143,5 @@ function suite(name) {
@@ -132,5 +143,5 @@ function suite(name) {
module.exports = {
module.exports = {
ISSUER, WEBHOOK_SECRET, privateKey, publicKey, silent, serviceToken, userToken, boot, request,
ISSUER, WEBHOOK_SECRET, privateKey, publicKey, silent, serviceToken, userToken, boot, request,
- doc, indexEvent, deliver, suite,
+ doc, indexEvent, deliver, suite, jwksDocument, stubJwks, JWKS_KID,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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();