openvibers/openvibe.billing Commits

Billing: Network keys and token checks from openvibe-sdk/auth (API and staff console) (#42)

OP OpenVibers committed 10 Oct 2026 · 7bbf899d00 · 11 files, +47 −82

* openvibe-sdk v0.40.0 (was v0.38.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.40.0 release.

* Billing: Network keys and token checks from openvibe-sdk/auth (API and staff console)

Plan T1: delete hand-rolled copies. Billing had its own Network key store (one PEM, no kid, a 6 h refresh) and its own RS256 verifier for the staff console's sign-in. Both now go through openvibe-sdk/auth (v0.40.0; this PR carries the pin bump from #41).

- **Keys:** `createKeyProvider` returns `createNetworkKeys`. A pinned `OV_NETWORK_PUBLIC_KEY` is used as is. Otherwise the JWKS is retried every 30 s until it loads, refreshed every 15 minutes, and a rotation is followed on an unknown kid. The required `network_jwks` readiness check reads `keys.loaded()`.
- **API:** `/api/v1` service tokens are checked by `verifyServiceToken` with Billing's pinned contracts. No key still answers 503 `auth.unavailable`. `authenticate` is async, and `needs()` sends errors to `next`.
- **Staff console:** the access token from the code exchange is checked by `verifyUserToken` (issuer, the console's audience, expiry at the console's clock). It also refuses service principals and typed tokens. The hand-rolled `verifyUserToken` in `server/console/sso.js` is deleted. A callback that finds no keys fetches them once before giving up, as before.

`ov test` (25 files) is green. `test/console.test.js` signs in through `/auth/callback` with the stub Network's tokens, including the refusals (state, forged flow cookie, PKCE mismatch).

Changes

ddiff --git a/README.md b/README.md
iindex 63aaf1e..e93184c 100644
---- a/README.md
++++ b/README.md
@@@ -63,7 +63,7 @@ promo write changes both in one transaction.
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)
- the payment providers whose secrets are configured (none in production today)
--- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.38.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`), `openvibe-shared` v3.0.0, pinned by release tarball
++- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.40.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`), `openvibe-shared` v3.0.0, pinned by release tarball
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
@@@ -458,6 +458,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.129.0
--- openvibe-sdk: v0.38.0
++- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 6c890b6..13a5317 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"deployed": true,
"deployment": "Deployed in shadow with `sudo ovhost deploy billing`: /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), /api/health, /api/ready (openvibe-shared/ready shape), /release.json and /webhooks/*. No provider secret is configured, so every provider is off and /webhooks/* answers 404 billing.provider_disabled.",
"contracts": "openvibe-contracts v0.129.0 (openvibe-sdk v0.28.0, openvibe-shared v2.13.0)",
-- "sdk": "openvibe-sdk v0.38.0",
++ "sdk": "openvibe-sdk v0.40.0",
"shared": "openvibe-shared v3.0.0",
"notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation (GET /api/v1/admin/reconciliations; 16 runs by 2026-09-24 05:06 UTC, ledger-vs-receipt checks), EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing; recorded only while Live is the authority), overflow guards and serializable settlement (PostgreSQL, ADR-035). Production holds the shadow import of Live (322 transactions, 996 ledger entries), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat; a duplicate-webhook proof in production follows it. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"features": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 25ffaae..3871394 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
},
@@@ -1075,9 +1075,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.38.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
-- "integrity": "sha512-/Z+1kB5l4Hff0E1O1Zo+DcXaZX93wrmISOPP75sB7NqVUZVeKuJgjYu6vBwG8ufJjlk0vD3ZJ0ntBLI/3IKdpg==",
++ "version": "0.40.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
++ "integrity": "sha512-fYX0zijJZDdwjXJGwroLDlb/wniY6NXVtgdPVJJHNgcdRmaAydpr6SKuxcLffmkTquyZQptwChsCvo+D6Wx+IA==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 6bc89d3..b75886d 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
},
ddiff --git a/server/api/auth.js b/server/api/auth.js
iindex 024fcb0..d1c23e4 100644
---- a/server/api/auth.js
++++ b/server/api/auth.js
@@@ -2,23 +2,27 @@
/**
* Service-token authentication (ADR-003). Every /api/v1 call carries a client-credentials JWT
-- * from OpenVibe.Network for audience 'openvibe.billing'; the capability is checked here against
-- * the token's `cap` claim.
++ * from OpenVibe.Network for audience 'openvibe.billing', verified by openvibe-sdk/auth verifyServiceToken (the
++ * pinned contracts rules, the key the token's kid names); the capability is checked here against the token's `cap`
++ * claim.
*
* The billing.* capabilities ship in openvibe-contracts v0.8.0. The grant is matched with
* contracts' own grants(): an exact id, or a trailing ".*" family grant ("billing.*",
* "billing.cashout.*").
*/
--const { serviceAuth, capabilities, http, validate } = require('openvibe-contracts');
++const contracts = require('openvibe-contracts');
++const { verifyServiceToken } = require('openvibe-sdk/auth');
++
++const { capabilities, http, validate } = contracts;
function createAuth({ config, keys }) {
-- function authenticate(req, res) {
++ /** true when req.principal is set; otherwise the problem has been answered. */
++ async function authenticate(req, res) {
if (req.principal) return true;
const header = String(req.headers.authorization || '');
if (!header.startsWith('Bearer ')) { http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: req.ov }); return false; }
-- const publicKey = keys.get();
-- if (!publicKey) { http.sendProblem(res, 503, 'auth.unavailable', { detail: 'the Network key is not loaded yet', ctx: req.ov }); return false; }
-- const r = serviceAuth.verifyServiceToken(header.slice(7).trim(), { publicKey, issuer: config.network.issuer, audience: config.audience });
++ const r = await verifyServiceToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, audience: config.audience, contracts });
++ if (!r.ok && r.code === 'token.unavailable') { http.sendProblem(res, 503, 'auth.unavailable', { detail: 'the Network key is not loaded yet', ctx: req.ov }); return false; }
if (!r.ok) { http.sendProblem(res, 401, r.code, { detail: r.reason, ctx: req.ov }); return false; }
req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };
return true;
@@@ -28,11 +32,13 @@ function createAuth({ config, keys }) {
function needs(caps) {
const list = Array.isArray(caps) ? caps : [caps];
return function billingCapability(req, res, next) {
-- if (!authenticate(req, res)) return;
-- if (!list.some((c) => capabilities.grants(req.principal.cap, c))) {
-- return http.sendProblem(res, 403, 'capability.denied', { detail: `${list.join(' or ')} not granted`, ctx: req.ov });
-- }
-- return next();
++ authenticate(req, res).then((ok) => {
++ if (!ok) return undefined;
++ if (!list.some((c) => capabilities.grants(req.principal.cap, c))) {
++ return http.sendProblem(res, 403, 'capability.denied', { detail: `${list.join(' or ')} not granted`, ctx: req.ov });
++ }
++ return next();
++ }, next);
};
}
ddiff --git a/server/app.js b/server/app.js
iindex de57696..7c28acc 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -89,7 +89,7 @@ async function createApp(opts = {}) {
service: 'billing', release: release.release,
checks: [
{ name: 'db', required: true, check: async () => (await db.prepare('SELECT 1 AS ok FROM settings WHERE id = 1').get()).ok === 1 || 'settings row missing' },
-- { name: 'network_jwks', required: true, check: () => Boolean(keys.get()) || 'Network public key not loaded' },
++ { name: 'network_jwks', required: true, check: () => keys.loaded() || 'Network public key not loaded' },
{ name: 'money_writes', required: false, check: async () => (await isFrozen(db) ? 'frozen' : true) },
{ name: 'events_outbox', required: false, check: async () => await outbox.status() },
{ name: 'valkey', required: false, check: async () => (valkey ? await valkey.ready() : { skipped: 'VALKEY_URL not set: per-person limits count in this process only' }) },
ddiff --git a/server/console/index.js b/server/console/index.js
iindex fd03219..caca117 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -189,9 +189,9 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
}
let who;
try {
-- const publicKey = keys.get() || await keys.load();
-- if (!publicKey) throw new Error('the Network key is not loaded');
-- who = await sso.exchange(ctx, { code: req.query.code, verifier: flow.v, publicKey, fetchImpl });
++ if (!keys.loaded()) await keys.refresh();
++ if (!keys.loaded()) throw new Error('the Network key is not loaded');
++ who = await sso.exchange(ctx, { code: req.query.code, verifier: flow.v, keys, fetchImpl });
} catch (e) {
log.warn(`[Billing] console sign-in failed: ${e.message}`);
return send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
ddiff --git a/server/console/sso.js b/server/console/sso.js
iindex 4a17ca1..894700c 100644
---- a/server/console/sso.js
++++ b/server/console/sso.js
@@@ -10,7 +10,8 @@ const { staff: staffMap } = require('openvibe-contracts');
* &code_challenge_method=S256
* exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier),
* server to server; the access token is then verified offline with the Network's
-- * RS256 key Billing already holds (issuer, audience, expiry)
++ * keys Billing already holds (openvibe-sdk/auth verifyUserToken: issuer, audience,
++ * expiry, never a service principal or a typed token)
*
* The Network's user access token carries `role` and `subject_id` (server/auth/oauth-routes.js
* issueTokenPair), minted from the users row at the moment of the code exchange, so the role read
@@@ -19,8 +20,7 @@ const { staff: staffMap } = require('openvibe-contracts');
* revoked straight away (best effort).
*/
const crypto = require('crypto');
--
--const b64urlJson = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
++const { verifyUserToken } = require('openvibe-sdk/auth');
function pkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
@@@ -41,26 +41,7 @@ function authorizeUrl(config, { state, challenge }) {
return `${config.network.url}/oauth/authorize?${q.toString()}`;
}
--/** Verify a Network user access token (RS256). Returns claims or throws with a short reason. */
--function verifyUserToken(token, { publicKey, issuer, audience, now }) {
-- const parts = typeof token === 'string' ? token.split('.') : [];
-- if (parts.length !== 3) throw new Error('not a JWT');
-- let header, claims;
-- try { header = b64urlJson(parts[0]); claims = b64urlJson(parts[1]); } catch { throw new Error('undecodable token'); }
-- if (header.alg !== 'RS256') throw new Error(`alg ${header.alg} not 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) throw new Error('signature does not verify');
-- const t = Math.floor(now / 1000);
-- if (typeof claims.exp !== 'number' || claims.exp + 30 < t) throw new Error('token expired');
-- if (typeof claims.iat === 'number' && claims.iat - 30 > t) throw new Error('token issued in the future');
-- if (claims.iss !== issuer) throw new Error('wrong issuer');
-- const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
-- if (!aud.includes(audience)) throw new Error(`token is not for ${audience}`);
-- return claims;
--}
--
--async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis.fetch }) {
++async function exchange(ctx, { code, verifier, keys, fetchImpl = globalThis.fetch }) {
const { config } = ctx;
const body = new URLSearchParams({
grant_type: 'authorization_code',
@@@ -91,8 +72,10 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
signal: AbortSignal.timeout(3000),
}).catch(() => {});
}
-- const claims = verifyUserToken(data.access_token, {
-- publicKey, issuer: config.network.issuer, audience: config.console.ssoAudience, now: ctx.now(),
++ // openvibe-sdk/auth: RS256 with Network's keys, expiry and issuer, the console's audience; never a service principal
++ // or a typed token.
++ const claims = await verifyUserToken(data.access_token, {
++ ...keys.verifyOptions, issuer: config.network.issuer, audience: config.console.ssoAudience, now: ctx.now(),
});
return {
subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
@@@ -104,4 +87,4 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
};
}
--module.exports = { pkcePair, authorizeUrl, verifyUserToken, exchange };
++module.exports = { pkcePair, authorizeUrl, exchange };
ddiff --git a/server/index.js b/server/index.js
iindex caa2277..7861341 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -25,7 +25,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
const config = loadConfig();
const app = await createApp({ config });
const { ctx, adapters, keys } = app.locals;
-- keys.start();
++ keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
const relay = ctx.outbox;
ddiff --git a/server/network.js b/server/network.js
iindex ff1fb09..9110516 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -3,45 +3,21 @@
/**
* OpenVibe.Network client.
*
-- * keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
-- * refreshed every 6 h and retried every 30 s until it loads) — verifies service tokens
++ * keys the Network's RS256 signing keys: openvibe-sdk/auth createNetworkKeys (OV_NETWORK_PUBLIC_KEY pinned,
++ * else the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on
++ * an unknown kid) — verifies service tokens and the console's sign-in
* identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch
* { system: 'live', type: 'user', ids } with a client-credentials token
* (audience openvibe.network, capability identity.subject.resolve)
*/
--const crypto = require('crypto');
const { serviceAuth } = require('openvibe-contracts');
++const { createNetworkKeys } = require('openvibe-sdk/auth');
const BATCH = 500;
++/** keys.verifyOptions (spread into the openvibe-sdk/auth verifiers), keys.loaded(), keys.start(), keys.stop(), keys.refresh(). */
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
-- let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
-- let timer = null;
-- async function load() {
-- const url = `${config.network.internalUrl}/api/.well-known/jwks`;
-- try {
-- const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
-- if (!res.ok) throw new Error(`JWKS ${res.status}`);
-- const body = await res.json();
-- const jwk = (body.keys || []).find((k) => k.kty === 'RSA') || (body.keys || [])[0];
-- if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
-- else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
-- else throw new Error('JWKS contained no keys');
-- return pem;
-- } catch (e) {
-- log.warn(`[Billing] Network key not loaded from ${url}: ${e.message}`);
-- return null;
-- }
-- }
-- function start() {
-- if (config.network.publicKey || timer) return;
-- const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
-- retry();
-- timer = setInterval(async () => { load(); }, 6 * 60 * 60 * 1000);
-- timer.unref();
-- }
-- function stop() { if (timer) clearInterval(timer); timer = null; }
-- return { get: () => pem, load, start, stop };
++ return createNetworkKeys({ network: config.network.internalUrl, publicKey: config.network.publicKey || null, fetch: fetchImpl, log });
}
function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 031d91b..1a80660 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const valkey = process.env.BILLING_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL
? require('openvibe-sdk/valkey').createValkey({ url: process.env.OV_TEST_VALKEY_URL, prefix: `ov:billing-test:${crypto.randomBytes(6).toString('hex')}:` }) : null;
const app = await createApp({ config, db, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow, valkey });
-- await app.locals.keys.load();
++ await app.locals.keys.refresh();
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const ctx = app.locals.ctx;