openvibers/openvibe.billing Commits
Billing: Network keys and token checks from openvibe-sdk/auth (API and staff console) (#42)
Changes
@@@ -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 -->@@@ -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": [@@@ -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"@@@ -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"},@@@ -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);};}@@@ -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' }) },@@@ -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.' }));@@@ -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 = globalThissignal: 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 };@@@ -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;@@@ -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 } = {}) {@@@ -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;
@@ -63,7 +63,7 @@ promo write changes both in one transaction.
@@ -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.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)
- 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)
- 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
- **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)
`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 [
@@ -458,6 +458,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.129.0
- openvibe-contracts: v0.129.0
-- openvibe-sdk: v0.38.0
+- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"deployed": true,
"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.",
"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)",
"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",
"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.",
"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": [
"features": [
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1075,9 +1075,9 @@
@@ -1075,9 +1075,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -2,23 +2,27 @@
@@ -2,23 +2,27 @@
/**
/**
* Service-token authentication (ADR-003). Every /api/v1 call carries a client-credentials JWT
* 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
* 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.*",
* contracts' own grants(): an exact id, or a trailing ".*" family grant ("billing.*",
* "billing.cashout.*").
* "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 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;
if (req.principal) return true;
const header = String(req.headers.authorization || '');
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; }
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; }
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 };
req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };
return true;
return true;
@@ -28,11 +32,13 @@ function createAuth({ config, keys }) {
@@ -28,11 +32,13 @@ function createAuth({ config, keys }) {
function needs(caps) {
function needs(caps) {
const list = Array.isArray(caps) ? caps : [caps];
const list = Array.isArray(caps) ? caps : [caps];
return function billingCapability(req, res, next) {
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);
};
};
}
}
@@ -89,7 +89,7 @@ async function createApp(opts = {}) {
@@ -89,7 +89,7 @@ async function createApp(opts = {}) {
service: 'billing', release: release.release,
service: 'billing', release: release.release,
checks: [
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: '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: 'money_writes', required: false, check: async () => (await isFrozen(db) ? 'frozen' : true) },
{ name: 'events_outbox', required: false, check: async () => await outbox.status() },
{ 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' }) },
{ name: 'valkey', required: false, check: async () => (valkey ? await valkey.ready() : { skipped: 'VALKEY_URL not set: per-person limits count in this process only' }) },
@@ -189,9 +189,9 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -189,9 +189,9 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
}
}
let who;
let who;
try {
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) {
} catch (e) {
log.warn(`[Billing] console sign-in failed: ${e.message}`);
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.' }));
return send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
@@ -10,7 +10,8 @@ const { staff: staffMap } = require('openvibe-contracts');
@@ -10,7 +10,8 @@ const { staff: staffMap } = require('openvibe-contracts');
* &code_challenge_method=S256
* &code_challenge_method=S256
* exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier),
* 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
* 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
* 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
* 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');
@@ -19,8 +20,7 @@ const { staff: staffMap } = require('openvibe-contracts');
* revoked straight away (best effort).
* revoked straight away (best effort).
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-
-const b64urlJson = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
+const { verifyUserToken } = require('openvibe-sdk/auth');
function pkcePair() {
function pkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
@@ -41,26 +41,7 @@ function authorizeUrl(config, { state, challenge }) {
@@ -41,26 +41,7 @@ function authorizeUrl(config, { state, challenge }) {
return `${config.network.url}/oauth/authorize?${q.toString()}`;
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 { config } = ctx;
const body = new URLSearchParams({
const body = new URLSearchParams({
grant_type: 'authorization_code',
grant_type: 'authorization_code',
@@ -91,8 +72,10 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
@@ -91,8 +72,10 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
signal: AbortSignal.timeout(3000),
signal: AbortSignal.timeout(3000),
}).catch(() => {});
}).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 {
return {
subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
@@ -104,4 +87,4 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
@@ -104,4 +87,4 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
};
};
}
}
-module.exports = { pkcePair, authorizeUrl, verifyUserToken, exchange };
+module.exports = { pkcePair, authorizeUrl, exchange };
@@ -25,7 +25,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
@@ -25,7 +25,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
const config = loadConfig();
const config = loadConfig();
const app = await createApp({ config });
const app = await createApp({ config });
const { ctx, adapters, keys } = app.locals;
const { ctx, adapters, keys } = app.locals;
- keys.start();
+ keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
const timers = [];
const relay = ctx.outbox;
const relay = ctx.outbox;
@@ -3,45 +3,21 @@
@@ -3,45 +3,21 @@
/**
/**
* OpenVibe.Network client.
* 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
* identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch
* { system: 'live', type: 'user', ids } with a client-credentials token
* { system: 'live', type: 'user', ids } with a client-credentials token
* (audience openvibe.network, capability identity.subject.resolve)
* (audience openvibe.network, capability identity.subject.resolve)
*/
*/
-const crypto = require('crypto');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
+const { createNetworkKeys } = require('openvibe-sdk/auth');
const BATCH = 500;
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 } = {}) {
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 } = {}) {
function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const valkey = process.env.BILLING_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL
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;
? 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 });
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 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 base = `http://127.0.0.1:${server.address().port}`;
const ctx = app.locals.ctx;
const ctx = app.locals.ctx;