openvibers/openvibe.ai Commits
Network tokens verify with keys from openvibe-sdk/auth's JWKS client (v0.23.1): fresh keys, the last good ones through a Network outage, backoff, and a rotation honoured on an unknown kid, in place of the hand-written key store. Every claim rule is still openvibe-contracts' verifyServiceToken (a sandbox token and a sub that is not a principal stay 401); the console's sign-in verifies with the SDK's verifyUserToken. Nothing public names the internal JWKS URL or its fetch error (503 detail, /api/ready), an unexpected guard error answers a plain 500, and /api/ready stays 503 until the key loads. Tests: a stub JWKS, an outage with cached keys, an unreachable JWKS, and an expired sign-in token (the SDK takes the clock as a number; a function made every expiry pass).
Changes
@@@ -19,9 +19,11 @@ VALKEY_URL=VALKEY_PREFIX=# Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).--# The public key is fetched from <Network>/api/.well-known/jwks; OV_NETWORK_PUBLIC_KEY pins a PEM instead.++# The public key is fetched from <OV_NETWORK_INTERNAL_URL>/api/.well-known/jwks by openvibe-sdk/auth++# (fresh keys, the last good ones through an outage, backoff, rotation); OV_NETWORK_PUBLIC_KEY pins a PEM instead.OV_NETWORK_URL=https://openvibe.networkOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++#OV_NETWORK_JWKS_URL=http://127.0.0.1:4000#OV_NETWORK_ISSUER=https://openvibe.network# Namespaces fail closed: a token runs only the namespaces its `ns` claim (its Network grant) names; a token# without `ns` runs nothing. (AI_NS_FALLBACK and AI_NS_REQUIRED were retired on 2026-09-28.)@@@ -1,7 +1,8 @@[Unit]Description=OpenVibe.AI — providers, routing, versioned templates and workflows, runs, citations, cache, quotas and audit# Network (openvibe-network) signs the service tokens callers present. The AI service starts without--# it and keeps retrying the JWKS fetch; /api/ready stays 503 until the key has loaded.++# it; the SDK's JWKS client fetches at boot and backs off and retries, and /api/ready stays 503 until++# the key has loaded (then the last good keys carry it through a Network outage).After=network.target postgresql.service pgbouncer.service valkey-server.serviceWants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service@@@ -13,7 +13,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.3",++ "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"},@@@ -1464,9 +1464,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.20.3",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",-- "integrity": "sha512-+g+ySveItzOBnUsw9aZZIJVKRrGuj0aO328W4N25T0Z8mY4fE6++ImvLnZ55LH2BRh0WYozC1KAVIShACm2KGQ==",++ "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"@@@ -25,7 +25,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.3",++ "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"},@@@ -50,7 +50,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysif (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => require('./api/runs').sendError(res, err, ctx, log) }));app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys, ops, env, clock, fetchImpl, log }));++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log }));// Crawlers: nothing here is for search engines, the console and sign-in least of all (no sitemap either).app.get('/robots.txt', (_req, res) => {@@@ -1,7 +1,11 @@'use strict';/*** Authentication: callers present OpenVibe.Network service tokens (RS256 client-credentials JWTs,-- * audience openvibe.ai), verified with openvibe-contracts serviceAuth.verifyServiceToken.++ * audience openvibe.ai), verified with openvibe-contracts serviceAuth.verifyServiceToken (the claim++ * schema, sandbox refusal, issuer and audience). Only the KEY comes from the SDK: one JWKS client per++ * URL (openvibe-sdk/auth jwksClient) gives fresh keys, the last good keys through an outage,++ * exponential backoff, and a rotation honoured on an unknown kid. OV_NETWORK_PUBLIC_KEY still pins a++ * PEM, which skips the fetch entirely.** Capabilities ai.run.create / ai.run.read / ai.workflow.manage / ai.provider.manage / ai.usage.read* are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts yet. Until they@@@ -14,8 +18,8 @@* ns-less service tokens (AI_NS_FALLBACK) and the open-rule lever (AI_NS_REQUIRED=false), shims* C-22 and C-23, were retired on 2026-09-28 once no token without `ns` was left.*/--const crypto = require('crypto');const { serviceAuth, capabilities, http } = require('openvibe-contracts');++const { jwksClient, jwksStatus } = require('openvibe-sdk/auth');const CAPS = Object.freeze({runCreate: 'ai.run.create',@@@ -25,55 +29,17 @@ const CAPS = Object.freeze({usageRead: 'ai.usage.read',});--/** Loads the Network signing key from /api/.well-known/jwks (retrying), or uses a configured PEM. */--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 };++/**++ * The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned++ * OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.++ */++function createNetworkKeys({ config }) {++ const pinned = config.networkPublicKey || null;++ return {++ pinned: Boolean(pinned),++ /** Every JWKS client's state (url, ready, keys, fetchedAt, stale, failures, lastError, nextTryAt). */++ status: () => (pinned ? [{ url: null, ready: true, keys: 1, pinned: true }] : jwksStatus()),++ };}/** Exact id or `family.*` grant (the rule openvibe-contracts applies to the capabilities it knows). */@@@ -110,11 +76,33 @@ function principalSubject(sub) {return { type: m[1] === 'svc' ? 'service' : m[1], id: m[2] };}--function createAuth({ config, keys }) {-- function verify(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, log = console }) {++ /** The kid a token's header names (null when absent or undecodable). */++ function headerKid(token) {++ try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }++ }++++ /**++ * A Network service/app token: the key from the SDK's JWKS client (or the pinned PEM), every rule++ * from openvibe-contracts' verifyServiceToken (identity.service-token-claims@1, env: sandbox refused).++ */++ async function verify(token) {++ const check = (publicKey) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });++ if (config.networkPublicKey) return check(config.networkPublicKey);++ let keys;++ try { keys = await jwksClient(config.networkJwksUrl, { log }).keysForKid(headerKid(token)); } catch (err) {++ // token.no_key (503): no keys loaded yet, so nothing can be verified; retryable, as before. The++ // SDK's message names the internal JWKS URL and the fetch error: logged by the client, never answered.++ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };++ }++ const kid = headerKid(token);++ const byKid = kid ? keys.filter((k) => k.kid === kid) : [];++ let last = { ok: false, code: 'token.unavailable', reason: 'no signing key' };++ for (const k of byKid.length ? byKid : keys) {++ last = check(k.key);++ if (last.ok || last.code !== 'token.bad_signature') return last;++ }++ return last;}/**@@@ -122,21 +110,26 @@ function createAuth({ config, keys }) {* Sets req.principal = { sub, subject: {type,id}, cap, ns, jti }.*/function requireCap(...anyOf) {-- 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 (audience openvibe.ai) is required', ctx });-- const r = verify(token);-- if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });-- let decision = null;-- for (const id of anyOf) {-- decision = allows(r.claims, id);-- if (decision.allowed) break;++ try {++ const token = bearer(req);++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token (audience openvibe.ai) is required', ctx });++ const r = await verify(token);++ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });++ let decision = null;++ for (const id of anyOf) {++ decision = allows(r.claims, id);++ if (decision.allowed) break;++ }++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });++ const subject = principalSubject(r.claims.sub);++ req.principal = { sub: r.claims.sub, subject, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };++ return next();++ } catch (err) {++ log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);++ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx });}-- if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });-- const subject = principalSubject(r.claims.sub);-- req.principal = { sub: r.claims.sub, subject, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };-- return next();};}@@@ -146,4 +139,4 @@ function createAuth({ config, keys }) {return { verify, requireCap, principalHas };}--module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };++module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };@@@ -53,6 +53,8 @@ function load(env = process.env) {// Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),networkInternalUrl: trimUrl(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ // The JWKS the SDK's client fetches (one client per URL): the internal URL unless OV_NETWORK_JWKS_URL says otherwise.++ networkJwksUrl: `${trimUrl(env.OV_NETWORK_JWKS_URL || env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000')}/api/.well-known/jwks`,issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),@@@ -88,7 +88,7 @@ const DEF_KINDS = {};const DAY_MS = 86400000;--function consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {const cc = config.console;const r = express.Router();const scoped = ['/console', '/auth'];@@@ -209,9 +209,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys,}let person;try {-- const publicKey = keys.get() || await keys.fetchOnce();-- if (!publicKey) throw new Error('the Network key is not loaded');-- person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, publicKey, now: clock.now(), fetchImpl });++ person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, now: clock.now(), fetchImpl, log });} catch (e) {log.warn(`[console] sign-in failed: ${e.message}`);return await send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));@@@ -7,8 +7,8 @@* &redirect_uri=<BASE_URL>/auth/callback&scope=profile&state=…&code_challenge=…* &code_challenge_method=S256* exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier), server-- * to server; the access token is then verified offline with the Network's RS256 key-- * AI already holds (issuer, audience, expiry)++ * to server; the access token is then verified with openvibe-sdk/auth verifyUserToken++ * against the Network's JWKS (or the pinned OV_NETWORK_PUBLIC_KEY), as a person** The Network's user access token carries `role`, `is_owner` and `subject_id` (and, once the Network* issues them, `staff_caps` / `staff_map`), minted at the code exchange, so what the console reads@@@ -17,8 +17,8 @@*/const crypto = require('crypto');const { ids, staff } = require('openvibe-contracts');++const { verifyUserToken } = require('openvibe-sdk/auth');--const b64urlJson = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));const PRINCIPAL_SUB = /^(svc|app|mod):/;function pkcePair() {@@@ -40,25 +40,6 @@ function authorizeUrl(config, { state, challenge }) {return `${config.networkUrl}/oauth/authorize?${q.toString()}`;}--/** Verify a Network user access token (RS256). Returns claims or throws with a short reason. */--function verifyUserToken(token, { publicKey, issuer, audience, now }) {-- const parts = typeof token === 'string' ? token.split('.') : [];-- if (parts.length !== 3) throw new Error('not a JWT');-- let header, claims;-- try { header = b64urlJson(parts[0]); claims = b64urlJson(parts[1]); } catch { throw new Error('undecodable token'); }-- if (header.alg !== 'RS256') throw new Error(`alg ${header.alg} not accepted`);-- let good = false;-- try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url')); } catch { good = false; }-- if (!good) throw new Error('signature does not verify');-- const t = Math.floor(now / 1000);-- if (typeof claims.exp !== 'number' || claims.exp + 30 < t) throw new Error('token expired');-- if (typeof claims.iat === 'number' && claims.iat - 30 > t) throw new Error('token issued in the future');-- if (claims.iss !== issuer) throw new Error('wrong issuer');-- const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];-- if (!aud.includes(audience)) throw new Error(`token is not for ${audience}`);-- return claims;--}--/*** The person a verified token names: { subject, username, staff } or { error }. Service, app and* module principals and FedCM assertions are not people; a person needs a usr_ subject. `staff` is@@@ -82,7 +63,7 @@ function personFromClaims(claims) {};}--async function exchange({ config, clientSecret, code, verifier, publicKey, now, fetchImpl = globalThis.fetch }) {++async function exchange({ config, clientSecret, code, verifier, now = Date.now(), fetchImpl = globalThis.fetch, log = null }) {const cc = config.console;const body = new URLSearchParams({grant_type: 'authorization_code',@@@ -113,7 +94,10 @@ async function exchange({ config, clientSecret, code, verifier, publicKey, now,signal: AbortSignal.timeout(3000),})).catch(() => {});}-- const claims = verifyUserToken(data.access_token, { publicKey, issuer: config.issuer, audience: cc.ssoAudience, now });++ const claims = await verifyUserToken(data.access_token, {++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),++ issuer: config.issuer, audience: cc.ssoAudience, now, log,++ });return personFromClaims(claims);}@@@ -17,7 +17,8 @@ const { createCache } = require('./cache');const { createQuotas } = require('./quota');const { createRuns } = require('./runs');const { seed } = require('./workflows/seed');--const { createKeyStore, createAuth } = require('./auth');++const { createAuth, createNetworkKeys } = require('./auth');++const { jwksClient } = require('openvibe-sdk/auth');const schemas = require('./schemas');const { createApp } = require('./app');@@@ -43,10 +44,18 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst interrupted = await runs.recoverInterrupted();if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);-- const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });-- const auth = createAuth({ config, keys });++ // The Network signing keys live in the SDK's process-wide JWKS client (one per URL): freshness,++ // last-good-keys, backoff and rotation are its job. A pinned PEM never fetches. Not started under++ // NODE_ENV=test (tests pin a PEM or point the URL at a stub and let the client fetch on first use).++ const keys = createNetworkKeys({ config });++ const auth = createAuth({ config, log });const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials });-- const keyLoaded = keys.start().catch(() => null);++ let jwks = null;++ let keyLoaded = Promise.resolve(null);++ if (!keys.pinned && config.nodeEnv !== 'test') {++ jwks = jwksClient(config.networkJwksUrl, { log, fetch: fetchImpl }).start();++ keyLoaded = jwks.keys().catch(() => null); // resolves once the first fetch is done (null if it failed)++ }const housekeeping = setInterval(async () => {try {@@@ -75,7 +84,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowasync function close() {clearInterval(housekeeping);userModules.stop();-- keys.stop();++ if (jwks) jwks.stop();if (server) {server.closeAllConnections?.();await new Promise(resolve => server.close(() => resolve()));@@@ -5,8 +5,9 @@** db required a real query on AI's PostgreSQL database (the provider registry answers)* workflows required at least one active workflow: without one there is nothing to run-- * network_jwks required the Network signing key has loaded. Every endpoint but health/ready-- * needs a verified service token, so without it AI serves nothing++ * network_jwks required the Network signing key has loaded (openvibe-sdk/auth jwksStatus()). Every++ * endpoint but health/ready needs a verified service token, so without it AI++ * serves nothing; once loaded, the SDK keeps the last good keys through an outage* providers optional provider configuration: every active non-stub provider has the* credentials it needs and a circuit that is not open. A missing key or an* open circuit sends runs to fallbacks (or to a 503 provider.unavailable),@@@ -48,7 +49,17 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {return n > 0 ? { ok: true, detail: { active: n } } : 'no active workflow';},},-- { name: 'network_jwks', required: true, check: () => keys.loaded() || 'Network signing key not loaded yet: no service token can be verified' },++ {++ name: 'network_jwks', required: true,++ // The SDK's JWKS clients: a pinned PEM reports ready on its own, otherwise the last good keys do++ // (they stay loaded through a Network outage). Public: counts and times only, never the internal++ // URL or the fetch error.++ check: () => {++ const ready = keys.status().find(s => s.ready);++ if (!ready) return 'Network signing key not loaded yet: no service token can be verified';++ return { ok: true, detail: { keys: ready.keys, pinned: Boolean(ready.pinned), stale: Boolean(ready.stale), failures: ready.failures || 0 } };++ },++ },{name: 'providers', required: false,check: async () => {@@@ -32,9 +32,9 @@ const news = token('news', ['ai.run.create', 'ai.run.read']);const codes = new Map();const grants = [];const revoked = [];--function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', staff_caps, challenge, redirect_uri }) {++function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', staff_caps, challenge, redirect_uri, expired = false }) {const code = crypto.randomBytes(16).toString('hex');-- codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri });++ codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri, expired });return code;}function userToken(u) {@@@ -42,7 +42,7 @@ function userToken(u) {return serviceAuth.signServiceToken({sub: 42, id: 42, ...(u.subject_id ? { subject_id: u.subject_id } : {}), username: u.username, role: u.role, ...(u.is_owner ? { is_owner: true } : {}),...(u.staff_caps ? { staff_caps: u.staff_caps, staff_map: '1.1.0' } : {}),-- iss: ISSUER, aud: ['openvibe.live', 'openvibe.network'], iat: now, exp: now + 3600,++ iss: ISSUER, aud: ['openvibe.live', 'openvibe.network'], iat: u.expired ? now - 7200 : now, exp: u.expired ? now - 3600 : now + 3600,}, privateKey);}const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });@@@ -215,6 +215,17 @@ t.test('the callback refuses a missing or mismatched state, a forged flow cookieassert.strictEqual(await sessionCount(), 0);});++// The SDK takes the clock as a number: a conversion that passed a function made every expiry check pass.++t.test('an expired access token from the exchange starts no session', async () => {++ const login = await get('/auth/login');++ const loc = new URL(login.headers.get('location'));++ const flow = login.cookies.find((c) => c.startsWith('ovai_flow=')).split(';')[0];++ const code = authorize({ subject_id: people.admin, role: 'admin', challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri'), expired: true });++ const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow);++ assert.ok(cb.status >= 400, `expired token: ${cb.status}`);++ assert.strictEqual(await sessionCount(), 0);++});++t.test('non-staff get 403 and no session: a user, a global_mod, a token without a subject; refusals are audited', async () => {for (const [subject, role] of [[people.user, 'user'], [people.mod, 'global_mod'], [null, 'admin']]) {const r = await signIn(subject, { role });nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// Plan T0/T1: Network tokens verify through openvibe-sdk/auth's JWKS client, not a hand-written copy.++// A token signed by a key in a stub JWKS verifies; with the keys cached a JWKS outage still verifies;++// /api/ready is 503 until a fetch succeeds, and nothing public names the internal JWKS URL or its error.++++const assert = require('assert');++const crypto = require('crypto');++const nodeHttp = require('http');++const { boot, request, token, suite, publicKey, ALL } = require('./helpers');++++const t = suite('jwks');++++const KID = 'test-key-1';++/** The test public key as Network publishes it (the SDK filters on kty/use/alg and matches on kid). */++function jwksDoc() {++ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });++ return { keys: [{ ...jwk, kid: KID, use: 'sig', alg: 'RS256' }] };++}++function jwksServer(doc) {++ const s = { calls: 0, doc };++ s.server = nodeHttp.createServer((_req, res) => {++ s.calls++;++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify(s.doc));++ });++ return new Promise((resolve) => s.server.listen(0, '127.0.0.1', () => {++ s.url = `http://127.0.0.1:${s.server.address().port}`;++ s.close = () => new Promise((r) => { s.server.closeAllConnections?.(); s.server.close(() => r()); });++ resolve(s);++ }));++}++++let h;++let jwks;++const body = { workflow: 'ai.generate', input: { prompt: 'jwks' } };++const run = (tok) => request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok, body });++++t.test('boot with a stub JWKS (no pinned key)', async () => {++ jwks = await jwksServer(jwksDoc());++ h = await boot({ env: { OV_NETWORK_PUBLIC_KEY: '', OV_NETWORK_INTERNAL_URL: jwks.url } });++});++++t.test('/api/ready: 503 until the keys load (every endpoint needs a verified token)', async () => {++ const r = await request(h.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 503, r.text);++ const c = r.body.checks.network_jwks;++ assert.strictEqual(c.required, true);++ assert.strictEqual(c.status, 'fail');++});++++t.test('a token signed by a key from the stub JWKS verifies', async () => {++ const r = await run(token('live', ALL));++ assert.strictEqual(r.status, 201, r.text);++ assert.ok(jwks.calls >= 1, 'the SDK fetched the JWKS');++});++++t.test('/api/ready: the JWKS check turns ok once the keys are cached', async () => {++ const r = await request(h.base, 'GET', '/api/ready');++ const c = r.body.checks.network_jwks;++ assert.strictEqual(c.status, 'ok', JSON.stringify(c));++ assert.strictEqual(c.detail.keys, 1);++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(jwks.url), 'the internal JWKS URL is never public');++});++++t.test('with the keys cached, a JWKS outage still verifies', async () => {++ await jwks.close();++ const before = jwks.calls;++ const r = await run(token('live', ALL)); // fresh token, same signing key++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(jwks.calls, before, 'the cached keys were used, no fetch attempted');++});++++// The SDK supplies only the key: every claim rule is still openvibe-contracts' verifyServiceToken (a review caught a++// conversion that had dropped both of these).++t.test('a sandbox token is still refused', async () => {++ const r = await run(token('probe', ALL, { extra: { env: 'sandbox' } }));++ assert.strictEqual(r.status, 401, r.text);++ assert.match(r.text, /sandbox/);++});++++t.test('a token whose sub is not a principal is a 401, never a 500', async () => {++ const r = await run(token('probe', ALL, { sub: 'not-a-service' }));++ assert.strictEqual(r.status, 401, r.text);++});++++t.test('shutdown', async () => { await h.stop(); });++++// A review caught the SDK's error (the internal JWKS URL and the connect error) answered as the 503 detail.++t.test('an unreachable JWKS is a 503 that names neither the URL nor the error', async () => {++ const dead = 'http://127.0.0.1:9';++ const h2 = await boot({ env: { OV_NETWORK_PUBLIC_KEY: '', OV_NETWORK_INTERNAL_URL: dead } });++ try {++ const r = await request(h2.base, 'POST', '/api/v1/runs?wait=3000', { tok: token('live', ALL), body });++ assert.strictEqual(r.status, 503, r.text);++ assert.ok(!r.text.includes('127.0.0.1:9') && !/ECONNREFUSED|fetch failed/i.test(r.text), r.text);++ const ready = await request(h2.base, 'GET', '/api/ready');++ assert.ok(!ready.text.includes('127.0.0.1:9') && !/ECONNREFUSED|fetch failed/i.test(ready.text), ready.text);++ } finally { await h2.stop(); }++});++++t.run();
@@ -19,9 +19,11 @@ VALKEY_URL=
@@ -19,9 +19,11 @@ VALKEY_URL=
VALKEY_PREFIX=
VALKEY_PREFIX=
# Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
# Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
-# The public key is fetched from <Network>/api/.well-known/jwks; OV_NETWORK_PUBLIC_KEY pins a PEM instead.
+# The public key is fetched from <OV_NETWORK_INTERNAL_URL>/api/.well-known/jwks by openvibe-sdk/auth
+# (fresh keys, the last good ones through an outage, backoff, rotation); OV_NETWORK_PUBLIC_KEY pins a PEM instead.
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_JWKS_URL=http://127.0.0.1:4000
#OV_NETWORK_ISSUER=https://openvibe.network
#OV_NETWORK_ISSUER=https://openvibe.network
# Namespaces fail closed: a token runs only the namespaces its `ns` claim (its Network grant) names; a token
# Namespaces fail closed: a token runs only the namespaces its `ns` claim (its Network grant) names; a token
# without `ns` runs nothing. (AI_NS_FALLBACK and AI_NS_REQUIRED were retired on 2026-09-28.)
# without `ns` runs nothing. (AI_NS_FALLBACK and AI_NS_REQUIRED were retired on 2026-09-28.)
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
[Unit]
[Unit]
Description=OpenVibe.AI — providers, routing, versioned templates and workflows, runs, citations, cache, quotas and audit
Description=OpenVibe.AI — providers, routing, versioned templates and workflows, runs, citations, cache, quotas and audit
# Network (openvibe-network) signs the service tokens callers present. The AI service starts without
# Network (openvibe-network) signs the service tokens callers present. The AI service starts without
-# it and keeps retrying the JWKS fetch; /api/ready stays 503 until the key has loaded.
+# it; the SDK's JWKS client fetches at boot and backs off and retries, and /api/ready stays 503 until
+# the key has loaded (then the last good keys carry it through a Network outage).
After=network.target postgresql.service pgbouncer.service valkey-server.service
After=network.target postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"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.3",
+ "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"
},
},
@@ -1464,9 +1464,9 @@
@@ -1464,9 +1464,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.20.3",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",
- "integrity": "sha512-+g+ySveItzOBnUsw9aZZIJVKRrGuj0aO328W4N25T0Z8mY4fE6++ImvLnZ55LH2BRh0WYozC1KAVIShACm2KGQ==",
+ "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"
@@ -25,7 +25,7 @@
@@ -25,7 +25,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.3",
+ "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"
},
},
@@ -50,7 +50,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -50,7 +50,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => require('./api/runs').sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => require('./api/runs').sendError(res, err, ctx, log) }));
app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys, ops, env, clock, fetchImpl, log }));
+ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log }));
// Crawlers: nothing here is for search engines, the console and sign-in least of all (no sitemap either).
// Crawlers: nothing here is for search engines, the console and sign-in least of all (no sitemap either).
app.get('/robots.txt', (_req, res) => {
app.get('/robots.txt', (_req, res) => {
@@ -1,7 +1,11 @@
@@ -1,7 +1,11 @@
'use strict';
'use strict';
/**
/**
* Authentication: callers present OpenVibe.Network service tokens (RS256 client-credentials JWTs,
* Authentication: callers present OpenVibe.Network service tokens (RS256 client-credentials JWTs,
- * audience openvibe.ai), verified with openvibe-contracts serviceAuth.verifyServiceToken.
+ * audience openvibe.ai), verified with openvibe-contracts serviceAuth.verifyServiceToken (the claim
+ * schema, sandbox refusal, issuer and audience). Only the KEY comes from the SDK: one JWKS client per
+ * URL (openvibe-sdk/auth jwksClient) gives fresh keys, the last good keys through an outage,
+ * exponential backoff, and a rotation honoured on an unknown kid. OV_NETWORK_PUBLIC_KEY still pins a
+ * PEM, which skips the fetch entirely.
*
*
* Capabilities ai.run.create / ai.run.read / ai.workflow.manage / ai.provider.manage / ai.usage.read
* Capabilities ai.run.create / ai.run.read / ai.workflow.manage / ai.provider.manage / ai.usage.read
* are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts yet. Until they
* are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts yet. Until they
@@ -14,8 +18,8 @@
@@ -14,8 +18,8 @@
* ns-less service tokens (AI_NS_FALLBACK) and the open-rule lever (AI_NS_REQUIRED=false), shims
* ns-less service tokens (AI_NS_FALLBACK) and the open-rule lever (AI_NS_REQUIRED=false), shims
* C-22 and C-23, were retired on 2026-09-28 once no token without `ns` was left.
* C-22 and C-23, were retired on 2026-09-28 once no token without `ns` was left.
*/
*/
-const crypto = require('crypto');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
+const { jwksClient, jwksStatus } = require('openvibe-sdk/auth');
const CAPS = Object.freeze({
const CAPS = Object.freeze({
runCreate: 'ai.run.create',
runCreate: 'ai.run.create',
@@ -25,55 +29,17 @@ const CAPS = Object.freeze({
@@ -25,55 +29,17 @@ const CAPS = Object.freeze({
usageRead: 'ai.usage.read',
usageRead: 'ai.usage.read',
});
});
-/** Loads the Network signing key from /api/.well-known/jwks (retrying), or uses a configured PEM. */
-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 };
+/**
+ * The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned
+ * OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.
+ */
+function createNetworkKeys({ config }) {
+ const pinned = config.networkPublicKey || null;
+ return {
+ pinned: Boolean(pinned),
+ /** Every JWKS client's state (url, ready, keys, fetchedAt, stale, failures, lastError, nextTryAt). */
+ status: () => (pinned ? [{ url: null, ready: true, keys: 1, pinned: true }] : jwksStatus()),
+ };
}
}
/** Exact id or `family.*` grant (the rule openvibe-contracts applies to the capabilities it knows). */
/** Exact id or `family.*` grant (the rule openvibe-contracts applies to the capabilities it knows). */
@@ -110,11 +76,33 @@ function principalSubject(sub) {
@@ -110,11 +76,33 @@ function principalSubject(sub) {
return { type: m[1] === 'svc' ? 'service' : m[1], id: m[2] };
return { type: m[1] === 'svc' ? 'service' : m[1], id: m[2] };
}
}
-function createAuth({ config, keys }) {
- function verify(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, log = console }) {
+ /** The kid a token's header names (null when absent or undecodable). */
+ function headerKid(token) {
+ try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }
+ }
+
+ /**
+ * A Network service/app token: the key from the SDK's JWKS client (or the pinned PEM), every rule
+ * from openvibe-contracts' verifyServiceToken (identity.service-token-claims@1, env: sandbox refused).
+ */
+ async function verify(token) {
+ const check = (publicKey) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
+ if (config.networkPublicKey) return check(config.networkPublicKey);
+ let keys;
+ try { keys = await jwksClient(config.networkJwksUrl, { log }).keysForKid(headerKid(token)); } catch (err) {
+ // token.no_key (503): no keys loaded yet, so nothing can be verified; retryable, as before. The
+ // SDK's message names the internal JWKS URL and the fetch error: logged by the client, never answered.
+ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
+ }
+ const kid = headerKid(token);
+ const byKid = kid ? keys.filter((k) => k.kid === kid) : [];
+ let last = { ok: false, code: 'token.unavailable', reason: 'no signing key' };
+ for (const k of byKid.length ? byKid : keys) {
+ last = check(k.key);
+ if (last.ok || last.code !== 'token.bad_signature') return last;
+ }
+ return last;
}
}
/**
/**
@@ -122,21 +110,26 @@ function createAuth({ config, keys }) {
@@ -122,21 +110,26 @@ function createAuth({ config, keys }) {
* Sets req.principal = { sub, subject: {type,id}, cap, ns, jti }.
* Sets req.principal = { sub, subject: {type,id}, cap, ns, jti }.
*/
*/
function requireCap(...anyOf) {
function requireCap(...anyOf) {
- 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);
- if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token (audience openvibe.ai) is required', ctx });
- const r = verify(token);
- if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
- let decision = null;
- for (const id of anyOf) {
- decision = allows(r.claims, id);
- if (decision.allowed) break;
+ try {
+ const token = bearer(req);
+ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token (audience openvibe.ai) is required', ctx });
+ const r = await verify(token);
+ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
+ let decision = null;
+ for (const id of anyOf) {
+ decision = allows(r.claims, id);
+ if (decision.allowed) break;
+ }
+ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });
+ const subject = principalSubject(r.claims.sub);
+ req.principal = { sub: r.claims.sub, subject, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };
+ return next();
+ } catch (err) {
+ log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
+ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx });
}
}
- if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });
- const subject = principalSubject(r.claims.sub);
- req.principal = { sub: r.claims.sub, subject, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };
- return next();
};
};
}
}
@@ -146,4 +139,4 @@ function createAuth({ config, keys }) {
@@ -146,4 +139,4 @@ function createAuth({ config, keys }) {
return { verify, requireCap, principalHas };
return { verify, requireCap, principalHas };
}
}
-module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
+module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
@@ -53,6 +53,8 @@ function load(env = process.env) {
@@ -53,6 +53,8 @@ function load(env = process.env) {
// Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
// Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkInternalUrl: trimUrl(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trimUrl(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ // The JWKS the SDK's client fetches (one client per URL): the internal URL unless OV_NETWORK_JWKS_URL says otherwise.
+ networkJwksUrl: `${trimUrl(env.OV_NETWORK_JWKS_URL || env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000')}/api/.well-known/jwks`,
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.
// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
@@ -88,7 +88,7 @@ const DEF_KINDS = {
@@ -88,7 +88,7 @@ const DEF_KINDS = {
};
};
const DAY_MS = 86400000;
const DAY_MS = 86400000;
-function consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {
+function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {
const cc = config.console;
const cc = config.console;
const r = express.Router();
const r = express.Router();
const scoped = ['/console', '/auth'];
const scoped = ['/console', '/auth'];
@@ -209,9 +209,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys,
@@ -209,9 +209,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys,
}
}
let person;
let person;
try {
try {
- const publicKey = keys.get() || await keys.fetchOnce();
- if (!publicKey) throw new Error('the Network key is not loaded');
- person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, publicKey, now: clock.now(), fetchImpl });
+ person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, now: clock.now(), fetchImpl, log });
} catch (e) {
} catch (e) {
log.warn(`[console] sign-in failed: ${e.message}`);
log.warn(`[console] sign-in failed: ${e.message}`);
return await send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
return await send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
@@ -7,8 +7,8 @@
@@ -7,8 +7,8 @@
* &redirect_uri=<BASE_URL>/auth/callback&scope=profile&state=…&code_challenge=…
* &redirect_uri=<BASE_URL>/auth/callback&scope=profile&state=…&code_challenge=…
* &code_challenge_method=S256
* &code_challenge_method=S256
* exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier), server
* exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier), server
- * to server; the access token is then verified offline with the Network's RS256 key
- * AI already holds (issuer, audience, expiry)
+ * to server; the access token is then verified with openvibe-sdk/auth verifyUserToken
+ * against the Network's JWKS (or the pinned OV_NETWORK_PUBLIC_KEY), as a person
*
*
* The Network's user access token carries `role`, `is_owner` and `subject_id` (and, once the Network
* The Network's user access token carries `role`, `is_owner` and `subject_id` (and, once the Network
* issues them, `staff_caps` / `staff_map`), minted at the code exchange, so what the console reads
* issues them, `staff_caps` / `staff_map`), minted at the code exchange, so what the console reads
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { ids, staff } = require('openvibe-contracts');
const { ids, staff } = require('openvibe-contracts');
+const { verifyUserToken } = require('openvibe-sdk/auth');
-const b64urlJson = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
function pkcePair() {
function pkcePair() {
@@ -40,25 +40,6 @@ function authorizeUrl(config, { state, challenge }) {
@@ -40,25 +40,6 @@ function authorizeUrl(config, { state, challenge }) {
return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
}
}
-/** Verify a Network user access token (RS256). Returns claims or throws with a short reason. */
-function verifyUserToken(token, { publicKey, issuer, audience, now }) {
- const parts = typeof token === 'string' ? token.split('.') : [];
- if (parts.length !== 3) throw new Error('not a JWT');
- let header, claims;
- try { header = b64urlJson(parts[0]); claims = b64urlJson(parts[1]); } catch { throw new Error('undecodable token'); }
- if (header.alg !== 'RS256') throw new Error(`alg ${header.alg} not accepted`);
- let good = false;
- try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url')); } catch { good = false; }
- if (!good) throw new Error('signature does not verify');
- const t = Math.floor(now / 1000);
- if (typeof claims.exp !== 'number' || claims.exp + 30 < t) throw new Error('token expired');
- if (typeof claims.iat === 'number' && claims.iat - 30 > t) throw new Error('token issued in the future');
- if (claims.iss !== issuer) throw new Error('wrong issuer');
- const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
- if (!aud.includes(audience)) throw new Error(`token is not for ${audience}`);
- return claims;
-}
-
/**
/**
* The person a verified token names: { subject, username, staff } or { error }. Service, app and
* The person a verified token names: { subject, username, staff } or { error }. Service, app and
* module principals and FedCM assertions are not people; a person needs a usr_ subject. `staff` is
* module principals and FedCM assertions are not people; a person needs a usr_ subject. `staff` is
@@ -82,7 +63,7 @@ function personFromClaims(claims) {
@@ -82,7 +63,7 @@ function personFromClaims(claims) {
};
};
}
}
-async function exchange({ config, clientSecret, code, verifier, publicKey, now, fetchImpl = globalThis.fetch }) {
+async function exchange({ config, clientSecret, code, verifier, now = Date.now(), fetchImpl = globalThis.fetch, log = null }) {
const cc = config.console;
const cc = config.console;
const body = new URLSearchParams({
const body = new URLSearchParams({
grant_type: 'authorization_code',
grant_type: 'authorization_code',
@@ -113,7 +94,10 @@ async function exchange({ config, clientSecret, code, verifier, publicKey, now,
@@ -113,7 +94,10 @@ async function exchange({ config, clientSecret, code, verifier, publicKey, now,
signal: AbortSignal.timeout(3000),
signal: AbortSignal.timeout(3000),
})).catch(() => {});
})).catch(() => {});
}
}
- const claims = verifyUserToken(data.access_token, { publicKey, issuer: config.issuer, audience: cc.ssoAudience, now });
+ const claims = await verifyUserToken(data.access_token, {
+ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
+ issuer: config.issuer, audience: cc.ssoAudience, now, log,
+ });
return personFromClaims(claims);
return personFromClaims(claims);
}
}
@@ -17,7 +17,8 @@ const { createCache } = require('./cache');
@@ -17,7 +17,8 @@ const { createCache } = require('./cache');
const { createQuotas } = require('./quota');
const { createQuotas } = require('./quota');
const { createRuns } = require('./runs');
const { createRuns } = require('./runs');
const { seed } = require('./workflows/seed');
const { seed } = require('./workflows/seed');
-const { createKeyStore, createAuth } = require('./auth');
+const { createAuth, createNetworkKeys } = require('./auth');
+const { jwksClient } = require('openvibe-sdk/auth');
const schemas = require('./schemas');
const schemas = require('./schemas');
const { createApp } = require('./app');
const { createApp } = require('./app');
@@ -43,10 +44,18 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -43,10 +44,18 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const interrupted = await runs.recoverInterrupted();
const interrupted = await runs.recoverInterrupted();
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
- const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
- const auth = createAuth({ config, keys });
+ // The Network signing keys live in the SDK's process-wide JWKS client (one per URL): freshness,
+ // last-good-keys, backoff and rotation are its job. A pinned PEM never fetches. Not started under
+ // NODE_ENV=test (tests pin a PEM or point the URL at a stub and let the client fetch on first use).
+ const keys = createNetworkKeys({ config });
+ const auth = createAuth({ config, log });
const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials });
const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials });
- const keyLoaded = keys.start().catch(() => null);
+ let jwks = null;
+ let keyLoaded = Promise.resolve(null);
+ if (!keys.pinned && config.nodeEnv !== 'test') {
+ jwks = jwksClient(config.networkJwksUrl, { log, fetch: fetchImpl }).start();
+ keyLoaded = jwks.keys().catch(() => null); // resolves once the first fetch is done (null if it failed)
+ }
const housekeeping = setInterval(async () => {
const housekeeping = setInterval(async () => {
try {
try {
@@ -75,7 +84,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -75,7 +84,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
async function close() {
async function close() {
clearInterval(housekeeping);
clearInterval(housekeeping);
userModules.stop();
userModules.stop();
- keys.stop();
+ if (jwks) jwks.stop();
if (server) {
if (server) {
server.closeAllConnections?.();
server.closeAllConnections?.();
await new Promise(resolve => server.close(() => resolve()));
await new Promise(resolve => server.close(() => resolve()));
@@ -5,8 +5,9 @@
@@ -5,8 +5,9 @@
*
*
* db required a real query on AI's PostgreSQL database (the provider registry answers)
* db required a real query on AI's PostgreSQL database (the provider registry answers)
* workflows required at least one active workflow: without one there is nothing to run
* workflows required at least one active workflow: without one there is nothing to run
- * network_jwks required the Network signing key has loaded. Every endpoint but health/ready
- * needs a verified service token, so without it AI serves nothing
+ * network_jwks required the Network signing key has loaded (openvibe-sdk/auth jwksStatus()). Every
+ * endpoint but health/ready needs a verified service token, so without it AI
+ * serves nothing; once loaded, the SDK keeps the last good keys through an outage
* providers optional provider configuration: every active non-stub provider has the
* providers optional provider configuration: every active non-stub provider has the
* credentials it needs and a circuit that is not open. A missing key or an
* credentials it needs and a circuit that is not open. A missing key or an
* open circuit sends runs to fallbacks (or to a 503 provider.unavailable),
* open circuit sends runs to fallbacks (or to a 503 provider.unavailable),
@@ -48,7 +49,17 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
@@ -48,7 +49,17 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
return n > 0 ? { ok: true, detail: { active: n } } : 'no active workflow';
return n > 0 ? { ok: true, detail: { active: n } } : 'no active workflow';
},
},
},
},
- { name: 'network_jwks', required: true, check: () => keys.loaded() || 'Network signing key not loaded yet: no service token can be verified' },
+ {
+ name: 'network_jwks', required: true,
+ // The SDK's JWKS clients: a pinned PEM reports ready on its own, otherwise the last good keys do
+ // (they stay loaded through a Network outage). Public: counts and times only, never the internal
+ // URL or the fetch error.
+ check: () => {
+ const ready = keys.status().find(s => s.ready);
+ if (!ready) return 'Network signing key not loaded yet: no service token can be verified';
+ return { ok: true, detail: { keys: ready.keys, pinned: Boolean(ready.pinned), stale: Boolean(ready.stale), failures: ready.failures || 0 } };
+ },
+ },
{
{
name: 'providers', required: false,
name: 'providers', required: false,
check: async () => {
check: async () => {
@@ -32,9 +32,9 @@ const news = token('news', ['ai.run.create', 'ai.run.read']);
@@ -32,9 +32,9 @@ const news = token('news', ['ai.run.create', 'ai.run.read']);
const codes = new Map();
const codes = new Map();
const grants = [];
const grants = [];
const revoked = [];
const revoked = [];
-function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', staff_caps, challenge, redirect_uri }) {
+function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', staff_caps, challenge, redirect_uri, expired = false }) {
const code = crypto.randomBytes(16).toString('hex');
const code = crypto.randomBytes(16).toString('hex');
- codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri });
+ codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri, expired });
return code;
return code;
}
}
function userToken(u) {
function userToken(u) {
@@ -42,7 +42,7 @@ function userToken(u) {
@@ -42,7 +42,7 @@ function userToken(u) {
return serviceAuth.signServiceToken({
return serviceAuth.signServiceToken({
sub: 42, id: 42, ...(u.subject_id ? { subject_id: u.subject_id } : {}), username: u.username, role: u.role, ...(u.is_owner ? { is_owner: true } : {}),
sub: 42, id: 42, ...(u.subject_id ? { subject_id: u.subject_id } : {}), username: u.username, role: u.role, ...(u.is_owner ? { is_owner: true } : {}),
...(u.staff_caps ? { staff_caps: u.staff_caps, staff_map: '1.1.0' } : {}),
...(u.staff_caps ? { staff_caps: u.staff_caps, staff_map: '1.1.0' } : {}),
- iss: ISSUER, aud: ['openvibe.live', 'openvibe.network'], iat: now, exp: now + 3600,
+ iss: ISSUER, aud: ['openvibe.live', 'openvibe.network'], iat: u.expired ? now - 7200 : now, exp: u.expired ? now - 3600 : now + 3600,
}, privateKey);
}, privateKey);
}
}
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
@@ -215,6 +215,17 @@ t.test('the callback refuses a missing or mismatched state, a forged flow cookie
@@ -215,6 +215,17 @@ t.test('the callback refuses a missing or mismatched state, a forged flow cookie
assert.strictEqual(await sessionCount(), 0);
assert.strictEqual(await sessionCount(), 0);
});
});
+// The SDK takes the clock as a number: a conversion that passed a function made every expiry check pass.
+t.test('an expired access token from the exchange starts no session', async () => {
+ const login = await get('/auth/login');
+ const loc = new URL(login.headers.get('location'));
+ const flow = login.cookies.find((c) => c.startsWith('ovai_flow=')).split(';')[0];
+ const code = authorize({ subject_id: people.admin, role: 'admin', challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri'), expired: true });
+ const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow);
+ assert.ok(cb.status >= 400, `expired token: ${cb.status}`);
+ assert.strictEqual(await sessionCount(), 0);
+});
+
t.test('non-staff get 403 and no session: a user, a global_mod, a token without a subject; refusals are audited', async () => {
t.test('non-staff get 403 and no session: a user, a global_mod, a token without a subject; refusals are audited', async () => {
for (const [subject, role] of [[people.user, 'user'], [people.mod, 'global_mod'], [null, 'admin']]) {
for (const [subject, role] of [[people.user, 'user'], [people.mod, 'global_mod'], [null, 'admin']]) {
const r = await signIn(subject, { role });
const r = await signIn(subject, { role });
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// Plan T0/T1: Network tokens verify through openvibe-sdk/auth's JWKS client, not a hand-written copy.
+// A token signed by a key in a stub JWKS verifies; with the keys cached a JWKS outage still verifies;
+// /api/ready is 503 until a fetch succeeds, and nothing public names the internal JWKS URL or its error.
+
+const assert = require('assert');
+const crypto = require('crypto');
+const nodeHttp = require('http');
+const { boot, request, token, suite, publicKey, ALL } = require('./helpers');
+
+const t = suite('jwks');
+
+const KID = 'test-key-1';
+/** The test public key as Network publishes it (the SDK filters on kty/use/alg and matches on kid). */
+function jwksDoc() {
+ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
+ return { keys: [{ ...jwk, kid: KID, use: 'sig', alg: 'RS256' }] };
+}
+function jwksServer(doc) {
+ const s = { calls: 0, doc };
+ s.server = nodeHttp.createServer((_req, res) => {
+ s.calls++;
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(s.doc));
+ });
+ return new Promise((resolve) => s.server.listen(0, '127.0.0.1', () => {
+ s.url = `http://127.0.0.1:${s.server.address().port}`;
+ s.close = () => new Promise((r) => { s.server.closeAllConnections?.(); s.server.close(() => r()); });
+ resolve(s);
+ }));
+}
+
+let h;
+let jwks;
+const body = { workflow: 'ai.generate', input: { prompt: 'jwks' } };
+const run = (tok) => request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok, body });
+
+t.test('boot with a stub JWKS (no pinned key)', async () => {
+ jwks = await jwksServer(jwksDoc());
+ h = await boot({ env: { OV_NETWORK_PUBLIC_KEY: '', OV_NETWORK_INTERNAL_URL: jwks.url } });
+});
+
+t.test('/api/ready: 503 until the keys load (every endpoint needs a verified token)', async () => {
+ const r = await request(h.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 503, r.text);
+ const c = r.body.checks.network_jwks;
+ assert.strictEqual(c.required, true);
+ assert.strictEqual(c.status, 'fail');
+});
+
+t.test('a token signed by a key from the stub JWKS verifies', async () => {
+ const r = await run(token('live', ALL));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.ok(jwks.calls >= 1, 'the SDK fetched the JWKS');
+});
+
+t.test('/api/ready: the JWKS check turns ok once the keys are cached', async () => {
+ const r = await request(h.base, 'GET', '/api/ready');
+ const c = r.body.checks.network_jwks;
+ assert.strictEqual(c.status, 'ok', JSON.stringify(c));
+ assert.strictEqual(c.detail.keys, 1);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(jwks.url), 'the internal JWKS URL is never public');
+});
+
+t.test('with the keys cached, a JWKS outage still verifies', async () => {
+ await jwks.close();
+ const before = jwks.calls;
+ const r = await run(token('live', ALL)); // fresh token, same signing key
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(jwks.calls, before, 'the cached keys were used, no fetch attempted');
+});
+
+// The SDK supplies only the key: every claim rule is still openvibe-contracts' verifyServiceToken (a review caught a
+// conversion that had dropped both of these).
+t.test('a sandbox token is still refused', async () => {
+ const r = await run(token('probe', ALL, { extra: { env: 'sandbox' } }));
+ assert.strictEqual(r.status, 401, r.text);
+ assert.match(r.text, /sandbox/);
+});
+
+t.test('a token whose sub is not a principal is a 401, never a 500', async () => {
+ const r = await run(token('probe', ALL, { sub: 'not-a-service' }));
+ assert.strictEqual(r.status, 401, r.text);
+});
+
+t.test('shutdown', async () => { await h.stop(); });
+
+// A review caught the SDK's error (the internal JWKS URL and the connect error) answered as the 503 detail.
+t.test('an unreachable JWKS is a 503 that names neither the URL nor the error', async () => {
+ const dead = 'http://127.0.0.1:9';
+ const h2 = await boot({ env: { OV_NETWORK_PUBLIC_KEY: '', OV_NETWORK_INTERNAL_URL: dead } });
+ try {
+ const r = await request(h2.base, 'POST', '/api/v1/runs?wait=3000', { tok: token('live', ALL), body });
+ assert.strictEqual(r.status, 503, r.text);
+ assert.ok(!r.text.includes('127.0.0.1:9') && !/ECONNREFUSED|fetch failed/i.test(r.text), r.text);
+ const ready = await request(h2.base, 'GET', '/api/ready');
+ assert.ok(!ready.text.includes('127.0.0.1:9') && !/ECONNREFUSED|fetch failed/i.test(ready.text), ready.text);
+ } finally { await h2.stop(); }
+});
+
+t.run();