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).

OP OpenVibers committed 29 Sept 2026 · dde5e8fd42 · 13 files, +230 −117


Changes

ddiff --git a/.env.example b/.env.example
iindex 5456df0..bb37abb 100644
---- a/.env.example
++++ b/.env.example
@@@ -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.network
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
# 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.)
ddiff --git a/deploy/systemd/openvibe-ai.service b/deploy/systemd/openvibe-ai.service
iindex 85e876a..a020c6c 100644
---- a/deploy/systemd/openvibe-ai.service
++++ b/deploy/systemd/openvibe-ai.service
@@@ -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.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
ddiff --git a/package-lock.json b/package-lock.json
iindex 2a29880..4dec4cd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 0db6617..84a642b 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/app.js b/server/app.js
iindex dfbc2c3..1ad9f24 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) }));
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) => {
ddiff --git a/server/auth.js b/server/auth.js
iindex c4935ba..8ad4e0e 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 1285e60..94df310 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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(),
ddiff --git a/server/console/index.js b/server/console/index.js
iindex b184603..4c794c0 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -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.' }));
ddiff --git a/server/console/sso.js b/server/console/sso.js
iindex 20a7831..98ff63b 100644
---- a/server/console/sso.js
++++ b/server/console/sso.js
@@@ -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);
}
ddiff --git a/server/index.js b/server/index.js
iindex 83cf9bf..9304455 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.now
const 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.now
async function close() {
clearInterval(housekeeping);
userModules.stop();
-- keys.stop();
++ if (jwks) jwks.stop();
if (server) {
server.closeAllConnections?.();
await new Promise(resolve => server.close(() => resolve()));
ddiff --git a/server/observability.js b/server/observability.js
iindex 6917e26..d99ebf1 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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 () => {
ddiff --git a/test/console.test.js b/test/console.test.js
iindex abc7ef1..26c31df 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -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 cookie
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 () => {
for (const [subject, role] of [[people.user, 'user'], [people.mod, 'global_mod'], [null, 'admin']]) {
const r = await signIn(subject, { role });
ddiff --git a/test/jwks.test.js b/test/jwks.test.js
nnew file mode 100644
iindex 0000000..a1eea66
---- /dev/null
++++ b/test/jwks.test.js
@@@ -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();