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.

OP OpenVibers committed 29 Sept 2026 · 063799e239 · 9 files, +286 −44


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a451713..0222290 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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).
ddiff --git a/STATUS.json b/STATUS.json
iindex ba722ba..a1bd360 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/esm/auth.mjs b/esm/auth.mjs
iindex 95cba4a..5cdae8f 100644
---- a/esm/auth.mjs
++++ b/esm/auth.mjs
@@@ -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;
ddiff --git a/package.json b/package.json
iindex 084ca32..57bcdb3 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/src/auth/index.js b/src/auth/index.js
iindex dcc4036..d3fcce5 100644
---- a/src/auth/index.js
++++ b/src/auth/index.js
@@@ -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 };
ddiff --git a/src/auth/jwks.js b/src/auth/jwks.js
nnew file mode 100644
iindex 0000000..e5817c4
---- /dev/null
++++ b/src/auth/jwks.js
@@@ -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 };
ddiff --git a/src/auth/jwt.js b/src/auth/jwt.js
iindex 78d0208..1d2b708 100644
---- a/src/auth/jwt.js
++++ b/src/auth/jwt.js
@@@ -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, clockSkewSe
let 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 };
ddiff --git a/test/jwks.test.js b/test/jwks.test.js
nnew file mode 100644
iindex 0000000..8b26387
---- /dev/null
++++ b/test/jwks.test.js
@@@ -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();
++ }],
++]);
ddiff --git a/types/auth.d.ts b/types/auth.d.ts
iindex 4443ddc..bd82ab8 100644
---- a/types/auth.d.ts
++++ b/types/auth.d.ts
@@@ -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}`;