openvibers/openvibe.sdk Commits
v0.23.0: a JWKS client behind token verification: the last good keys through outages, backoff, rotation honoured at once, unknown-kid floods throttled, status for readiness (plan T0/T1). npm test 44/44 on Node 22 with the test services.
Changes
@@@ -8,6 +8,10 @@ release may change an API and says so here.++## 0.23.0 (2026-09-29)++++- **JWKS client** (`openvibe-sdk/auth`: `jwksClient(url, opts)`, `createJwksClient`, `jwksStatus()`), now behind `verifyUserToken`/`verifyAppToken` for a JWKS URL (plan T0/T1: one refresher instead of hand-written copies that swallowed errors). Keys stay fresh for 6 h; after that the last good keys are served while one refresh runs in the background, and a failed fetch keeps them and backs off (1 s doubling to 5 min) instead of rejecting sign-ins or hammering Network. A token with an unknown `kid` refetches at once (a rotation is honoured immediately); unknown-kid refetches are spaced 30 s apart, so a flood of made-up kids costs one fetch per 30 s. Only state changes are logged (`log` option: the first failure, the recovery). `status()` / `jwksStatus()` report readiness, staleness, failures and the next try for `/api/ready`; `start({ intervalMs })` refreshes in the background on an unref'd timer. Before: an expired cache plus a failed fetch rejected every token with 503, and each unknown kid forced a refetch.++## 0.22.0 (2026-09-28)- **`openvibe-sdk/placement`** (roadmap WS-Z9, the adaptive fabric): `plan(requirements, offers, { rateCards, states, current })` places a workload on the best eligible node or provider and explains it (platform.placement-result@1). Hard constraints first (capabilities, trust, residency, health, latency ceiling, capacity, cost ceiling; excluded candidates carry the reason), then the objective (cheapest, lowest-latency, balanced; private and first-party-only never leave first-party capacity; critical work stays with its authority). `marginalCost` prices work in a provider's real billing unit after its free allowance, forecast to the end of the period, with a reserve kept for high-priority work; owned capacity is ~free until its binding resource is busy, then work spills over. Hysteresis keeps a placement unless an alternative is clearly better (15% by default); failover is immediate. `rendezvous` (weighted highest-random-weight hashing), `pickTwo` (power of two choices), and Ed25519-signed route plans: `signPlan`, `verifyPlan`, `createPlanHolder` (keeps the last valid plan when a new one is bad, expired or older).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.22.0",++ "version": "0.23.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",@@@ -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, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema,-- TOKEN_VALID_AFTER,++ verifyAppToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken,++ createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,} = cjs;export default cjs;@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.22.0",++ "version": "0.23.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, 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",@@@ -6,7 +6,8 @@const browser = require('./browser');const { createServiceTokenClient } = require('./tokens');const { verifyUserToken, verifyAppToken } = require('./jwt');++const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');const { exchangeCode, refreshUserToken } = require('./oauth');const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');--module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++/**++ * JWKS keys for offline token verification, kept fresh and never a single point of failure (plan T0/T1: one++ * refresher for every service instead of hand-written copies that swallowed errors).++ *++ * const client = jwksClient(url, { fetch, log })++ * await client.keys() the current keys: fresh ones, or the last good ones while a refresh runs or fails++ * await client.keysForKid(kid) refetches when a token names a key we do not have (a rotation), at most every 30 s++ * client.status() { ready, keys, fetchedAt, stale, failures, lastError, nextTryAt } for /api/ready++ * client.start({ intervalMs }) refresh in the background (a timer that does not hold the process open)++ *++ * Behaviour:++ * - Fresh for `ttlMs` (6 h). After that the cached keys are still served while one refresh runs in the background:++ * keys rarely change, and a JWKS outage must not turn into rejected sign-ins.++ * - A failed fetch keeps the last good keys and backs off exponentially (1 s, 2 s, 4 s … 5 min) before the next try,++ * so a Network restart is not met by a thundering herd.++ * - A token whose `kid` is unknown triggers a refetch at once (a rotation is honoured immediately), but unknown-kid++ * refetches are spaced at least `minRefetchMs` (30 s) apart: a flood of made-up kids costs one fetch per 30 s.++ * - Only state changes are logged (the first failure, the recovery), never every request.++ * - Without any keys yet (the first fetch failed) `keys()` throws a 503-class error: nothing to verify with.++ */++const crypto = require('node:crypto');++const { OpenVibeError } = require('../core/errors');++++const TTL_MS = 6 * 60 * 60 * 1000;++const MIN_REFETCH_MS = 30 * 1000;++const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 32000, 64000, 128000, 300000];++++function keysFromJwks(doc) {++ const out = [];++ for (const jwk of (doc && Array.isArray(doc.keys) ? doc.keys : [])) {++ if (jwk.kty !== 'RSA' || (jwk.use && jwk.use !== 'sig') || (jwk.alg && jwk.alg !== 'RS256')) continue;++ try { out.push({ kid: jwk.kid || null, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) }); } catch { /* skip unusable key */ }++ }++ if (!out.length && doc && typeof doc.public_key === 'string') {++ try { out.push({ kid: null, key: crypto.createPublicKey(doc.public_key) }); } catch { /* skip */ }++ }++ return out;++}++++function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null, ttlMs = TTL_MS, minRefetchMs = MIN_REFETCH_MS, timeoutMs = 5000, now = () => Date.now() } = {}) {++ const st = { keys: null, fetchedAt: 0, attemptAt: 0, kidAt: 0, failures: 0, nextTryAt: 0, lastError: null, inflight: null, timer: null };++ const warn = (msg) => { try { (log && (log.warn || log.error) || console.warn).call(log || console, msg); } catch { /* never throw from logging */ } };++ const info = (msg) => { try { (log && (log.info || log.log) || (() => {})).call(log || console, msg); } catch { /* */ } };++++ function refresh() {++ if (st.inflight) return st.inflight;++ st.attemptAt = now();++ st.inflight = (async () => {++ try {++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });++ if (!res.ok) throw new Error(`answered ${res.status}`);++ const keys = keysFromJwks(await res.json());++ if (!keys.length) throw new Error('no usable RS256 key in the document');++ if (st.failures) info(`[openvibe-sdk] JWKS ${url} recovered after ${st.failures} failed fetch${st.failures === 1 ? '' : 'es'}`);++ Object.assign(st, { keys, fetchedAt: now(), failures: 0, nextTryAt: 0, lastError: null });++ return keys;++ } catch (err) {++ st.failures += 1;++ st.lastError = err && err.message ? err.message : String(err);++ st.nextTryAt = now() + BACKOFF_MS[Math.min(st.failures - 1, BACKOFF_MS.length - 1)];++ if (st.failures === 1) warn(`[openvibe-sdk] JWKS ${url} fetch failed (${st.lastError}); ${st.keys ? 'serving the last good keys' : 'no keys yet'}, retrying with backoff`);++ if (st.keys) return st.keys;++ throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url}: ${st.lastError}` });++ } finally { st.inflight = null; }++ })();++ return st.inflight;++ }++++ const due = () => now() >= st.nextTryAt;++++ async function keys() {++ if (!st.keys) {++ // Nothing cached: fetch now (or wait out the backoff window by failing fast with the last error).++ if (!due() && st.lastError) throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url}: ${st.lastError}` });++ return await refresh();++ }++ // Stale: serve what we have, refresh in the background (unless backing off).++ if (now() - st.fetchedAt >= ttlMs && due() && !st.inflight) refresh().catch(() => { /* logged in refresh */ });++ return st.keys;++ }++++ async function keysForKid(kid) {++ const have = await keys();++ if (!kid || have.some((k) => k.kid === kid)) return have;++ // An unknown kid: a rotation, or garbage. Unknown-kid refetches are spaced minRefetchMs apart, never inside a++ // backoff, and one already running is joined.++ if (st.inflight) return await st.inflight;++ if (now() - st.kidAt < minRefetchMs || !due()) return have;++ st.kidAt = now();++ return await refresh();++ }++++ function status() {++ return { url, ready: !!(st.keys && st.keys.length), keys: st.keys ? st.keys.length : 0, fetchedAt: st.fetchedAt || null,++ stale: !!st.keys && now() - st.fetchedAt >= ttlMs, failures: st.failures, lastError: st.lastError, nextTryAt: st.nextTryAt || null };++ }++++ function start({ intervalMs = 15 * 60 * 1000 } = {}) {++ if (st.timer) return api;++ const tick = () => { if (due()) refresh().catch(() => { /* logged */ }); };++ tick();++ st.timer = setInterval(tick, intervalMs);++ if (st.timer.unref) st.timer.unref();++ return api;++ }++ function stop() { if (st.timer) clearInterval(st.timer); st.timer = null; }++++ const api = { url, keys, keysForKid, refresh, status, start, stop };++ return api;++}++++// One client per URL for the process: verifyUserToken/verifyAppToken share it, and services read its status.++const clients = new Map();++function jwksClient(url, opts = {}) {++ let c = clients.get(url);++ if (!c) { c = createJwksClient(url, opts); clients.set(url, c); }++ return c;++}++/** Status of every JWKS client this process uses (for /api/ready). */++function jwksStatus() { return [...clients.values()].map((c) => c.status()); }++++module.exports = { createJwksClient, jwksClient, jwksStatus, keysFromJwks, _clients: clients };@@@ -12,7 +12,8 @@* claims.subject_id // usr_… (canonical subject; absent on very old tokens)** `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL-- * to fetch it from (cached), or pass `publicKey` (PEM or KeyObject). Throws OpenVibeError (401)++ * 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)* 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.@@@ -21,42 +22,12 @@ const crypto = require('node:crypto');const { OpenVibeError } = require('../core/errors');const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');--const JWKS_TTL_MS = 6 * 60 * 60 * 1000;--const jwksCache = new Map(); // url -> { at, keys: KeyObject-with-kid[] , inflight }++const { jwksClient, keysFromJwks, _clients } = require('./jwks');function fail(code, detail, kind = 'user') {return new OpenVibeError({ code, status: 401, detail, message: `${kind} token rejected: ${detail}` });}--function keysFromJwks(doc) {-- const out = [];-- if (!doc || typeof doc !== 'object') return out;-- for (const jwk of Array.isArray(doc.keys) ? doc.keys : []) {-- if (!jwk || jwk.kty !== 'RSA' || (jwk.alg && jwk.alg !== 'RS256') || (jwk.use && jwk.use !== 'sig')) continue;-- try { out.push({ kid: jwk.kid || null, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) }); } catch { /* skip unusable key */ }-- }-- if (typeof doc.public_key === 'string' && doc.public_key.includes('BEGIN')) {-- try { out.push({ kid: null, key: crypto.createPublicKey(doc.public_key) }); } catch { /* skip */ }-- }-- return out;--}----async function loadJwks(url, fetchImpl, force) {-- const hit = jwksCache.get(url);-- if (hit && hit.keys && !force && Date.now() - hit.at < JWKS_TTL_MS) return hit.keys;-- if (hit && hit.inflight) return hit.inflight;-- const entry = hit || {};-- entry.inflight = (async () => {-- const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });-- if (!res.ok) throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url} answered ${res.status}` });-- const keys = keysFromJwks(await res.json());-- Object.assign(entry, { at: Date.now(), keys });-- return keys;-- })().finally(() => { entry.inflight = null; });-- jwksCache.set(url, entry);-- return entry.inflight;--}--function verifyWith(keys, header, input, sig) {const byKid = header.kid ? keys.filter((k) => k.kid === header.kid) : [];const candidates = byKid.length ? byKid : keys;@@@ -66,7 +37,7 @@ function verifyWith(keys, header, input, sig) {}/** Signature, time and issuer/audience checks shared by both verifiers; returns the claims. */--async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch } = {}, kind) {++async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch, log = null } = {}, kind) {const parts = typeof token === 'string' ? token.split('.') : [];if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT', kind);let header, claims;@@@ -76,16 +47,15 @@ async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSelet keys;if (publicKey) keys = [{ kid: null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];-- else if (typeof jwks === 'string') keys = await loadJwks(jwks, fetchImpl, false);++ // 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 keys = keysFromJwks(jwks);if (!keys.length) throw fail('token.no_key', 'no RS256 verification key', kind);const input = `${parts[0]}.${parts[1]}`;const sig = fromB64url(parts[2]);-- let good = verifyWith(keys, header, input, sig);-- if (!good && typeof jwks === 'string' && header.kid && !keys.some((k) => k.kid === header.kid)) {-- good = verifyWith(await loadJwks(jwks, fetchImpl, true), header, input, sig); // key rotation-- }++ const good = verifyWith(keys, header, input, sig);if (!good) throw fail('token.bad_signature', 'signature does not verify', kind);const t = Math.floor(now / 1000);@@@ -142,4 +112,4 @@ async function verifyAppToken(token, opts = {}) {return claims;}--module.exports = { verifyUserToken, verifyAppToken, keysFromJwks, _jwksCache: jwksCache };++module.exports = { verifyUserToken, verifyAppToken, keysFromJwks, _jwksClients: _clients };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/** The JWKS client: fresh keys, the last good ones through outages, backoff, rotation, a throttled unknown kid. */++const assert = require('node:assert/strict');++const crypto = require('node:crypto');++const { run } = require('./helpers');++const { createJwksClient } = require('../src/auth/jwks');++++const pub = (kid) => ({ ...crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid });++const K1 = pub('k1'), K2 = pub('k2');++++// A fake JWKS endpoint and clock: `doc` is served, or `fail` makes the fetch throw / answer 503.++function world() {++ const w = { t: 1_000_000, doc: { keys: [K1] }, fail: null, fetches: 0, logs: [] };++ w.fetch = async () => {++ w.fetches += 1;++ if (w.fail === 'throw') throw new Error('connect ECONNREFUSED');++ if (w.fail === '503') return { ok: false, status: 503, json: async () => ({}) };++ return { ok: true, status: 200, json: async () => w.doc };++ };++ w.log = { warn: (m) => w.logs.push(['warn', m]), info: (m) => w.logs.push(['info', m]) };++ w.client = (opts = {}) => createJwksClient('https://net.example/jwks', { fetch: w.fetch, log: w.log, now: () => w.t, ...opts });++ return w;++}++const kids = (keys) => keys.map((k) => k.kid);++const tick = () => new Promise((r) => setImmediate(r));++++run([++ ['fresh keys are fetched once and cached', async () => {++ const w = world(), c = w.client();++ assert.deepEqual(kids(await c.keys()), ['k1']);++ await c.keys(); await c.keys();++ assert.equal(w.fetches, 1);++ assert.equal(c.status().ready, true);++ }],++++ ['stale keys are served at once while one refresh runs in the background', async () => {++ const w = world(), c = w.client({ ttlMs: 1000 });++ await c.keys();++ w.t += 2000; w.doc = { keys: [K1, K2] };++ assert.deepEqual(kids(await c.keys()), ['k1'], 'the stale keys answer immediately');++ await tick(); await tick();++ assert.equal(w.fetches, 2, 'one background refresh');++ assert.deepEqual(kids(await c.keys()), ['k1', 'k2']);++ }],++++ ['a failed refresh keeps the last good keys, backs off, and logs once', async () => {++ const w = world(), c = w.client({ ttlMs: 1000 });++ await c.keys();++ w.t += 2000; w.fail = 'throw';++ assert.deepEqual(kids(await c.keys()), ['k1']);++ await tick(); await tick();++ assert.equal(w.fetches, 2);++ for (let i = 0; i < 5; i++) await c.keys();++ await tick();++ assert.equal(w.fetches, 2, 'no refetch inside the backoff window');++ assert.equal(w.logs.filter(([l]) => l === 'warn').length, 1, 'the failure is logged once, not per request');++ const s = c.status();++ assert.equal(s.ready, true); assert.equal(s.failures, 1); assert.match(s.lastError, /ECONNREFUSED/); assert.ok(s.nextTryAt > w.t);++ // After the backoff it retries; recovery is logged.++ w.t = s.nextTryAt + 1; w.fail = null;++ await c.keys(); await tick(); await tick();++ assert.equal(w.fetches, 3);++ assert.equal(c.status().failures, 0);++ assert.ok(w.logs.some(([l, m]) => l === 'info' && /recovered/.test(m)));++ }],++++ ['no keys at all: a 503-class token.no_key, without hammering the endpoint', async () => {++ const w = world(), c = w.client(); w.fail = '503';++ await assert.rejects(c.keys(), { code: 'token.no_key', status: 503 });++ await assert.rejects(c.keys(), { code: 'token.no_key' });++ assert.equal(w.fetches, 1, 'the second call fails fast inside the backoff');++ assert.equal(c.status().ready, false);++ }],++++ ['an unknown kid refetches once (a rotation), a flood of them only every 30 s', async () => {++ const w = world(), c = w.client();++ await c.keys();++ w.doc = { keys: [K1, K2] };++ assert.deepEqual(kids(await c.keysForKid('k2')), ['k1', 'k2'], 'a rotation is honoured at once');++ assert.equal(w.fetches, 2);++ for (let i = 0; i < 20; i++) await c.keysForKid(`made-up-${i}`);++ assert.equal(w.fetches, 2, 'twenty garbage kids right after the rotation refetch: none (30 s apart)');++ w.t += 31_000;++ await c.keysForKid('another-made-up');++ for (let i = 0; i < 20; i++) await c.keysForKid(`more-made-up-${i}`);++ assert.equal(w.fetches, 3, 'after 30 s: one refetch for the next flood');++ assert.deepEqual(kids(await c.keysForKid('k1')), ['k1', 'k2'], 'a known kid never refetches');++ assert.equal(w.fetches, 3);++ }],++++ ['start() refreshes in the background without holding the process open', async () => {++ const w = world(), c = w.client();++ c.start({ intervalMs: 60_000 });++ await tick(); await tick();++ assert.equal(w.fetches, 1);++ assert.equal(c.status().ready, true);++ c.stop();++ }],++]);@@@ -63,6 +63,51 @@ export interface VerifyUserTokenOptions {}export declare function verifyUserToken(token: string, opts: VerifyUserTokenOptions): Promise<UserTokenClaims>;++/** One JWKS key usable for RS256 verification. */++export interface JwksKey { kid: string | null; key: import('node:crypto').KeyObject; }++/** A JWKS client's state, for readiness endpoints. */++export interface JwksStatus {++ url: string;++ /** Keys are loaded (possibly stale): tokens can be verified. */++ ready: boolean;++ keys: number;++ fetchedAt: number | null;++ /** Past the TTL; still served while a refresh runs or fails. */++ stale: boolean;++ failures: number;++ lastError: string | null;++ nextTryAt: number | null;++}++export interface JwksClientOptions {++ fetch?: typeof fetch;++ /** Receives state changes (first failure, recovery), never per request. */++ log?: { warn?(msg: string): void; info?(msg: string): void; error?(msg: string): void; log?(msg: string): void } | null;++ /** Fresh for this long (default 6 h); after it the last keys are served while one refresh runs. */++ ttlMs?: number;++ /** Unknown-kid refetches are spaced at least this far apart (default 30 s). */++ minRefetchMs?: number;++ timeoutMs?: number;++ now?: () => number;++}++export interface JwksClient {++ url: string;++ /** The current keys: fresh ones, or the last good ones while a refresh runs or fails. */++ keys(): Promise<JwksKey[]>;++ /** Refetches when a token names a key the client does not have (a rotation), throttled. */++ keysForKid(kid: string | null | undefined): Promise<JwksKey[]>;++ refresh(): Promise<JwksKey[]>;++ status(): JwksStatus;++ /** Refresh in the background (an unref'd timer). */++ start(opts?: { intervalMs?: number }): JwksClient;++ stop(): void;++}++/** A new JWKS client (tests; services normally use jwksClient()). */++export declare function createJwksClient(url: string, opts?: JwksClientOptions): JwksClient;++/** The process-wide client for a JWKS URL (verifyUserToken/verifyAppToken share it). */++export declare function jwksClient(url: string, opts?: JwksClientOptions): JwksClient;++/** Every JWKS client this process uses, for /api/ready. */++export declare function jwksStatus(): JwksStatus[];++/** A developer app's token (identity.service-token-claims@1 with actor_type app). */export type AppTokenClaims = ServiceTokenClaims & {sub: `app:app_${string}`;
@@ -8,6 +8,10 @@ release may change an API and says so here.
@@ -8,6 +8,10 @@ release may change an API and says so here.
+## 0.23.0 (2026-09-29)
+
+- **JWKS client** (`openvibe-sdk/auth`: `jwksClient(url, opts)`, `createJwksClient`, `jwksStatus()`), now behind `verifyUserToken`/`verifyAppToken` for a JWKS URL (plan T0/T1: one refresher instead of hand-written copies that swallowed errors). Keys stay fresh for 6 h; after that the last good keys are served while one refresh runs in the background, and a failed fetch keeps them and backs off (1 s doubling to 5 min) instead of rejecting sign-ins or hammering Network. A token with an unknown `kid` refetches at once (a rotation is honoured immediately); unknown-kid refetches are spaced 30 s apart, so a flood of made-up kids costs one fetch per 30 s. Only state changes are logged (`log` option: the first failure, the recovery). `status()` / `jwksStatus()` report readiness, staleness, failures and the next try for `/api/ready`; `start({ intervalMs })` refreshes in the background on an unref'd timer. Before: an expired cache plus a failed fetch rejected every token with 503, and each unknown kid forced a refetch.
+
## 0.22.0 (2026-09-28)
## 0.22.0 (2026-09-28)
- **`openvibe-sdk/placement`** (roadmap WS-Z9, the adaptive fabric): `plan(requirements, offers, { rateCards, states, current })` places a workload on the best eligible node or provider and explains it (platform.placement-result@1). Hard constraints first (capabilities, trust, residency, health, latency ceiling, capacity, cost ceiling; excluded candidates carry the reason), then the objective (cheapest, lowest-latency, balanced; private and first-party-only never leave first-party capacity; critical work stays with its authority). `marginalCost` prices work in a provider's real billing unit after its free allowance, forecast to the end of the period, with a reserve kept for high-priority work; owned capacity is ~free until its binding resource is busy, then work spills over. Hysteresis keeps a placement unless an alternative is clearly better (15% by default); failover is immediate. `rendezvous` (weighted highest-random-weight hashing), `pickTwo` (power of two choices), and Ed25519-signed route plans: `signPlan`, `verifyPlan`, `createPlanHolder` (keeps the last valid plan when a new one is bad, expired or older).
- **`openvibe-sdk/placement`** (roadmap WS-Z9, the adaptive fabric): `plan(requirements, offers, { rateCards, states, current })` places a workload on the best eligible node or provider and explains it (platform.placement-result@1). Hard constraints first (capabilities, trust, residency, health, latency ceiling, capacity, cost ceiling; excluded candidates carry the reason), then the objective (cheapest, lowest-latency, balanced; private and first-party-only never leave first-party capacity; critical work stays with its authority). `marginalCost` prices work in a provider's real billing unit after its free allowance, forecast to the end of the period, with a reserve kept for high-priority work; owned capacity is ~free until its binding resource is busy, then work spills over. Hysteresis keeps a placement unless an alternative is clearly better (15% by default); failover is immediate. `rendezvous` (weighted highest-random-weight hashing), `pickTwo` (power of two choices), and Ed25519-signed route plans: `signPlan`, `verifyPlan`, `createPlanHolder` (keeps the last valid plan when a new one is bad, expired or older).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.22.0",
+ "version": "0.23.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",
@@ -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, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema,
- TOKEN_VALID_AFTER,
+ verifyAppToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken,
+ createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.22.0",
+ "version": "0.23.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, 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, 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",
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
const browser = require('./browser');
const browser = require('./browser');
const { createServiceTokenClient } = require('./tokens');
const { createServiceTokenClient } = require('./tokens');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { verifyUserToken, verifyAppToken } = require('./jwt');
+const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');
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, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };
+module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+/**
+ * JWKS keys for offline token verification, kept fresh and never a single point of failure (plan T0/T1: one
+ * refresher for every service instead of hand-written copies that swallowed errors).
+ *
+ * const client = jwksClient(url, { fetch, log })
+ * await client.keys() the current keys: fresh ones, or the last good ones while a refresh runs or fails
+ * await client.keysForKid(kid) refetches when a token names a key we do not have (a rotation), at most every 30 s
+ * client.status() { ready, keys, fetchedAt, stale, failures, lastError, nextTryAt } for /api/ready
+ * client.start({ intervalMs }) refresh in the background (a timer that does not hold the process open)
+ *
+ * Behaviour:
+ * - Fresh for `ttlMs` (6 h). After that the cached keys are still served while one refresh runs in the background:
+ * keys rarely change, and a JWKS outage must not turn into rejected sign-ins.
+ * - A failed fetch keeps the last good keys and backs off exponentially (1 s, 2 s, 4 s … 5 min) before the next try,
+ * so a Network restart is not met by a thundering herd.
+ * - A token whose `kid` is unknown triggers a refetch at once (a rotation is honoured immediately), but unknown-kid
+ * refetches are spaced at least `minRefetchMs` (30 s) apart: a flood of made-up kids costs one fetch per 30 s.
+ * - Only state changes are logged (the first failure, the recovery), never every request.
+ * - Without any keys yet (the first fetch failed) `keys()` throws a 503-class error: nothing to verify with.
+ */
+const crypto = require('node:crypto');
+const { OpenVibeError } = require('../core/errors');
+
+const TTL_MS = 6 * 60 * 60 * 1000;
+const MIN_REFETCH_MS = 30 * 1000;
+const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 32000, 64000, 128000, 300000];
+
+function keysFromJwks(doc) {
+ const out = [];
+ for (const jwk of (doc && Array.isArray(doc.keys) ? doc.keys : [])) {
+ if (jwk.kty !== 'RSA' || (jwk.use && jwk.use !== 'sig') || (jwk.alg && jwk.alg !== 'RS256')) continue;
+ try { out.push({ kid: jwk.kid || null, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) }); } catch { /* skip unusable key */ }
+ }
+ if (!out.length && doc && typeof doc.public_key === 'string') {
+ try { out.push({ kid: null, key: crypto.createPublicKey(doc.public_key) }); } catch { /* skip */ }
+ }
+ return out;
+}
+
+function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null, ttlMs = TTL_MS, minRefetchMs = MIN_REFETCH_MS, timeoutMs = 5000, now = () => Date.now() } = {}) {
+ const st = { keys: null, fetchedAt: 0, attemptAt: 0, kidAt: 0, failures: 0, nextTryAt: 0, lastError: null, inflight: null, timer: null };
+ const warn = (msg) => { try { (log && (log.warn || log.error) || console.warn).call(log || console, msg); } catch { /* never throw from logging */ } };
+ const info = (msg) => { try { (log && (log.info || log.log) || (() => {})).call(log || console, msg); } catch { /* */ } };
+
+ function refresh() {
+ if (st.inflight) return st.inflight;
+ st.attemptAt = now();
+ st.inflight = (async () => {
+ try {
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
+ if (!res.ok) throw new Error(`answered ${res.status}`);
+ const keys = keysFromJwks(await res.json());
+ if (!keys.length) throw new Error('no usable RS256 key in the document');
+ if (st.failures) info(`[openvibe-sdk] JWKS ${url} recovered after ${st.failures} failed fetch${st.failures === 1 ? '' : 'es'}`);
+ Object.assign(st, { keys, fetchedAt: now(), failures: 0, nextTryAt: 0, lastError: null });
+ return keys;
+ } catch (err) {
+ st.failures += 1;
+ st.lastError = err && err.message ? err.message : String(err);
+ st.nextTryAt = now() + BACKOFF_MS[Math.min(st.failures - 1, BACKOFF_MS.length - 1)];
+ if (st.failures === 1) warn(`[openvibe-sdk] JWKS ${url} fetch failed (${st.lastError}); ${st.keys ? 'serving the last good keys' : 'no keys yet'}, retrying with backoff`);
+ if (st.keys) return st.keys;
+ throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url}: ${st.lastError}` });
+ } finally { st.inflight = null; }
+ })();
+ return st.inflight;
+ }
+
+ const due = () => now() >= st.nextTryAt;
+
+ async function keys() {
+ if (!st.keys) {
+ // Nothing cached: fetch now (or wait out the backoff window by failing fast with the last error).
+ if (!due() && st.lastError) throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url}: ${st.lastError}` });
+ return await refresh();
+ }
+ // Stale: serve what we have, refresh in the background (unless backing off).
+ if (now() - st.fetchedAt >= ttlMs && due() && !st.inflight) refresh().catch(() => { /* logged in refresh */ });
+ return st.keys;
+ }
+
+ async function keysForKid(kid) {
+ const have = await keys();
+ if (!kid || have.some((k) => k.kid === kid)) return have;
+ // An unknown kid: a rotation, or garbage. Unknown-kid refetches are spaced minRefetchMs apart, never inside a
+ // backoff, and one already running is joined.
+ if (st.inflight) return await st.inflight;
+ if (now() - st.kidAt < minRefetchMs || !due()) return have;
+ st.kidAt = now();
+ return await refresh();
+ }
+
+ function status() {
+ return { url, ready: !!(st.keys && st.keys.length), keys: st.keys ? st.keys.length : 0, fetchedAt: st.fetchedAt || null,
+ stale: !!st.keys && now() - st.fetchedAt >= ttlMs, failures: st.failures, lastError: st.lastError, nextTryAt: st.nextTryAt || null };
+ }
+
+ function start({ intervalMs = 15 * 60 * 1000 } = {}) {
+ if (st.timer) return api;
+ const tick = () => { if (due()) refresh().catch(() => { /* logged */ }); };
+ tick();
+ st.timer = setInterval(tick, intervalMs);
+ if (st.timer.unref) st.timer.unref();
+ return api;
+ }
+ function stop() { if (st.timer) clearInterval(st.timer); st.timer = null; }
+
+ const api = { url, keys, keysForKid, refresh, status, start, stop };
+ return api;
+}
+
+// One client per URL for the process: verifyUserToken/verifyAppToken share it, and services read its status.
+const clients = new Map();
+function jwksClient(url, opts = {}) {
+ let c = clients.get(url);
+ if (!c) { c = createJwksClient(url, opts); clients.set(url, c); }
+ return c;
+}
+/** Status of every JWKS client this process uses (for /api/ready). */
+function jwksStatus() { return [...clients.values()].map((c) => c.status()); }
+
+module.exports = { createJwksClient, jwksClient, jwksStatus, keysFromJwks, _clients: clients };
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
* claims.subject_id // usr_… (canonical subject; absent on very old tokens)
* claims.subject_id // usr_… (canonical subject; absent on very old tokens)
*
*
* `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 (cached), or pass `publicKey` (PEM or KeyObject). Throws OpenVibeError (401)
+ * 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)
* 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.
@@ -21,42 +22,12 @@ const crypto = require('node:crypto');
@@ -21,42 +22,12 @@ const crypto = require('node:crypto');
const { OpenVibeError } = require('../core/errors');
const { OpenVibeError } = require('../core/errors');
const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
-const JWKS_TTL_MS = 6 * 60 * 60 * 1000;
-const jwksCache = new Map(); // url -> { at, keys: KeyObject-with-kid[] , inflight }
+const { jwksClient, keysFromJwks, _clients } = require('./jwks');
function fail(code, detail, kind = 'user') {
function fail(code, detail, kind = 'user') {
return new OpenVibeError({ code, status: 401, detail, message: `${kind} token rejected: ${detail}` });
return new OpenVibeError({ code, status: 401, detail, message: `${kind} token rejected: ${detail}` });
}
}
-function keysFromJwks(doc) {
- const out = [];
- if (!doc || typeof doc !== 'object') return out;
- for (const jwk of Array.isArray(doc.keys) ? doc.keys : []) {
- if (!jwk || jwk.kty !== 'RSA' || (jwk.alg && jwk.alg !== 'RS256') || (jwk.use && jwk.use !== 'sig')) continue;
- try { out.push({ kid: jwk.kid || null, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) }); } catch { /* skip unusable key */ }
- }
- if (typeof doc.public_key === 'string' && doc.public_key.includes('BEGIN')) {
- try { out.push({ kid: null, key: crypto.createPublicKey(doc.public_key) }); } catch { /* skip */ }
- }
- return out;
-}
-
-async function loadJwks(url, fetchImpl, force) {
- const hit = jwksCache.get(url);
- if (hit && hit.keys && !force && Date.now() - hit.at < JWKS_TTL_MS) return hit.keys;
- if (hit && hit.inflight) return hit.inflight;
- const entry = hit || {};
- entry.inflight = (async () => {
- const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
- if (!res.ok) throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url} answered ${res.status}` });
- const keys = keysFromJwks(await res.json());
- Object.assign(entry, { at: Date.now(), keys });
- return keys;
- })().finally(() => { entry.inflight = null; });
- jwksCache.set(url, entry);
- return entry.inflight;
-}
-
function verifyWith(keys, header, input, sig) {
function verifyWith(keys, header, input, sig) {
const byKid = header.kid ? keys.filter((k) => k.kid === header.kid) : [];
const byKid = header.kid ? keys.filter((k) => k.kid === header.kid) : [];
const candidates = byKid.length ? byKid : keys;
const candidates = byKid.length ? byKid : keys;
@@ -66,7 +37,7 @@ function verifyWith(keys, header, input, sig) {
@@ -66,7 +37,7 @@ function verifyWith(keys, header, input, sig) {
}
}
/** Signature, time and issuer/audience checks shared by both verifiers; returns the claims. */
/** Signature, time and issuer/audience checks shared by both verifiers; returns the claims. */
-async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch } = {}, kind) {
+async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch, log = null } = {}, kind) {
const parts = typeof token === 'string' ? token.split('.') : [];
const parts = typeof token === 'string' ? token.split('.') : [];
if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT', kind);
if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT', kind);
let header, claims;
let header, claims;
@@ -76,16 +47,15 @@ async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSe
@@ -76,16 +47,15 @@ async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSe
let keys;
let keys;
if (publicKey) keys = [{ kid: null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];
if (publicKey) keys = [{ kid: null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];
- else if (typeof jwks === 'string') keys = await loadJwks(jwks, fetchImpl, false);
+ // 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 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);
const input = `${parts[0]}.${parts[1]}`;
const input = `${parts[0]}.${parts[1]}`;
const sig = fromB64url(parts[2]);
const sig = fromB64url(parts[2]);
- let good = verifyWith(keys, header, input, sig);
- if (!good && typeof jwks === 'string' && header.kid && !keys.some((k) => k.kid === header.kid)) {
- good = verifyWith(await loadJwks(jwks, fetchImpl, true), header, input, sig); // key rotation
- }
+ const good = verifyWith(keys, header, input, sig);
if (!good) throw fail('token.bad_signature', 'signature does not verify', kind);
if (!good) throw fail('token.bad_signature', 'signature does not verify', kind);
const t = Math.floor(now / 1000);
const t = Math.floor(now / 1000);
@@ -142,4 +112,4 @@ async function verifyAppToken(token, opts = {}) {
@@ -142,4 +112,4 @@ async function verifyAppToken(token, opts = {}) {
return claims;
return claims;
}
}
-module.exports = { verifyUserToken, verifyAppToken, keysFromJwks, _jwksCache: jwksCache };
+module.exports = { verifyUserToken, verifyAppToken, keysFromJwks, _jwksClients: _clients };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/** The JWKS client: fresh keys, the last good ones through outages, backoff, rotation, a throttled unknown kid. */
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const { run } = require('./helpers');
+const { createJwksClient } = require('../src/auth/jwks');
+
+const pub = (kid) => ({ ...crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid });
+const K1 = pub('k1'), K2 = pub('k2');
+
+// A fake JWKS endpoint and clock: `doc` is served, or `fail` makes the fetch throw / answer 503.
+function world() {
+ const w = { t: 1_000_000, doc: { keys: [K1] }, fail: null, fetches: 0, logs: [] };
+ w.fetch = async () => {
+ w.fetches += 1;
+ if (w.fail === 'throw') throw new Error('connect ECONNREFUSED');
+ if (w.fail === '503') return { ok: false, status: 503, json: async () => ({}) };
+ return { ok: true, status: 200, json: async () => w.doc };
+ };
+ w.log = { warn: (m) => w.logs.push(['warn', m]), info: (m) => w.logs.push(['info', m]) };
+ w.client = (opts = {}) => createJwksClient('https://net.example/jwks', { fetch: w.fetch, log: w.log, now: () => w.t, ...opts });
+ return w;
+}
+const kids = (keys) => keys.map((k) => k.kid);
+const tick = () => new Promise((r) => setImmediate(r));
+
+run([
+ ['fresh keys are fetched once and cached', async () => {
+ const w = world(), c = w.client();
+ assert.deepEqual(kids(await c.keys()), ['k1']);
+ await c.keys(); await c.keys();
+ assert.equal(w.fetches, 1);
+ assert.equal(c.status().ready, true);
+ }],
+
+ ['stale keys are served at once while one refresh runs in the background', async () => {
+ const w = world(), c = w.client({ ttlMs: 1000 });
+ await c.keys();
+ w.t += 2000; w.doc = { keys: [K1, K2] };
+ assert.deepEqual(kids(await c.keys()), ['k1'], 'the stale keys answer immediately');
+ await tick(); await tick();
+ assert.equal(w.fetches, 2, 'one background refresh');
+ assert.deepEqual(kids(await c.keys()), ['k1', 'k2']);
+ }],
+
+ ['a failed refresh keeps the last good keys, backs off, and logs once', async () => {
+ const w = world(), c = w.client({ ttlMs: 1000 });
+ await c.keys();
+ w.t += 2000; w.fail = 'throw';
+ assert.deepEqual(kids(await c.keys()), ['k1']);
+ await tick(); await tick();
+ assert.equal(w.fetches, 2);
+ for (let i = 0; i < 5; i++) await c.keys();
+ await tick();
+ assert.equal(w.fetches, 2, 'no refetch inside the backoff window');
+ assert.equal(w.logs.filter(([l]) => l === 'warn').length, 1, 'the failure is logged once, not per request');
+ const s = c.status();
+ assert.equal(s.ready, true); assert.equal(s.failures, 1); assert.match(s.lastError, /ECONNREFUSED/); assert.ok(s.nextTryAt > w.t);
+ // After the backoff it retries; recovery is logged.
+ w.t = s.nextTryAt + 1; w.fail = null;
+ await c.keys(); await tick(); await tick();
+ assert.equal(w.fetches, 3);
+ assert.equal(c.status().failures, 0);
+ assert.ok(w.logs.some(([l, m]) => l === 'info' && /recovered/.test(m)));
+ }],
+
+ ['no keys at all: a 503-class token.no_key, without hammering the endpoint', async () => {
+ const w = world(), c = w.client(); w.fail = '503';
+ await assert.rejects(c.keys(), { code: 'token.no_key', status: 503 });
+ await assert.rejects(c.keys(), { code: 'token.no_key' });
+ assert.equal(w.fetches, 1, 'the second call fails fast inside the backoff');
+ assert.equal(c.status().ready, false);
+ }],
+
+ ['an unknown kid refetches once (a rotation), a flood of them only every 30 s', async () => {
+ const w = world(), c = w.client();
+ await c.keys();
+ w.doc = { keys: [K1, K2] };
+ assert.deepEqual(kids(await c.keysForKid('k2')), ['k1', 'k2'], 'a rotation is honoured at once');
+ assert.equal(w.fetches, 2);
+ for (let i = 0; i < 20; i++) await c.keysForKid(`made-up-${i}`);
+ assert.equal(w.fetches, 2, 'twenty garbage kids right after the rotation refetch: none (30 s apart)');
+ w.t += 31_000;
+ await c.keysForKid('another-made-up');
+ for (let i = 0; i < 20; i++) await c.keysForKid(`more-made-up-${i}`);
+ assert.equal(w.fetches, 3, 'after 30 s: one refetch for the next flood');
+ assert.deepEqual(kids(await c.keysForKid('k1')), ['k1', 'k2'], 'a known kid never refetches');
+ assert.equal(w.fetches, 3);
+ }],
+
+ ['start() refreshes in the background without holding the process open', async () => {
+ const w = world(), c = w.client();
+ c.start({ intervalMs: 60_000 });
+ await tick(); await tick();
+ assert.equal(w.fetches, 1);
+ assert.equal(c.status().ready, true);
+ c.stop();
+ }],
+]);
@@ -63,6 +63,51 @@ export interface VerifyUserTokenOptions {
@@ -63,6 +63,51 @@ export interface VerifyUserTokenOptions {
}
}
export declare function verifyUserToken(token: string, opts: VerifyUserTokenOptions): Promise<UserTokenClaims>;
export declare function verifyUserToken(token: string, opts: VerifyUserTokenOptions): Promise<UserTokenClaims>;
+/** One JWKS key usable for RS256 verification. */
+export interface JwksKey { kid: string | null; key: import('node:crypto').KeyObject; }
+/** A JWKS client's state, for readiness endpoints. */
+export interface JwksStatus {
+ url: string;
+ /** Keys are loaded (possibly stale): tokens can be verified. */
+ ready: boolean;
+ keys: number;
+ fetchedAt: number | null;
+ /** Past the TTL; still served while a refresh runs or fails. */
+ stale: boolean;
+ failures: number;
+ lastError: string | null;
+ nextTryAt: number | null;
+}
+export interface JwksClientOptions {
+ fetch?: typeof fetch;
+ /** Receives state changes (first failure, recovery), never per request. */
+ log?: { warn?(msg: string): void; info?(msg: string): void; error?(msg: string): void; log?(msg: string): void } | null;
+ /** Fresh for this long (default 6 h); after it the last keys are served while one refresh runs. */
+ ttlMs?: number;
+ /** Unknown-kid refetches are spaced at least this far apart (default 30 s). */
+ minRefetchMs?: number;
+ timeoutMs?: number;
+ now?: () => number;
+}
+export interface JwksClient {
+ url: string;
+ /** The current keys: fresh ones, or the last good ones while a refresh runs or fails. */
+ keys(): Promise<JwksKey[]>;
+ /** Refetches when a token names a key the client does not have (a rotation), throttled. */
+ keysForKid(kid: string | null | undefined): Promise<JwksKey[]>;
+ refresh(): Promise<JwksKey[]>;
+ status(): JwksStatus;
+ /** Refresh in the background (an unref'd timer). */
+ start(opts?: { intervalMs?: number }): JwksClient;
+ stop(): void;
+}
+/** A new JWKS client (tests; services normally use jwksClient()). */
+export declare function createJwksClient(url: string, opts?: JwksClientOptions): JwksClient;
+/** The process-wide client for a JWKS URL (verifyUserToken/verifyAppToken share it). */
+export declare function jwksClient(url: string, opts?: JwksClientOptions): JwksClient;
+/** Every JWKS client this process uses, for /api/ready. */
+export declare function jwksStatus(): JwksStatus[];
+
/** 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}`;