openvibers/openvibe.sdk Commits
openvibe-sdk 0.40.0: createNetworkKeys (the Network signing keys every verifier reads) (#36)
Changes
@@@ -3,6 +3,19 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.40.0 (2026-10-10)++++- **New: `createNetworkKeys()`** (`openvibe-sdk/auth`, server). The keys a service verifies Network tokens with, as one++ object: a pinned key (`publicKey`, e.g. `OV_NETWORK_PUBLIC_KEY`) used as is, or Network's JWKS (`network` or++ `jwksUrl`) through the process-wide JWKS client, so a token naming an unknown kid is verified after one refetch++ (a rotation) and the last good keys are served through a Network outage. `start()` fetches at once and retries every++ 30 s until the first load (Network may still be booting), then refreshes every 15 minutes; `stop()` clears both.++ `keys.verifyOptions` spreads into `verifyUserToken`, `verifyAppToken` and `verifyServiceToken`; `keys.loaded()` is the++ readiness check; `keys.keysFor(kid)` answers the keys for a token type a service checks itself. Services each had++ their own key store (one PEM, no kid, a 6 h refresh); this replaces them.++- `verifyUserToken` and `verifyAppToken` accept a JWKS client as `jwks` (a URL, a document or a PEM as before), as++ `verifyServiceToken` already did.++## 0.39.0 (2026-10-10)- **New: `openvibe-sdk/notifications`** (server). `createNotificationsClient(client).push({ subjectId | userId, type, title,@@@ -13,6 +26,7 @@ release may change an API and says so here.The push is attempted once, because Network keeps no idempotency record for it: callers retry from their own queue.Search and Watch each had (or were about to write) their own copy.++## 0.38.1 (2026-10-10)- **`migrate`: a fresh database applies its contract migrations at once.** When a run starts with no row in`ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be@@@ -162,7 +162,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| Subpath | Where | What ||---|---|---|| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |--| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |++| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `createNetworkKeys()`, `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` || `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` || `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` || `openvibe-sdk/notifications` | server | `createNotificationsClient(client)`: `push({subjectId \| userId, type, title, message, url, category, priority, …})` through Network (`network.notifications.push`); `{ sent, skipped }` or `{ sent: false, reason: 'unknown_subject' }`; attempted once, the caller retries |@@@ -223,11 +223,23 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun### Auth details- `createServiceTokenClient()` follows the rules of openvibe-contracts `serviceAuth.createTokenClient()` but needs no dependencies. It sends `POST /oauth/token` with `grant_type=client_credentials`, `audience` and an optional `scope`, which can be a string, an array, or a map from audience to scope. It keeps one cached token per audience until 60 s before expiry and shares one in-flight request. As a `tokenProvider`, each call gets a token for the audience of the service it calls: `openvibe.<service>` by default, or the value in `audiences` if you set one.++- `createNetworkKeys({ network, publicKey?, log? })` holds the keys a service verifies Network tokens with: a pinned PEM++ (`publicKey`) as is, or `<network>/api/.well-known/jwks` through the shared JWKS client (a rotation is honoured on an++ unknown `kid`, the last good keys are kept through an outage). Call `start()` at boot (it retries every 30 s until the++ first load, then refreshes every 15 minutes) and `stop()` at shutdown; spread `keys.verifyOptions` into the verifiers++ below; `keys.loaded()` is your readiness check.++++ ```js++ const keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: config.networkPublicKey, log });++ keys.start();++ const claims = await verifyUserToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice' });++ const svc = await verifyServiceToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice', contracts });++ ```- `verifyUserToken(token, { jwks, issuer, audience })` checks a token offline:- It accepts RS256 only. `alg: none`, HS256 and anything else fail as `token.malformed` before any key is used.-- - It checks the signature against the JWKS document or URL. A URL is cached for 6 h and fetched again when a token carries an unknown `kid`. It also accepts the Network's legacy `public_key` field or a PEM string.++ - It checks the signature against the JWKS document, URL or client (`createNetworkKeys().verifyOptions`). A URL is cached for 6 h and fetched again when a token carries an unknown `kid`. It also accepts the Network's legacy `public_key` field or a PEM string.- It checks `exp`, `nbf` and `iat` (30 s clock skew), then `iss` and `aud`.-- - It rejects service-principal tokens (`token.not_user`).++ - It rejects service-principal tokens and typed tokens (a realtime ticket, a FedCM assertion: `typ` or `purpose` set) as `token.not_user`.It returns the claims, including `subject_id`.- `verifyServiceToken(token, { jwks, issuer, audience, contracts })` checks a service or app token for a service that receives them: the SDK chooses the key (the `kid`'s, else each; the shared JWKS client or a pinned PEM), and every rule is your own pinned openvibe-contracts `serviceAuth.verifyServiceToken` (pass the module as `contracts`). No key answers `{ ok: false, code: 'token.unavailable' }` with a fixed reason, never the internal JWKS URL.@@@ -3,14 +3,14 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.39.0",++ "version": "0.40.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag","contracts": "openvibe-contracts v0.129.0 (devDependency; runtime contracts range >=0.5.0 <1.0.0)","features": ["core client: retries, timeouts, problem+json errors, trace propagation",-- "auth: service and app tokens, PKCE, offline verification, revocation cutoffs",++ "auth: service and app tokens, PKCE, offline verification, the Network signing keys (createNetworkKeys), revocation cutoffs","registry discovery and contracts version checks","events: publish, pull, app events, webhook verification (v2), outbox and inbox","realtime SSE",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.39.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.40.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.39.0';++const SDK_VERSION = '0.40.0';module.exports = {createClient,@@@ -4,7 +4,7 @@ import cjs from '../src/auth/index.js';export const {createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,-- verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, exchangeCode,-- refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,++ verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, createNetworkKeys,++ exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.39.0",++ "version": "0.40.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.39.0",++ "version": "0.40.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.39.0",++ "version": "0.40.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -8,7 +8,8 @@ const { createServiceTokenClient } = require('./tokens');const { verifyUserToken, verifyAppToken } = require('./jwt');const { verifyServiceToken } = require('./service');const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');++const { createNetworkKeys } = require('./keys');const { exchangeCode, refreshUserToken } = require('./oauth');const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');--module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, createNetworkKeys, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };@@@ -13,7 +13,8 @@** `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL* to fetch it from (one client per URL: ./jwks.js keeps the keys fresh, serves the last good ones through a JWKS-- * outage and backs off), or pass `publicKey` (PEM or KeyObject). `log` receives the client's state changes. Throws OpenVibeError (401)++ * outage and backs off), a JWKS client the service already holds (createNetworkKeys().verifyOptions passes its own),++ * or pass `publicKey` (PEM or KeyObject). `log` receives the client's state changes. Throws OpenVibeError (401)* with a stable code: token.malformed | token.bad_signature | token.expired | token.not_yet_valid |* token.wrong_issuer | token.wrong_audience | token.not_user | token.not_app | token.invalid_claims |* token.sandbox_refused | token.no_key.@@@ -50,6 +51,7 @@ async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSe// A JWKS URL: the process-wide client (./jwks.js) serves the last good keys through outages, backs off, and// refetches for an unknown kid (a rotation) at most every 30 s.else if (typeof jwks === 'string') keys = await jwksClient(jwks, { fetch: fetchImpl, log }).keysForKid(header.kid);++ else if (jwks && typeof jwks.keysForKid === 'function') keys = await jwks.keysForKid(header.kid);else keys = keysFromJwks(jwks);if (!keys.length) throw fail('token.no_key', 'no RS256 verification key', kind);nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * Server only. The keys a service verifies OpenVibe.Network tokens with, as one object every verifier reads.++ *++ * const keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: config.networkPublicKey, log });++ * keys.start(); // at boot (never at module load); keys.stop() at shutdown++ * await verifyUserToken(token, { ...keys.verifyOptions, issuer, audience });++ * await verifyServiceToken(token, { ...keys.verifyOptions, issuer, audience, contracts });++ * keys.loaded() // the readiness check: a token can be verified++ *++ * Two sources:++ * - a pinned key (`publicKey`: a PEM or a KeyObject, e.g. OV_NETWORK_PUBLIC_KEY): that one key, never fetched;++ * - otherwise Network's JWKS (`jwksUrl`, or `<network>/api/.well-known/jwks`) through the process-wide JWKS client++ * (./jwks.js, shared with every verifier that names the same URL): the last good keys through a Network outage,++ * backoff on failures, and a token naming an unknown kid refetches at once (a rotation), throttled.++ *++ * start() fetches at once and retries every `retryMs` until the first load succeeds (Network may still be booting:++ * the client's own backoff would wait minutes), then refreshes every `refreshMs`. Every timer is unref'd.++ *++ * keysFor(kid) answers [{ kid, key }] for a token type a service checks itself (OpenVibe.Events' realtime tickets):++ * the key the kid names after a rotation refetch, else every loaded key; [] when none has loaded. It never throws.++ *++ * Every service had its own copy of this (a key store with a retry timer and a 6 h refresh, one PEM, no kid): this++ * is the one that stays.++ */++const crypto = require('node:crypto');++const { jwksClient } = require('./jwks');++++const RETRY_MS = 30 * 1000;++const REFRESH_MS = 15 * 60 * 1000;++++function createNetworkKeys({ network = null, jwksUrl = null, publicKey = null, fetch: fetchImpl, log = null, retryMs = RETRY_MS, refreshMs = REFRESH_MS } = {}) {++ const pinned = publicKey ? (typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey) : null;++ const url = pinned ? null : (jwksUrl || (network ? `${String(network).replace(/\/+$/, '')}/api/.well-known/jwks` : null));++ if (!pinned && !url) throw new TypeError('createNetworkKeys: pass `network` (Network\'s base URL), `jwksUrl` or a pinned `publicKey`');++ const client = url ? jwksClient(url, { ...(fetchImpl ? { fetch: fetchImpl } : {}), ...(log ? { log } : {}) }) : null;++ let retryTimer = null;++ let started = false;++++ const loaded = () => Boolean(pinned) || client.status().ready;++++ /** One fetch now: the keys, or null when it failed (the client logs the first failure and the recovery). */++ async function refresh() {++ if (!client) return [{ kid: null, key: pinned }];++ try { return await client.refresh(); } catch { return null; }++ }++++ /** Start fetching (idempotent). Resolves after the first attempt, loaded or not; retries go on in the background. */++ async function start() {++ if (!client || started) return loaded();++ started = true;++ client.start({ intervalMs: refreshMs });++ const attempt = async () => {++ retryTimer = null;++ await refresh();++ if (started && !loaded()) {++ retryTimer = setTimeout(() => { attempt().catch(() => { /* refresh never throws */ }); }, retryMs);++ if (retryTimer.unref) retryTimer.unref();++ }++ };++ await attempt();++ return loaded();++ }++++ function stop() {++ started = false;++ if (retryTimer) clearTimeout(retryTimer);++ retryTimer = null;++ if (client) client.stop();++ }++++ async function keysFor(kid) {++ if (pinned) return [{ kid: null, key: pinned }];++ try { return await client.keysForKid(kid || null); } catch { return []; }++ }++++ function status() {++ if (pinned) return { source: 'pinned', url: null, ready: true, keys: 1 };++ return { source: 'jwks', ...client.status() };++ }++++ // Spread into verifyUserToken / verifyAppToken / verifyServiceToken: the pinned key, or this client.++ const verifyOptions = pinned ? { publicKey: pinned } : { jwks: client };++ return { url, pinned: Boolean(pinned), verifyOptions, loaded, status, start, stop, refresh, keysFor };++}++++module.exports = { createNetworkKeys };@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.39.0';++const SDK_VERSION = '0.40.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * createNetworkKeys: a pinned PEM or Network's JWKS, retried until the first load, read by every verifier (user, app++ * and service tokens) through verifyOptions, with a rotation honoured on an unknown kid and keysFor() for a token a++ * service checks itself.++ */++const assert = require('node:assert/strict');++const crypto = require('node:crypto');++const contracts = require('openvibe-contracts');++const { run, stubServer } = require('./helpers');++const { createNetworkKeys, verifyUserToken, verifyServiceToken, verifyAppToken } = require('../src/auth');++++const ISS = 'https://openvibe.network';++const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++const K1 = pair(), K2 = pair(), ROGUE = pair();++const jwkOf = (k, kid) => ({ ...k.publicKey.export({ format: 'jwk' }), alg: 'RS256', use: 'sig', kid });++const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++function sign(k, kid, claims) {++ const input = `${enc({ alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) })}.${enc(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), k.privateKey).toString('base64url')}`;++}++const now = () => Math.floor(Date.now() / 1000);++const user = (extra = {}) => ({ iss: ISS, sub: 7, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', aud: ['openvibe.network'], iat: now(), exp: now() + 300, ...extra });++const service = () => ({ iss: ISS, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.events'], cap: ['events.event.publish'], ns: [], iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });++const app = () => ({ iss: ISS, sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPR', actor_type: 'app', aud: ['openvibe.events'], cap: [], ns: ['prj_01JAB2C3D4E5F6G7H8J9K0MNPR'], project_id: 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR', env: 'production', iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });++const until = async (fn, ms = 2000) => { const end = Date.now() + ms; while (!fn()) { if (Date.now() > end) throw new Error('timed out'); await new Promise((r) => setTimeout(r, 10)); } };++const quiet = { warn() {}, info() {} };++++// A Network whose JWKS answers 503 until `up`, then serves `keys`; counts fetches.++async function network() {++ const n = { up: false, keys: [jwkOf(K1, 'k1')], fetches: 0 };++ n.srv = await stubServer(async (req, res) => {++ if (req.url !== '/api/.well-known/jwks') { res.statusCode = 404; return res.end(); }++ n.fetches += 1;++ if (!n.up) { res.statusCode = 503; return res.end(); }++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ keys: n.keys }));++ });++ return n;++}++++run([++ ['a pinned key is used as is: loaded at once, never fetched, read by every verifier', async () => {++ const pem = K1.publicKey.export({ type: 'spki', format: 'pem' });++ const keys = createNetworkKeys({ publicKey: pem, fetch: async () => { throw new Error('never fetched'); } });++ assert.equal(keys.loaded(), true);++ assert.equal(keys.pinned, true);++ assert.equal(await keys.start(), true);++ assert.equal((await verifyUserToken(sign(K1, null, user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' })).sub, 7);++ const s = await verifyServiceToken(sign(K1, null, service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts });++ assert.equal(s.ok, true, JSON.stringify(s));++ assert.equal((await keys.keysFor('anything')).length, 1);++ assert.deepEqual(keys.status(), { source: 'pinned', url: null, ready: true, keys: 1 });++ keys.stop();++ }],++++ ['without a key or a URL it refuses to start rather than verify nothing', async () => {++ assert.throws(() => createNetworkKeys({}), /pass `network`/);++ }],++++ ['the JWKS is retried until it loads; then user, app and service tokens verify against it', async () => {++ const n = await network();++ const keys = createNetworkKeys({ network: `${n.srv.url}/`, retryMs: 20, log: quiet });++ try {++ assert.equal(keys.url, `${n.srv.url}/api/.well-known/jwks`, 'one slash');++ assert.equal(await keys.start(), false, 'Network still booting');++ assert.equal(keys.loaded(), false);++ assert.deepEqual(await keys.keysFor('k1'), [], 'nothing to verify with yet, and no throw');++ const early = await verifyServiceToken(sign(K1, 'k1', service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts });++ assert.equal(early.code, 'token.unavailable', 'no key yet is unavailable, not a bad token');++ n.up = true;++ await until(() => keys.loaded());++ const retries = n.fetches;++ await new Promise((r) => setTimeout(r, 80));++ assert.equal(n.fetches, retries, 'the retry stops once loaded');++ assert.equal((await verifyUserToken(sign(K1, 'k1', user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' })).subject_id, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR');++ assert.equal((await verifyAppToken(sign(K1, 'k1', app()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events' })).env, 'production');++ assert.equal((await verifyServiceToken(sign(K1, 'k1', service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts })).ok, true);++ assert.equal(keys.status().source, 'jwks');++ assert.equal(keys.status().ready, true);++ await assert.rejects(verifyUserToken(sign(K1, 'k1', user({ typ: 'realtime' })), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' }), { code: 'token.not_user' }, 'a typed token is never a session');++ } finally { keys.stop(); await n.srv.close(); }++ }],++++ ['a rotation: a token naming a new kid verifies after one refetch, keysFor sees it, an unpublished key never verifies', async () => {++ const n = await network();++ n.up = true;++ const keys = createNetworkKeys({ jwksUrl: `${n.srv.url}/api/.well-known/jwks`, log: quiet });++ try {++ assert.equal(await keys.start(), true);++ const before = n.fetches;++ n.keys = [jwkOf(K2, 'k2'), jwkOf(K1, 'k1')];++ assert.equal((await verifyUserToken(sign(K2, 'k2', user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' })).sub, 7);++ assert.equal(n.fetches, before + 1, 'one refetch');++ assert.deepEqual((await keys.keysFor('k2')).map((k) => k.kid), ['k2', 'k1']);++ const rogue = await verifyServiceToken(sign(ROGUE, 'k1', service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts });++ assert.equal(rogue.ok, false);++ assert.equal(rogue.code, 'token.bad_signature');++ await assert.rejects(verifyUserToken(sign(ROGUE, 'k1', user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' }), { code: 'token.bad_signature' });++ } finally { keys.stop(); await n.srv.close(); }++ }],++++ ['stop() ends the retry: a Network that never answers is not polled after shutdown', async () => {++ const n = await network();++ const keys = createNetworkKeys({ network: n.srv.url, retryMs: 20, log: quiet });++ try {++ await keys.start();++ keys.stop();++ const at = n.fetches;++ await new Promise((r) => setTimeout(r, 80));++ assert.equal(n.fetches, at);++ assert.equal(await keys.refresh(), null, 'a failed refresh answers null, never throws');++ } finally { await n.srv.close(); }++ }],++]);@@@ -52,8 +52,8 @@ export interface UserTokenClaims {export interface VerifyUserTokenOptions {/** Receives the JWKS client's state changes (the first failure, the recovery). */log?: JwksClientOptions['log'];-- /** JWKS document, or its URL (fetched and cached 6 h, refetched on an unknown kid). */-- jwks?: { keys?: object[]; public_key?: string } | string;++ /** JWKS document, its URL (fetched and cached 6 h, refetched on an unknown kid), or a JWKS client you hold. */++ jwks?: { keys?: object[]; public_key?: string } | string | JwksClient;/** PEM string or a crypto KeyObject instead of a JWKS. */publicKey?: string | object;issuer?: string;@@@ -110,6 +110,41 @@ export declare function jwksClient(url: string, opts?: JwksClientOptions): JwksC/** Every JWKS client this process uses, for /api/ready. */export declare function jwksStatus(): JwksStatus[];++export interface NetworkKeysOptions {++ /** Network's base URL (its JWKS is <network>/api/.well-known/jwks). */++ network?: string | null;++ /** The JWKS URL itself, instead of `network`. */++ jwksUrl?: string | null;++ /** A pinned key (PEM or KeyObject, e.g. OV_NETWORK_PUBLIC_KEY): used as is, never fetched. */++ publicKey?: string | import('node:crypto').KeyObject | null;++ fetch?: typeof fetch;++ log?: JwksClientOptions['log'];++ /** Until the first load, retry this often (default 30 s). */++ retryMs?: number;++ /** After it, refresh this often (default 15 min). */++ refreshMs?: number;++}++export type NetworkKeysStatus = { source: 'pinned'; url: null; ready: true; keys: 1 } | ({ source: 'jwks' } & JwksStatus);++export interface NetworkKeys {++ /** The JWKS URL, or null for a pinned key. */++ url: string | null;++ pinned: boolean;++ /** Spread into verifyUserToken / verifyAppToken / verifyServiceToken. */++ verifyOptions: { publicKey: import('node:crypto').KeyObject } | { jwks: JwksClient };++ /** A token can be verified (the readiness check). */++ loaded(): boolean;++ status(): NetworkKeysStatus;++ /** Fetch now and retry until the first load, then refresh (idempotent; resolves after the first attempt with loaded()). */++ start(): Promise<boolean>;++ stop(): void;++ /** One fetch now: the keys, or null when it failed. */++ refresh(): Promise<JwksKey[] | null>;++ /** The keys to check a token of your own with: the kid's after a rotation refetch, else every loaded key; [] when none. */++ keysFor(kid: string | null | undefined): Promise<JwksKey[]>;++}++/** The keys a service verifies Network tokens with: a pinned PEM, or Network's JWKS (rotation, last good keys, retry until loaded). */++export declare function createNetworkKeys(opts: NetworkKeysOptions): NetworkKeys;++/** A developer app's token (identity.service-token-claims@1 with actor_type app). */export type AppTokenClaims = ServiceTokenClaims & {sub: `app:app_${string}`;@@@ -124,7 +159,7 @@ export type AppTokenClaims = ServiceTokenClaims & {export interface VerifyAppTokenOptions {/** Receives the JWKS client's state changes (the first failure, the recovery). */log?: JwksClientOptions['log'];-- jwks?: { keys?: object[]; public_key?: string } | string;++ jwks?: { keys?: object[]; public_key?: string } | string | JwksClient;publicKey?: string | object;issuer?: string;/** Required: the audience your service answers for (openvibe.<service>). */
@@ -3,6 +3,19 @@
@@ -3,6 +3,19 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.40.0 (2026-10-10)
+
+- **New: `createNetworkKeys()`** (`openvibe-sdk/auth`, server). The keys a service verifies Network tokens with, as one
+ object: a pinned key (`publicKey`, e.g. `OV_NETWORK_PUBLIC_KEY`) used as is, or Network's JWKS (`network` or
+ `jwksUrl`) through the process-wide JWKS client, so a token naming an unknown kid is verified after one refetch
+ (a rotation) and the last good keys are served through a Network outage. `start()` fetches at once and retries every
+ 30 s until the first load (Network may still be booting), then refreshes every 15 minutes; `stop()` clears both.
+ `keys.verifyOptions` spreads into `verifyUserToken`, `verifyAppToken` and `verifyServiceToken`; `keys.loaded()` is the
+ readiness check; `keys.keysFor(kid)` answers the keys for a token type a service checks itself. Services each had
+ their own key store (one PEM, no kid, a 6 h refresh); this replaces them.
+- `verifyUserToken` and `verifyAppToken` accept a JWKS client as `jwks` (a URL, a document or a PEM as before), as
+ `verifyServiceToken` already did.
+
## 0.39.0 (2026-10-10)
## 0.39.0 (2026-10-10)
- **New: `openvibe-sdk/notifications`** (server). `createNotificationsClient(client).push({ subjectId | userId, type, title,
- **New: `openvibe-sdk/notifications`** (server). `createNotificationsClient(client).push({ subjectId | userId, type, title,
@@ -13,6 +26,7 @@ release may change an API and says so here.
@@ -13,6 +26,7 @@ release may change an API and says so here.
The push is attempted once, because Network keeps no idempotency record for it: callers retry from their own queue.
The push is attempted once, because Network keeps no idempotency record for it: callers retry from their own queue.
Search and Watch each had (or were about to write) their own copy.
Search and Watch each had (or were about to write) their own copy.
+## 0.38.1 (2026-10-10)
- **`migrate`: a fresh database applies its contract migrations at once.** When a run starts with no row in
- **`migrate`: a fresh database applies its contract migrations at once.** When a run starts with no row in
`ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be
`ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be
@@ -162,7 +162,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -162,7 +162,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| Subpath | Where | What |
| Subpath | Where | What |
|---|---|---|
|---|---|---|
| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |
| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |
-| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |
+| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `createNetworkKeys()`, `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/notifications` | server | `createNotificationsClient(client)`: `push({subjectId \| userId, type, title, message, url, category, priority, …})` through Network (`network.notifications.push`); `{ sent, skipped }` or `{ sent: false, reason: 'unknown_subject' }`; attempted once, the caller retries |
| `openvibe-sdk/notifications` | server | `createNotificationsClient(client)`: `push({subjectId \| userId, type, title, message, url, category, priority, …})` through Network (`network.notifications.push`); `{ sent, skipped }` or `{ sent: false, reason: 'unknown_subject' }`; attempted once, the caller retries |
@@ -223,11 +223,23 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
@@ -223,11 +223,23 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
### Auth details
### Auth details
- `createServiceTokenClient()` follows the rules of openvibe-contracts `serviceAuth.createTokenClient()` but needs no dependencies. It sends `POST /oauth/token` with `grant_type=client_credentials`, `audience` and an optional `scope`, which can be a string, an array, or a map from audience to scope. It keeps one cached token per audience until 60 s before expiry and shares one in-flight request. As a `tokenProvider`, each call gets a token for the audience of the service it calls: `openvibe.<service>` by default, or the value in `audiences` if you set one.
- `createServiceTokenClient()` follows the rules of openvibe-contracts `serviceAuth.createTokenClient()` but needs no dependencies. It sends `POST /oauth/token` with `grant_type=client_credentials`, `audience` and an optional `scope`, which can be a string, an array, or a map from audience to scope. It keeps one cached token per audience until 60 s before expiry and shares one in-flight request. As a `tokenProvider`, each call gets a token for the audience of the service it calls: `openvibe.<service>` by default, or the value in `audiences` if you set one.
+- `createNetworkKeys({ network, publicKey?, log? })` holds the keys a service verifies Network tokens with: a pinned PEM
+ (`publicKey`) as is, or `<network>/api/.well-known/jwks` through the shared JWKS client (a rotation is honoured on an
+ unknown `kid`, the last good keys are kept through an outage). Call `start()` at boot (it retries every 30 s until the
+ first load, then refreshes every 15 minutes) and `stop()` at shutdown; spread `keys.verifyOptions` into the verifiers
+ below; `keys.loaded()` is your readiness check.
+
+ ```js
+ const keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: config.networkPublicKey, log });
+ keys.start();
+ const claims = await verifyUserToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice' });
+ const svc = await verifyServiceToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice', contracts });
+ ```
- `verifyUserToken(token, { jwks, issuer, audience })` checks a token offline:
- `verifyUserToken(token, { jwks, issuer, audience })` checks a token offline:
- It accepts RS256 only. `alg: none`, HS256 and anything else fail as `token.malformed` before any key is used.
- It accepts RS256 only. `alg: none`, HS256 and anything else fail as `token.malformed` before any key is used.
- - It checks the signature against the JWKS document or URL. A URL is cached for 6 h and fetched again when a token carries an unknown `kid`. It also accepts the Network's legacy `public_key` field or a PEM string.
+ - It checks the signature against the JWKS document, URL or client (`createNetworkKeys().verifyOptions`). A URL is cached for 6 h and fetched again when a token carries an unknown `kid`. It also accepts the Network's legacy `public_key` field or a PEM string.
- It checks `exp`, `nbf` and `iat` (30 s clock skew), then `iss` and `aud`.
- It checks `exp`, `nbf` and `iat` (30 s clock skew), then `iss` and `aud`.
- - It rejects service-principal tokens (`token.not_user`).
+ - It rejects service-principal tokens and typed tokens (a realtime ticket, a FedCM assertion: `typ` or `purpose` set) as `token.not_user`.
It returns the claims, including `subject_id`.
It returns the claims, including `subject_id`.
- `verifyServiceToken(token, { jwks, issuer, audience, contracts })` checks a service or app token for a service that receives them: the SDK chooses the key (the `kid`'s, else each; the shared JWKS client or a pinned PEM), and every rule is your own pinned openvibe-contracts `serviceAuth.verifyServiceToken` (pass the module as `contracts`). No key answers `{ ok: false, code: 'token.unavailable' }` with a fixed reason, never the internal JWKS URL.
- `verifyServiceToken(token, { jwks, issuer, audience, contracts })` checks a service or app token for a service that receives them: the SDK chooses the key (the `kid`'s, else each; the shared JWKS client or a pinned PEM), and every rule is your own pinned openvibe-contracts `serviceAuth.verifyServiceToken` (pass the module as `contracts`). No key answers `{ ok: false, code: 'token.unavailable' }` with a fixed reason, never the internal JWKS URL.
@@ -3,14 +3,14 @@
@@ -3,14 +3,14 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.39.0",
+ "version": "0.40.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
"contracts": "openvibe-contracts v0.129.0 (devDependency; runtime contracts range >=0.5.0 <1.0.0)",
"contracts": "openvibe-contracts v0.129.0 (devDependency; runtime contracts range >=0.5.0 <1.0.0)",
"features": [
"features": [
"core client: retries, timeouts, problem+json errors, trace propagation",
"core client: retries, timeouts, problem+json errors, trace propagation",
- "auth: service and app tokens, PKCE, offline verification, revocation cutoffs",
+ "auth: service and app tokens, PKCE, offline verification, the Network signing keys (createNetworkKeys), revocation cutoffs",
"registry discovery and contracts version checks",
"registry discovery and contracts version checks",
"events: publish, pull, app events, webhook verification (v2), outbox and inbox",
"events: publish, pull, app events, webhook verification (v2), outbox and inbox",
"realtime SSE",
"realtime SSE",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.39.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.40.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.39.0';
+const SDK_VERSION = '0.40.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -4,7 +4,7 @@ import cjs from '../src/auth/index.js';
@@ -4,7 +4,7 @@ import cjs from '../src/auth/index.js';
export const {
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
- verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, exchangeCode,
- refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,
+ verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, createNetworkKeys,
+ exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.39.0",
+ "version": "0.40.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.39.0",
+ "version": "0.40.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.39.0",
+ "version": "0.40.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,8 @@ const { createServiceTokenClient } = require('./tokens');
@@ -8,7 +8,8 @@ const { createServiceTokenClient } = require('./tokens');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { verifyServiceToken } = require('./service');
const { verifyServiceToken } = require('./service');
const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');
const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');
+const { createNetworkKeys } = require('./keys');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');
const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');
-module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };
+module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, createNetworkKeys, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
*
*
* `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL
* `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL
* to fetch it from (one client per URL: ./jwks.js keeps the keys fresh, serves the last good ones through a JWKS
* to fetch it from (one client per URL: ./jwks.js keeps the keys fresh, serves the last good ones through a JWKS
- * outage and backs off), or pass `publicKey` (PEM or KeyObject). `log` receives the client's state changes. Throws OpenVibeError (401)
+ * outage and backs off), a JWKS client the service already holds (createNetworkKeys().verifyOptions passes its own),
+ * or pass `publicKey` (PEM or KeyObject). `log` receives the client's state changes. Throws OpenVibeError (401)
* with a stable code: token.malformed | token.bad_signature | token.expired | token.not_yet_valid |
* with a stable code: token.malformed | token.bad_signature | token.expired | token.not_yet_valid |
* token.wrong_issuer | token.wrong_audience | token.not_user | token.not_app | token.invalid_claims |
* token.wrong_issuer | token.wrong_audience | token.not_user | token.not_app | token.invalid_claims |
* token.sandbox_refused | token.no_key.
* token.sandbox_refused | token.no_key.
@@ -50,6 +51,7 @@ async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSe
@@ -50,6 +51,7 @@ async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSe
// A JWKS URL: the process-wide client (./jwks.js) serves the last good keys through outages, backs off, and
// A JWKS URL: the process-wide client (./jwks.js) serves the last good keys through outages, backs off, and
// refetches for an unknown kid (a rotation) at most every 30 s.
// refetches for an unknown kid (a rotation) at most every 30 s.
else if (typeof jwks === 'string') keys = await jwksClient(jwks, { fetch: fetchImpl, log }).keysForKid(header.kid);
else if (typeof jwks === 'string') keys = await jwksClient(jwks, { fetch: fetchImpl, log }).keysForKid(header.kid);
+ else if (jwks && typeof jwks.keysForKid === 'function') keys = await jwks.keysForKid(header.kid);
else keys = keysFromJwks(jwks);
else keys = keysFromJwks(jwks);
if (!keys.length) throw fail('token.no_key', 'no RS256 verification key', kind);
if (!keys.length) throw fail('token.no_key', 'no RS256 verification key', kind);
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * Server only. The keys a service verifies OpenVibe.Network tokens with, as one object every verifier reads.
+ *
+ * const keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: config.networkPublicKey, log });
+ * keys.start(); // at boot (never at module load); keys.stop() at shutdown
+ * await verifyUserToken(token, { ...keys.verifyOptions, issuer, audience });
+ * await verifyServiceToken(token, { ...keys.verifyOptions, issuer, audience, contracts });
+ * keys.loaded() // the readiness check: a token can be verified
+ *
+ * Two sources:
+ * - a pinned key (`publicKey`: a PEM or a KeyObject, e.g. OV_NETWORK_PUBLIC_KEY): that one key, never fetched;
+ * - otherwise Network's JWKS (`jwksUrl`, or `<network>/api/.well-known/jwks`) through the process-wide JWKS client
+ * (./jwks.js, shared with every verifier that names the same URL): the last good keys through a Network outage,
+ * backoff on failures, and a token naming an unknown kid refetches at once (a rotation), throttled.
+ *
+ * start() fetches at once and retries every `retryMs` until the first load succeeds (Network may still be booting:
+ * the client's own backoff would wait minutes), then refreshes every `refreshMs`. Every timer is unref'd.
+ *
+ * keysFor(kid) answers [{ kid, key }] for a token type a service checks itself (OpenVibe.Events' realtime tickets):
+ * the key the kid names after a rotation refetch, else every loaded key; [] when none has loaded. It never throws.
+ *
+ * Every service had its own copy of this (a key store with a retry timer and a 6 h refresh, one PEM, no kid): this
+ * is the one that stays.
+ */
+const crypto = require('node:crypto');
+const { jwksClient } = require('./jwks');
+
+const RETRY_MS = 30 * 1000;
+const REFRESH_MS = 15 * 60 * 1000;
+
+function createNetworkKeys({ network = null, jwksUrl = null, publicKey = null, fetch: fetchImpl, log = null, retryMs = RETRY_MS, refreshMs = REFRESH_MS } = {}) {
+ const pinned = publicKey ? (typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey) : null;
+ const url = pinned ? null : (jwksUrl || (network ? `${String(network).replace(/\/+$/, '')}/api/.well-known/jwks` : null));
+ if (!pinned && !url) throw new TypeError('createNetworkKeys: pass `network` (Network\'s base URL), `jwksUrl` or a pinned `publicKey`');
+ const client = url ? jwksClient(url, { ...(fetchImpl ? { fetch: fetchImpl } : {}), ...(log ? { log } : {}) }) : null;
+ let retryTimer = null;
+ let started = false;
+
+ const loaded = () => Boolean(pinned) || client.status().ready;
+
+ /** One fetch now: the keys, or null when it failed (the client logs the first failure and the recovery). */
+ async function refresh() {
+ if (!client) return [{ kid: null, key: pinned }];
+ try { return await client.refresh(); } catch { return null; }
+ }
+
+ /** Start fetching (idempotent). Resolves after the first attempt, loaded or not; retries go on in the background. */
+ async function start() {
+ if (!client || started) return loaded();
+ started = true;
+ client.start({ intervalMs: refreshMs });
+ const attempt = async () => {
+ retryTimer = null;
+ await refresh();
+ if (started && !loaded()) {
+ retryTimer = setTimeout(() => { attempt().catch(() => { /* refresh never throws */ }); }, retryMs);
+ if (retryTimer.unref) retryTimer.unref();
+ }
+ };
+ await attempt();
+ return loaded();
+ }
+
+ function stop() {
+ started = false;
+ if (retryTimer) clearTimeout(retryTimer);
+ retryTimer = null;
+ if (client) client.stop();
+ }
+
+ async function keysFor(kid) {
+ if (pinned) return [{ kid: null, key: pinned }];
+ try { return await client.keysForKid(kid || null); } catch { return []; }
+ }
+
+ function status() {
+ if (pinned) return { source: 'pinned', url: null, ready: true, keys: 1 };
+ return { source: 'jwks', ...client.status() };
+ }
+
+ // Spread into verifyUserToken / verifyAppToken / verifyServiceToken: the pinned key, or this client.
+ const verifyOptions = pinned ? { publicKey: pinned } : { jwks: client };
+ return { url, pinned: Boolean(pinned), verifyOptions, loaded, status, start, stop, refresh, keysFor };
+}
+
+module.exports = { createNetworkKeys };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.39.0';
+const SDK_VERSION = '0.40.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * createNetworkKeys: a pinned PEM or Network's JWKS, retried until the first load, read by every verifier (user, app
+ * and service tokens) through verifyOptions, with a rotation honoured on an unknown kid and keysFor() for a token a
+ * service checks itself.
+ */
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const contracts = require('openvibe-contracts');
+const { run, stubServer } = require('./helpers');
+const { createNetworkKeys, verifyUserToken, verifyServiceToken, verifyAppToken } = require('../src/auth');
+
+const ISS = 'https://openvibe.network';
+const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const K1 = pair(), K2 = pair(), ROGUE = pair();
+const jwkOf = (k, kid) => ({ ...k.publicKey.export({ format: 'jwk' }), alg: 'RS256', use: 'sig', kid });
+const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+function sign(k, kid, claims) {
+ const input = `${enc({ alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) })}.${enc(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), k.privateKey).toString('base64url')}`;
+}
+const now = () => Math.floor(Date.now() / 1000);
+const user = (extra = {}) => ({ iss: ISS, sub: 7, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', aud: ['openvibe.network'], iat: now(), exp: now() + 300, ...extra });
+const service = () => ({ iss: ISS, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.events'], cap: ['events.event.publish'], ns: [], iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });
+const app = () => ({ iss: ISS, sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPR', actor_type: 'app', aud: ['openvibe.events'], cap: [], ns: ['prj_01JAB2C3D4E5F6G7H8J9K0MNPR'], project_id: 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR', env: 'production', iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });
+const until = async (fn, ms = 2000) => { const end = Date.now() + ms; while (!fn()) { if (Date.now() > end) throw new Error('timed out'); await new Promise((r) => setTimeout(r, 10)); } };
+const quiet = { warn() {}, info() {} };
+
+// A Network whose JWKS answers 503 until `up`, then serves `keys`; counts fetches.
+async function network() {
+ const n = { up: false, keys: [jwkOf(K1, 'k1')], fetches: 0 };
+ n.srv = await stubServer(async (req, res) => {
+ if (req.url !== '/api/.well-known/jwks') { res.statusCode = 404; return res.end(); }
+ n.fetches += 1;
+ if (!n.up) { res.statusCode = 503; return res.end(); }
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ keys: n.keys }));
+ });
+ return n;
+}
+
+run([
+ ['a pinned key is used as is: loaded at once, never fetched, read by every verifier', async () => {
+ const pem = K1.publicKey.export({ type: 'spki', format: 'pem' });
+ const keys = createNetworkKeys({ publicKey: pem, fetch: async () => { throw new Error('never fetched'); } });
+ assert.equal(keys.loaded(), true);
+ assert.equal(keys.pinned, true);
+ assert.equal(await keys.start(), true);
+ assert.equal((await verifyUserToken(sign(K1, null, user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' })).sub, 7);
+ const s = await verifyServiceToken(sign(K1, null, service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts });
+ assert.equal(s.ok, true, JSON.stringify(s));
+ assert.equal((await keys.keysFor('anything')).length, 1);
+ assert.deepEqual(keys.status(), { source: 'pinned', url: null, ready: true, keys: 1 });
+ keys.stop();
+ }],
+
+ ['without a key or a URL it refuses to start rather than verify nothing', async () => {
+ assert.throws(() => createNetworkKeys({}), /pass `network`/);
+ }],
+
+ ['the JWKS is retried until it loads; then user, app and service tokens verify against it', async () => {
+ const n = await network();
+ const keys = createNetworkKeys({ network: `${n.srv.url}/`, retryMs: 20, log: quiet });
+ try {
+ assert.equal(keys.url, `${n.srv.url}/api/.well-known/jwks`, 'one slash');
+ assert.equal(await keys.start(), false, 'Network still booting');
+ assert.equal(keys.loaded(), false);
+ assert.deepEqual(await keys.keysFor('k1'), [], 'nothing to verify with yet, and no throw');
+ const early = await verifyServiceToken(sign(K1, 'k1', service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts });
+ assert.equal(early.code, 'token.unavailable', 'no key yet is unavailable, not a bad token');
+ n.up = true;
+ await until(() => keys.loaded());
+ const retries = n.fetches;
+ await new Promise((r) => setTimeout(r, 80));
+ assert.equal(n.fetches, retries, 'the retry stops once loaded');
+ assert.equal((await verifyUserToken(sign(K1, 'k1', user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' })).subject_id, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR');
+ assert.equal((await verifyAppToken(sign(K1, 'k1', app()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events' })).env, 'production');
+ assert.equal((await verifyServiceToken(sign(K1, 'k1', service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts })).ok, true);
+ assert.equal(keys.status().source, 'jwks');
+ assert.equal(keys.status().ready, true);
+ await assert.rejects(verifyUserToken(sign(K1, 'k1', user({ typ: 'realtime' })), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' }), { code: 'token.not_user' }, 'a typed token is never a session');
+ } finally { keys.stop(); await n.srv.close(); }
+ }],
+
+ ['a rotation: a token naming a new kid verifies after one refetch, keysFor sees it, an unpublished key never verifies', async () => {
+ const n = await network();
+ n.up = true;
+ const keys = createNetworkKeys({ jwksUrl: `${n.srv.url}/api/.well-known/jwks`, log: quiet });
+ try {
+ assert.equal(await keys.start(), true);
+ const before = n.fetches;
+ n.keys = [jwkOf(K2, 'k2'), jwkOf(K1, 'k1')];
+ assert.equal((await verifyUserToken(sign(K2, 'k2', user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' })).sub, 7);
+ assert.equal(n.fetches, before + 1, 'one refetch');
+ assert.deepEqual((await keys.keysFor('k2')).map((k) => k.kid), ['k2', 'k1']);
+ const rogue = await verifyServiceToken(sign(ROGUE, 'k1', service()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.events', contracts });
+ assert.equal(rogue.ok, false);
+ assert.equal(rogue.code, 'token.bad_signature');
+ await assert.rejects(verifyUserToken(sign(ROGUE, 'k1', user()), { ...keys.verifyOptions, issuer: ISS, audience: 'openvibe.network' }), { code: 'token.bad_signature' });
+ } finally { keys.stop(); await n.srv.close(); }
+ }],
+
+ ['stop() ends the retry: a Network that never answers is not polled after shutdown', async () => {
+ const n = await network();
+ const keys = createNetworkKeys({ network: n.srv.url, retryMs: 20, log: quiet });
+ try {
+ await keys.start();
+ keys.stop();
+ const at = n.fetches;
+ await new Promise((r) => setTimeout(r, 80));
+ assert.equal(n.fetches, at);
+ assert.equal(await keys.refresh(), null, 'a failed refresh answers null, never throws');
+ } finally { await n.srv.close(); }
+ }],
+]);
@@ -52,8 +52,8 @@ export interface UserTokenClaims {
@@ -52,8 +52,8 @@ export interface UserTokenClaims {
export interface VerifyUserTokenOptions {
export interface VerifyUserTokenOptions {
/** Receives the JWKS client's state changes (the first failure, the recovery). */
/** Receives the JWKS client's state changes (the first failure, the recovery). */
log?: JwksClientOptions['log'];
log?: JwksClientOptions['log'];
- /** JWKS document, or its URL (fetched and cached 6 h, refetched on an unknown kid). */
- jwks?: { keys?: object[]; public_key?: string } | string;
+ /** JWKS document, its URL (fetched and cached 6 h, refetched on an unknown kid), or a JWKS client you hold. */
+ jwks?: { keys?: object[]; public_key?: string } | string | JwksClient;
/** PEM string or a crypto KeyObject instead of a JWKS. */
/** PEM string or a crypto KeyObject instead of a JWKS. */
publicKey?: string | object;
publicKey?: string | object;
issuer?: string;
issuer?: string;
@@ -110,6 +110,41 @@ export declare function jwksClient(url: string, opts?: JwksClientOptions): JwksC
@@ -110,6 +110,41 @@ export declare function jwksClient(url: string, opts?: JwksClientOptions): JwksC
/** Every JWKS client this process uses, for /api/ready. */
/** Every JWKS client this process uses, for /api/ready. */
export declare function jwksStatus(): JwksStatus[];
export declare function jwksStatus(): JwksStatus[];
+export interface NetworkKeysOptions {
+ /** Network's base URL (its JWKS is <network>/api/.well-known/jwks). */
+ network?: string | null;
+ /** The JWKS URL itself, instead of `network`. */
+ jwksUrl?: string | null;
+ /** A pinned key (PEM or KeyObject, e.g. OV_NETWORK_PUBLIC_KEY): used as is, never fetched. */
+ publicKey?: string | import('node:crypto').KeyObject | null;
+ fetch?: typeof fetch;
+ log?: JwksClientOptions['log'];
+ /** Until the first load, retry this often (default 30 s). */
+ retryMs?: number;
+ /** After it, refresh this often (default 15 min). */
+ refreshMs?: number;
+}
+export type NetworkKeysStatus = { source: 'pinned'; url: null; ready: true; keys: 1 } | ({ source: 'jwks' } & JwksStatus);
+export interface NetworkKeys {
+ /** The JWKS URL, or null for a pinned key. */
+ url: string | null;
+ pinned: boolean;
+ /** Spread into verifyUserToken / verifyAppToken / verifyServiceToken. */
+ verifyOptions: { publicKey: import('node:crypto').KeyObject } | { jwks: JwksClient };
+ /** A token can be verified (the readiness check). */
+ loaded(): boolean;
+ status(): NetworkKeysStatus;
+ /** Fetch now and retry until the first load, then refresh (idempotent; resolves after the first attempt with loaded()). */
+ start(): Promise<boolean>;
+ stop(): void;
+ /** One fetch now: the keys, or null when it failed. */
+ refresh(): Promise<JwksKey[] | null>;
+ /** The keys to check a token of your own with: the kid's after a rotation refetch, else every loaded key; [] when none. */
+ keysFor(kid: string | null | undefined): Promise<JwksKey[]>;
+}
+/** The keys a service verifies Network tokens with: a pinned PEM, or Network's JWKS (rotation, last good keys, retry until loaded). */
+export declare function createNetworkKeys(opts: NetworkKeysOptions): NetworkKeys;
+
/** A developer app's token (identity.service-token-claims@1 with actor_type app). */
/** A developer app's token (identity.service-token-claims@1 with actor_type app). */
export type AppTokenClaims = ServiceTokenClaims & {
export type AppTokenClaims = ServiceTokenClaims & {
sub: `app:app_${string}`;
sub: `app:app_${string}`;
@@ -124,7 +159,7 @@ export type AppTokenClaims = ServiceTokenClaims & {
@@ -124,7 +159,7 @@ export type AppTokenClaims = ServiceTokenClaims & {
export interface VerifyAppTokenOptions {
export interface VerifyAppTokenOptions {
/** Receives the JWKS client's state changes (the first failure, the recovery). */
/** Receives the JWKS client's state changes (the first failure, the recovery). */
log?: JwksClientOptions['log'];
log?: JwksClientOptions['log'];
- jwks?: { keys?: object[]; public_key?: string } | string;
+ jwks?: { keys?: object[]; public_key?: string } | string | JwksClient;
publicKey?: string | object;
publicKey?: string | object;
issuer?: string;
issuer?: string;
/** Required: the audience your service answers for (openvibe.<service>). */
/** Required: the audience your service answers for (openvibe.<service>). */