openvibers/openvibe.codes Commits

Network tokens verify through openvibe-sdk/auth's JWKS client (v0.23.1) in place of Codes' own key fetcher: the last good keys through a Network outage, backoff, a rotation honoured at once. Sign-in sessions and playground app tokens use the SDK's verifyUserToken/verifyAppToken (the clock passed as a number, so expiry is enforced; principal tokens are never a person). The /api/v1 guards verify each service token against the key its kid names, so a rotation that publishes two keys keeps working. Nothing public names the internal JWKS URL or the fetch error: fixed reasons in answers, the real error logged, and /api/ready's network_jwks (still optional) reports counts and times only. Tests: a stub JWKS, two keys by kid, an outage with cached keys, an unreachable JWKS, an expired session. npm test and test:pg 29/29.

OP OpenVibers committed 29 Sept 2026 · 57340a97fb · 13 files, +277 −89


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 272d559..d4f6896 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pg": "^8.23.0"
},
@@@ -1173,9 +1173,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.20.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
-- "integrity": "sha512-3Zwz8sJhWhnuw9GYr6ieIDp9CIl6ug0QqJE5ifDsk4ckrethBjcDzjadE1GSCGQDFXXLf/+bVWtI7X3VOVXmFQ==",
++ "version": "0.23.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
++ "integrity": "sha512-6u1Ym8u+nSKyq+uJjel4/3QDQ9HOjpQzFa8oMasorXQhgHUlDEQ7X6F6Xf1zZTuuVdMVkxOeqJqOnQbraktosg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 425bda3..9c3379e 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pg": "^8.23.0"
},
ddiff --git a/server/app.js b/server/app.js
iindex 343e211..7e9ebb8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -116,7 +116,7 @@ async function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
-- const readiness = createCodesReadiness({ store, keys, network, outbox, docs, config, release: release.release, valkey: ctx.valkey });
++ const readiness = createCodesReadiness({ store, network, outbox, docs, config, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
// ── Who is asking (verified offline; refreshed when expired) ──
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
iindex 4658515..61e73aa 100644
---- a/server/auth/keys.js
++++ b/server/auth/keys.js
@@@ -1,74 +1,102 @@
'use strict';
/**
-- * The Network's RS256 signing key, fetched from GET /api/.well-known/jwks (internal URL first, then
-- * the public one) and cached. Tokens are verified offline against it: the person's access token
-- * (sign-in) and, in playgrounds, the app token a developer brings.
++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured
++ * internal URL and kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline
++ * against it: the person's session token (sign-in) and, in playgrounds, the app token a developer
++ * brings.
++ *
++ * The SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
++ * rotation honoured at once, unknown-kid floods throttled, the refresh timer unref'd. This module
++ * is a thin shim so the rest of Codes (the contracts-based requireCapability guard, the readiness
++ * check, the SSO middleware) can keep talking to `keys.get()` / `keys.ensure()` / `keys.loaded()`,
++ * while verification goes through the SDK's verifyUserToken / verifyAppToken with `jwks: <url>`.
++ *
++ * Nothing the SDK says about a failed key fetch leaves this module: its message names the internal
++ * JWKS URL and the fetch error. Every failure gets a fixed, public reason ('signing key not loaded
++ * yet' for token.no_key, 'token does not verify' otherwise) and the real error is logged.
*/
--const crypto = require('crypto');
++const sdk = require('openvibe-sdk/auth');
--function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
-- let publicKey = null;
-- let lastFetch = 0;
-- let inflight = null;
++/** Public reasons. The SDK's own text (the JWKS URL, the fetch error) never goes over the wire. */
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
-- async function fetchKey() {
-- for (const base of [config.networkInternalUrl, config.networkUrl]) {
-- if (!base) continue;
-- try {
-- const res = await fetchImpl(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
-- if (!res.ok) continue;
-- const jwks = await res.json();
-- if (jwks && typeof jwks.public_key === 'string') { publicKey = jwks.public_key; return publicKey; }
-- const jwk = jwks && Array.isArray(jwks.keys) ? jwks.keys.find((k) => k.kty === 'RSA') : null;
-- if (jwk) { publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' }); return publicKey; }
-- } catch (err) {
-- log.warn(`[Codes] JWKS fetch failed from ${base}: ${err && err.name === 'TimeoutError' ? 'timeout' : (err && err.message)}`);
-- }
-- }
-- return null;
-- }
++/** Resolve the JWKS URL for the configured Network (internal first, then public). */
++function jwksUrl(config) {
++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
++}
++
++/**
++ * `keys` is what app.js hands the rest of the service: a facade over the SDK's jwksClient. The
++ * contracts-based requireCapability wants a synchronous PEM, so we mirror the cached first key as
++ * PEM — refreshed every time the SDK gives us keys (ensure(), verifyUser(), verifyApp()).
++ */
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null; // { pem, count } — the first cached key as PEM
-- async function ensure() {
-- if (publicKey) return publicKey;
-- if (inflight) return inflight;
-- if (Date.now() - lastFetch < 30_000) return null; // don't hammer a Network that is down
-- lastFetch = Date.now();
-- inflight = fetchKey().finally(() => { inflight = null; });
-- return inflight;
++ async function refreshMirror() {
++ try {
++ const ks = await client.keys();
++ const first = ks && ks[0];
++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
++ } catch (err) { log.warn(`[Codes] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
}
++ /** First fetch (or wait out the backoff window). Refreshes the mirror. */
++ async function ensure() { await refreshMirror(); }
++ /** Cached public key as PEM — for openvibe-contracts' verifyServiceToken (synchronous). */
++ function get() { return mirror ? mirror.pem : null; }
++ function loaded() { return Boolean(mirror); }
-- return { ensure, get: () => publicKey, loaded: () => Boolean(publicKey) };
--}
++ /** The SDK's `now` is a NUMBER of milliseconds (it does Math.floor(now / 1000)); a function disables every expiry check. */
++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
--const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
++ // Verify a user (session) token via the SDK. Returns { ok, claims } | { ok: false, reason, code, expired }.
++ // `reason` is one of the two fixed public strings; the SDK's text is logged, never returned.
++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[Codes] user token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: code === 'token.no_key' ? KEY_UNAVAILABLE : DOES_NOT_VERIFY, code, expired: code === 'token.expired' };
++ }
++ }
++ // Verify an app token. Same shape and same fixed reasons. The playground reads v.reason for its detail.
++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[Codes] app token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: code === 'token.no_key' ? KEY_UNAVAILABLE : DOES_NOT_VERIFY, code };
++ }
++ }
--/**
-- * Verify an RS256 JWT signed by the Network. Returns { ok, claims } or { ok: false, reason, expired }.
-- * `audience` is optional (Network user tokens are checked by issuer, like every OpenVibe site).
-- */
--function verifyJwt(token, { publicKey, issuer, audience, now = Date.now(), skewSec = 30 }) {
-- const fail = (reason, extra = {}) => ({ ok: false, reason, ...extra });
-- if (!publicKey) return fail('signing key not loaded');
-- const parts = typeof token === 'string' ? token.split('.') : [];
-- if (parts.length !== 3) return fail('not a JWT');
-- let header, claims;
-- try { header = b64json(parts[0]); claims = b64json(parts[1]); } catch { return fail('undecodable token'); }
-- if (!header || header.alg !== 'RS256') return fail('only RS256 tokens are 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) return fail('signature does not verify');
-- if (!claims || typeof claims !== 'object') return fail('no claims');
-- if (issuer && claims.iss !== issuer) return fail('wrong issuer');
-- if (audience) {
-- const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
-- if (!aud.includes(audience)) return fail(`token is not for ${audience}`);
++ /**
++ * The PEM for the key a token's header names (a rotation publishes two keys: the first one is not always it), for
++ * openvibe-contracts' synchronous guard. The SDK refetches for an unknown kid (at most every 30 s). Null when no
++ * key is loaded; a token that names no kid gets the first key.
++ */
++ async function pemForToken(token) {
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
++ try {
++ const ks = await client.keysForKid(kid);
++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
++ } catch { return null; }
}
-- const t = Math.floor(now / 1000);
-- if (typeof claims.iat === 'number' && claims.iat - skewSec > t) return fail('issued in the future');
-- if (typeof claims.exp !== 'number') return fail('no expiry');
-- if (claims.exp + skewSec < t) return fail('expired', { expired: true, claims });
-- return { ok: true, claims };
++
++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
}
--module.exports = { createKeyStore, verifyJwt };
++module.exports = { createKeyStore, jwksUrl };
\\ No newline at end of file
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex e223537..9ee598e 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -19,7 +19,6 @@ const { staff: staffMap } = require('openvibe-contracts');
*/
const crypto = require('crypto');
const express = require('express');
--const { verifyJwt } = require('./keys');
const ACCESS_COOKIE = 'codes_at';
const REFRESH_COOKIE = 'codes_rt';
@@@ -94,8 +93,8 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
* signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and
* internal tokens with the same key and issuer; none of them may become a session here.
*/
-- function verifySession(token) {
-- const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
++ async function verifySession(token) {
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
const c = v.claims;
if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
return v;
@@@ -129,7 +128,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
try {
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
setSession(res, data);
-- const v = verifySession(data.access_token);
++ const v = await verifySession(data.access_token);
return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
} catch (err) {
if (err.status && err.status < 500) clearSession(res);
@@@ -144,7 +143,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
const token = req.cookies && req.cookies[ACCESS_COOKIE];
if (!token) return next();
await keys.ensure();
-- const v = verifySession(token);
++ const v = await verifySession(token);
if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
if (v.expired) {
const fresh = await refresh(req, res);
ddiff --git a/server/domain/playground.js b/server/domain/playground.js
iindex 04afa82..9b1e1d6 100644
---- a/server/domain/playground.js
++++ b/server/domain/playground.js
@@@ -28,7 +28,6 @@ const contracts = require('openvibe-contracts');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createEventsClient } = require('openvibe-sdk/events');
const { createMediaClient } = require('openvibe-sdk/media');
--const { verifyJwt } = require('../auth/keys');
const { GRANTABLE_VISIBILITIES } = require('../docs/generate');
const contractsVersion = require('openvibe-contracts/package.json').version;
@@@ -133,7 +132,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
token = credential.value;
}
await keys.ensure();
-- const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, audience: k.audience, now: store.now() });
++ const v = await keys.verifyApp(token, { issuer: config.networkIssuer, audience: k.audience, acceptSandbox: true, now: store.now() });
if (!v.ok) return { ok: false, code: 'playground.token_invalid', detail: `the token is not usable: ${v.reason}` };
const c = v.claims;
const problems = [];
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a5ce1b1..c55b282 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -55,11 +55,14 @@ function createApi(ctx) {
res.status(v.valid ? 200 : 422).json({ ...v, contracts_version: docs.contractsVersion });
});
-- // Guards (the Network key is fetched before the synchronous guard runs).
-- const guardOpts = { getPublicKey: () => keys.get(), issuer: config.networkIssuer, audience: 'openvibe.codes', acceptSandbox: true };
-- const loadKey = (req, res, next) => { keys.ensure().then(() => next(), () => next()); };
-- const manageGuard = serviceAuth.requireCapability('codes.release.manage', guardOpts);
-- const readGuard = serviceAuth.requireCapability('codes.release.read', guardOpts);
++ // Guards: the key the token names is looked up first (the SDK's JWKS client), then the synchronous contracts guard
++ // runs against it.
++ const guardOpts = { issuer: config.networkIssuer, audience: 'openvibe.codes', acceptSandbox: true };
++ const bearerToken = (req) => String(req.headers.authorization || '').replace(/^Bearer\s+/, '');
++ const loadKey = (req, res, next) => { keys.pemForToken(bearerToken(req)).then((pem) => { req.ovNetworkKey = pem; next(); }, () => next()); };
++ const guardFor = (cap) => (req, res, next) => serviceAuth.requireCapability(cap, { ...guardOpts, getPublicKey: () => req.ovNetworkKey || null })(req, res, next);
++ const manageGuard = guardFor('codes.release.manage');
++ const readGuard = guardFor('codes.release.read');
const bearer = (req) => String(req.headers.authorization || '').startsWith('Bearer ');
// Public reads: anonymous callers pass; a presented token must be valid and hold codes.release.read.
const readAccess = [loadKey, (req, res, next) => (bearer(req) ? readGuard(req, res, next) : next())];
ddiff --git a/server/index.js b/server/index.js
iindex 598592f..3a1732d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -17,7 +17,9 @@ const server = app.listen(config.port, config.host, () => {
});
server.keepAliveTimeout = 65_000;
ctx.outbox.start();
--ctx.keys.ensure().catch(() => {});
++// Keep the JWKS cache fresh in the background (openvibe-sdk/auth): one client per URL, the last
++// good keys through outages, exponential backoff, unknown-kid floods throttled, an unref'd timer.
++ctx.keys.client.start();
function shutdown(signal) {
console.log(`[Codes] ${signal}: closing`);
ddiff --git a/server/observability.js b/server/observability.js
iindex dbba3d2..22f02a3 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -4,17 +4,20 @@
*
* db required a real query on Codes' PostgreSQL store: every Codes table answers
* docs required the reference was generated at boot from the pinned packages
-- * network_jwks optional the Network signing key is loaded; without it nobody can sign in and
-- * app tokens cannot be verified (docs and tools still serve)
++ * jwks optional openvibe-sdk/auth's JWKS cache has the Network signing key; without
++ * it nobody can sign in and app tokens cannot be verified (docs and
++ * tools still serve). The SDK keeps one client per URL and reports
++ * readiness, staleness, failures and the next try for us.
* network optional the Network answers (its /.well-known/openvibe descriptor); without
* it the portal shows Network's failure on every project page
* oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in and the events relay need it)
* events_relay optional the outbox relay is configured and has no rejected rows
*/
++const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
const { TABLES } = require('./db');
--function createCodesReadiness({ store, keys, network, outbox, docs, config, valkey = null, release = null }) {
++function createCodesReadiness({ store, network, outbox, docs, config, valkey = null, release = null }) {
const { db } = store;
return createReadiness({
service: 'codes',
@@@ -40,10 +43,14 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, valk
{ name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
name: 'network_jwks', required: false,
++ // Public: counts and times only. Never the internal JWKS URL and never the SDK's
++ // lastError (it names both) — the SDK logs the real error server-side.
check: () => {
-- if (keys.loaded()) return true;
-- keys.ensure().catch(() => {});
-- return 'Network signing key not loaded yet: sign-in and token checks are unavailable';
++ const statuses = jwksStatus();
++ if (!statuses.length) return 'no JWKS clients (misconfigured)';
++ const first = statuses[0];
++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and token checks are unavailable';
++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
},
},
{
ddiff --git a/test/auth-jwks.test.js b/test/auth-jwks.test.js
nnew file mode 100644
iindex 0000000..ba42ee2
---- /dev/null
++++ b/test/auth-jwks.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * The Network signing key is fetched and verified through openvibe-sdk/auth (plan T0/T1). The
++ * SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
++ * rotation honoured at once, unknown-kid floods throttled. Codes is a thin shim around it: the
++ * boot reads the key and verifies a session/app token against the URL,
++ * a JWKS outage with cached keys still verifies,
++ * readiness (network_jwks) shows the cache state.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++function jwksServer({ doc = null, fail = null } = {}) {
++ const seen = [];
++ const srv = http.createServer((req, res) => {
++ seen.push(req.url);
++ if (fail) { res.writeHead(503); return res.end('down'); }
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(doc));
++ });
++ return new Promise((resolve) => {
++ srv.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${srv.address().port}`,
++ close: () => new Promise((r) => srv.close(r)),
++ seen,
++ setDoc: (d) => { doc = d; },
++ setFail: (f) => { fail = f; },
++ }));
++ });
++}
++
++function signJwt(claims, privateKey, { kid = null } = {}) {
++ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };
++ const input = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++function pem(k) { return k.export({ type: 'spki', format: 'pem' }); }
++function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid }; }
++
++(async () => {
++ await check('a token signed by a key from a stub JWKS verifies', async () => {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
++ const { createKeyStore, jwksUrl } = require('../server/auth/keys');
++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: console });
++ const url = jwksUrl(config);
++ await keys.ensure();
++ const now = Math.floor(Date.now() / 1000);
++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, true, `verify should succeed: ${v.reason || ''}`);
++ assert.strictEqual(v.claims.username, 'demo');
++ assert.ok(keys.get(), 'the cached PEM is exposed to the contracts guard');
++ assert.deepStrictEqual(jwks.seen, ['/api/.well-known/jwks']);
++ await jwks.close();
++ });
++
++ // A rotation publishes two keys: the /api/v1 guard must verify against the one the token names, not the first.
++ await check('service tokens get the key their kid names (a rotation keeps two keys)', async () => {
++ const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] } });
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: { warn() {}, info() {} } });
++ const now = Math.floor(Date.now() / 1000);
++ const claims = { iss: config.networkIssuer, sub: 'app:x', aud: ['openvibe.codes'], iat: now, exp: now + 300 };
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, b.privateKey, { kid: 'new' })), pem(b.publicKey), 'the new key for a token signed with it');
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey, { kid: 'old' })), pem(a.publicKey));
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey)), pem(a.publicKey), 'no kid: the first key');
++ assert.strictEqual(await keys.pemForToken('garbage'), pem(a.publicKey), 'an undecodable header falls back to the first key (the guard refuses the token)');
++ await jwks.close();
++ });
++
++ await check('with keys cached, a JWKS outage still verifies (the last good keys)', async () => {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: console });
++ await keys.ensure();
++ const before = jwks.seen.length;
++ jwks.setFail(true); // Network goes away
++ const now = Math.floor(Date.now() / 1000);
++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, true, `outage should not break verification: ${v.reason || ''}`);
++ assert.strictEqual(jwks.seen.length, before, 'no new fetch during the outage');
++ await jwks.close();
++ });
++
++ await check('readiness (network_jwks) shows the SDK JWKS state', async () => {
++ const t = await boot();
++ const r = await t.get('/api/ready');
++ const b = r.json();
++ assert.strictEqual(b.checks.network_jwks.status, 'ok', `readiness JWKS not ok: ${JSON.stringify(b.checks.network_jwks)}`);
++ assert.ok(b.checks.network_jwks.detail && b.checks.network_jwks.detail.keys >= 1, `detail.keys should be >= 1: ${JSON.stringify(b.checks.network_jwks.detail)}`);
++ assert.strictEqual(b.checks.network_jwks.detail.failures, 0);
++ await t.close();
++ });
++
++ await check('an unreachable JWKS never names the URL or the fetch error (503, /api/ready)', async () => {
++ // Port 9 (discard) refuses instantly: the SDK's message is "JWKS http://127.0.0.1:9/…: connect ECONNREFUSED".
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { networkInternalUrl: 'http://127.0.0.1:9', networkUrl: 'http://127.0.0.1:9', networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: { warn() {}, log() {}, info() {}, error() {} } });
++ await keys.ensure();
++ const now = Math.floor(Date.now() / 1000);
++ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey);
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, false);
++ assert.strictEqual(v.code, 'token.no_key', `the failure is token.no_key: ${v.code}`);
++ assert.strictEqual(v.reason, 'signing key not loaded yet', 'a fixed public reason, not the SDK text');
++ assert.ok(!/127\.0\.0\.1|jwks|ECONNREFUSED|fetch failed/i.test(v.reason), `reason leaks: ${v.reason}`);
++
++ // The same unreachable JWKS behind a booted Codes: neither /api/v1 nor /api/ready names it.
++ const t = await boot({ env: { OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9' } });
++ const ready = await t.get('/api/ready');
++ const readyText = ready.text;
++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),
++ `/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);
++ const api = await t.get('/api/v1/releases/rel_missing', { headers: { authorization: `Bearer ${token}` } });
++ assert.strictEqual(api.status, 401, `an unverifiable token is 401: ${api.status} ${api.text.slice(0, 200)}`);
++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(api.text),
++ `the API answer leaks the JWKS URL or the fetch error: ${api.text.slice(0, 300)}`);
++ await t.close();
++ });
++
++ await check('an expired session token starts no session (the SDK takes the clock as a number)', async () => {
++ const t = await boot();
++ const u = t.network.addUser('expired-demo');
++ // Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.
++ const expired = t.network.userToken(u, { ttl: -3600 });
++ const me = await t.get('/auth/me', { cookie: `codes_at=${expired}` });
++ assert.strictEqual(me.status, 401, `an expired cookie is not a session: ${me.status} ${me.text.slice(0, 200)}`);
++ const page = await t.get('/', { cookie: `codes_at=${expired}` });
++ assert.ok(page.status < 400, `a page still renders: ${page.status}`);
++ assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');
++ await t.close();
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
\\ No newline at end of file
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 4fbb039..084032e 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -34,8 +34,10 @@ const { check, done } = require('./helpers/boot');
assert.strictEqual(c.status, 'active');
}
const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
-- assert.match(api, /requireCapability\('codes\.release\.manage'/);
-- assert.match(api, /requireCapability\('codes\.release\.read'/);
++ // The guards are serviceAuth.requireCapability built per request with the key the token names (guardFor).
++ assert.match(api, /serviceAuth\.requireCapability\(cap,/);
++ assert.match(api, /guardFor\('codes\.release\.manage'\)/);
++ assert.match(api, /guardFor\('codes\.release\.read'\)/);
});
await check('no proposal left behind: everything proposed is released', async () => {
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex 8c2064a..1e5453d 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -22,7 +22,7 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.strictEqual(tag('openvibe-contracts'), 'v0.76.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
-- assert.strictEqual(sdkPkg.version, '0.20.1');
++ assert.strictEqual(sdkPkg.version, '0.23.1');
// The contracts tag v0.76.0 carries the released codes manifest and codes.app-manifest@1.
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex c43dd73..82f0569 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -52,7 +52,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
const REVIEWED = {
-- 'server/auth/keys.js': 'Network JWKS (configured)',
++ 'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',
'server/domain/project-archive.js': 'no request: a shell snippet in the archive\'s README text',