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